线程之间通信有多种方式比如:Lock锁的Condition、CountDownLantch、CyclicBarrier、Semaphore和本文讲到的Object类中的wait()、notify()、notifyAll()方法。wait()、notify()、notifyAll()方法都会释放锁。
线程阻塞和线程等待
- 线程阻塞:线程调用了sleep()、wait()方法线程进入阻塞状态。阻塞状态的线程放到等待池中。调用notify()、notifyAll()会把等待池中的线程放到锁池中。
- 线程等待:线程没有竞争到锁(synchronized锁),等待状态的线程放到锁池中。锁释放,锁池中的线程开始抢锁。
等待池中的线程只有放到锁池中,才可以抢锁。比如notify唤醒线程,唤醒的线程会放到锁池中,与锁池中的线程一同抢锁
等待池、锁池概念及notify和notifyAll的区别。
Reference: 等待池与锁池
notify死锁理论分析
死锁跟竞争资源产生的死锁的概念有点不同,现象相同,线程不再执行。
- notify产生的死锁:线程都在等待池中,等待被唤醒。若有新的消费者、生产者线程,有一定几率可以唤醒等待池中的线程。理想状态下,等待池不再有线程。即解除死锁
- 竞争资源产生的死锁:线程互相获取不到锁,相互等待锁。此时线程在锁池中。
notify产生的死锁有一定几率可救,竞争资源产生的死锁不可救
为什么notify产生的死锁有一定几率可救?带着这个问题的思考往下看
假设条件:
- 场景:生产者消费者
- 队列长度:2
- 消费者线程个数:4
- 生产者线程个数:4
- 唤醒线程使用的方法:notify()。
线程获取锁的流程图
逻辑分析死锁:
说明:生产者线程以P开头四个线程名简化为P1、P2、P3、P4,消费者线程以C开头四个线程名简化为C1、C2、C3、C4。
1.四个消费者线程先去消费,此时队列为空。四个消费者线程阻塞,进入等待池中,等待被唤醒。如图。
2.四个生产者线程去生产,P1线程抢到锁把元素放到队列中(其它生产者线程进入到锁池),接着调用notify方法唤醒等待池的线程。一定会唤醒等待池中的线程(此时等待池中只有消费者线程),即会唤醒一个消费者线程,假设唤醒的消费者线程为C4,C4进入到锁池中,与锁池中的其它线程抢锁。假设P4线程抢到锁,此时也会唤醒一个消费者假设C1(此时等待池中只有消费者线程)。假设P2线程抢到锁,但是队列满了,P2线程进入到等待池中,同样P3也进入到等待池中。此时等待池中,有两个生产者P2、P3,两个消费者C2、C3。锁池中C1、C4。
3.锁池中的消费者线程C1,C4抢锁。假设C4抢到锁,从队列取出元素。接着调用notify方法唤醒等待池中的线程,假设唤醒的是C2线程。C1抢到锁继续消费。消费之后唤醒的是C3。此时锁池中的两个消费者抢锁,假设抢锁顺序为C2,C3,因队列为空,会再次进入到等待池中。此时等待池中有两个生产者线程P2、P3,两个消费者线程C2、C3,都在等待被唤醒,但是没有线程来唤醒它们,形同死锁。
说明:以上只是线程执行顺序的一种假设,即可能出现这种情况。
生产者消费者使用notify产生死锁的代码样例
生产者代码
public class Producer extends Thread {
private Queue<Integer> queue;
private int maxSize;
Producer(Queue<Integer> queue, int maxSize) {
this.queue = queue;
this.maxSize = maxSize;
}
@Override
public void run() {
synchronized (queue) {
while (queue.size() == maxSize) {
try {
System.out.println("已满");
queue.wait();
System.out.println("已唤醒生产者---");
} catch (InterruptedException e) {
System.out.println("生产者报错");
}
}
Random random = new Random();
int i = random.nextInt();
System.out.println("Produce " + i);
queue.add(i);
queue.notify();
}
}
}
消费者代码
public class Consumer extends Thread {
private Queue<Integer> queue;
private int maxSize;
Consumer(Queue<Integer> queue, int maxSize) {
this.queue = queue;
this.maxSize = maxSize;
}
@Override
public void run() {
synchronized (queue) {
while (queue.isEmpty()) {
try {
System.out.println("已空");
queue.wait();
System.out.println("已唤醒消费者---");
} catch (InterruptedException e) {
System.out.println("消费者报错");
}
}
int v = queue.remove();
System.out.println("Consume " + v);
queue.notify();
}
}
}
main函数测试
为了更大概率的产生死锁现象,把队列大小依旧设置为2。生产者、消费者线程为40个,且先产生40个消费者。
public class ProducerAndConsumer {
public static void main(String[] args) throws Exception{
Queue<Integer> queue = new LinkedList<>();
int maxSize = 2;
for (int i=0;i<10;i++) {
Consumer c1 = new Consumer(queue, maxSize);
Consumer c2 = new Consumer(queue, maxSize);
Consumer c3 = new Consumer(queue, maxSize);
Consumer c4 = new Consumer(queue, maxSize);
c1.start();
c2.start();
c3.start();
c4.start();
}
//休眠200ms是为了让尽可能多的消费者进入到等待池中
Thread.currentThread().sleep(200);
for (int i=0;i<10;i++) {
Producer p1 = new Producer(queue, maxSize);
Producer p2 = new Producer(queue, maxSize);
Producer p3 = new Producer(queue, maxSize);
Producer p4 = new Producer(queue, maxSize);
p1.start();
p2.start();
p3.start();
p4.start();
}
}
}
执行两次产生了死锁现象。若你幸运的话执行一次应该就会出现。此时可以用jstack去看下线程快照。windows下执行tasklist | findstr “java” 查找出运行main函数的java进程Id。执行 jstack -l PID >> D:jstack.txt
D:代表是D盘的绝对路径,不加默认下载到当前路径下。
Reference: windows下下载线程快照信息
问题:线程最终是在锁池还是在等待池?还是都有?如何验证?
思路:观察生产者,若打印的Produce、生产者报错加起来为40则证名锁池中无生产者,小于40则证明剩余的线程在锁池和等待池中。观察消费者也同理。
方法①想到的是用wait(3000)等待超时就报错,但此方法不报错,等待三秒之后自动唤醒继续执行。废弃。
方法② 给生产者加一个事件属性。更改后的生产者代码如下:
class Producer extends Thread {
private Queue<Integer> queue;
private int maxSize;
//来记录进来时的时间
private long currentTime;
Producer(Queue<Integer> queue, int maxSize) {
this.queue = queue;
this.maxSize = maxSize;
}
@Override
public void run() {
synchronized (queue) {
while (queue.size() == maxSize) {
try {
System.out.println("已满");
//产生的Produce加上报错的,生产者应该等于40。原因若是notify唤醒的则不会报错,因为时间小于3秒。大于三秒的就是阻塞的并且自己唤醒自己的
currentTime = System.currentTimeMillis();
queue.wait(3000);
//大于三秒手动抛异常
if(System.currentTimeMillis()-currentTime>=3000)
//只要抛出异常被捕获就会接着执行
throw new RuntimeException("报错,自己唤醒的");//抛出的不是捕获的则会不起作用
System.out.println("已唤醒生产者---");
} catch (InterruptedException e) {
System.out.println("生产者报错");
}
}
Random random = new Random();
int i = random.nextInt();
System.out.println("Produce " + i);
queue.add(i);
queue.notify();
}
}
}
休眠三秒的原因是确保不是notify唤醒的,notify唤醒不会这么长时间。本人的cup是i7内存16G。若电脑差点就把wait的参数调大,好点就调小。
无论报错还是输出了Produce都代表此线程已执行完毕。
小技巧: ieda输出只能查找内容,不能计算个数。把输出的内容copy出来,copy到sublime Text或者nodepad++或者其它编辑器。查找内容的同时就能看到总个数。刚开始我是一点点数的,哎。。。(掌握技巧的重要性)
线程最终是在锁池还是在等待池
根据以上分析,相信你已知道答案了。最终的线程在等待池中。
总结:
-
线程调用wait()方法,线程从运行状态进入阻塞状态,线程放到等待池中。唤醒进入锁池,线程状态从阻塞状态到就绪状态(获取到锁就能执行)。
-
锁池中的线程会执行完毕
- 正常结束(正常执行完毕,抛出异常)
- 进入到阻塞状态
-
解决办法把notify换成notifyAll即可notifyAll会把等待池中的线程都换醒放到锁池中。
注意事项:
synchronized与wait()、notify()、notifyAll()所使用的必须是同一把锁。否则报错。
解答nofify可自救
还是以逻辑分析死锁为例:生产者进线程进来执行的顺序有一种为:
生产者线程–>唤醒生产者P2–>唤醒消费者C2–唤醒生产者P3–>唤醒消费者C3。此时队列还有一个未消费元素。死锁现象解除。
每篇一语
路漫漫其修远兮 ——《离骚》