[toc]
还记得前面用ArrayList实现阻塞队列的文章:《什么?面试官让我用ArrayList实现一个阻塞队列?》。我们通过synchronized并配合wait和notify实现了一个阻塞队列。在介绍完前文的synchronized关键字的基本使用之后,本文来对这些方法进行分析。
1.生产者和消费者模型
Producer代码如下:
public class Producer implements Runnable {
List cache;
public Producer(List cache) {
new Object();
this.cache = cache;
}
public void put() throws InterruptedException {
synchronized (cache) {
System.out.println(Thread.currentThread().getName()+"获得锁");
cache.notify();
while (cache.size() == 1) {
try {
System.out.println(Thread.currentThread().getName()+"wait");
cache.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
TimeUnit.SECONDS.sleep(1);
cache.add(1);
System.out.println(Thread.currentThread().getName() + "生产者写入1条消息");
}
}
@Override
public void run() {
while (true) {
try {
put();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
Consumer代码如下:
public class Consumer implements Runnable {
List cache;
public Consumer(List cache) {
this.cache = cache;
}
private void consumer() {
synchronized (cache) {
System.out.println(Thread.currentThread().getName()+"获得锁");
cache.notify();
while (cache.size() == 0) {
try {
System.out.println(Thread.currentThread().getName()+"wait");
cache.wait();
}catch (InterruptedException e) {
e.printStackTrace();
}
}
cache.remove(0);
System.out.println(Thread.currentThread().getName()+" 消费者消费了1条消息");
}
}
@Override
public void run() {
while (true) {
consumer();
}
}
}
我们来调用上述的生产者和消费者模型:
public static void main(String[] args) {
List cache = new ArrayList<>();
new Thread(new Producer(cache),"P1").start();
new Thread(new Consumer(cache),"C1").start();
}
启用了两个线程,分别调用生产者和消费者,可以看到这个过程交替执行:
P1获得锁
P1生产者写入1条消息
P1获得锁
P1wait
C1获得锁
C1 消费者消费了1条消息
C1获得锁
C1wait
P1生产者写入1条消息
P1获得锁
P1wait
C1 消费者消费了1条消息
C1获得锁
C1wait
P1生产者写入1条消息
P1获得锁
P1wait
C1 消费者消费了1条消息
C1获得锁
C1wait
P1生产者写入1条消息
P1获得锁
P1wait
C1 消费者消费了1条消息
C1获得锁
C1wait
P1生产者写入1条消息
P1获得锁
P1wait
C1 消费者消费了1条消息
C1获得锁
只要生产者产生了数据,那么消费者就能进行消费。
2. 死锁产生
还是利用上述代码,我们来增加一个消费者:
public static void main(String[] args) {
List cache = new ArrayList<>();
new Thread(new Producer(cache),"P1").start();
new Thread(new Consumer(cache),"C1").start();
new Thread(new Consumer(cache),"C2").start();
}
我们发现程序执行了一段时间之后都停止不动了:
P1获得锁
P1生产者写入1条消息
P1获得锁
P1wait
C2获得锁
C2 消费者消费了1条消息
C2获得锁
C2wait
C1获得锁
C1wait
C2wait
P1生产者写入1条消息
P1获得锁
P1wait
C1 消费者消费了1条消息
C1获得锁
C1wait
C2wait
在IDEA中,我们对这三个线程的状态dump了进行查看:
"P1" #12 prio=5 os_prio=0 tid=0x0000000020c20800 nid=0x1e9c in Object.wait() [0x00000000219fe000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
- waiting on <0x000000076ba2eae0> (a java.util.ArrayList)
at java.lang.Object.wait(Object.java:502)
at com.dhb.notify.Producer.put(Producer.java:22)
- locked <0x000000076ba2eae0> (a java.util.ArrayList)
at com.dhb.notify.Producer.run(Producer.java:40)
at java.lang.Thread.run(Thread.java:748)
"C1" #13 prio=5 os_prio=0 tid=0x0000000020c2f000 nid=0x3448 in Object.wait() [0x0000000021aff000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
- waiting on <0x000000076ba2eae0> (a java.util.ArrayList)
at java.lang.Object.wait(Object.java:502)
at com.dhb.notify.Consumer.consumer(Consumer.java:21)
- locked <0x000000076ba2eae0> (a java.util.ArrayList)
at com.dhb.notify.Consumer.run(Consumer.java:35)
at java.lang.Thread.run(Thread.java:748)
"C2" #14 prio=5 os_prio=0 tid=0x0000000020c30000 nid=0x904 in Object.wait() [0x0000000021bff000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
- waiting on <0x000000076ba2eae0> (a java.util.ArrayList)
at java.lang.Object.wait(Object.java:502)
at com.dhb.notify.Consumer.consumer(Consumer.java:21)
- locked <0x000000076ba2eae0> (a java.util.ArrayList)
at com.dhb.notify.Consumer.run(Consumer.java:35)
at java.lang.Thread.run(Thread.java:748)
可以发现,这三个线程,都是处于WATTING状态。
这说明产生了死锁,没有人再去对线程进行唤醒操作。
3.解决问题
实际上,上述问题的解决方式很简单,只需要在Consumer和Producer中将notify方法都缓冲notifyAll方法就能解决。我们在后面章节来分析产生死锁的具体原因,在此先看看修改代码后的执行效果.
将所有
cache.notify();
替换为:
cache.notifyAll();
执行效果:
P1获得锁
P1生产者写入1条消息
P1获得锁
P1wait
C1获得锁
C1 消费者消费了1条消息
C1获得锁
C1wait
C2获得锁
C2wait
C1wait
P1生产者写入1条消息
P1获得锁
P1wait
C1 消费者消费了1条消息
C1获得锁
C1wait
C2wait
P1生产者写入1条消息
P1获得锁
P1wait
C2 消费者消费了1条消息
C2获得锁
C2wait
C1wait
P1生产者写入1条消息
P1获得锁
P1wait
C1 消费者消费了1条消息
C1获得锁
C1wait
C2wait
P1生产者写入1条消息
P1获得锁
P1wait
C2 消费者消费了1条消息
C2获得锁
C2wait
C1wait
可以看到上述程序可以正常运行,没有任何死锁问题。
4.notify和notifyAll区别
这才是本文的关键知识点,通过前面这两个示例,相信大家都能看出来,notify会造成死锁,而notify则不会。这是因为,在前面我们分析过Object的源码,在注释中,就提到,notify只会选择等待队列其中之一的线程,将其变为阻塞状态,等待获得CPU的执行权。而NotifyAll则是将等待队列全部的线程都添加到等EntryList,然后这些线程都会等待获得CPU的执行时间。
实际上,我们在前文分析Thread源码的时候,对线程的各自运行状态进行了总结:
在java中线程的运行状态共有6种。而wait则是将线程从RUNNING变为WAITING状态。而notify和notifyAll则是将线程从WAITING状态变为RUNNING的方法。实际上,首先这个转换过程需要再经过BLOCK状态转换。因为只有部分线程能获得锁,进入执行状态,而获得不到的,自然进入了BLOCK状态。
notify只会选择等待队列的一个线程,这个过程是不确定的,由的jvm可能是随机选择,而hotspot再1.8种,直接是从WaitSet的头部拿到第一个线程。
notify过程如下图所示:
而notifyAll则不同于notify,由于会将全部wait的线程都进入EntryList队列,这个过程如下:
这就是这两个方法的区别。
5.死锁产生的原因
5.1 两个线程
首先看看两个线程的时候:
P1获得锁
P1生产者写入1条消息
P1获得锁
P1wait
C1获得锁
C1 消费者消费了1条消息
C1获得锁
C1wait
P1生产者写入1条消息
P1获得锁
P1wait
C1 消费者消费了1条消息
C1获得锁
C1wait
P1生产者写入1条消息
P1获得锁
P1wait
C1 消费者消费了1条消息
C1获得锁
C1wait
P1生产者写入1条消息
P1获得锁
P1wait
这个过程日志可以用下图表示:
-
-
由于两个线程都启动了,P1首先获得锁,那么C1阻塞。状态为P1执行,C1阻塞。
-
-
2.再这之后,P1执行完调用wait方法,C1获得锁,那么此时C1执行,P1等待:
-
3.之后,C1调用notify方法将P1唤醒,由于C1还需要执行后续相关代码,那么此时P1进入阻塞队列
-
4.随后,C1执行完毕进入wait状态,这时候P1重写获得锁:
-
等P1执行的时候,又会再次notify C1
上述过程就会不断循环,这样线程就不会卡顿。会一直执行下去。
5.2 三个线程
那么现在换成三个线程,我们来看看日志:
P1获得锁
P1生产者写入1条消息
P1获得锁
P1wait
C2获得锁
C2 消费者消费了1条消息
C2获得锁
C2wait
C1获得锁
C1wait
C2wait
P1生产者写入1条消息
P1获得锁
P1wait
C1 消费者消费了1条消息
C1获得锁
C1wait
C2wait
上述过程我们用画图的方式来进行:
-
1.P1首先获得锁,而C1、C2阻塞。P1虽然会调用notify但是waitSet没有线程。
-
2.之后C2获得了锁,此时P1等待,C1阻塞
-
3.此后C2调用Notify方法,将P1变为阻塞状态。然后消费数据。此时cache长度为0。
-
4.此后C1获得锁,此时C2等待,P1阻塞。
-
5.此时C1调用notify将C2变为阻塞。此时while循环由于cache长度为0,因此C1会调用wait方法。
-
6.此后,P1再次获得锁。此时C1等待,C2执行。
-
7.从此时开始,每个线程的执行都需要注意了,此时线程再次获得锁的时候只会执行wait之后的代码。对于P1,由于cache的size为0,因此会继续写入数据。之后再次进入循环会发notify。将C1变为阻塞。
-
8.之后 C1获得锁,而P1等待,C2阻塞
9.C1再次获得锁,只会执行wait之后的内容:
while (cache.size() == 0) {
try {
System.out.println(Thread.currentThread().getName()+"wait");
cache.wait();
}catch (InterruptedException e) {
e.printStackTrace();
}
}
可以看到这个代码,当从wait方法之后由于cache.size为0,再次进入wait状态,这里没办法调用notify了。
-
10.之后C2也再次获得锁,但是与C1一样,由于条件不满足,不会执行notify。
这样就对线程死锁进行了复盘。可以看到,notify导致问题的原因是,如果我们的条件没设置好,这会导致线程没办法去执行notify操作。而这样的话所有的线程都可能进入wait状态。再也没有人来调用notify,这就导致了死锁。悲剧就这样发生了。这也进一步说明,notify方法是非常脆弱的,如果我们的代码种在同一个锁上的竞争的线程只有2个的话,notify是完全能胜任的。但是如果超过2个,就会因为条件设置不合理而导致了死锁。
而notify,则是无论什么时候,只要被调用,就会将所有的线程全部移动到阻塞队列等待锁。只要有一个线程调用notifyAll就能将所有的线程唤醒。
5.总结
本文对notify和notifyAll方法进行了分析,需要注意的是,notify和notifyAll方法的区别,一个是唤醒其中之一的等待线程,不同的JVM实现的方式不同,而HotSpot源码中,是从WaitSet中取的head元素。也就是说,谁先进入Wait状态则就会将谁notify出来。而notifyAll则是将全部的线程都从WaitSet取出。这样就不会有线程等待。通过上述分析可见,产生死锁的根本原因还是在条件变量的控制。
但是需要注意的是,虽然从WaitSet拿到元素不一定随机。但是,多个线程对锁的竞争的情况确是不一定的。上述的生产者消费者模型也只能用于模拟演示,因为有可能,生产者线程可能一直抢不到锁,导致全部都是消费者线程互相争抢。