引文
Java JDK 中的 JUC 包,提供了非常丰富的并发工具类,包括 ReentrantLock , Semaphore , CountDownLoatch 甚至是 ThreadPoolExectur 中的 Worker 其实都是基于同一个超类的实现,这个就是 AbstractQueuedSynchronizer ,简称 AQS。
功能改善
AQS 提供了一个阻塞同步的框架,AQS 自身不实现具体的阻塞同步实现,而是提供了一个改造的 CLH 队列,用于实现阻塞同步。
同步的背景 – AQS 和 synchronized
我们知道在 Java 中,最常用的同步方式,是使用 synchronized 同步原语,然而 synchronized 本质上依旧是通过 CAS 操作实现的自旋锁,虽然很多文章提到过 synchronized 方法现在性能已经极大的提升了,还会介绍大量的 synchronized 的实现原理,包括重量级锁,轻量级锁,偏向锁 (参考博客Java 并发编程: 核心理论 的分析,但是其实究其本质,synchronized 的优化方向其实一直都是,假设现在现在竞态较低,或者几乎没有锁竞争的时候,如何以最小的代价实现同步。总结下来就是
synchronized 适用于锁竞争不强的场景使用,它的种种优化,都是加速当前锁竞争较低,如何优化性能,
让锁更轻量,占用更少的系统资源。
然而如果当前锁竞争十分强,继续使用自旋锁,会极大地耗费计算资源,造成大量的 CPU 空转。为了解决这个问题,阻塞同步策略应运而生。
阻塞同步的本质
由于自旋锁空转的特性,阻塞同步本质上是通过挂起获取锁失败的线程实现的,在锁释放的时候再唤醒被挂起的线程,这样就极大地减少了锁竞争,提升了性能。
AQS 实现
了解了上述的前因,我们来看看 AQS 怎么实现的。在具体讲怎么实现之前,我们需要先介绍一点基础知识。
CLH 队列
CLH 队列的名称,来自三个人名,分别是 ( Craig, Landin 和 Hagersten )。 CLH 队列中,每个节点会有一个 locked 字段,这个字段用于标识当前是否需要持有锁,并且每个节点还会有一个指向前缀节点的指针。结构如下
class CLHNode {
bool isLocked;
CLHNode prev;
}
当线程需要获取锁的时候,会创建一个新的节点,这个节点的 isLocked = true,表示需要获取锁,然后将节点入队列,并且让 prev 指向前缀节点。在队列头部的节点,将会获取锁,当线程释放锁的时候,会将 isLocked 置为 false,表示不需要获取锁了,后续的节点,将会自旋前缀节点的 isLocked 字段,当发现 isLocked = false 的时候,线程会将自身对应的节点置为队列头部,表示当前获取锁。与 CLH 队列相类似的还有一个 MCS 队列,两个队列的区别在于 CLH 自旋前缀节点的 isLocked 字段,而 MCS 自旋自身的 isLocked 队列。在对称多处理器的环境下,一般使用 CLH 队列。具体可以参考论文 A Hierarchical CLH Queue Lock
注意:CLH 队列其实本质上还是一个使用自旋方式实现的队列。
JUC LockSupport 类
我们刚刚提到了阻塞同步的一个重要特性是使线程挂起,在 Java 的 JUC 包中,有一个 LockSupport 方法,这个方法的作用就是用于挂起和唤起线程的。这个类的实现方法都是通过 native 实现的。
AQS
AQS 并没有使用原始的 CLH 队列,而是在 CLH 中做了一些改造,最主要的改造就是使用 LockSupport 将线程挂起,使得其不再是自旋而是改为了挂起和唤醒。AQS 使用了 CLH ,因为 CLH 更易于实现取消和超时操作。并且 AQS 添加了一个用 volatile 修饰的 state 变量用于表示状态。
AQS 的应用
到这里我们了解到,AQS 提供了一个锁获取保护机制,这个机制会让暂时没有获取锁的线程等待进入队列,当可以获取锁的时候被唤醒。Java 中 JUC 包几乎都是通过这个机制实现的,来我们一个一个剖析。(注意:我在后面的剖析中,只讨论主要的思想核心,不讨论实现细节)
ReentrantLock 的实现原理
ReentrantLock 是一个 synchronized 很好的替代方案,在高并发的情况下拥有比 synchronized 更好的性能,而 ReentrantLock 就是一个基于 AQS 的一个简单实现。我们知道 ReentrantLock 拥有两个使用方式,一个是公平锁,一个是非公平锁,除此之外 ReentrantLock 还有一个很好的特性就是可重入性。
ReentrantLock 的重入性
重入性主要指当一个线程获取锁的时候,它可以重复多次获取锁,直达它在所有的地方退出锁的时候才结束。ReentrantLock 实现这个用了一个很简单的策略,就是直接使用一个 state 值,用于保存当前锁被线程持有的情况。
state == 0 表示当前锁没有被任何线程持有 state > 0 表示当前锁被某个线程持有中
公平锁如何实现
ReentrantLock 锁加速有两个关键实现,一个是 tryLock 尝试获取锁,另一个是 lock 获取锁,如果失败会一直阻塞到获取锁为止。
对于 tryLock 可以看其具体实现的代码
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
而所谓线程对锁的竞争,其实就是当发现 state == 0 的时候,尝试通过 CAS 操作将 state 赋值为 1 ,成功的线程即为成功的线程,并被记录(宣誓主权)
而对于 lock
操作来说
final void lock() {
acquire(1);
}
实际上就是调用了 AQS 的入队列方法,将当前线程加入 AQS 队列中进行排队,等待获取锁的机会。
非公平锁
根据前面的内容,我们知道当锁资源被释放的时候,AQS 队列会唤醒队列后续的线程,使其尝试获取锁。这个获取过程是要消耗时间的,非公平锁的非公平就在于它会在一开始的时候就检查当前 state 是否为 0 ,如果是,当前线程就会直接尝试竞争获取锁,而不给之前排在队列的线程机会。我们看非公平锁的 lock 操作
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
这样做可以减少线程被锁 hold 住的时间,有助于提高并发率。所以一般情况下,我们没有特殊需求的话,都会优先使用非公平锁,而 ReentrantLock 的默认构造函数也提供的是一个非公平锁。
Semaphore (信号量) 实现原理
有了 ReentrantLock 的介绍再介绍 Semaphore 就很简单了, 不同于 ReentrantLock 使用 state 表示线程重入的次数, Semaphore 使用 state 表示当前还剩有的信号量,其余和 ReentrantLock 基本一致。要注意 Semaphore 不支持重入性。Semaphore 同样也支持公平和非公平,不过其实实现和 ReentrantLock 大同小异。
CountDownLatch 与 CyclicBarrier
有了 Semaphore 的介绍, CountDownLatch 和 CyclicBarrier 都变得很好理解,对于 CountDownLatch 来说,它使用 state 来标识还没有被取用的数量,当取用完成之后则进行,这和 Semaphore 区别仅仅在于当 state = 0 的时候, Semaphore 认为应该锁住不再继续,而 CountDownLatch 恰恰相反,认为被解放了,所以要释放执行。而 CyclicBarrier 和 CountDownLatch 的区别在于 CyclicBarrier 到达临界点的时候是多个线程都会同时执行,相当于等待锁被释放的信号。
ReentrantReadWriteLock 读写锁
读写锁这个比较特别,和 ReentrantLock 一样它是可重入的,那这意味着它需要同时记录持有读和持有写的线程的情况,并且要保证变更是原子的。如果使用两个变量,由于变量互相独立有可能变更不一致,为了防止这种情况发生,ReentrantReadWriteLock 将一个 state 掰成两个部分,高位表示写重入的数量,地位表示读重入的数量。这样的做法就能保证操作是原子的了,但是这样也会导致可重入的上限被极大的减少,这是一个平衡取舍的结果。
总结
到此基本上将 JUC 中的工具都给总结了一遍,Java 中的 JUC 包基本上都是基于 AQS 实现的, AQS 提供了一个 AQS 队列的实现的模板方法,让具体的实现类通过 AQS 快速实现相关功能。 AQS 本质上是个自旋等待的 CLH 队列,但是通过 LockSupport 方法将本需要自旋等待的线程给挂起了,减少了对 cpu 的占用。除此之外还提供了 state 等变量给子类使用。基于 AQS 的能力,JUC 通过对 state 的花式使用提供了各种方法。所以学习东西一定要先从原理开始,这样才能学的快。