四个层次说线程阻塞和唤醒(代码层/jvm层/系统用户层/系统内核层)

本文从四个层面,垂直的方式阐述了线程阻塞和唤醒。分别为java代码层,jvm层,linux用户层,linux内核层。通过可视化运行的方式具体的看到和感知到每层是怎么做到的,希望对大家有所帮助

背景

java lock锁对并发资源访问是比跨过的坎。而lock的本质又是AQS,AQS可以说是juc package的核心,一个类就可以支撑这个高级又重要的mutil thread framework。想想作者都厉害。网上已经有很多大牛对AQS、ReentrantLock有很好的讲解阐述了。但是都偏理论,有没有一种方式:可视化的看到多线程是怎么抢占lock的,抢不到的时候是怎么排队的,排队时刻整个AQS是什么样的状态,排队的线程是怎么做到阻塞的,底层原理是什么,抢到的线程释放lock时排队的线程是怎么拿到锁的,等等一系列的疑问。

本文从实践的角度出发,通过debug模式创造多线程切换访问lock的场景,可视化上述问题的产生和解决的方式方法。

》本文力求专注和精简,希望你有所收获和想法

关键点位

炒股的同学都听过关键点位法。落到我们技术上,了解和明白这些关键点位,能够真切的理解AQS和AQS的一个具体的应用场景:ReentrantLock

  1. 从实践的角度可视化阻塞,排队,唤醒,出队等场景
  2. 简述几种线程排队各场景时AQS对象的状态
  3. 线程阻塞和唤醒的代码点
  4. java线程阻塞的实现原理,java代码层面实现、jvm层面实现、系统用户态层面实现、系统内核层面实现,四个层面的联系

正文

  • 概念上讲,对于lock的原理是:当两个线程抢占一个资源时,本质是竞争得到这个资源的锁,抢到的线程开始做事情,没抢到的线程排队等待抢到的线程释放锁并通知他。然后他获得锁,也开始做事情。

java代码层

  • 具体上讲,对于ReentrantLock和 AQS,ReentrantLock使用了AQS实现的功能,而AQS通过更新它的字段:state和队列来实现锁

我们从一个lock的使用示例开始这个”路程”。
20210910121228
注意图中的breakpoint断点,为了分清,我做了标识: bp1,bp2,bp3,bp4,bp5。注意,breakpoint断点的suspend 选择thread类型

现在开始run debug程序。
20210910145044
如图,看到两个线程都起来了。切换线程时你会发现两个线程分别是 bp1和 bp2处。我们让两个线程都执行到 bp3处,此时,两个线程都即将要获取锁了。
我们看下此时 Lock锁的状态:可以看到state为0,队列为空
20210910150021
我们接着执行线程 Thread-0,线程 Thread-0开始进入Lock内部。

1
2
3
4
5
6
7
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锁的状态,如下图
20210910151132
可以看到只有一处变化: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,同时持有当前的线程
    20210910161650
    BTW: waitStatus的value很重要,如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // 表明节点持有的线程被取消了
    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对象的信息快照
    20210910165233

    – parkAndCheckInterrupt:让当前线程停车(线程挂起),本质是通过 UNSAFE.park(false, 0L)实现的。这个方法是native的,即要查看jvm源码(这个下面会说)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    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字段,这个字段用于诊断和分析工具使用。
20210910165549
如图,当执行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(),即如下图
20210910173302

我们进入unlock方法内部

1
2
3
4
5
6
7
8
9
10
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断点行
20210910180345

线程Thread-1在acquireQueued方法的(5)处被唤醒后,代码还是在acquireQueued方法的无线循环内。所以它的前继节点如果是head节点,那么会将此节点赋值为head节点,同时会尝试获取lock锁,即将AQS的state进行加一操作,这里即从0更新为1, 表明此时Thread-1获取了lock锁,同时Thread-1线程所在的节点成为了AQS的head节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
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对象的信息快照,如下图
20210910182201

到这里,关键点中的1,2,3都已经有答案了。对于4,我们继续 >>>

jvm层

通过上面的实践,我们知道了,线程的挂起和唤醒是通过Unsafe.park(false,0L) 和Unsafe.unpark(thread)来完成的。但是他们是native类型的。所以源码的话需要去jvm里看了

这时需要我们安装调试版的openjdk。我安装的openjdk8u60并使用Clion IDE打开。具体的安装过程自行google就好。open Clion,如下图
20210910214115
20210910215552
我们先看个大概,其他的先忽略,记住一点:我们要的东西都在hotspot/src目录下,jvm和java代码的映射关系有两个规则:
1、类的对应关系:类名称相同,扩展名不同
2、方法的对应关系:XXX.xxx() ==> XXX_Xxx()。
如我们要看Unsafe.park,所以我们先搜索unsafe类,会得到unsafe.cpp;然后再文件中找到Unsafe_Park,结果如下图
20210910220614
上图中圈中的是重点,可以看到thread->parker()->park(isAbsolute != 0, time),根据它,我们找到了os_bsd.cpp文件(这是mac系统,如果linux系统,文件名是os_linux.cpp)中的Parker::park方法,如下图

20210911000051
整个方法大致是看看_counter 这个计数器是否大于0,是否有线程中断。如果执行到pthread_mutex_trylock方法(确切叫函数),尝试加 mutex 锁。pthread_mutex_trylock 方法是一个系统调用,它会针对操作系统的一个互斥量进行加锁,加锁成功将返回 0。而Unpark函数和park函数大体一致的。
pthread.h

1
2
3
4
5
__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

20210911184631

pthread_mutex_lock的实现如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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是一个结构体,结构如下:

1
2
3
4
5
6
7
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类型。
20210911191057

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

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
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,如下

image.png

strace命令产生这个结果的原理,我是学习大神的,感谢。

现在只需要知道C1_1_LockSupportTest程序的main线程的信息是在out.31105文件就行,可执行tail -f out.31105,实时查看main线程的执行信息。

main线程在等待输入动作,注意查看win4窗口,最后一个out文件现在是:out.31113。当我们输入1时,会运行线程t1,然后我们在看会发现多了一个out文件:out.32102。如下图win4

image.png

此时,线程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结束”后,整个线程就结束了。

image.png

接着输入3,结束这个程序。这就是整个java线程挂起和唤醒时在linux内核态所做的事情的演示。现在,关键点4也说完了。

下图是线程在各时段下,AQS的状态变化图,展示了AQS从初始化阶段到两个线程在AQS中抢锁,再到都释放锁的整个过程

image.png

下图为各个层的阻塞的方法和函数
20210913161558

it`s time to summary

这里描述了java的park和unpark从java层到jvm层、到linux用户层、到linux内核层所做的一系列事情。本文更多的是串联整个过程,而非具体详细讲解。目的是起到一个引导和引入的作用。一是能力有限,更多的是可视化整个流程,从而使大家能具体的看到和感知整个过程。