要实现多个线程之间的协同,如:线程执行先后顺序、获取某个线程执行的结果等等。涉及到线程之间互相通信,分为下面四类:
①、文件共享
②、网络共享
③、共享变量
④、jdk提供的线程协调API,细分为suspend/resume、wait/notify、park/unpark
前三种都相对比较简单,本文主要是讲解第④种,jdk提供的API方式。

1、线程协作-JDK API

JDK中对于需要多线程协作完成任务的场景,提供了对应API支持。多线程协作的典型场景是:生产者-消费者模式。下面我们以买包子的场景来举例:

线程1去买包子,如果没有包子,则阻塞等待,不再执行。线程2生产出包子后,通知线程1可以继续执行,并购买包子。

示例流程如下:
图片

1.1、suspend/resume

调用suspend挂起目标线程,通过resume可以恢复线程执行。但此种方式已被JDK弃用,原因是因为如果使用不当,很容易照成线程死锁,后面会着重讲解导致死锁的情况。

1.1.1、正常执行

下面这段代码是通过suspend/resume来演示买包子的场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class SuspendResumeTest {
//定义一个空的包子店,表示包子店暂时没有任何包子
private static Object baozidian = null;

public static void main(String[] args) throws Exception {
//启动消费者,买包子的人
Thread consumerThread = new Thread(()->{
if (baozidian == null){
System.out.println("1、没有包子,进入等待");
Thread.currentThread().suspend();
}
System.out.println("2、买到包子,回家");
});
consumerThread.start();
//3秒之后生产第一个包子
Thread.sleep(3000);
baozidian = new Object();
//通知消费者买包子
consumerThread.resume();
System.out.println("3、通知消费者");
}
}

程序执行结果:

1、没有包子,进入等待
3、通知消费者
2、买到包子,回家

消费者成功买到包子,似乎没有什么问题。那如果在suspend/resume都在同步代码块(synchronized)中被执行,或者它们的执行顺序被调换(即先执行resume,再执行suspend)后结果又会如何,下面修改下上面代码,分别来看看这两种情况。

1.1.2、在synchronized中执行

修改后的代码如下(注意suspend和resume必须锁的时同一把锁):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class SuspendResumeTestWithSynch {
//定义一个空的包子店,表示包子店暂时没有任何包子
private static Object baozidian = null;

public static void main(String[] args) throws Exception {
//启动消费者,买包子的人
Thread consumerThread = new Thread(()->{
if (baozidian == null){
System.out.println("1、没有包子,进入等待");
synchronized (SuspendResumeTestWithSynch.class){
Thread.currentThread().suspend();
}
}
System.out.println("2、买到包子,回家");
});
consumerThread.start();
//3秒之后生产第一个包子
Thread.sleep(3000);
baozidian = new Object();
//通知消费者买包子
synchronized (SuspendResumeTestWithSynch.class){
consumerThread.resume();
}
System.out.println("3、通知消费者");
}
}

运行上面代码,输出的结果只有:

1、没有包子,进入等待

且程序一直处于阻塞状态,并不能执行结束,结论便是suspend/resume都在同步代码块(synchronized)中被执行时会照成死锁。原因是代码第10行,consumerThread 线程先获得锁后,执行suspend方法,该线程处于阻塞状态状态,但是它并没有释放锁,而在主线程执行到代码第21行时,主线程一直等待consumerThread 线程释放锁,而获取不到锁,所以线程就一直卡死了。

1.1.3、调换执行顺序

正常情况下,应该是先执行suspend阻塞线程,然后执行resume唤醒线程,而如果程序写的不当,刚好将两者执行的顺序调换下,我们下面来模拟这种场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class SuspendResumeTestWithOrder {
//定义一个空的包子店,表示包子店暂时没有任何包子
private static Object baozidian = null;

public static void main(String[] args) throws Exception {
//启动消费者,买包子的人
Thread consumerThread = new Thread(()->{
if (baozidian == null){
System.out.println("1、没有包子,进入等待");
try {
//让线程延迟一会儿,保证resume先执行
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread.currentThread().suspend();
}
System.out.println("2、买到包子,回家");
});
consumerThread.start();
//3秒之后生产第一个包子
Thread.sleep(3000);
baozidian = new Object();
//通知消费者买包子
consumerThread.resume();
System.out.println("3、通知消费者");
}
}

上述代码第12行中,先让线程consumerThread 延迟10秒,保证25行的resume先执行,程序执行的结果为:

1、没有包子,进入等待
3、通知消费者

虽然通知了消费者,但是提前通知了消费者,后面消费者调用suspend后,仍然会被阻塞,导致程序死锁。就好比我们要远行,如果选择火车或者高铁出行,一般是我们在要车开之前购票进站、上车,但是如果你路途中耽搁了没有及时赶到,等你到达车站,你再怎么等都是没有用的,错过了就错过了,这辆车不再会等你,你只能原路返回或者等待下一辆(如果还有,就相当于还有其他线程调用resume唤醒该线程)。

1.2、wait/notify

wait/notify方法只能由同一对象锁的持有者线程调用,也就是写在同步块里面,否则会抛出IllegalMonitorStateException异常。
特点:

1)、必须在同步代码块中执行,调用wait方法时会释放锁
2)、wait/notify执行顺序不能被调换,否则线程永远处于WAITING状态,导致死锁

1.2.1、正常执行

wait/notify正常执行即在同步代码块synchronized中执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class WaitNotifyTest {

//定义一个空的包子店,表示包子店暂时没有任何包子
private static Object baozidian = null;

@Test
public void test() throws Exception {
//启动消费者,买包子的人
Thread consumerThread = new Thread(()->{
if (baozidian == null){
try {
System.out.println("1、没有包子,进入等待");
synchronized (this){
//线程进入等待状态,同时释放锁
this.wait();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("2、买到包子,回家");
});
consumerThread.start();
//3秒之后生产第一个包子
Thread.sleep(3000);
baozidian = new Object();
//通知消费者买包子
synchronized (this){
this.notify();
}
System.out.println("3、通知消费者");
}
}

执行结果:

1、没有包子,进入等待
3、通知消费者
2、买到包子,回家

消费者能够正常买到包子,且程序正常结束。

1.2.2、调换执行顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class WaitNotifyTest {

//定义一个空的包子店,表示包子店暂时没有任何包子
private static Object baozidian = null;

@Test
public void test() throws Exception {
//启动消费者,买包子的人
Thread consumerThread = new Thread(()->{
if (baozidian == null){
try {
System.out.println("1、没有包子,进入等待");
//延迟线程,使得notify先执行
Thread.sleep(10000);
synchronized (this){
//线程进入等待状态,同时释放锁
this.wait();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("2、买到包子,回家");
});
consumerThread.start();
//3秒之后生产第一个包子
Thread.sleep(3000);
baozidian = new Object();
//通知消费者买包子
synchronized (this){
this.notify();
}
System.out.println("3、通知消费者");
System.out.println("consumerThread状态:" + consumerThread.getState().toString());
}

上述代码第14行,使消费者线程休眠10秒,保证31行的notify先执行。执行结果:

1、没有包子,进入等待
3、通知消费者
consumerThread状态:TIMED_WAITING

主线程结束后,获取consumerThread为TIMED_WAITING,且没有买到包子,线程consumerThread将永远处于consumerThread状态。

1.3、park/unpark

线程调用park则等待“许可”,unpark方法为指定线程提供“许可(permit)”。
特点:

1)、不要求park和unpark的调用顺序,多次调用unpark之后,再调用park,线程会直接运行,但是不会叠加,也就是说,多次调用unpark后,再多次调用park方法,第一次会拿到许可直接运行,后续还是会进入等待;
2)、在同步代码块中执行会造成死锁。

1.3.1、正常执行

park/unpark不需要在同步代码块中执行,直接通过LockSupport的API。测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ParkUnParkTest {

//定义一个空的包子店,表示包子店暂时没有任何包子
private static Object baozidian = null;

@Test
public void test() throws InterruptedException {
//启动消费者,买包子的人
Thread consumerThread = new Thread(()->{
if (baozidian == null){
System.out.println("1、没有包子,进入等待");
//线程进入等待状态
LockSupport.park();
}
System.out.println("2、买到包子,回家");
});
consumerThread.start();
//3秒之后生产第一个包子
Thread.sleep(3000);
baozidian = new Object();
//通知消费者买包子
LockSupport.unpark(consumerThread);
System.out.println("3、通知消费者");
System.out.println("consumerThread状态:" + consumerThread.getState().toString());
}
}

代码13行LockSupport.park(),等待许可,代码第22行LockSupport.unpark(consumerThread),为线程consumerThread提供许可。测试结果如下:

1、没有包子,进入等待
3、通知消费者
2、买到包子,回家
consumerThread状态:RUNNABLE

成功买到包子回家。

1.3.2、调换执行顺序

由于park/unpark不能在同步代码块中执行,因为park同样不会释放锁,这里不再做演示,我们直接演示一下调换执行顺序的。调整后的test方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Test
public void test() throws InterruptedException {
//启动消费者,买包子的人
Thread consumerThread = new Thread(()->{
if (baozidian == null){
System.out.println("1、没有包子,进入等待");
try {
//延迟,目的是让unpark先执行
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//线程进入等待状态
LockSupport.park();
}
System.out.println("2、买到包子,回家");
});
consumerThread.start();
//3秒之后生产第一个包子
Thread.sleep(3000);
baozidian = new Object();
//通知消费者买包子
LockSupport.unpark(consumerThread);
System.out.println("3、通知消费者");
System.out.println("consumerThread状态:" + consumerThread.getState().toString());
while(consumerThread.isAlive()){
//保证子线程执行结束程序再结束
}
}

测试结果:

1、没有包子,进入等待
3、通知消费者
consumerThread状态:TIMED_WAITING
2、买到包子,回家

也能够成功买到包子,程序结束,所以park/unpark可以先获取许可,再请求许可,且许可不能叠加,这里不做演示,感兴趣的话可以自行演示。

2、伪唤醒

伪唤醒是指线程并非因为notify、notifyAll、unpark等api调用而唤醒,是更底层原因导致的

如果细心的同学应该注意到,我们在第1节演示的代码中,使用到notify或者unpark的地方,都是使用if条件语句来判断,是否进入等待状态,是错误的!
官方建议应该在循环中检查等待条件,原因是处于等待状态的线程肯能会收到错误警报和伪唤醒,如果不在循环中检查等待条件,程序就会在没有满足约束条件的情况下推出,容易造成错误。所以正确的代码应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
//wait
synchronized (obj){
while (<条件判断>){
obj.wait();
//其他操作
}
}
//park
while (<条件判断>){
LockSupport.park();
//其他操作
}

3、总结

3.1、suspend/resume、wait/notify、park/unpark的区别

①、suspend/resume
suspend/resume是直接由线程本身提供的方法,suspend阻塞线程,等待唤醒。而resume方法是唤醒当前线程。已被JDK-API弃用,原因是它们在同步代码块以及顺序使用不当时,很容易造成程序死锁。
②、wait/notify
wait和notify/notifiAll是Object对象提供的方法,也就是所有对象都有wait和notify/notifiAll方法,wait方法阻塞当前线程,等待被唤醒,它还可以指定阻塞的超时时间,时间超时后未被其他线程唤醒的话,会自动唤醒。notify是随机唤醒作用在当前对象上的其中一个线程,而notifiAll是唤醒所以的线程。
特点:

1)必须在同步代码块synchronized中执行,否则会抛出IllegalMonitorStateException异常;
2)执行顺序不能调换,也就是说如果先执行notify/notifyAll,再执行wait的话,会很容易造成死锁。

③、park/unpark
LockSupport.park()和LockSupport.unpark(Thread thread)调用的是Unsafe中的native。park/unpark强调的是许可,park阻塞当前线程,等待许可被唤醒,unpark为指定的线程提供许可。
特点:

1)在同步代码块synchronized中执行,容易造成死锁;
2)不要求park和unpark的执行顺序,但是不可以叠加的。

不可以叠加,是说unpark提供的许可是不可以叠加,比如线程A调用了unpark方法3次,都是给线程B添加许可,线程B第一次调用park后就消费了该许可,第二次以后还是会进入阻塞,等待许可。

3.2、伪唤醒

伪唤醒是指线程并非因为notify、notifyAll、unpark等api调用而唤醒,是更底层原因导致的

官方建议notify/notifyAll、unpark等进入阻塞的条件判断使用while语句,切不可使用if判断。

最后更新: 2019年09月15日 17:07

原始链接: https://www.sunnymaple.cn/2019/09/15/线程通信/

× 请我吃糖~
打赏二维码