在java.util.concurrent包(下称j.u.c包)中,大部分的同步器(例如锁,屏障等等)都是基于AbstractQueuedSynchronizer(下称AQS类)这个类来构建的;它是个抽象的FIFO队列式的同步器,AQS定义了一套多线程访问共享资源的同步器框架。
J.U.C包下的同步器的实现都主要依赖于以下几个功能:
- 内部同步状态的管理
- 同步状态的更新和检查操作
- 且至少有一个方法会导致调用线程在同步状态被获取时阻塞,以及在
其他线程改变这个同步状态时解除线程的阻塞
而AQS就实现了以上功能,供其他同步器使用。
所有同步器都有两个基本方法,acquire,release。acquire操作阻塞调用的线程,直到或除非同步状态允许其继续执行。而release操作则是通过某种方式改变同步状态,使得一或多个被acquire阻塞的线程继续执行。(不用同步器命名不同Lock.lock,Semaphore.acquire,CountDownLatch.await...)
之前提过Synchronized内置锁,JVM对其进行了许多优化,其性能已经比ReentrentLock更好,但是常规的JVM锁优化策略并不适用于严重依赖于J.U.C包的典型多线程服务端应用。
大部分情况下,特别在同步器有竞争的情况下,稳定地保证其效率才是J.U.C包的主要目标。
同步器的acquire与release
acquire
while(同步状态不允许acquire){
放入队列 if 没有进队;
依具体需求来决定是否阻塞当前线程;
}
出队 if 已入对;
release
更新同步状态;
if(状态允许被阻塞线程acquire){
解除一个或多个队列里的阻塞线程;
}
要实现上述功能需要三个基本组建的相互协作:
- 同步状态的原子性管理
- 线程的阻塞与解除阻塞
- 队列的管理
同步状态
AQS用单个32位int值 state 来表示同步状态
阻塞
利用LockSupport来阻塞/唤醒线程
队列:
无法获取执行资格的线程会构建一个节点Node加入队列,AQS中使用的是CLH队列:CLH同步队列是一个FIFO双向队列,AQS依赖它来完成同步状态的管理,当前线程如果获取同步状态失败时,AQS则会将当前线程以及等待状态等信息构造成一个节点(Node)并将其加入到CLH同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点唤醒,使其再次尝试获取同步状态。对于队列中的某个节点来说,它只需要通过判断其前一个节点的状态信息来
关于队列的节点Node:5条属性,分别是wateStatus 、prev、next、thread、nextWater。
1,其中 prev,next用于构建双向链表,thread 指向节点对应的线程。
2,waitStatus表示当前被封装成Node结点的等待状态,共有4种取值CANCELLED、SIGNAL、CONDITION、PROPAGATE。
CANCELLED:值为1,在同步队列中等待的线程等待超时或被中断,需要从同步队列中取消该Node的结点,其结点的waitStatus为CANCELLED,进入该状态后的结点将会被删除。
SIGNAL:值为-1,表明该节点之后有节点在阻塞,当该节点被唤醒或删除后会必须唤醒其后继节点
CONDITION:值为-2,与Condition相关,该标识的结点处于条件队列中,结点的线程等待在Condition上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从条件队列转移到同步队列中,等待获取同步锁。
PROPAGATE:值为-3,与共享模式相关。这个标记只在一处用到,即共享锁的doReleaseShared方法,该方法在同步队列头节点状态waitStatus=0 时将其设为PROPAGATE ,本身doReleaseShared是为了释放后继节点的,但是当头节点状态为0,我们不知道有没有后继节点,所以就采用这种方式,将头节点标记为PROPAGATE,意味着将共享锁的释放传递下去,它与setHeadAndPropagate方法有关,可以去看我的CountDownLatch文章里对该方法的分析
static final class Node {
//表示共享模式,如CountDownLatch
static final Node SHARED = new Node();
//表示独占模式,如ReentrentLoack
static final Node EXCLUSIVE = null;
//节点操作因为超时或者中断而被删除。节点不应该留在此状态,
//一旦达到此状态将从同步队列中删除。
static final int CANCELLED = 1;
//表明该节点之后有节点在阻塞,当该节点被唤醒或删除后会必须唤醒其后继节点
static final int SIGNAL = -1;
//表明节点在Condition队列中。之后它会被放回同步队列中,状态设为为0
static final int CONDITION = -2;
// 这个标记只在一处用到,即共享锁的doReleaseShared方法,该方法在同步队列头节点状态waitStatus=0
//时将其设为PROPAGATE ,本身doReleaseShared是为了释放后继节点的,但是当头节点状态为0,我们不知
//道有没有后继节点,所以就采用这种方式,将头节点标记为PROPAGATE,意味着将共享锁的释放传递下去,它与setHeadAndPropagate方法有关,
//可以去看我的CountDownLatch文章里对该方法的分析
static final int PROPAGATE = -3;
// 节点没有特别标记的状态就为0,如初始节点。
volatile int waitStatus;
//此节点的前一个节点。节点的waitStatus依赖于前一个节点的状态。
volatile Node prev;
//此节点的后一个节点。后一个节点是否被唤醒(uppark())依赖于当前节点是否被释放。
volatile Node next;
//节点绑定的线程
volatile Thread thread;
//标记当前节点的模式是共享还是独占
Node nextWaiter;
...
}
AQS
public abstract class AbstractQueuedSynchronizer extends
AbstractOwnableSynchronizer implements java.io.Serializable {
//同步队列的头节点,该节点并没有持有对任何线程对象的引用
private transient volatile Node head;
//等待队列的尾节点
private transient volatile Node tail;
//同步状态
private volatile int state;
......
}
实现原理
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
//在节点加入队列过程中若线程被中断,则会调用该方法,底层调用interrupt()
selfInterrupt();
}
需要子类重写tryAcquire与tryRelease方法利用CAS来修改同步状态status;tryAcquire为false即修改状态失败,说明此时有其它线程获取了锁正在执行,这里的锁指的是执行资格。来看看接下来的两个方法:
多线程下各个线程都会尝试修改状态,如果可以修改则tryAcquire返回true,acquire直接返回;若不能修改,放进队列;
addWaiter
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
// 该方法在两种情况下调用:1,队列为空,会在该方法中先创建一个空的头节点,之后利用循环CAS将node节点添到队列尾部去。
//2,在addWaiter中,由于并发竞争激烈导致之前获得的队列尾部已经失效,cas初次设置失败,enq被调用,在循环cas中不断尝试直到成功。
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
addWaiter方法为了使安全性得到保障,采用循环CAS来将node添加到队列尾部。
acquireQueued
接下来总的逻辑是:放入队列后,会检查前一个节点的状态,前一个节点状态为SIGNAL则挂起当前线程通过LockSupport.park(this);
来看看acquireQueued的实现
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) { // 无限循环保证了竞争下的安全性,只有在轮到自己(即一个节点为head)且竞
//争更改同步状态成功后,根据中断情况返回值
final Node p = node.predecessor();
// 如果当前节点的前一个节点为头节点,则代表等待队列中没有等待的线程,多以再次尝
// 试tryAcquire更改同步状态,
// 若成功则调用setHead将node设为头节点,其thread设为空
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally { // 确保异常情况下该节点会被从队列中删除
if (failed)
cancelAcquire(node); //将节点从等待队列中删除
}
}
private void setHead(Node node) {
head = node;
node.thread = null;
node.prev = null;
}
当前一个节点为head时说明该轮到它了,再次尝试tryAcquire,同新的(还未进入队列)尝试更改同步状态的线程竞争;
没轮到它的时候,会执行shouldParkAfterFailedAcquire,该方法只有在节点状态为SIGNAL返回true,CANCELLED则删除节点,其它情况就用CAS将状态改为SIGNAL;
来看看shouldParkAfterFailedAcquire方法实现:
shouldParkAfterFailedAcquire会将当前节点node的前一个节点的状态变为SIGNAL,若前一个节点状态为CANCELLED则删除
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
/*
* This node has already set status asking a release
* to signal it, so it can safely park.
*/
return true;
if (ws > 0) {
/*
* Predecessor was cancelled. Skip over predecessors and
* indicate retry.
*/
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
/*
* 该情况下waitStatus 一定是0或者PROPAGATE。将其改为SIGNAL,代码回到acquireQueue
* 中,再次for循环一次,再次tryAcquire尝试更改同步状态,之所以如此是因为任务可能很
* 快执行完,当前线程只需等待一瞬即可无需放入队列中阻塞等待,所以代码设计如此。
*/
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
shouldParkAfterFailedAcquire返回true,则会调用parkAndCheckInterrupt将线程挂起,被唤醒后根据线程中断标记来返回boolean值;
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted(); //会清除标记位
}
若一个线程被唤醒后它能得到执行资格吗?这涉及到公平与非公平的问题?非公平指的是当前线程release唤醒head.next节点的线程,它需要与新线程竞争;公平就是在这里加限制,新线程尝试获取锁时会先判断队列里有无等待的节点,有则加入队列等待。举例来说,A之前一直挂起,现在轮到A了,也就是前一个节点是head,与是唤醒A,A在acquireQueued方法for循环中苏醒尝试tryAcquire,但是这时一个新的线程先一步执行acquire方法,先于A 执行tryAcquire,A又得挂起;由此可看出这是非公平的,有线程插队
Release
子类需要实现tryRelease,实现自己的逻辑。此时阻塞队列中的线程未被唤醒,若有一新线程tryAcquire成功(获取锁),意味着head.next节点被插队,线程被唤醒后回到acquireQueued中tryAcquire失败,继续阻塞。
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
//判断条件成立说明队列中还有线程等待被唤醒
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
//唤醒后继节点
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
LockSupport.unpark(s.thread);
}
总结
举例说明变化情况:现在有一个线程N正在执行,阻塞队列为空,A线程tryAcquire失败,addWaiter创建了一个空的Node作为Head,它的next指向A线程的Node,随后进入acquireQueued,再次尝试tryAcquire因为N线程可能已经执行完了,若失败调用shouldParkAfterFailedAcquire,此时将头Head的同步状态由0变为SIGNAL返回false,回到acquireQueued里再次循环,还是尝试再次tryAcquire,失败调用shouldParkAfterFailedAcquire,由于Head的waitStatus为SIGNAL返回true,进入parkAndCheckInterrupt,将A线程阻塞;若又一线程B也失败,它将会将A 的waitStatus变为SIGNAL,排在A的后面阻塞着;
执行的线程完成了,release释放同步状态,唤醒阻塞队列里的线程;接着上面的逻辑,首先N线程tryRelease成功,取出head节点执行unparkSuccessor,将head节点waitStatus重置为0,取出head.next也就是A,LockSupport.unpark唤醒A线程,逻辑回到了A阻塞的地方也就是acquireQueued的for循环里,再次尝试tryAcquire(在这里可能被插队),成功,将A设为head,将原先为空的head的next指针清除以便GC回收;