Java 线程通信
相信大家在实际工作中,都或多或少了解过生产者消费者模型,在一些基于内存进行设计的消息队列模型中,当有新消息写入的时候,消息会被投递到一条内存队列中,然后消费者会自动收到通知进行消费。
通常我们称投递消息的一方为生产者,取出消息的一方为消费者。如果要用伪代码去表示这个流程的话,大概如下所示:
//生产者调用该函数 投递消息
void pushMsg(Msg msg){
msgQueue.push(msg);
}
//消费者调用该函数 取出消息
Msg takeMsg(){
return msgQueue.take();
}
如果队列中没有消息,消费者就会处于等待状态,当生产者将消息投递到队列之后,则会自动返回数据给到消费者。在这种场景下,如果我们细心思考就会发现一个问题:如何让生产者投递完消息之后就会主动通知到消费者呢?
其实这个问题的本质就和我们今天要讲解的线程间通信的案例有关。为什么这么说呢?我们可以将生产者和消费者看作是两个线程的角色,一个负责往内存队列中投递消息,一个负责从内存队列中取出消息,当生产速度等于消费速度的时候,两者的协调关系就如同下图所示:
在 JDK 中,我将常见的负责线程间通信的手段做了些归类,大致如下:
-
wait
-
notify
-
notifyAll
-
condition
这四种方式都有一个共同的特点,它们都必须要在加锁之后才能使用。wait,notify,notifyAll 是配合着 synchronized 关键字去使用的,condition 则是配合着 Lock 去使用的。
在线程间做通信的时候,双方都需要处于一个稳定的状态,类似于一问一答的模式,如果不是这种模式就可能会出现:A 线程在发送给 B 线程某种信号之后,B 线程却在执行其他任务,从而“忽略”了这个信号,所以当两个线程之间进行通信的时候,一定是需要一方处于等待状态,另一方去发送信号。
如果要将两个线程之间的通信模式进行抽象的话,我们可以用下边这张图来描述:
一个线程去通知协调者,然后让协调者去将信息传达给到另一个线程。
下边我们来通过实战案例,更加深入地理解线程间通信的机制。
wait 、 notify 、 notifyAll
下边是一个实战案例,利用多线程的思路去实现交替打印 ABC 三个字符的效果。其实这个案例也有点类似于生产者消费者模型,只不过它所涉及到的角色不止两个,因此实现的思路会比生产者消费者模型要复杂一些。下边我们来看如何通过 wait + notifyAll 的方式去实现,大概的程序代码如下:
package 并发编程05.交替打印ABC;
/**
* 多线程间的通信
*
* @Author idea
* @Date created in 11:20 上午 2022/6/5
*/
public class PrintAbcDemo_1 {
private int signal = 0;
public synchronized void printA() {
while (signal != 0) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("a");
signal = 1;
notifyAll();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public synchronized void printB() {
while (signal != 1) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("b");
signal = 2;
notifyAll();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public synchronized void printC() {
while (signal != 2) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("c");
signal = 0;
notifyAll();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
PrintAbcDemo_1 printAbcDemo_1 = new PrintAbcDemo_1();
Thread printAThread = new Thread(new Runnable() {
@Override
public void run() {
while (true){
printAbcDemo_1.printA();
}
}
});
Thread printBThread = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
printAbcDemo_1.printB();
}
}
});
Thread printCThread = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
printAbcDemo_1.printC();
}
}
});
printAThread.start();
printBThread.start();
printCThread.start();
Thread.yield();
}
}
在这段代码中,我们分别构建了三个线程负责打印任务,A 线程,B 线程,C 线程各自负责打印 ABC 字符,每个线程一开始都是处于等待状态,需要当 signal 信号达到自己满足的条件之后,才会完成打印工作,否则就会一直处于等待状态。当打印完毕之后,线程自己就会修改 signal 数值,并且调用 notifyAll 方法去通知其他处于等待状态的线程。
这里有个点要注意下,当线程调用了 wait 方法之后,synchronized 锁会直接晋升到重量级锁的级别,这一点是和其他锁不太相同的点。
这样一段代码虽然能够实现我们想要的功能,但是在性能方面还是存在着一些瑕疵,需要完善,由于notifyAll的底层是会将所有处于等待状态的且属于同一个monitor监管的线程统统都唤醒,所以被唤醒的线程们后续又要参与一次条件竞争,但是实际上我们只需要唤醒一个线程就足够了,因此我们可以通过Condition来优化这个效果。采用了 Condition 之后,具体的代码案例如下所示:
package 并发编程05.交替打印ABC;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @Author idea
* @Date created in 11:02 下午 2022/6/6
*/
public class PrintAbcDemo_2 {
private int signal = 0;
Lock lock = new ReentrantLock();
Condition a = lock.newCondition();
Condition b = lock.newCondition();
Condition c = lock.newCondition();
public void printA() {
lock.lock();
while (signal != 0) {
try {
a.await();
} catch (Exception e) {
e.printStackTrace();
}
}
signal++;
System.out.println("a");
b.signal();
lock.unlock();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public void printB() {
lock.lock();
while (signal != 1) {
try {
b.await();
} catch (Exception e) {
e.printStackTrace();
}
}
signal++;
System.out.println("b");
c.signal();
lock.unlock();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public void printC() {
lock.lock();
while (signal != 2) {
try {
c.await();
} catch (Exception e) {
e.printStackTrace();
}
}
signal = 0;
System.out.println("c");
a.signal();
lock.unlock();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
PrintAbcDemo_2 printAbcDemo_2 = new PrintAbcDemo_2();
Thread printAThread = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
printAbcDemo_2.printA();
}
}
});
printAThread.start();
Thread printBThread = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
printAbcDemo_2.printB();
}
}
});
printBThread.start();
Thread printCThread = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
printAbcDemo_2.printC();
}
}
});
printCThread.start();
}
}
可以看到,这组代码案例中我使用了 signal 和 signalAll 函数,这两个函数的效果其实有些类似于 notify 和 notifyAll,但是底层的实现原理还是有些出入,下边我们来深入了解下上边我们所说的这些个函数的底层原理。
synchronized 中的等待队列
在 synchronized 关键字中提供了两个通知和等待的函数,它们分别是 notify,notifyAll 和 wait。这三个函数在我们编写生产者消费者模型时经常需要使用到。例如当消息被放入到内存队列之后,生产者程序会触发一个 notifyAll 的函数去通知各个消费者消费,当内存队列中的元素被消费完了之后,消费者主动调用 wait 函数让当前线程进入等待状态。
我们知道了 synchronized 锁在加锁成功之后,拥有锁的线程会被关联到对应的 monitor 监视器,而没有持有锁的线程则会进入到等待队列中等待。
如果持有锁的线程触发了 wait 函数,那么该线程则会被放入到一个 waitset 的集合中,当我们将 waitSet 集合中的线程进行唤醒的时候,又会发生怎样的变化呢?
下边我画了一张图来带大家深入了解下 wait 函数和 notify 函数的原理。
在 synchronized 内部的 Monitor 对象中,其实管理者两个同步队列,分别是 _cxq 和 EntryList,而等待队列就是 _WaitSet。
被notify或者 notifyAll 唤醒的线程会根据具体的policy策略去选择不同的队列加入,而当处于wait的线程被唤醒之后,会根据QMode参数值来选择具体的线程去进行唤醒(默认policy和QMode均为0)。
使用 wait 和 notify、notifyAll三个函数的时候,为了保证整个操作的数据一致性,所以它要求开发者们在使用它们的时候,必须要有 synchronized 锁的保护。这一点在宏观上看来,其实就是使用这三个函数时,外层必须要有 synchronized 锁修饰。
synchronized 中的同步队列和等待队列之间有着一个关系,那就是一个同步队列对应了一个等待队列, 而 JDK 的开发者设计的 AQS 中的同步和等待关系正好弥补了这种设计的不足点,采用的是一个同步队列 , 后边可以加入多个等待队列。
下边让我们一起来深入了解下 AQS 中的同步和等待组件 Condition 的原理。
Condition 中的等待队列
通常 Condition 关键字都是和 ReetranLock 一同携带使用的。
在 Condition 的内部其实包含了一个等待队列的东西,这个队列拥有首节点和尾节点,当线程发起对 Condition.await 函数调用之后,该线程便会被放入到等待队列的尾部。具体的流程图如下所示:
要注意,这个放入队列尾部的操作是已经在锁保护的范围内了,所以不会存在什么线程安全性的问题。
首先是 await 函数的底层实现。
public final void await() throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
//将当前的线程放入到一个等待队列中
Node node = addConditionWaiter();
//这里会尝试释放掉当前线程所持有的锁
int savedState = fullyRelease(node);
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
LockSupport.park(this);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null) // clean up if cancelled
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}
如果我们通过绘图的方式来看的话,调用 await 函数的变化如下图所示:
当发起了 await 调用之后,同步队列的首个节点,也就是当前持有锁的 Node 节点会被放入到等待队列的尾部,并且和该节点关联的线程也会被挂起。
而 signal 方法呢,则是会先检查当前线程是否持有锁,然后会通过 cas 的方式去修改等待队列首个节点的 waitStatus 状态值,将首个节点的线程唤醒,这一方面的源代码实现如下所示:
public final void signal() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
Node first = firstWaiter;
if (first != null)
//底层是会唤醒等待队列的首个节点
doSignal(first);
}
//通过循环重试的机制去调整等待队列的首个node节点的线程状态
private void doSignal(Node first) {
do {
if ( (firstWaiter = first.nextWaiter) == null)
lastWaiter = null;
first.nextWaiter = null;
} while (!transferForSignal(first) &&
(first = firstWaiter) != null);
}
final boolean transferForSignal(Node node) {
if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
return false;
Node p = enq(node);
int ws = p.waitStatus;
//cas + lockSupport的方式去唤醒线程
if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
LockSupport.unpark(node.thread);
return true;
}
在理解了 signal 函数之后,再去看 signalAll 方法,你就会发现其实它们的共同点蛮多的。signalAll 方法的底层会通过循环对每个等待队列中的节点执行 signal 方法,具体体现在了源代码的这个位置:
public final void signalAll() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
Node first = firstWaiter;
if (first != null)
//唤醒等待队列的所有节点
doSignalAll(first);
}
private void doSignalAll(Node first) {
lastWaiter = firstWaiter = null;
do {
Node next = first.nextWaiter;
first.nextWaiter = null;
//这个函数和signal函数的底层是同一套方法
transferForSignal(first);
first = next;
} while (first != null);
}
好看到这里,我们大概已经了解了, Condition 的底层原理是将关注不同条件的线程分别挂起到不同的同步队列和等待队列中。例如我们上边介绍的通过使用 Condition 关键字实现的交替打印字母的案例中,就正好是一个同步队列,对应了多个条件队列,如下图所示:
关于 CountDownLatch、CyclicBarrier、Semaphore 这三个类,在平时工作中,我们或多或少还是会接触到的,下边我整理了一张表格来帮大家理解:
类名 | 效果 | 应用场景 |
---|---|---|
CountDownLatch | 内部有个计数器,当计数器为 0 的时候,才会放开请求。 | 一些等待通知模式的接口会使用到,例如 MQ 发送消息之后调用 await 方法,broker 返回消息写入信号之后执行 countDown 方法。 |
CyclicBarrier | 内部也是有一个计数器用于记录请求抵达屏障点的线程数,当最后一个线程抵达屏障点后,屏障才会放开。 | 在一些压测接口中会使用,例如当准备好了 1000 个线程的数据之后,同一发送请求。 |
Semaphore | 内部有一个计数器,当有线程抵达临界区就会给计数器加一,当计数器的值达到一定阈值,则不再允许后边的线程访问。 | 通常在一些限流组件中会使用。 |