自旋锁缺点

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

这是因为自旋锁锁状态中心化,在竞争激烈的情况下,锁状态变更会导致多个 CPU 的高速缓存的频繁同步,从而拖慢 CPU 效率。

什么是 CLH 锁

CLH 锁是对自旋锁的一种改进,有效的解决了以上的两个缺点。

  • 首先它将线程组织成一个队列,保证先请求的线程先获得锁,避免了饥饿问题。
  • 其次锁状态去中心化,让每个线程在不同的状态变量中自旋,这样当一个线程释放它的锁时,只能使其后续线程的高速缓存失效,缩小了影响范围,从而减少了 CPU 的开销

CLH 锁数据结构很简单,类似一个链表队列,所有请求获取锁的线程会排列在链表队列中,自旋访问队列中前一个节点的状态。

当一个节点释放锁时,只有它的后一个节点才可以得到锁。

CLH 锁本身有一个队尾指针 Tail,它是一个原子变量,指向队列最末端的 CLH 节点。

每一个 CLH 节点有两个属性:所代表的线程和标识是否持有锁的状态变量。

当一个线程要获取锁时,它会对 Tail 进行一个 getAndSet 的原子操作。该操作会返回 Tail 当前指向的节点,也就是当前队尾节点,然后使 Tail 指向这个线程对应的 CLH 节点,成为新的队尾节点。

入队成功后,该线程会轮询**上一个队尾节点**的状态变量,当上一个节点释放锁后,它将得到这个锁。

CLH 锁 Java 实现

节点中的状态变量为什么用 volatile 修饰?可以不用 volatile 吗?

Java Memory Model(下称 JMM)规范中有一条 Happens-Before(先行发生)规则:“一个监视器锁上的解锁发生在该监视器锁的后续锁定之前”,synchronized 关键字依靠这个规范解决重排序问题。

自定义互斥锁就需要自己保证这一规则的成立。

因此上述代码通过 volatile 的 Happens-Before(先行发生)规则来解决重排序问题。

JMM 的 Happens-Before(先行发生)规则有一条针对 volatile 关键字的规则:“volatile 变量的写操作发生在该变量的后续读之前”。

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

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

CLH 优缺点分析

优点:

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

缺点:

  • 第一是因为有自旋操作,当锁持有时间长时会带来较大的 CPU 开销。
  • 第二是基本的 CLH 锁功能单一,不改造不能支持复杂的功能。

    AQS 对 CLH 队列锁的改造

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

针对第一个缺点,AQS 将自旋操作改为阻塞线程操作。
针对第二个缺点,AQS 对 CLH 锁进行改造和扩展,原作者 Doug Lea 称之为“CLH 锁的变体”。

AQS 中的对 CLH 锁数据结构的改进主要包括三方面:

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

正是这些改进使 AQS 可以支撑 j.u.c 丰富多彩的同步器实现。

扩展每个节点的状态

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

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

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

通过在节点中显式地维护前驱节点,CLH 锁就可以处理“超时”和各种形式的“取消”。

AQS 用阻塞等待替换了自旋操作,线程会阻塞等待锁的释放,不能主动感知到前驱节点状态变化的信息。AQS 中显式的维护前驱节点和后继节点,需要释放锁的节点会显式通知下一个节点解除阻塞

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

辅助 GC

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