从 Java AQS 看 JUC

引文

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 的花式使用提供了各种方法。所以学习东西一定要先从原理开始,这样才能学的快。

参考资料

  1. JAVA并发编程学习笔记之CLH队列锁
  2. Java 并发编程: 核心理论
  3. A Hierarchical CLH Queue Lock

你可能感兴趣的:(JDK阅读日记)