JAVA并发-AQS知识笔记

概述

AQS是AbstractQueuedSynchronizer的缩写,翻译成中文就是抽象队列同步器,AbstractQueuedSynchronizer这个类也是在java.util.concurrent.locks下面。简单来说AQS定义了一套多线程访问共享资源的同步器框架,这套框架定义了共同的基础行为,比如等待队列、条件队列、独占获取、共享获取等,AQS也是一个依赖状态state的同步器,而且java并发编程的核心包java.concurrent.util都需要这套框架。比如Lock,Latch,Barrier等,都是基于AQS框架实现。

我们在学习一套并发工具的时候,我们首先要抓住这3点:

状态:一般是一个state属性,它基本是整个工具的核心,通常整个工具都是在设置和修改状态,很多方法的操作都依赖于当前状态是什么。由于状态是全局共享的,一般会被设置成volatile类型,以保证其修改的可见性。

队列:队列通常是一个等待对象 Node 的集合,大多数以链表的形式实现。队列采用的是悲观锁的思想,表示当前所等待的资源,状态或者条件短时间内可能无法满足。因此,它会将当前线程包装成某种 类型的数据结构 Node ,放入一个等待队列中,当一定条件满足后,再从等待队列中取出。

CAS:CAS操作是最轻量的并发处理,通常我们对于状态的修改都会用到CAS操作,因为状态可能被多个线程同时修改,CAS操作保证了同一个时刻,只有一个线程 能修改成功,从而保证了线程安全,CAS操作基本是由Unsafe工具类的compareAndSwapXXX来实现的;CAS采用的是乐观锁的思想,因 此常常伴随着自旋,如果发现当前无法成功地执行CAS,则不断重试,直到成功为止,自旋的的表现形式通常是一个死循环for(;;)。

AQS具备特性

特点:1,阻塞等待队列;2,共享/独占;3,公平/非公平;4,可重入;5,允许中断。

first-in-first-out (FIFO) wait queues

blocking locks and related synchronizers (semaphores, events, etc)

乐观锁

共享锁shared是一个乐观锁。可以允许多个线程阻塞点,可以多个线程同时获取到锁。它允许一个资源可以被多个读操作,或者被一个写操作访问,但是两个操作不能同时访问。

Java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值版本号是否一样,一样的更新,否则失败。

悲观锁

独占锁exclusive是一个悲观锁。保证只有一个线程经过一个阻塞点,只有一个线程可以获得锁。

Java中的悲观锁就是synchronized,AQS框架下的锁则是先尝试CAS乐观锁去获取,获取不到才会转为悲观锁,如ReentrantLock

大量使用了CAS操作, 并且在冲突时,采用自旋方式重试,以实现轻量级和高效地获取锁。

AQS可以实现独占锁和共享锁

通 过一个CLH队列实现的(CLH锁即Craig, Landin, and Hagersten (CLH) locks,CLH锁是一个自旋锁,能确保无饥饿性,提供先来先服务的公平性。CLH锁也是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本 地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。)

AQS框架

AbstractQueuedSynchronizer是JDK实现其他同步工具的基础。
AQS内部封装了一个状态volatile int state用来表示资源,提供了独占以及共享两种操作:acquire(acquireShare)/release(releaseShare)。
acquire的语义是:获取资源,如果当前资源满足条件,则直接返回,否则挂起当前线程
release的语义是:释放资源,唤醒挂起线程

它维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。这里volatile是核心关键词,具体volatile的语义,在此不述。state的访问方式有三种:getState()、setState()、compareAndSetState()

AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。

不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:

isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。

tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。

tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。

tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。

tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。

以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将 state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会 获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能 保证state是能回到零态的。

再以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个 子线程执行完后countDown()一次,state会CAS减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然 后主调用线程就会从await()函数返回,继续后余动作。

一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、 tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如 ReentrantReadWriteLock。

双向CLH链表

 

 

 AQS核心业务逻辑

1.AQS中用state属性表示锁同步状态,如果能成功将state属性通过CAS操作从0设置成1即获取了锁. 当state>0时表示已经获取了锁,当state = 0无锁。

2.获取了锁的线程才能将exclusiveOwnerThread设置成自己

3.addWaiter负责将当前等待锁的线程包装成Node,并成功地添加到队列的末尾,这一点是由它调用的enq方法保证的,enq方法同时还负责在队列为空时初始化队列。

4.acquireQueued方法用于在Node成功入队后,继续尝试获取锁(取决于Node的前驱节点是不是head),或者将线程挂起

5.shouldParkAfterFailedAcquire方法用于保证当前线程的前驱节点的waitStatus属性值为SIGNAL,从而保证了自己挂起后,前驱节点会负责在合适的时候唤醒自己。

6.parkAndCheckInterrupt方法用于挂起当前线程,并检查中断状态。

7.如果最终成功获取了锁,线程会从lock()方法返回,继续往下执行;否则,线程会阻塞等待。

AQS三板斧

状态

volatile state属性

private volatile int state;

该属性的值即表示了锁的状态,state为0表示锁没有被占用,state大于0表示当前已经有线程持有该锁,这里之所以说大于0而不说等于1是因为可能存在可重入的情况。你可以把state变量当做是当前持有该锁的线程数量。

CAS 操作用来改变状态

waitStatus 的状态值

它不是表征当前节点的状态,而是当前节点的下一个节点的状态。

当 一个节点的waitStatus被置为SIGNAL,就说明它的下一个节点(即它的后继节点)已经被挂起了(或者马上就要被挂起了),因此在当前节点释放 了锁或者放弃获取锁时,如果它的waitStatus属性为SIGNAL,它还要完成一个额外的操作——唤醒它的后继节点。

表示Node所代表的当前线程已经取消了排队,即放弃获取锁了。

static final int CANCELLED = 1;

static final int SIGNAL = -1;

static final int CONDITION = -2;

static final int PROPAGATE = -3;

CAS操作

AQS的3个属性state,head和tail

Node对象的两个属性waitStatus,next

CAS操作主要针对5个属性。

 1    private static final Unsafe unsafe = Unsafe.getUnsafe();
 2    private static final long stateOffset;
 3    private static final long headOffset;
 4    private static final long tailOffset;
 5    private static final long waitStatusOffset;
 6    private static final long nextOffset;
 7 
 8    static {
 9        try {
10            stateOffset = unsafe.objectFieldOffset
11                (AbstractQueuedSynchronizer.class.getDeclaredField("state"));
12            headOffset = unsafe.objectFieldOffset
13                (AbstractQueuedSynchronizer.class.getDeclaredField("head"));
14            tailOffset = unsafe.objectFieldOffset
15                (AbstractQueuedSynchronizer.class.getDeclaredField("tail"));
16            waitStatusOffset = unsafe.objectFieldOffset
17                (Node.class.getDeclaredField("waitStatus"));
18            nextOffset = unsafe.objectFieldOffset
19                (Node.class.getDeclaredField("next"));
20 
21        } catch (Exception ex) { throw new Error(ex); }
22    }

CAS操作码

 CAS操作是最轻量的并发处理,通常我们对于状态的修改都会用到CAS操作,因为状态可能被多个线程同时修改,CAS操作保证了同一个时刻,只有一个线程能修改成功,从而保证了线程安全,CAS操作基本是由Unsafe工具类的compareAndSwapXXX来实现的;CAS采用的是乐观锁的思想,因此常常伴随着自旋,如果发现当前无法成功地执行CAS,则不断重试,直到成功为止,自旋的的表现形式通常是一个死循环for(;;);

 1 protected final boolean compareAndSetState(int expect, int update) {
 2     return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
 3 }
 4 private final boolean compareAndSetHead(Node update) {
 5     return unsafe.compareAndSwapObject(this, headOffset, null, update);
 6 }
 7 private final boolean compareAndSetTail(Node expect, Node update) {
 8     return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
 9 }
10 private static final boolean compareAndSetWaitStatus(Node node, int expect,int update) {
11     return unsafe.compareAndSwapInt(node, waitStatusOffset, expect, update);
12 }
13 private static final boolean compareAndSetNext(Node node, Node expect, Node update) {
14     return unsafe.compareAndSwapObject(node, nextOffset, expect, update);
15 }

队列 

AQS中,队列的实现是一个双向链表,被称为sync queue,它表示所有等待锁的线程的集合

AQS 中的队列是一个CLH队列,它的head节点永远是一个哑结点(dummy node), 它不代表任何线程(某些情况下可以看做是代表了当前持有锁的线程),因此head所指向的Node的thread属性永远是null。只有从次头节点往后 的所有节点才代表了所有等待锁的线程。也就是说,在当前线程没有抢到锁被包装成Node扔到队列中时,即使队列是空的,它也会排在第二个,我们会在它的前 面新建一个dummy节点

在并发编程中使用队列通常是将当前线程包装成某种类型的数据结构扔到等待队列中.

队列中的节点数据结构。

 1 static final class Node {
 2 
 3     // 共享
 4     static final Node SHARED = new Node();
 5     // 独占
 6     static final Node EXCLUSIVE = null;
 7 
 8     /**
 9      * 因为超时或者中断,节点会被设置为取消状态,被取消的节点时不会参与到竞争中的,他会一直保持取消状态不会转变为其他状态
10      */
11     static final int CANCELLED =  1;
12     /**
13      * 后继节点的线程处于等待状态,而当前节点的线程如果释放了同步状态或者被取消,将会通知后继节点,使后继节点的线程得以运行
14      * (说白了就是处于等待被唤醒的线程(或是节点)只要前继结点释放锁,就会通知标识为SIGNAL状态的后继结点的线程执行)
15      */
16     static final int SIGNAL    = -1;
17     /**
18      * 节点在等待队列中,节点线程等待在Condition上,当其他线程对Condition调用了signal()后,该节点将会从等待队列中转移到同步队列中,加入到同步状态的获取中
19      */
20     static final int CONDITION = -2;
21     /**
22      * 表示下一次共享式同步状态获取,将会无条件地传播下去
23      */
24     static final int PROPAGATE = -3;
25 
26     /** 等待状态 */
27     volatile int waitStatus;
28 
29     /** 前驱节点,当节点添加到同步队列时被设置(尾部添加) */
30     volatile Node prev;
31 
32     /** 后继节点 */
33     volatile Node next;
34 
35     /** 等待队列中的后续节点。如果当前节点是共享的,那么字段将是一个 SHARED 常量,也就是说节点类型(独占和共享)和等待队列中的后续节点共用同一个字段 */
36     Node nextWaiter;
37     
38     /** 获取同步状态的线程 */
39     volatile Thread thread;
40 
41     final boolean isShared() {
42         return nextWaiter == SHARED;
43     }
44 
45     final Node predecessor() throws NullPointerException {
46         Node p = prev;
47         if (p == null)
48             throw new NullPointerException();
49         else
50             return p;
51     }
52 
53     Node() { // Used to establish initial head or SHARED marker
54     }
55 
56     Node(Thread thread, Node mode) { // Used by addWaiter
57         this.nextWaiter = mode;
58         this.thread = thread;
59     }
60 
61     Node(Thread thread, int waitStatus) { // Used by Condition
62         this.waitStatus = waitStatus;
63         this.thread = thread;
64     }
65     
66 }

状态变量waitStatus
表示当前Node所代表的线程的等待锁的状态,在独占锁模式下,我们只需要关注CANCELLED SIGNAL两种状态即可。

nextWaiter属性
在独占锁模式下永远为null,仅仅起到一个标记作用,没有实际意义。

AQS2种队列

同步等待队列

AQS当中的同步等待队列也称CLH队列,CLH队列是Craig、Landin、Hagersten三人发明的一种基于双向链表数据结构的队列,是FIFO先入先出线程等待队列,Java中的CLH队列是原CLH队列的一个变种,线程由原自旋机制改为阻塞机制。

不过这里有一点我们提前说一下,在AQS中的队列是一个CLH队列,它的head节点永远是一个哑结点(dummy node), 它不代表任何线程(某些情况下可以看做是代表了当前持有锁的线程),因此head所指向的Node的thread属性永远是null。只有从次头节点往后的所有节点才代表了所有等待锁的线程。也就是说,在当前线程没有抢到锁被包装成Node扔到队列中时,即使队列是空的,它也会排在第二个,我们会在它的前面新建一个dummy节点(具体的代码我们在后面分析源码时再详细讲)。为了便于描述,下文中我们把除去head节点的队列称作是等待队列,在这个队列中的节点才代表了所有等待锁的线程。

thread:表示当前Node所代表的线程

waitStatus:表示节点所处的等待状态,共享锁模式下只需关注三种状态:SIGNALCANCELLED初始态(0)

prevnext:节点的前驱和后继

nextWaiter:进作为标记,值永远为null,表示当前处于独占锁模式

条件等待队列

Condition是一个多线程间协调通信的工具类,使得某个,或者某些线程一起等待某个条件(Condition),只有当该条件具备时,这些等待线程才会被唤醒,从而重新争夺锁。

AQS核心属性

锁相关的属性有两个

private volatile int state; //锁的状态

private transient Thread exclusiveOwnerThread; // 当前持有锁的线程,注意这个属性是从AbstractOwnableSynchronizer继承而来

sync queue相关的属性有两个

private transient volatile Node head; // 队头,为dummy node

private transient volatile Node tail; // 队尾,新入队的节点

队列中的Node属性

 1 // 节点所代表的线程
 2 volatile Thread thread;
 3  
 4 // 双向链表,每个节点需要保存自己的前驱节点和后继节点的引用
 5 volatile Node prev;
 6 volatile Node next;
 7  
 8 // 线程所处的等待锁的状态,初始化时,该值为0
 9 volatile int waitStatus;
10 static final int CANCELLED =  1;
11 static final int SIGNAL    = -1;

acquire分析 

tryAcquire()尝试直接去获取资源,如果成功则直接返回;

addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;

acquireQueued()使线程在等待队列中获取资源,一直获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。

如果线程在等待过程中被中断过,先不响应的。在获取资源后才再进行自我中断selfInterrupt()。

tryAcquire(arg) : 获取锁的业务逻辑

判断当前锁有没有被占用:

1.如果锁没有被占用, 尝试以公平的方式获取锁
2.如果锁已经被占用, 检查是不是锁重入
获取锁成功返回true, 失败则返回false

addWaiter(Node mode)

当tryAcquire失败后,才会调用acquireQueued(addWaiter(Node.EXCLUSIVE), arg),addWaiter方法用于将当前线程加入到等待队列的队尾,并返回当前线程所在的结点。

使用了自旋保证插入队尾成功。

在获取锁失败后调用, 将当前请求锁的线程包装成Node扔到sync queue中去,并返回这个Node。

 1  private Node addWaiter(Node mode) {
 2         Node node = new Node(Thread.currentThread(), mode);
 3         // Try the fast path of enq; backup to full enq on failure
 4         Node pred = tail;
 5 // 如果队列不为空, 则用CAS方式将当前节点设为尾节点
 6         if (pred != null) {
 7             node.prev = pred;
 8  // 检查tail的状态,如果当前是pred
 9             if (compareAndSetTail(pred, node)) { // 将当前节点设为尾节点
10                 pred.next = node; // 把tail的next节点指向当前Node
11                 return node;
12             }
13         }
14     
15 
16     // 代码会执行到这里, 只有两种情况:
17     //    1. 队列为空
18     //    2. CAS失败
19     // 注意, 这里是并发条件下, 所以什么都有可能发生, 尤其注意CAS失败后也会来到这里. 例如: 有可能其他线程已经成为了新的尾节点,导致尾节点不再是我们之前看到的那个pred了。
20 
21    // 如果当前node插入队尾失败,则通过自旋保证替换成功(自旋+CAS)
22         enq(node);
23         return node;
24     }

enq()方法:在该方法中, 我们使用了死循环, 即以自旋方式将节点插入队列,如果失败则不停的尝试, 直到成功为止, 另外, 该方法也负责在队列为空时, 初始化队列,这也说明,队列是延时初始化的(lazily initialized):

 1 private Node enq(final Node node) {
 2    for (;;) {
 3        Node t = tail;
 4        // 如果是空队列, 首先进行初始化
 5        // 这里也可以看出, 队列不是在构造的时候初始化的, 而是延迟到需要用的时候再初始化, 以提升性能
 6        if (t == null) {
 7 // 注意,初始化时使用new Node()方法新建了一个dummy节点
 8 // 从这里可以看出, 在这个等待队列中,头结点是一个“哑节点”,它不代表任何等待的线程。
 9 // head节点不代表任何线程,它就是一个空节点!
10            if (compareAndSetHead(new Node()))
11                tail = head; // 这里仅仅是将尾节点指向dummy节点,并没有返回
12        } else {
13        // 到这里说明队列已经不是空的了, 这个时候再继续尝试将节点加到队尾
14 
15 // 1.设置node的前驱节点为当前的尾节点
16            node.prev = t;
17 
18 // 2.修改tail属性,使它指向当前节点; 这里的CAS保证了同一时刻只有一个节点能成为尾节点,其他节点将失败,失败后将回到for循环中继续重试。
19            if (compareAndSetTail(t, node)) {
20 
21 // 3.修改原来的尾节点,使它的next指向当前节点
22                t.next = node;
23                return t;
24            }
25        }
26    }
27 }

添加到queue队尾步骤

 将一个节点node添加到sync queue的末尾需要三步:

 设置node的前驱节点为当前的尾节点:node.prev = t

修改tail属性,使它指向当前节点 

修改原来的尾节点,使它的next指向当前节点尾分叉。

需要注意,这里的三步并不是一个原子操作,第一步很容易成功;而第二步由于是一个CAS操作,在并发条件下有可能失败,第三步只有在第二步成功的条件下才执行。这里的CAS保证了同一时刻只有一个节点能成为尾节点,其他节点将失败,失败后将回到for循环中继续重试所以,当有大量的线程在同时入队的时候,同一时刻,只有一个线程能完整地完成这三步,而其他线程只能完成第一步,于是就出现了尾分叉:

 这 里第三步是在第二步执行成功后才执行的,这就意味着,有可能即使我们已经完成了第二步,将新的节点设置成了尾节点,此时原来旧的尾节点的next值可能还 是null(因为还没有来的及执行第三步),所以如果此时有线程恰巧从头节点开始向后遍历整个链表,则它是遍历不到新加进来的尾节点的,但是这显然是不合 理的,因为现在的tail已经指向了新的尾节点。

另一方面,当我们完成了第二步之后,第一步一定是完成了的,所以如果我们从尾节点开始向前遍历,已经可以遍历到所有的节点。

这也就是为什么我们在AQS相关的源码中 (比如:unparkSuccessor(Node node) 中的:

 1 for (Node t = tail; t != null && t != node; t = t.prev)) 

通常是从尾节点开始逆向遍历链表——因为一个节点要能入队,则它的prev属性一定是有值的,但是它的next属性可能暂时还没有值。

至于那些“分叉”的入队失败的其他节点,在下一轮的循环中,它们的prev属性会重新指向新的尾节点,继续尝试新的CAS操作,最终,所有节点都会通过自旋不断的尝试入队,直到成功为止。

acquireQueued(final Node node, int arg)

addWaiter的将当前线程加入队列后,使用acquireQueued进行阻塞,直到获取到资源后返回

 1 final boolean acquireQueued(final Node node, int arg) {
 2         boolean failed = true;
 3         try {
 4             boolean interrupted = false;
 5             for (;;) {
 6                 final Node p = node.predecessor();
 7                 // 当前节点的前驱是 head 节点时, 再次尝试获取锁
 8                 if (p == head && tryAcquire(arg)) {
 9                     setHead(node);
10                     p.next = null; // help GC
11                     failed = false;
12                     return interrupted;
13                 }
14                 //在获取锁失败后, 判断是否需要把当前线程挂起
15                 if (shouldParkAfterFailedAcquire(p, node) &&
16                     parkAndCheckInterrupt())
17                     interrupted = true;
18             }
19         } finally {
20             if (failed)
21                 cancelAcquire(node);
22         }
23     }

shouldParkAfterFailedAcquire(Node pred, Node node)

这个函数只有在当前节点的前驱节点的waitStatus状态本身就是SIGNAL的时候才会返回true, 其他时候都会返回false:

 1 // Returns true if thread should block.
 2 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
 3     int ws = pred.waitStatus; // 获得前驱节点的ws
 4     if (ws == Node.SIGNAL)
 5         // 前驱节点的状态已经是SIGNAL了(This node has already set status asking a release),说明闹钟已经设了,可以直接高枕无忧地睡了(so it can safely park)
 6         return true;
 7     if (ws > 0) {
 8         // 当前节点的 ws > 0, 则为 Node.CANCELLED 说明前驱节点已经取消了等待锁(由于超时或者中断等原因)
 9         // 既然前驱节点不等了, 那就继续往前找, 直到找到一个还在等待锁的节点
10         // 然后我们跨过这些不等待锁的节点, 直接排在等待锁的节点的后面 (是不是很开心!!!)
11         do {
12             node.prev = pred = pred.prev;
13         } while (pred.waitStatus > 0);
14         pred.next = node;
15     } else {
16         // 前驱节点的状态既不是SIGNAL,也不是CANCELLED
17         // 用CAS设置前驱节点的ws为 Node.SIGNAL,给自己定一个闹钟
18         compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
19     }
20     return false;
21 }

parkAndCheckInterrupt()

 到这个函数已经是最后一步了, 就是将线程挂起, 等待被唤醒. Convenience method to park and then check if interrupted. return true if interrupted:

1 private final boolean parkAndCheckInterrupt() {
2     LockSupport.park(this); // 线程被挂起,停在这里不再往下执行了
3     return Thread.interrupted();
4 }

LockSupport.park()

 public class LockSupport extends Object用于创建锁和其他同步类的基本线程阻塞原语:

 1 public static void park(Object blocker) {
 2        Thread t = Thread.currentThread();
 3        setBlocker(t, blocker);
 4        UNSAFE.park(false, 0L);
 5        setBlocker(t, null);
 6    }
 7 
 8    private static void setBlocker(Thread t, Object arg) {
 9        // Even though volatile, hotspot doesn't need a write barrier here.
10        UNSAFE.putObject(t, parkBlockerOffset, arg);
11    }

总结

感谢网络大神的分享,

https://juejin.im/post/5aeb07ab6fb9a07ac36350c8

https://www.cnblogs.com/waterystone/p/4920797.html

https://mp.weixin.qq.com/s?__biz=MzA5OTI2MTE3NA==&mid=2658337633&idx=1&sn=6a18fc2310406a2f35ccd4bb7db41a54&chksm=8b02acf8bc7525ee5714d223efd4c27b41ae7d938518f8de8e52faa8a68601167699aac73b9f&mpshare=1&scene=1&srcid=0105vqyUJ4LKmg9TDVyoQdDk&sharer_sharetime=1578208728620&sharer_shareid=d40e8d2bb00008844e69867bcfc0d895#rd

https://www.zfl9.com/java-juc-framework.html

 

原文地址:https://www.cnblogs.com/boanxin/p/12152547.html