JUC详解
一、AQS
2004年,Java大神Doug Lea发表了一篇名为”The java.util.concurrent Synchronizer Framework“的论文,详细阐述了Java的AbstractQueuedSynchronizer(抽象队列同步器,简称AQS)的设计原理与实现方案。juc中如ReentrantLock、CountDownLatch、Semaphore及并发集合等都基于AQS实现。
本文介绍的AQS代码基于JDK17,因为在JDK17中对原来JDK8的代码进行了优化,逻辑更清晰,性能更优,功能更完善。
1、AQS发展史
1)自旋锁
自旋锁的实现方式简单来说就是通过一个死循环,通过CAS不断尝试修改一个标志位,修改成功的获取锁执行。共有四种自旋锁模型,感兴趣的话可以自己查一下。
- SPIN ON TEST-AND-SET
- TEST-AND-TEST-AND-SET
- DELAY BETWEEN EACH REFERENCE
- READ-AND-INCREMENT
CAS:Compare And Swap 硬件同步原语
CAS是解决多线程并行情况下使用mutex互斥锁造成性能损耗的一种机制。CAS包含3个操作参数:内存位置(V)、预期原值(A)、新值(B)。如果内存位置的值与预期原值相等,那么CPU自动将该位置值更新为新值。如果不相等则不进行任何操作。CAS操作是通过CPU指令来完成的,它需要硬件支持。
2)MCS锁的实现
MCS锁:一种自旋排队锁机制。整个MCS锁就是一个由单向链表构成的等待队列,队列中的每个节点成为Node。MCS锁会维护一个tail指针,该指针会指向整个链表的尾部节点。
其加锁和解锁过程如下:
当前线程想要获取锁,使用当前线程对象构建一个Node节点,locked=true,通过CAS将当前node对象入队并自旋,判断当前节点的locked值,当locked值为false时向下执行
当头节点释放锁时,修改下一个节点的locked值,并把当前节点删除
MCS锁的不足:
- 每个节点都轮询当前节点状态,浪费CPU资源
- 当前节点释放锁时,同时有多个节点想加入队列,head节点必须等待后续节点与当前节点连接后才能释放,在某些条件下需要等待
3)CLH锁的实现
CLH锁是一个由单向链表构成的等待队列。队列中每个节点记录了当前节点指针curNode、前驱节点指针preNode以及锁请求节点。锁请求节点定义了锁状态标志locked。每个线程都会通过自旋方式来观察前驱节点上的locked值。
获取锁过程:
当线程获取锁时,CLH会先构造锁请求的节点curNode,并将curNode的locked状态设置成true,表示需要获取锁。然后通过tail指针获取队列尾部的节点,并将第队列的尾部节点作为当前节点的前驱节点,通过CAS方式将tail指针指向curNode。获取锁的线程会自旋观察preNode节点的locked标志。如果preNode的locked为false就表示当前线程获取到锁了。
释放锁过程:
获取当前线程的锁节点,然后将当前节点的locked设置为false,这样后继节点就能自动获取到锁。
CLH锁与MCS锁的差别
差别点 | 差别描述 |
---|---|
链表结构 | MCS锁是由前驱节点的next指针指向后继节点的,而CLH锁是由当前节点的preNode指针指向前驱节点 |
等待方式 | 在等待锁的时候,MCS锁时观察当前节点的状态来判断是否可以获取锁,而CLH锁是观察前驱节点的状态来判断自己是否获取到了锁 |
锁释放处理 | 在锁释放的时候,MCS锁需要注意后继节点是否正在添加的过程中,需要做容错处理,而CLH锁比较简单,只修改当前节点的状态就可以实现锁释放 |
同时CLH锁还会维护一个tail指针指向最后一个节点。
4)AQS的实现
AQS是一个由双向链表构成的等待队列。每个节点记录了前驱节点prev指针、后继节点next指针,waiter指针指向当前要加锁的线程,status标识当前节点状态。同时增加了head和tail指针,使用status来标识当前加锁状态
注意区分节点的status和AQS加锁状态status。
节点status是定义在node对象中的,标识当前节点状态,共有四个值
- 0-初始状态
- 1-等待获取锁状态
- 0x80000000-取消状态,为int最小值(1000 0000 0000 0000 0000 0000 0000 0000)
- 2-条件等待状态
2、AQS详解
1)功能简述
AQS主要提供了3个方面的通用功能
- 锁状态管理:提供了getState、setState、compareAndSetState这三个方法来管理同步状态
- 等待队列管理:提供了线程等待队列的入队、出队、清理无效节点、判断队列长度,统计队列中线程等通用功能
- 加锁与解锁:提供了独占锁、共享锁的获取与释放功能。在获取锁的时候,支持轻量级获取锁、超时等待获取锁、永久等待获取锁的能力
AQS提供了以下方法来实现自定义同步器,juc下如ReentrantLock,CountDownLatch等都是通过重写以下方法实现的
- tryAcquire
- tryRelease
- tryAcquireShared
- tryReleaseShared
- isHeldExclusively
2)获取锁
AQS提供了acquire模板方法来获取锁,我们把acquire方法分成几块来看,这样的话更清晰。这些代码看不明白没关系,我会在后续的流程图里给大家解释,配合流程图再看这部分代码就很容易理解了
第一部分:
各种参数的初始化
Thread current = Thread.currentThread();
byte spins = 0, postSpins = 0; // retries upon unpark of first thread
boolean interrupted = false, first = false;
Node pred = null; // predecessor of node when enqueued
第二部分:
从第二部分开始之后的代码都是在一个死循环中,我在此省略死循环的代码,只看里边具体逻辑
这一部分主要是在头节点变化时修改第一部分参数的,并且清理无效节点
if (!first && (pred = (node == null) ? null : node.prev) != null &&
!(first = (head == pred))) {
if (pred.status < 0) {
cleanQueue(); // predecessor cancelled
continue;
} else if (pred.prev == null) {
Thread.onSpinWait(); // ensure serialization
continue;
}
}
第三部分:
这一部分是抢锁的逻辑以及抢锁成功后出队的逻辑
if (first || pred == null) {
boolean acquired;
try {
if (shared)
acquired = (tryAcquireShared(arg) >= 0);
else
acquired = tryAcquire(arg);
} catch (Throwable ex) {
cancelAcquire(node, interrupted, false);
throw ex;
}
if (acquired) {
if (first) {
node.prev = null;
head = node;
pred.next = null;
node.waiter = null;
if (shared)
signalNextIfShared(node);
if (interrupted)
current.interrupt();
}
return 1;
}
}
第四部分:
这一部分的代码都是关于node操作和线程相关操作的,具体里边的代码何时触发请看我下边的流程图
if (node == null) { // allocate; retry before enqueue
if (shared)
node = new SharedNode();
else
node = new ExclusiveNode();
} else if (pred == null) { // try to enqueue
node.waiter = current;
Node t = tail;
node.setPrevRelaxed(t); // avoid unnecessary fence
if (t == null)
tryInitializeHead();
else if (!casTail(t, node))
node.setPrevRelaxed(null); // back out
else
t.next = node;
} else if (first && spins != 0) {
--spins; // reduce unfairness on rewaits
Thread.onSpinWait();
} else if (node.status == 0) {
node.status = WAITING; // enable signal and recheck
} else {
long nanos;
spins = postSpins = (byte)((postSpins << 1) | 1);
if (!timed)
LockSupport.park(this);
else if ((nanos = time - System.nanoTime()) > 0L)
LockSupport.parkNanos(this, nanos);
else
break;
node.clearStatus();
if ((interrupted |= Thread.interrupted()) && interruptible)
break;
}
我们就以ReentrantLock为例,来看看多线程获取锁的时候,acquire方法是如何工作的
通过上图可以分析出以下几点:
-
如果锁已经被其他线程持有,当调用acquire方法时会经过多次循环,每次循环的功能不一样,主要总结如下
- 第一次循环:尝试获取锁,获取失败则构建node对象
- 第二次循环:尝试获取锁,获取失败如果没有构建CLH队列则多进行一次循环构建,然后将当前节点入队
- 第三次循环:对于已入队节点,如果是头节点尝试获取锁,不是头节点不获取锁,将当前节点状态改成WAITING
- 第四次循环:抢不到锁就调用park()方法暂停线程
-
对于第一个入队的节点和后续节点的行为不一致,主要体现在以下三个方面
-
第一个节点永远不会执行第二部分的逻辑
-
第一个节点会构建CLH队列,后续节点只需入队
-
第一个节点会在每次循环中都尝试获取锁,而后续节点只在未入队时会尝试获取锁
再谈公平与非公平,通过acquire()方法的行为可以判定,未入队时是非公平抢锁,已入队时是公平抢锁
-
讲到这里,我们还需要特殊说明一下第二部分代码的逻辑,这里情况比较复杂,需要单独说明:
首先,代码块二进入的条件必须满足以下两个条件
- 当前节点已入队,node.prev不为空
- 当前节点不能是头节点,node.pred != head
满足以上两个条件后,后续的逻辑都是为了保证在头节点变动的时候能够更新当前node和CLH队列的最新状态
-
pred.status < 0 判断条件是为了应对非头节点已经入队但是还未调用park()方法时如果前驱节点取消的情况,在这种情况下需要清理队列中的无效节点
-
pred.prev == null判断条件是为了应对非头节点已经入队但是还未调用park()方法时前驱节点获取到了锁的情况,在这种情况下需要重新对pred和first属性进行复制,需要再次循环。会进入第四部分pred == null的判断再次入队
if (acquired) { if (first) { node.prev = null; head = node; pred.next = null; node.waiter = null; if (shared) signalNextIfShared(node); if (interrupted) current.interrupt(); } return 1; }
当头节点获取到锁的时候,第一步就是先释放prev节点,将prev置为空,就会出现pred.prev==null
3)释放锁
AQS通过release()方法来释放锁,该方法也是一个模板方法,其中的tryRelease()方法需要具体实现类重写。整个方法的内容比较简单,分为两部分
- tryRelease:修改AQS中state的状态等相关属性
- signalNext:通知下一个等待的线程
public final boolean release(int arg) {
if (tryRelease(arg)) {
signalNext(head);
return true;
}
return false;
}
关于以上两部分的代码就不展开了,整理过程比较简单,自己理解即可。
3、再谈AQS
前面我们已经讲了AQS的实现原理,回过头来再看AQS我们发现其实现思路与CLH队列有很大区别
1)双向队列
AQS定义了head和tail指针,每个节点定义了prev和next两个指针,使用双向队列的意义在于:
- 当加锁解锁的时候可以沿着队列从前向后找
- 当清理队列中无效节点的时候需要从后往前找因为头节点的状态易变
2)关于状态
CLH队列每个节点通过自旋观察前一个节点的状态来获取锁,AQS则通过state变量来标识锁状态,AQS中每个节点的state值只表示当前节点的状态而非加锁状态。同时通过exclusiveOwnerThread来判断当前持锁线程进而判断是否可重入。
3)关于公平与非公平
我们都知道ReentrantLock中有公平和非公平之分,实际上ReentrantLock中的公平与非公平的区别只是tryAcquire方法不同。对于公平锁,在入队前的循环中尝试加锁,如果队列中已经有线程在等待锁了,那么当前线程是无法获取锁的,以此来达到公平的目的。而非公平是无论有没有线程等待都会尝试加锁。