AQS
什么是AQS
我们在使用锁时,一般会有如下几点考虑:
- 希望这个锁是同一时间只能被一个线程获取的,还是能够被多个线程获取。
- 当几个线程在同时争抢锁的占有权时,怎么处理没有获取到锁的线程
- 某个线程一直获取不到锁,怎么处理中断
基于JVM实现的锁Synchronized只支持独占,并且无法处理中断,也不支持公平和非公平的选择。所以在JDK1.5时引入了基于AQS实现的锁,它们功能更加地强大,比如ReentrantLock、CountDownLatch、CyclicBarrier等等。
AQS即AbstractQueuedSynchronizer的缩写,它是Java并发用来构建锁和其他同步组件的基础框架。在内部维护了一个Volatile的变量state和一个CLH队列
state
代表着共享资源,获取和释放锁本质上就是对state进行的修改,修改成功则获取锁,否则会被加入到等待队列
state值 | 描述 |
---|---|
0 | 当前锁没有线程持有 |
1 | 被线程持有 |
大于1 | 同一个线程重复获得了锁(锁的可重入性) |
CLH队列
CLH队列:是一个FIFO的双端双向队列,如下图:
该队列由Node结点构成,每个Node结点维护一个pre引用和next引用,分别指向前驱节点和后继节点。AQS维护两个指针,分别指向队列的头结点和尾节点
CLH队列其实就是一个双端双向链表,当线程获取资源失败(tryAcquire失败)时会被构造成一个Node结点加入CLH队列,同时该线程会被阻塞。当持有锁的线程释放锁时会唤醒后继结点再次尝试获取锁
Node结点
获取锁失败的线程会被包装成一个Node结点加入到CLH队列中,它是AQS中的一个静态内部类,其中有一个int变量waitStatus标识结点的状态
waitStatus值 | 描述 |
---|---|
CANCELLED (1) | 表示当前线程超时、中断 |
SIGNAL (-1) | 表示后续节点会被唤醒 |
PROPAGATE (-2) | 表示下一次唤醒是共享的,会无条件地传播下去(主要实现共享锁) |
waitStatus (0) | 初始状态 |
AQS的使用
AQS采用了模板方法模式进行设计,使用方法如下:
-
使用者继承AbstractQueuedSynchronizer并重写指定的方法(就是对于共享资源state的修改操作)
-
在具体的锁中创建一个内部类继承自AQS,并调用其模板方法
需要重写的方法:
方法名 | 描述 |
---|---|
tryAcquire | 独占获取锁,成功返回true,失败false |
tryRelease | 独占释放锁,等待队列中的其他线程此时将有机会获取到同步状态 |
tryAcquireShared | 共享获取锁,返回值大于等于0表示成功 |
tryReleaseShared | 共享释放锁,成功返回true |
如何使用
首先,我们需要去继承AQS类,然后根据需要去重写对应的方法,比如要实现独占锁就去重写tryAcquire、tryRelease方法。最后在我们的锁中调用AQS的模板方法就可以了,而这些模板方法会调用我们重写的方法
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
tryAcquire就是我们需要重写的,而像addWaiter、acquireQueued方法是AQS为我们写好的
AQS源码分析
接下来通过ReentrantLock、CoutDownLatch来分别讲下AQS的独占功能和共享功能以及整个加锁释放锁流程是怎么实现的
独占锁
假设这里有3个线程ThreadA、ThreadB、ThreadC,一个独占锁ReentrantLock
第一个线程获取锁
在进行锁的创建时通过传入的boolean值决定该锁是公平锁还是非公平锁。如果不传入值则默认是非公平锁
整个锁的获取流程如上图所示:首先线程调用lock方法开始尝试获取锁,然后会调用acquire方法,AQS会调用重写的tryAcquire方法
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
任何一种基于AQS实现的独占锁都会来到这一步,各种锁通过对tryAcquire的不同的重写方式实现了不同的功能。比如ReentrantLock就是通过两个不同的内部类重写了tryAcquire方法来实现了公平与非公平锁
公平与非公平的区别就是公平锁在尝试获取时会进行一次判断,当前等待队列中是否有结点而且是当前结点的前驱节点
最开始等待队列是这个样子:
接着通过CAS操作更新state的值,更改成功,则获取锁成功
如果State的值不为0,也就是说当前锁已经被占有了,那就判断是否是重入地获得锁。
第一个线程时由于tryAcquire方法成功,因此能够直接获取锁
第二个线程尝试获取锁
依然是会调用tryAcquire方法,这里以公平锁为例
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
此时由于state状态不为0,并且获取锁的线程不是当前线程,因此tryAcquire方法会失败。就会将该线程加入等待队列中
private Node addWaiter(Node mode) {
Node node = new Node(mode);
for (;;) {
Node oldTail = tail;
if (oldTail != null) {
U.putObject(node, Node.PREV, oldTail); //将当前节点的pre结点设置为前尾节点
if (compareAndSetTail(oldTail, node)) { //通过CAS操作将当前结点设置为尾节点
oldTail.next = node;
return node;
}
} else {
initializeSyncQueue(); //如果等待队列没有初始化则先进行初始化
}
}
}
这里是一个自旋操作,假如当前尾节点不为空,说明等待队列已经被初始化,那么会通过CAS操作将当前结点设置为尾节点,否则会初始化这个队列。
不过初始化这个队列时并不会将当前结点作为头结点插入,而是会新建一个头结点,然后自旋再次进入将当前节点作为首节点。这样做的目的主要是为了后续的阻塞唤醒操作
接着就是通过自旋获取锁或者进入阻塞状态
final boolean acquireQueued(final Node node, int arg) {
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor(); //获取当前节点的前驱结点
if (p == head && tryAcquire(arg)) { //只有前驱节点是头结点,也就是当前结点是等待队列中的第一个线程时才会再次尝试获取锁
setHead(node); //当前结点获取锁成功后则置为头结点
p.next = null; // help GC
return interrupted;
}
//假如不是首节点或者获取锁失败,那么就要判断是否应该进入阻塞状态
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} catch (Throwable t) {
cancelAcquire(node);
throw t;
}
}
定义头结点的目的是为了第一个获取锁失败的线程进入等待队列后使它能够进入阻塞状态
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus; //获取前驱结点的状态
if (ws == Node.SIGNAL) //表示前驱节点在将来是会唤醒当前结点的,当前节点可以放心地进入阻塞状态
return true;
if (ws > 0) { //前驱节点被中断获异常,应该将前驱节点从队列中移出,并且为了保证当前节点能够进入阻塞状态需要往前寻找有效的前驱结点
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node; //将重新找到的有效的前驱节点的next指向当前节点
} else {
pred.compareAndSetWaitStatus(ws, Node.SIGNAL);
}
return false;
}
由于是一个自旋操作,所以就算前驱结点的状态不为SIGNAL,也会先将前驱节点的状态置为SIGNAL,然后后面会再次执行shouldParkAfterFailedAcquire方法,这一次成功后就能接着执行parkAndCheckInterrupt将当前结点阻塞
一个线程是否能够进入阻塞状态和它的前驱节点有关,它必须得确保自己进入阻塞后将来能够被其他线程唤醒才会安心地阻塞。这也是为什么初始化队列时要新建一个头结点而不是直接将当前节点作为头结点的原因。因为当前结点是可能会阻塞的。
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this); //阻塞当前节点
return Thread.interrupted();
}
此时等待队列如下:
第三个线程获取锁
ThreadC线程尝试获取锁也是同样的,它会将ThreadB的waitStatus也变为SIGNAL
第一个线程释放锁
protected final boolean tryRelease(int releases) {
int c = getState() - releases; //并不是直接置为0,因为可能是重入锁
if (Thread.currentThread() != getExclusiveOwnerThread()) //当前线程必须是锁的占有线程
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null); //将锁的占有线程清空
}
setState(c);
return free;
}
释放锁的操作其实也是对State的值进行更改,因为是可重入的锁,所以并不是直接将State置为0
public final boolean release(int arg) {
if (tryRelease(arg)) { //释放锁成功
Node h = head;
if (h != null && h.waitStatus != 0) //头结点非空并且头结点的状态不为0
unparkSuccessor(h); //唤醒等待队列中的下一个结点
return true;
}
return false;
}
private void unparkSuccessor(Node node) {
int ws = node.waitStatus; //获取头结点的状态
if (ws < 0)
node.compareAndSetWaitStatus(ws, 0);
Node s = node.next;
if (s == null || s.waitStatus > 0) { //下一个结点被中断或者为空
s = null;
for (Node p = tail; p != node && p != null; p = p.prev) //从尾部开始向前遍历将中断的结点从等待队列中移除
if (p.waitStatus <= 0)
s = p;
}
if (s != null)
LockSupport.unpark(s.thread); //唤醒结点
}
首先会将前驱结点的waitStatus通过CAS操作设置为0,接下来会进行判断,假如当前节点的状态>0,说明出现了异常,那么会从为尾节点开始到当前节点的所有中断的结点都从等待队列中移除,然后唤醒当前首节点。
唤醒后首节点会通过自旋获取锁,这次获取成功将自己也置为头结点
这就是独占锁的获取释放功能
共享锁
可以发现共享锁和独占锁的加锁流程几乎一致。
这里以CountDownLatch为例
在CountDownLatch中是await、countDown方法,其实就是acquire、release方法
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
public void countDown() {
sync.releaseShared(1);
}
acquireShared和acquireSharedInterruptibly的区别就是后者被中断时会抛出异常
初始化CountDownLatch时会传入一个值就是state的值,最开始不为0所以获取锁都会失败。因此都会调用doAcquireShared方法进入等待队列中,这里就和acquireQueued方法几乎一致
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1; //state值为0时则获取锁成功,否则失败
}
最开始时各个线程调用await方法时,由于state都不为0,所以获取锁都失败
此时的等待队列如下:
因此会调用和独占锁类似的那一套流程,包装成结点加入等待队列中,然后判断是否阻塞,修改前驱结点的状态,当前线程进入阻塞状态
当调用countDown方法后:
public void countDown() {
sync.releaseShared(1); //释放锁
}
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
更新state的值。
protected boolean tryReleaseShared(int releases) {
for (;;) { //自旋操作保证一定能够释放锁成功
int c = getState();
if (c == 0) //锁已经被释放了。因为锁是共享锁,所以可能存在并发释放的情况
return false;
int nextc = c - 1;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
释放共享锁成功后的操作
private void doReleaseShared() {
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
if (!h.compareAndSetWaitStatus(Node.SIGNAL, 0))
continue; // CAS操作失败
unparkSuccessor(h); //唤醒等待队列中的结点
}
else if (ws == 0 &&
!h.compareAndSetWaitStatus(0, Node.PROPAGATE)) //将头结点的状态置为PROPAGATE表示将才去传播的方式连续唤醒等待队列中的所有节点
continue;
}
if (h == head)
break; //假如头结点发生了改变
}
}
此时的等待队列如下:
唤醒等待队列里面的首节点后,会继续执行doAcquireShared里面的自旋操作,这一次由于state的值已经为0,所以tryAcquireShared为true,接下来就能够获取到锁。
而和独占锁获取到锁不同的是:共享锁获取到锁后会调用setHeadAndPropagate方法:
private void doAcquireShared(int arg) {
final Node node = addWaiter(Node.SHARED);
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg);
if (r >= 0) {
setHeadAndPropagate(node, r); //将当前结点设置为头结点并继续唤醒下一个结点
p.next = null; // help GC
if (interrupted)
selfInterrupt();
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} catch (Throwable t) {
cancelAcquire(node);
throw t;
}
}
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below
setHead(node);
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}
此时的等待队列如下:
又会调用doReleaseShared方法,它会唤醒等待队列中的后续结点(也就是B结点)。由于上面调用countDownLatch后已经将State的状态置为0,并且setHeadAndPropagate方法将前驱节点置为头结点。因此后续节点自旋操作获取锁时也能成功