共计 8516 个字符,预计需要花费 22 分钟才能阅读完成。
本文从四个层面,垂直的形式论述了线程阻塞和唤醒。别离为 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