自旋锁缺点
- 第一个是锁饥饿问题。在锁竞争激烈的情况下,可能存在一个线程一直被其他线程”插队“而一直获取不到锁的情况。
- 第二是性能问题。在实际的多处理上运行的自旋锁在锁竞争激烈时性能较差。
这是因为自旋锁锁状态中心化,在竞争激烈的情况下,锁状态变更会导致多个 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 锁数据结构的改进主要包括三方面:
- 扩展每个节点的状态
- 显式的维护前驱节点和后继节点
- 出队节点显式设为 null 等辅助 GC 的优化。
正是这些改进使 AQS 可以支撑 j.u.c 丰富多彩的同步器实现。
扩展每个节点的状态
AQS 每个节点的状态如下所示
状态名 |
描述 |
---|---|
SIGNAL | 表示该节点正常等待 |
PROPAGATE | 应将 releaseShared 传播到其他节点 |
CONDITION | 该节点位于条件队列,不能用于同步队列节点 |
CANCELLED | 由于超时、中断或其他原因,该节点被取消 |
显式的维护前驱节点和后继节点
通过在节点中显式地维护前驱节点,CLH 锁就可以处理“超时”和各种形式的“取消”。
AQS 用阻塞等待替换了自旋操作,线程会阻塞等待锁的释放,不能主动感知到前驱节点状态变化的信息。AQS 中显式的维护前驱节点和后继节点,需要释放锁的节点会显式通知下一个节点解除阻塞
需要关注的一个细节**:**
由于没有针对双向链表节点的类似 compareAndSet 的原子性无锁插入指令,因此后驱节点的设置并非作为原子性插入操作的一部分,而仅是在节点被插入后简单地赋值。在释放锁时,如果当前节点的后驱节点不可用时,将从利用队尾指针 Tail 从尾部遍历到直到找到当前节点正确的后驱节点。
辅助 GC
JVM 的垃圾回收机制使开发者无需手动释放对象。但在 AQS 中需要在释放锁时显式的设置为 null,避免引用的残留,辅助垃圾回收。