深入理解Java线程的等待与唤醒机制(一)
多线程并发是Java语言中非常重要的一块内容,同时,也是Java基础的一个难点。说它重要是因为多线程是日常开发中频繁用到的知识,说它难是因为多线程并发涉及到的知识点非常之多,想要完全掌握Java的并发相关知识并非易事。也正因此,Java并发成了Java面试中最高频的知识点之一。本系列文章将从Java内存模型、volatile关键字、synchronized关键字、ReetrantLock、Atomic并发类以及线程池等方面来系统的认识Java的并发知识。通过本系列文章的学习你将深入理解volatile关键字的作用,了解到synchronized实现原理、AQS和CLH队列锁,清晰的认识自旋锁、偏向锁、乐观锁、悲观锁…等等一系列让人眼花缭乱的并发知识。
多线程并发系列文章:
这一次,彻底搞懂Java中的ReentranLock实现原理
Java并发系列番外篇:ThreadLocal原理其实很简单
本文是Java并发系列的第五篇文章,将深入分析Java的唤醒与等待机制。
关于线程的等待与唤醒想必大家都不陌生,毕竟在初学Java基础时都是重点学习的内容。在前两篇分析synchronized与ReentranLock的文章中我们略过了线程的等待与唤醒相关内容,主要是因为想要深入的理解线程的等待与唤醒机制并不容易,因此将这一知识点单独写篇文章来分析。那么本篇文章我们将从synchronized与ReentranLock两个方面来深入分下线程的等待与唤醒。
开始之前先给大家推荐一下AndroidNote这个GitHub仓库,这里是我的学习笔记,同时也是我文章初稿的出处。这个仓库中汇总了大量的java进阶和Android进阶知识。是一个比较系统且全面的Android知识库。对于准备面试的同学也是一份不可多得的面试宝典,欢迎大家到GitHub的仓库主页关注。
一、从synchronized锁看线程等待与唤醒
初学Java的时候想必大家都用synchronized实现过“生产者-消费者”模型的代码,其中用到了几个Object中的方法如wait()、notify()、notifyAll(),不知道当时的你是否有些困惑,线程等待与唤醒相关的方法为什么会定义在Object类中呢?
什么?你连“生产者-消费者”模型都忘了是什么了?好吧,我们还是先来看下回顾一下“生产者-消费者”模型吧。
1.“生产者-消费者”模型
“生产者-消费者”模型是一个典型的线程协作通信的例子。在这一模型中有两类角色,即若干个生产者线程和若干个消费者线程。生产者线程负责提交用户请求,消费者线程负责处理生产者提交的请求。很多情况下,生产者与消费者不能够达到一定的平衡,即有时候生产者生产的速度过快,消费之来不及消费;而有时候可能是消费者过于旺盛,生产者来不及生产。在此情况下就需要一个生产者与消费者共享的内存缓存区来平衡二者的协作。生产者与消费者之间通过共享内存缓存区进行通信,从而平衡生产者与消费者线程,并将生产者和消费者解耦。如下图所示:
当队列容器中没有商品的时候,就需要让消费者处于等待状态,而当容器满了之后就需要生产者处于等待状态。而消费者每消费一个商品,又会通知正在等待的生产者可以进行生产了;当生产则生产一个商品,也会通知正在等待的消费者可以消费了。
2.使用synchronized实现“生产者-消费者”模型
了解了“生产者-消费者”模型后,我们尝试使用synchronized关键字结合wait()与notifyAll()方法来实现一个”生产者-消费者“模型的例子。
我们选一个比较经典的生产面包的例子来看,首先需要一个面包容器类,容器类中有放入面包和取出面包两个操作,代码如下:
1 |
|
在上述代码的put方法会将生产好的面包放入到容器中。如果容器已经满了,那么需要阻塞生产者线程来停止生产,当生产者成功将面包放入容器后则需要尝试唤醒等待中的消费者线程进行消费。
而take方法则是取出面包的操作,当容器为空,则阻塞消费者线程,让其进行等待,如果成功消费面包后则通知生产者开始生产。
另外需要注意一下,这两个方法都使用了synchronized关键字,如果你看过这一次,彻底搞懂Java中的synchronized关键字这篇文章的话应该知道此时synchronized加锁的对象就是这两个方法所在的实例对象,即BreadContainer对象,而在这两个方法中调用的wait()和notify()两个方法同样属于BreadContainer对象。记住这段话,这里留个Flag,我们后边分析。
接下来生产者与消费者的实现就比较简单了,代码如下:
1 |
|
接下来在测试代码中,同时开启多个生产者线程与多个消费者线程
1 |
|
此时运行main方法,生成者与消费者线程就可以很好的协同工作了。
注意,在main方法中我们实例化了一个BreadContainer对象,上边Flag处说的synchronized锁的对象即为这个container,调用的wait和notify方法也是container实例的方法。到这里不知道你是否会有疑问,究竟container的wait和notify方法对象成做了什么能让线程阻塞和唤醒呢?被阻塞的线程放到哪里去了?为什么要调用container对象中的wait和notify方法?如果换成调用其他对象的wait和notify是否可行呢?
二、wait()与notify底层实现原理
在这一次,彻底搞懂Java中的synchronized关键字中我们已经知道,使用synchronized关键字后,synchronized锁住的对象会关联一个monitor对象,当有一个线程获得synchronized锁后,monitor对象中的count就会被加1,并且会将这个线程的id存入到monitor的_ower中。此时,如果其他线程来尝试拿锁则会被放入到_EntryList
队列中阻塞。
还记得上一节中我们立的一个Flag了吗?synchronized锁的是container对象,而wait和notify也是container对象的方法,这么一看我们上一节中留下的问题就有些眉目了。是不是调用wait方法的时候线程也会被加入到一个等待队列,而等到notify或者notifyAll的时候再从等待队列中将线程唤醒呢?关于这个问题在这一次,彻底搞懂Java中的synchronized关键字这篇文章中其实已经有解读了,就是调用wait方法的线程会被加入到一个_WaitSet
集合中,并会将线程挂起。但是,这里要再次强调一下_WaitSet
与_EntryList
这两个集合。_EntryList
集合中存放的是没有抢到锁,而被阻塞的线程,而_WaitSet集合中存放的是调用了wait方法后,处于等待状态的线程。**
想要证明上述的结论,就需要我们来看下wait和notify/notifyAll到底做了什么。
我们看下Object中wait、notify、notifyAll三个方法的实现
1 |
|
很可惜,这几个方法都是native方法,也就是说这几个方法都是在虚拟机中使用C/C++实现的。既然如此,不妨扒一扒虚拟机的代码来一探究竟,毕竟口说无凭。
1.虚拟机对wait的实现
承接上一节中”生产者-消费者“模型的代码来分析,当生产者线程往容器里边放面包的时候发现容器已经满了,则调用wait方法,那么此时这个线程就会释放锁并进入到阻塞状态。
Object 中 wait 方法的实现是在 objectMonitor.cpp 中的 ObjectMonitor::wait(jlong millis, bool interruptible, TRAPS)
函数中,ObjectMonitor::wait中的核心相关代码如下:
1 |
|
可以看到,调用 wait 函数后,线程被封装成了一个 ObjectWaiter 对象,并通过AddWaiter 函数将线程加入到等待队列中,先来看下 AddWaiter 函数的代码:
1 |
|
AddWaiter函数的实现其实比较简单,会初始化一个_WaitSet
链表,并将node插入到_WaitSet
的队尾,从代码中也可以看出这个 _WaitSet
链表是一个循环的双向链表。
完成线程的插入队列操作后,继续调用 exit 函数来释放 monito r锁,并挂起自己。关于这个方法,后边还会涉及到,源码后边再看。
2.虚拟机对notify的实现
在生产者生产完面包后则会调用notifyAll来唤醒消费者线程。notifyAll 方法会唤醒所有线程,而 notify 只会唤醒一个线程。此处我们以notify为例来看objectMonitor.cpp中 notify 函数是如何唤醒线程的。
1 |
|
在 notify 函数中首先调用了DequeueWaiter 函数, DequeueWaiter 函数的作用是取出_WaitSet
链表的头结点,代码如下:
1 |
|
可以看到,DequeueWaiter 函数中又调用了 DequeueSpecificWaiter 函数,在这个函数中,如果队列只有一个节点,则将_WaitSet
置空,即取出头结点后,队列中没有元素了。如果有多个节点,那么 会将头结点从队列中取出,并重新拼接好 _WaitSet
队列。然后将取出的这个节点的前驱节点和后继节点置空。
notify 函数接下来的代码判断如果 iterator 不为 NULL 说明存在等待状态的线程,需要将这个等待的线程转入阻塞线程的队列中去。接下来根据 Policy 来执行不同的逻辑,Policy 默认值为2,所以这里只关注默认情况情况。即当Policy为2时,接着将 _EntryList
赋值给List,如果List等于NULL,说明此时没有阻塞状态的线程。那么就将 _EntryList
指向 iterator。标志着这个等待中的线程进入了阻塞状态,并且能够获取锁了,但此时线程还未被唤醒。如果List等于NULL,那么就通过CAS将等待状态的线程移入到了_cxq
队列,_cxq
队列只是一个临时队列,在后边exit函数中最终还是会被移入_EntryList
中。这里一定要注意区分阻塞状态与等待状态,以及等待队列和阻塞队列。
3.虚拟机的exist函数
可见notify函数中只是对线程进行了队列转移,并没有被实际唤醒。而实际唤醒线程的操作就是在本章第1小节中已经提到的exist中实现的。只不过此时exist函数的调用时机是在虚拟机读取到 monitorexist 指令之后。看下简化后的 exit 函数代码,如下:
1 |
|
exist函数的代码比较繁杂,这里做了简化,由于QMode默认值是0,因此只讨论这种情况。
首先,如果
_EntryList
不为NULL,那么直接调用 ExitEpilog 函数从_EntryList
中取出头结点并唤醒线程;如果
_EntryList
为NULL,但是_cxq
队列不为 NULL,说明有等待状态的线程被notify了,但是还没真正的被唤醒,那么将_cxq
队列中的所有元素移入_EntryList
队列中,并将其改造成一个双向链表。然后通过 ExitEpilog 唤醒_EntryList
的头结点。
三、小结
本篇文章从一个简单的“生产者-消费者”模型着手,认识了Object中wait和notify/notifyAll方法,并且深入的分析了虚拟机底层对这两个方法的实现。Java代码中的 synchronized 关键字通过编译器编译成字节码的monitorenter/monitorexist指令,当虚拟机执行到相关指令后则会调用虚拟机底层相关的函数,进行拿锁和释放锁的操作。而由于锁对象Object关联了monitor对象,故可以调用这个Object对象中的 wait 和 notify/notifyAll 方法来阻塞和唤醒线程。而这两个方法亦是调用了虚拟机底层的相关函数,wait 函数会将线程封装成 WaitObject 并将其插入到等待队列中,而notify/notifyAll 则会将线程从等待队列中取出并转移到_EntryList
队列或者转移到_cxq
队列,等到持有锁的线程执行完毕并读取到 monitorexist 指令后调用了虚拟机的 exist 函数来释放锁并唤醒_EntryList
队列或者_cxq
队列中的线程。
synchronized 锁的这种等待与唤醒机制很显然有一个弊端。仍然以”生产者-消费者“模型为例,由于生产者线程和消费者线程都会被加入到同一个WaitSet队列中,通过 notifyAll 方法并不能精确的控制唤醒哪一类线程。而在这一次,彻底搞懂Java中的ReentranLock实现原理这篇文章中我们认识了ReentranLock,ReentranLock与 synchronized 相仿也有类似的等待与唤醒机制,并且能够精确的控制唤醒指定的线程。那么,ReentranLock是怎么实现的呢?我们下篇再议。
参考&推荐阅读
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!