JUC学习笔记:AQS核心数据结构-CLH锁

68 阅读7分钟

什么是AQS?

Java 中常用的锁主要有两类,一种是 Synchronized 修饰的锁,被称为 Java 内置锁或监视器锁。

另一种是JUC包中各类同步器包括ReentrantLock(可重入锁),Semaphore(信号量),CountDownLatch等,这些同步器都是基于AbstractQueuedSynchronizer(下称 AQS)来构建的。

而AQS类的核心数据结构是一种名为Craig, Landin, and Hagersten locks(下称 CLH 锁)的变体。

CLH锁

CLH锁是对自旋锁的一种改良。

自旋锁是互斥锁的一种实现

public class SpinLock {
    private AtomicReference<Thread> owner = new AtomicReference<Thread>();

    public void lock() {
        Thread currentThread = Thread.currentThread();
        // 如果锁未被占用,则设置当前线程为锁的拥有者
        while (!owner.compareAndSet(null, currentThread)) {
        }
    }

    public void unlock() {
        Thread currentThread = Thread.currentThread();
        // 只有锁的拥有者才能释放锁
        owner.compareAndSet(currentThread, null);
    }
}

自旋锁通过不断检查锁的状态来尝试获取锁,如果锁已被其他线程占用,当前线程会在一个循环中反复尝试获取锁,而不会让出 CPU 执行权。

自旋锁实现简单,同时避免了操作系统进程调度和线程上下文切换的开销,但他有两个缺点:

  1. 锁饥饿问题。在锁竞争激烈的情况下,可能存在一个线程一直被其他线程”插队“而一直获取不到锁的情况。
  2. 性能问题。在实际的多处理上运行的自旋锁在锁竞争激烈时性能较差。

针对以上两个问题,CLH对自旋锁进行了改进。

CLH是一种基于链表的自旋锁。

CLH锁将线程组织成一个队列,保证先请求的进程先获得锁。

CLH锁只会自旋获取前驱节点的锁,避免了大量线程同时竞争锁

CLH锁Java实现

public class CLH{
    private final ThreadLocal<Node> node = ThreadLocal.withInitial(Node::new);
    //原子变量,指向队列最末端的 CLH 节点。
    private final AtomicReference<Node> tail = new AtomicReference<>(new Node());
    //CLH锁节点
    private static class Node{
        //锁状态:true表示线程获取到锁或者正在等待
        //这里的volatile不是为了volatile的内存可见性,而是防止指令重排序
        private volatile boolean locked;
    }

    public void lock() {
        Node node = this.node.get();
        node.locked = true;
        //入队操作,把自己的Node节点设置为tail指向的新节点,并把之前的tail节点作为前驱节点
        Node pre = this.tail.getAndSet(node);
        while(pre.locked);
    }
    public void unlock() {
        final Node node = this.node.get();
        node.locked = false;
        //this.node.set(new Node()) 没有这行代码,Node可能被复用,导致死锁
        this.node.set(new Node());
    }
}

使用 volatile 修饰状态变量不是为了利用 volatile 的内存可见性,因为这个状态变量只会被持有该状态变量的线程写入,只会被队列中该线程的后驱节点对应的线程读,而且后者会轮询读取。因此,可见性问题不会影响锁的正确性。

使用volatile是为了解决重排序的问题,在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得到正确的结论。

对于 Java synchronized 关键字提供的内置锁(又叫监视器),Java Memory Model(下称 JMM)规范中有一条 Happens-Before(先行发生)规则:“一个监视器锁上的解锁发生在该监视器锁的后续锁定之前”,因此 JVM 会保证这条规则成立。

自定义互斥锁就需要自己保证这条规则成立,因此上述代码通过volatile的Happens-Before(先行发生)规则来解决重排序问题。JMM 的 Happens-Before(先行发生)规则有一条针对 volatile 关键字的规则:“volatile 变量的写操作发生在该变量的后续读之前”

CLH锁是一个链表队列,为什么Node节点没有指向前驱或后继指针呢?

CLH锁是一种隐式的链表队列,没有显式的维护前驱或后继执政。因为每个等待获取锁的线程只需要轮询前一个节点的状态就够了,而不需要遍历整个队列。在这种情况下,只需要使用一个局部变量保存前驱节点,而不需要显示的维护前驱或者后继指针。

this.node.set(new Node())死锁情况:

CLH 优缺点

优点:

  1. 性能优异,获取和释放锁开销小。CLH 的锁状态不再是单一的原子变量,而是分散在每个节点的状态中,降低了自旋锁在竞争激烈时频繁同步的开销。在释放锁的开销也因为不需要使用 CAS 指令而降低了。
  2. 公平锁。先入队的线程会先得到锁。
  3. 实现简单,易于理解。
  4. 扩展性强。下面会提到 AQS 如何扩展 CLH 锁实现了 j.u.c 包下各类丰富的同步器。

缺点:

  1. 自旋操作,当锁持有时间长时会带来较大的CPU开销。
  2. 基本的CLH锁功能单一,不改造不能支持复杂的功能

AQS对CLH队列锁的改造

针对CLH的缺点,AQS对CLH队列锁进行了一定的改造。

针对第一个缺点,AQS将自旋操作改为阻塞线程操作

针对第二个缺点,AQS对CLH锁进行改造和扩展,主要包括三个方面:

  1. 扩展每个节点的状态
  2. 显式的维护前驱节点和后继节点
  3. 诸如出队节点显式设为 null 等辅助 GC 的优化

扩展每个节点状态

AQS每个节点的状态如下所示:

volatile int waitStatus; 

AQS同样提供了改状态变量的原子读写操作,但和同步器状态不同的是,节点状态在AQS中被清晰定义,如下表所示:

状态名描述
SIGNAL表示该节点正常等待
PROPAGATE应将 releaseShared 传播到其他节点
CONDITION该节点位于条件队列,不能用于同步队列节点
CANCELLED由于超时、中断或其他原因,该节点被取消

显式的维护前驱节点和后继节点

上文我们提到在原始版本的 CLH 锁中,节点间甚至都没有互相链接。但是,通过在节点中显式地维护前驱节点,CLH 锁就可以处理“超时”和各种形式的“取消”:如果一个节点的前驱节点取消了,这个节点就可以滑动去使用前面一个节点的状态字段。对于通过自旋获取锁的 CLH 锁来说,只需要显式的维护前驱节点就可以实现取消功能,如下图所示:

但是在 AQS 的实现稍有不同。因为 AQS 用阻塞等待替换了自旋操作,线程会阻塞等待锁的释放,不能主动感知到前驱节点状态变化的信息。AQS 中显式的维护前驱节点和后继节点,需要释放锁的节点会显式通知下一个节点解除阻塞,如下图所示,Thread1 释放锁后主动唤醒 Thread2,使 Thread2 检测到锁已释放,获取锁成功。

细节:由于没有针对双向链表节点的类似 compareAndSet 的原子性无锁插入指令,因此后驱节点的设置并非作为原子性插入操作的一部分,而仅是在节点被插入后简单地赋值。在释放锁时,如果当前节点的后驱节点不可用时,将从利用队尾指针 Tail 从尾部遍历到直到找到当前节点正确的后驱节点。

辅助 GC

JVM 的垃圾回收机制使开发者无需手动释放对象。但在 AQS 中需要在释放锁时显式的设置为 null,避免引用的残留,辅助垃圾回收。