本文从四个层面,垂直的形式论述了线程阻塞和唤醒。别离为java代码层,jvm层,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的应用示例开始这个"途程"。

留神图中的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