首页 > 编程笔记 > Java笔记 阅读:15

Java多线程同步机制详解

Java 程序中,多线程可以提高程序的运行效率,但是多线程也会导致很多不合理的现象的出现,比如在卖外卖时出现超卖的现象。

之所以出现这些现象,是因为系统的调度具有随机性,多线程在操作同一数据时,很容易出现这种错误。接下来,我们就来讲解如何解决这种错误。

Java线程安全

关于线程安全,我们通过卖外卖来展示。卖外卖的基本流程大致为:
如果是单线程,这个流程不会出现什么问题,但是如果这个流程放在多线程并发的情况下,就会出现超卖的情况。

接下来,通过案例来演示这个问题(【实例 1】):
public class Demo {
    public static void main(String[] args) {
        Takeout takeout = new Takeout();
        Thread t1 = new Thread(takeout);
        Thread t2 = new Thread(takeout);
        Thread t3 = new Thread(takeout);
        t1.start();
        t2.start();
        t3.start();
    }
}

class Takeout implements Runnable {
    private int takeout = 5;

    public void run() {
        for (int i = 0; i < 100; i++) {
            if (takeout > 0) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(
                    "卖出第" + (5 - takeout + 1) + "份外卖,还剩" + --takeout + "份外卖");
            }
        }
    }
}
程序的运行结果如下:

卖出第1份外卖,还剩4份外卖
卖出第1份外卖,还剩3份外卖
卖出第3份外卖,还剩2份外卖
卖出第4份外卖,还剩1份外卖
卖出第5份外卖,还剩0份外卖
卖出第6份外卖,还剩-1份外卖

程序中声明了 Takeout 类,实现了 Runnable 接口。首先,在类中定义一个 int 类型的变量 takeout,这个变量代表的是外卖的总数量;然后,重写 run() 方法,run() 方法中循环卖外卖,每卖 1 份外卖,外卖总数减 1,为了演示可能出现的问题,通过调用 sleep() 方法让程序在每次循环时休眠 100 毫秒;最后,Demo 类在 main() 方法中创建并启动 3 个线程,模拟 3 个窗口同时卖外卖。

运行结果可以看出,第 1 份外卖重复卖了 2 次,剩余的外卖还出现了 -1 份。

出现上述情况显然是不合理的,这里以剩余的外卖出现 -1 份为例进行讲解。之所以会出现超卖的情况,是因为 run() 方法的循环中判断外卖总数量是否大于 0,如果大于 0 就会继续售卖,但售卖的时候线程调用了 sleep() 方法,导致程序每次循环都会休眠 100 毫秒,这就会出现某个线程执行到此处进入休眠时,另外两个线程也进入执行,所以卖出的数量就会变多,这就是线程安全问题。

多线程中的同步代码块

我们使用多个线程访问同一资源时,若多个线程只有读操作,那么不会发生线程安全问题,但是如果多个线程都对资源有读和写的操作,就容易出现线程安全问题。前面卖外卖的案例中就出现了线程安全问题。为了解决这种问题,可以使用线程锁。

线程锁主要是给方法或代码块加锁。这样,某个方法或者代码块使用锁时,在同一时刻至多仅有一个线程在执行该段代码。当有多个线程访问同一对象的加锁方法或代码块时,同一时间只有一个线程在执行,其余线程必须要等待当前线程执行完之后才能执行该代码段。但是,其余线程可以访问该对象中的非加锁代码块。

Java 的多线程引入了同步代码块,当多个线程使用同一个共享资源时,可以将处理共享资源的代码放置在一个使用 synchronized 关键字来修饰的代码块中。具体示例如下:
synchronized (obj) {
    …  // 要同步的代码块
}
Java 中每个对象都有一个内置锁。当程序运行到 synchronized 同步代码块时,就会获得当前执行的代码块里面的对象锁。一个对象只有一个锁,称为锁对象。如果一个线程获得该锁,其他线程就无法再次获得这个对象的锁,直到第 1 个线程释放锁。释放锁是指此线程退出了 synchronized 同步方法或代码块。

如上所示,synchronized(obj) 中的 obj 就是同步锁,它是同步代码块的关键,当线程执行同步代码块时,会先检查同步监视器的标志位,默认情况下标志位为 1。标志位为 1 时线程会执行同步代码块,同时将标志位改为 0;当第 2 个线程执行同步代码块前,先检查标志位,如果检查到标志位为 0,第 2 个线程就会进入阻塞状态;当第 1 个线程执行完同步代码块内的代码时,将标志位重新改为 1,第 2 个线程进入同步代码块。

接下来,通过修改实例 1 的代码来演示如何使用同步代码块解决线程安全问题(【实例 2】):
public class Demo {
    public static void main(String[] args) {
        Takeout takeout = new Takeout();
        Thread t1 = new Thread(takeout);
        Thread t2 = new Thread(takeout);
        Thread t3 = new Thread(takeout);
        t1.start();
        t2.start();
        t3.start();
    }
}

class Takeout implements Runnable {
    private int takeout = 5;

    public void run() {
        for (int i = 0; i < 100; i++) {
            synchronized (this) {  // this代表当前对象
                if (takeout > 0) {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("卖出第" + (5 - takeout + 1) + "份外卖,还剩" + --takeout + "份外卖");
                }
            }
        }
    }
}
程序的运行结果如下:

卖出第1份外卖,还剩4份外卖
卖出第2份外卖,还剩3份外卖
卖出第3份外卖,还剩2份外卖
卖出第4份外卖,还剩1份外卖
卖出第5份外卖,还剩0份外卖

两个实例程序几乎是完全一样,区别就是实例 2 在 run() 方法的循环中执行售卖操作时,将操作变量 takeout 的操作都放到同步代码块中。在使用同步代码块时必须指定一个需要同步的对象,一般使用当前对象(this)即可。将实例 1 修改为实例 2 后,多次运行该程序,同样不会出现重复售卖或超卖的情况。

注意,同步代码块中的锁对象可以是任意类型的对象,但多个线程共享的锁对象必须是相同的。“任意”说的是共享锁对象的类型。所以,锁对象的创建代码不能放到 run() 方法中,否则每个线程运行到 run() 方法都会创建一个新对象,这样每个线程都会有一个不同的锁,而每个锁都有自己的标志位,线程之间便无法产生同步的效果。

synchronized修饰的同步方法

前面讲解了使用同步代码块解决线程安全问题。另外,Java 还提供了同步方法,即用 synchronized 关键字修饰的方法,它的监视器是调用该方法的对象。使用同步方法同样可以解决线程安全的问题。

接下来,通过修改实例 1 的代码来演示如何使用同步方法解决线程安全问题(【实例 3】):
public class Demo {
    public static void main(String[] args) throws Exception {
        Takeout takeout = new Takeout();
        Thread t1 = new Thread(takeout);
        Thread t2 = new Thread(takeout);
        Thread t3 = new Thread(takeout);
        t1.start();
        t2.start();
        t3.start();
    }
}

class Takeout implements Runnable {
    private int takeout = 5;

    public synchronized void run() {
        for (int i = 0; i < 100; i++) {
            if (takeout > 0) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(
                    "卖出第" + (5 - takeout + 1) + "份外卖,还剩" + --takeout + "份外卖"
                );
            }
        }
    }
}
程序的运行结果如下:

卖出第1份外卖,还剩4份外卖
卖出第2份外卖,还剩3份外卖
卖出第3份外卖,还剩2份外卖
卖出第4份外卖,还剩1份外卖
卖出第5份外卖,还剩0份外卖

实例 3 与实例 1 几乎一样,区别就是例 1 的 run() 方法没有使用 synchronized 关键字修饰。将例 1 修改为例 3 后,多次运行程序不会出现超卖或者重复售卖的情况。

注意,同步方法的锁就是调用该方法的对象,也就是 this 所指向的对象,但是静态方法不需要创建对象就可以用“类名.方法名()”的方式进行调用,这时的锁则不再是 this,而是该方法所在类的 class 对象,该对象可以直接用“类名.class”的方式获取。

生产者和消费者

不同的线程执行不同的任务,有些复杂的程序需要多个线程共同完成一个任务,这时就需要线程之间能够相互通信。线程通信中的一个经典问题就是生产者和消费者问题。

java.lang 包中的 Object 类中提供了 3 种方法用于线程的通信,如下表所示:

表:Object类中的线程通信方法
方法 方法描述
void wait() 导致当前线程等待,直到另一个线程调用该对象的 notify() 方法或 notifyAll() 方法
void notify() 唤醒正在等待对象监视器的单个线程
void notifyAll() 唤醒正在等待对象监视器的所有线程

表中列举了线程通信需要使用的 3 个方法,这 3 个方法只有在 synchronized 方法或 synchronized 代码块中才能使用,否则会报 IllegalMonitorStateException 异常。

生产者和消费者问题也称有限缓冲问题,是一个多线程同步问题的经典案例。该问题描述了两个共享固定大小缓冲区的线程(即所谓的“生产者”和“消费者”)在实际运行时会发生的问题。生产者的主要作用是生成一定量的数据放到缓冲区中,然后重复此过程,与此同时消费者也在缓冲区消耗这些数据,如下图所示。


图 1 生产者和消费者

生产者和消费者问题会导致死锁的出现,下面简单介绍一下死锁。

死锁是指两个或两个以上的线程在执行过程中,由于竞争资源或者彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。

生产者和消费者问题如果不加以协调可能会出现以下情况:缓冲区中数据已满,而生产者依然占用着它,消费者等着生产者让出空间从而去消费产品,生产者等着消费者消费产品从而向空间中添加产品。互相等待,从而发生死锁。

接下来,通过一个案例来演示如何解决生产者和消费者问题:
import java.util.LinkedList;

public class Demo {
    private static final int MAX_NUM = 5; // 设置仓库的最大值
    private LinkedList<Object> list = new LinkedList<>(); // 缓存区

    class Producer implements Runnable { // 生产者
        @Override
        public void run() {
            while (true) {
                try {
                    Thread.sleep(1000);
                    synchronized (list) {
                        while (list.size() + 1 > MAX_NUM) {
                            System.out.println("生产者:" +
                                Thread.currentThread().getName() + " 仓库已满");
                            try {
                                list.wait();
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                        }
                        list.add(new Object());
                        System.out.println("生产者:" + Thread.currentThread().getName() +
                            " 生产了一个产品, 现库存量:" + list.size());
                        list.notifyAll();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    class Consumer implements Runnable { // 消费者
        @Override
        public void run() {
            while (true) {
                try {
                    Thread.sleep(3000);
                    synchronized (list) {
                        while (list.size() == 0) {
                            System.out.println("消费者:" +
                                Thread.currentThread().getName() + " 仓库为空");
                            try {
                                list.wait();
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                        }
                        list.remove();
                        System.out.println("消费者:" + Thread.currentThread().getName() +
                            " 消费了一个产品, 现库存量:" + list.size());
                        list.notifyAll();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
        Demo1313 proAndCon = new Demo1313();
        Producer producer = proAndCon.new Producer();
        Consumer consumer = proAndCon.new Consumer();

        // 开启3个生产者线程和3个消费者线程
        for (int i = 0; i < 3; i++) {
            Thread pro = new Thread(producer);
            pro.start();
            Thread con = new Thread(consumer);
            con.start();
        }
    }
}
程序的运行结果如下:
生产者:Thread-2 生产了一个产品,现库存量:1
生产者:Thread-0 生产了一个产品,现库存量:2
生产者:Thread-4 生产了一个产品,现库存量:3
生产者:Thread-2 生产了一个产品,现库存量:4
生产者:Thread-0 生产了一个产品,现库存量:5
生产者:Thread-4 仓库已满
消费者:Thread-3 消费一个产品,现库存量:4
生产者:Thread-4 生产了一个产品,现库存量:5
生产者:Thread-2 仓库已满
消费者:Thread-1 消费一个产品,现库存量:4
生产者:Thread-0 生产了一个产品,现库存量:5
消费者:Thread-5 消费一个产品,现库存量:4
生产者:Thread-2 生产了一个产品,现库存量:5
生产者:Thread-4 仓库已满
生产者:Thread-2 仓库已满
生产者:Thread-0 仓库已满
消费者:Thread-3 消费一个产品,现库存量:4
生产者:Thread-0 生产了一个产品,现库存量:5
生产者:Thread-2 仓库已满
生产者:Thread-4 仓库已满
。。。。。。。。。。。。。。
程序中使用 wait() 和 notify() 方法来解决生产者和消费者的问题。

对于生产者而言,如果缓存区的容量大于设定的最大容量,程序就会调用 wait() 方法来阻塞线程;否则,就会向缓存区中添加对象,然后调用 notifyAll() 方法来唤醒其他被阻塞的线程。

对于消费者而言,如果缓存区中没有对象,程序会调用 wait() 方法阻塞线程;否则,就移除缓冲区的对象,并调用 notifyAll() 方法来唤醒其他被阻塞的线程。

本例中有 3 个生产者和 3 个消费者,属于多对多的情况。仓库的容量为 5,生产者线程运行 1 次休眠 1s,消费者线程运行一次休眠 3s,消费的速度明显慢于生产的速度,从而避免了死锁的出现。

相关文章