本文从四个层面,垂直的形式论述了线程阻塞和唤醒。别离为java代码层,jvm层,linux用户层。通过可视化运行的形式具体的看到和感知到每层是怎么做到的,心愿对你有所帮忙
背景
java lock锁对并发资源拜访是比跨过的坎。而lock的实质又是AQS,AQS能够说是juc package的外围,一个类就能够撑持这个高级又重要的mutil thread framework。想想作者都厉害。网上曾经有很多大牛对AQS、ReentrantLock有很好的解说论述了。然而都偏实践,有没有一种形式:可视化的看到多线程是怎么抢占lock的,抢不到的时候是怎么排队的,排队时刻整个AQS是什么样的状态,排队的线程是怎么做到阻塞的,底层原理是什么,抢到的线程开释lock时排队的线程是怎么拿到锁的,等等一系列的疑难。
本文从实际的角度登程,通过debug模式发明多线程切换拜访lock的场景,可视化上述问题的产生和解决的形式办法。
》本文力求专一和精简,心愿你有所播种和想法
关键点位
炒股的同学都听过关键点位法。落到咱们技术上,理解和明确这些关键点位,可能真切的了解AQS和AQS的一个具体的利用场景:ReentrantLock
- 从实际的角度可视化阻塞,排队,唤醒,出队等场景
- 简述几种线程排队各场景时AQS对象的状态
- 线程阻塞和唤醒的代码点
- java线程阻塞的实现原理,java代码层面实现、jvm层面实现、零碎用户态层面实现、零碎内核层面实现,四个层面的分割
注释
- 概念上讲,对于lock的原理是:当两个线程抢占一个资源时,实质是竞争失去这个资源的锁,抢到的线程开始做事件,没抢到的线程排队期待抢到的线程开释锁并告诉他。而后他取得锁,也开始做事件。
java代码层
- 具体上讲,对于ReentrantLock和 AQS,ReentrantLock应用了AQS实现的性能,而AQS通过更新它的字段:state和队列来实现锁
咱们从一个lock的应用示例开始这个"途程"。
留神图中的breakpoint断点,为了分清,我做了标识: bp1,bp2,bp3,bp4,bp5。留神,breakpoint断点的suspend 抉择thread类型
当初开始run debug程序。
如图,看到两个线程都起来了。切换线程时你会发现两个线程别离是 bp1和 bp2处。咱们让两个线程都执行到 bp3处,此时,两个线程都行将要获取锁了。
咱们看下此时 Lock锁的状态:能够看到state为0,队列为空
咱们接着执行线程 Thread-0,线程 Thread-0开始进入Lock外部。
final void lock() { if (compareAndSetState(0, 1)) //(1) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); //(2)} //代码段 1
在 (1)执行后,线程 Thread-0将AQS的state执行加一操作,这里是state从0更新为1,表明线程 Thread-0失去了lock锁,此时看下lock锁的状态,如下图
能够看到只有一处变动:state从0变成了1。线程 Thread-0回到 outPut()办法执行业务逻辑。
咱们切换到线程 Thread-1,线程 Thread-1也进入了Lock外部。因为线程 Thread-0在持有锁,所以线程 Thread-1执行"代码段 1"的(2)处,因为本文关注的开始处的关键点位,所以简单化acquire(1)办法的逻辑:acquire(1)由三个办法组成:tryAcquire(arg)、addWaiter(Node.EXCLUSIVE)、acquireQueued(node, arg)
- tryAcquire(arg): 尝试再获取一次,或者进行可重入锁
addWaiter(Node.EXCLUSIVE): 创立一个节点,将这个节点在AQS队列(head,tail)上排队,排在队尾。咱们看下这个节点的信息快照,如下图,创立是waitStatus为0,同时持有以后的线程
BTW: waitStatus的value很重要,如下// 表明节点持有的线程被勾销了CANCELLED = 1;// 表明节点的后继节点的线程须要勾销停车SIGNAL = -1;// 表明节点的线程正在期待condition条件CONDITION = -2;// 下一个共享模式的获取锁应该无条件的传递PROPAGATE = -3;0:None of the above
acquireQueued(node, arg): 由shouldParkAfterFailedAcquire 和parkAndCheckInterrupt组成
-- shouldParkAfterFailedAcquire:将以后node节点的前继节点的waitStatus更新为SIGNAL(-1)。因为以后node节点的唤醒是须要它的前继节点触发的,SIGNAL(-1)就是标记做这个事儿的。这个办法执行完,咱们看下此时的AQS对象的信息快照-- parkAndCheckInterrupt:让以后线程停车(线程挂起),实质是通过 UNSAFE.park(false, 0L)实现的。这个办法是native的,即要查看jvm源码(这个上面会说)。
private final boolean parkAndCheckInterrupt() { LockSupport.park(this); //this:AQS对象 return Thread.interrupted();}public static void park(Object blocker) { // blocker:AQS对象 Thread t = Thread.currentThread(); setBlocker(t, blocker); UNSAFE.park(false, 0L); setBlocker(t, null);}
BTW:setBlocker(t, blocker)其实是将AQS对象赋值到以后线程thread的parkBlocker字段,这个字段用于诊断和剖析工具应用。
如图,当执行bp6断点行后,以后线程 Thread-1就进入了阻塞状态。马上你就想到了,什么时候唤醒呢。
到这里咱们梳理下:当初AQS队列中有两个节点:线程Thread-0所在的节点为AQS队列的head节点,标记A节点;线程Thread-1所在的节点为AQS队列的tail节点,标记B节点。A是B的前继节点,B是A的后继节点。
咱们把线程切换回 Thread-0。后面咱们说过,Thread-0线程进入了业务办法outPut执行,执行完业务后,就能够开释锁了,即lock.unlock(),即如下图
咱们进入unlock办法外部
public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false;} //代码段 2
release: 由两个办法组成: tryRelease(arg)和unparkSuccessor(head)
-- tryRelease:将AQS的state执行减一操作:这里是从1更新为0。
-- unparkSuccessor(head):将head节点的waitStatus从-1更新为0。调用LockSupport.unpark(s.thread)唤醒s节点的线程
咱们晓得以后线程Thread-0的节点是AQS队列的head节点,head节点的waitStatus为SINGAL(-1),它的意思是唤醒后继节点。所以在这里unparkSuccessor唤醒的就是Thread-1线程所在的节点。只有执行LockSupport.unpark(s.thread),即会唤醒Thread-0线程。如下图,由阻塞在bp6断点行,唤醒了线程Thread-1执行到了bp7断点行
线程Thread-1在acquireQueued办法的(5)处被唤醒后,代码还是在acquireQueued办法的无线循环内。所以它的前继节点如果是head节点,那么会将此节点赋值为head节点,同时会尝试获取lock锁,行将AQS的state进行加一操作,这里即从0更新为1, 表明此时Thread-1获取了lock锁,同时Thread-1线程所在的节点成为了AQS的head节点。
boolean acquireQueued(final Node node, int arg) { boolean interrupted = false; for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { // (3) 会尝试获取lock锁 setHead(node); // (4) 此节点赋值为head节点 p.next = null; // help GC return interrupted; } ... parkAndCheckInterrupt() 唤醒 // (5) 唤醒 } }
当初,Thread-0开释了lock锁,而Thread-1获取了lock锁。咱们在看下此时AQS对象的信息快照,如下图
到这里,关键点中的1,2,3都曾经有答案了。对于4,咱们持续 >>>
jvm层
通过下面的实际,咱们晓得了,线程的挂起和唤醒是通过Unsafe.park(false,0L) 和Unsafe.unpark(thread)来实现的。然而他们是native类型的。所以源码的话须要去jvm里看了
这时须要咱们装置调试版的openjdk。我装置的openjdk8u60并应用Clion IDE关上。具体的装置过程自行google就好。open Clion,如下图
咱们先看个大略,其余的先疏忽,记住一点:咱们要的货色都在hotspot/src目录下,jvm和java代码的映射关系有两个规定:
1、类的对应关系:类名称雷同,扩展名不同
2、办法的对应关系:XXX.xxx() ==> XXX_Xxx()。
如咱们要看Unsafe.park,所以咱们先搜寻unsafe类,会失去unsafe.cpp;而后再文件中找到Unsafe_Park,后果如下图
上图中圈中的是重点,能够看到thread->parker()->park(isAbsolute != 0, time),依据它,咱们找到了os_bsd.cpp文件(这是mac零碎,如果linux零碎,文件名是os_linux.cpp)中的Parker::park办法,如下图
整个办法大抵是看看_counter 这个计数器是否大于0,是否有线程中断。如果执行到pthread_mutex_trylock办法(确切叫函数),尝试加 mutex 锁。pthread_mutex_trylock 办法是一个零碎调用,它会针对操作系统的一个互斥量进行加锁,加锁胜利将返回 0。而Unpark函数和park函数大体一致的。
pthread.h
__API_AVAILABLE(macos(10.4), ios(2.0))int pthread_mutex_trylock(pthread_mutex_t *);__API_AVAILABLE(macos(10.4), ios(2.0))int pthread_mutex_unlock(pthread_mutex_t *);
每个线程都会关联一个 Parker 对象,每个 Parker 对象都各自保护了三个角色:计数器、互斥量、条件变量。
park 操作:
获取以后线程关联的 Parker 对象。
将计数器置为 0,同时查看计数器的原值是否为 1,如果是则放弃后续操作。
在互斥量上加锁。
在条件变量上阻塞,同时开释锁并期待被其余线程唤醒,当被唤醒后,将从新获取锁。
当线程复原至运行状态后,将计数器的值再次置为 0。
开释锁。
unpark 操作:
获取指标线程关联的 Parker 对象(留神指标线程不是以后线程)。
在互斥量上加锁。
将计数器置为 1。
唤醒在条件变量上期待着的线程。
开释锁。
这就是jvm层咱们看到的线程挂起和唤醒。
linux用户层
上面看看pthread_mutex_trylock的零碎层是怎么实现的
下面我跟踪到了pthread.h文件,这里只有pthread_mutex_lock函数的申明,其实现是通过 C/C++ Runtime Library库。咱们能够通过man 命令查看pthread_mutex_lock,会发现它是3类命令,即Library calls命令。最终pthread_mutex_lock调用System calls类型的LLL_UNLOCK(基于Linux的futex)来实现的。
咱们来查看下:
$ man pthread_mutex_lock
pthread_mutex_lock的实现如下,
pthread_mutex_lock (pthread_mutex_t *mutex) { if (type == PTHREAD_MUTEX_TIMED_NP)) { /* Normal mutex. */ /*LLL_UNLOCK宏是lll_unlock (mutex->__data.__lock, PTHREAD_MUTEX_PSHARED (mutex)); PTHREAD_MUTEX_PSHARED 是不同过程间的, 线程见的话,为false */ LLL_UNLOCK(mutex); } else if (type == PTHREAD_MUTEX_RECURSIVE_NP) { /* Recursive mutex. */ pid_t id = THREAD_GETMEM (THREAD_SELF, tid); /* 若曾经持有了此锁, 减少计数, 无需block此线程 */ if (mutex->__data.__owner == id){ ++mutex->__data.__count; return 0; } // 去判断锁变量, 如果不行, 被OS休眠掉 LLL_MUTEX_LOCK (mutex); // 拿到了锁, 锁变量是ok的,则设置count mutex->__data.__count = 1; } // ...非凡解决和其余类型锁的逻辑疏忽...}
mutex是一个构造体,构造如下:
pthread_mutex_t { int __lock; // 锁变量, 传给零碎调用futex,用作用户空间的锁变量 usigned int __count; // 可重入的计数 int __owner; // 被哪个线程占有了 int __kind; // 是否过程间共享,等等... // int __nusers; // 其余字段略}
Tips: 通过man man 能够查看命令属于的哪类:如库函数调用,还是内核调用等,咱们罕用的是1类命令
大体来看,pthread_mutex_lock次要是调用底层的lll_lock/lll_unlock, 其实就是调用futex的FUTEX_WAIT/FUTEX_WAKE操作, 来实现线程的休眠和唤醒工作。
咱们称pthread_mutex_lock为用户态,它调用的是内核态的futex函数
linux内核层
当初咱们一起理解下内核的futex函数,通过man futex 能够晓得是system calls类型。
Futex 的思路是把总线 LOCK 替换成自旋,而且把自旋局部放在用户态。
pthread_mutex_lock 函数将交由 lowlevellock 的 lll_lock() 执行自旋,自旋的第一步是尝试把 futex 标记变量从 0 置为 1,标记着从闲暇到申请但无竞争,一旦原子性的变量笼罩胜利,意味着锁获取胜利,否则执行 __lll_lock_wait() 自旋,把标记置为 2,标记存在竞争并陷入内核,执行 futex() 零碎调用对线程/过程进行阻塞。
上面咱们理论运行一个程序,同时通过strace命令查看下程序运行时对应的零碎内核函数调用状况。为此,咱们筹备一个java程序,运行在linux环境下,代码如下
/root/project/lock/C1_1_LockSupportTest.java
public class C1_1_LockSupportTest { public static void main(String[] args) { Thread t1 = new Thread(() -> { System.out.println("park开始"); LockSupport.park(); System.out.println("park完结"); }, "t1"); Thread t2 = new Thread(() -> { System.out.println("unpark开始"); LockSupport.unpark(t1); System.out.println("unpark完结"); }, "t2"); Scanner scanner = new Scanner(System.in); String input; System.out.println("输出“1”启动t1线程,输出“2”启动t2线程,输出“3”退出"); while (!(input = scanner.nextLine()).equals("3")) { if (input.equals("1")) { if (t1.getState().equals(Thread.State.NEW)) { t1.start(); } } else if (input.equals("2")) { if (t2.getState().equals(Thread.State.NEW)) { t2.start(); } } } }}
程序的内容是定义两个线程,一个是LockSupport.park()阻塞,一个是LockSupport.unpark(t1)唤醒。输出1:启动线程t1,从而暂停线程t1;输出2:启动线程t2,从而唤醒线程t1;输出3:程序执行完结
javac编译失去C1_1_LockSupportTest.class,而后strace -ff -o out java C1_1_LockSupportTest 运行,这个命令会将C1_1_LockSupportTest的每个的线程对内核的调用具体的输入。输入的文件是out结尾的,执行命令后,为了不便查看,我开了4个window,如下
strace命令产生这个后果的原理,我是从周志垒大神那里学来的,感激。
当初只须要晓得C1_1_LockSupportTest程序的main线程的信息是在out.31105文件就行,可执行tail -f out.31105,实时查看main线程的执行信息。
main线程在期待输出动作,留神查看win4窗口,最初一个out文件当初是:out.31113。当咱们输出1时,会运行线程t1,而后咱们在看会发现多了一个out文件:out.32102。如下图win4
此时,线程t1执行了LockSupport.park(),所以线程t1挂起。在win3的页面,咱们通过tail -f out.32102查看,会发现linux内核调用了futex函数,futex函数的入参和等号后的数字的含意,自行google就好。
当初咱们再输出2,运行线程t2,执行LockSupport.unpark(t1)来唤醒t1,此时再看4个win窗口的变动。如下图,重点看win3窗口,能够发现,原来是停在futex的地位,当初多输入了几行,直到输出exit(0)。因为线程t1唤醒后,接着输入"park完结"后,整个线程就完结了。
接着输出3,完结这个程序。这就是整个java线程挂起和唤醒时在linux内核态所做的事件的演示。当初,关键点4也说完了。
下图是线程在各时段下,AQS的状态变动图,展现了AQS从初始化阶段到两个线程在AQS中抢锁,再到都开释锁的整个过程
下图为各个层的阻塞的办法和函数
it`s time to summary
这里形容了java的park和unpark从java层到jvm层、到linux用户层、到linux内核层所做的一系列事件。本文更多的是串联整个过程,而非具体具体解说。目标是起到一个疏导和引入的作用。一是能力无限,更多的是可视化整个流程,从而使大家能具体的看到和感知整个过程。加深了解,而不是死记硬背。
本文的一些实践来自网上的大牛的文章,感激大佬。
附件
- t1,t2代码起源
JVM 源码剖析(四):深刻了解 park / unpark - pthread_mutex_lock:
Chapter 4 Programming with Synchronization Objects
中文版
mutex-lock-for-linux-thread-synchronization/
pthread_mutex_trylock的源码在 glibc/nptl/pthread_mutex_trylock.c文件,
门路:https://code.woboq.org/usersp...
- futex:
some-synchronization-designs-of-user-mode
translation-basics-of-futexes