CLH锁的原理和实现
Posted dm_vincent
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了CLH锁的原理和实现相关的知识,希望对你有一定的参考价值。
前情回顾
上一篇文章中主要讨论了MCS自旋锁的特点和其适用场景,并分析了其原理和实现细节。
MCS锁存在的问题
MCS锁解决了简单自旋锁的一个最大痛点:频繁地缓存同步操作会导致繁重的系统总线和内存的流量,从而大大降低了系统整体的性能。
解决这个问题的思路是将自旋操作限制在一个本地变量上,从而在根本上避免了频繁地多CPU之间的缓存同步。但是MCS锁的实现并不简单,需要注意的事项主要有以下几点:
- MCS锁的节点对象需要有两个状态,next用来维护单向链表的结构,blocked用来表示节点的状态,true表示处于自旋中;false表示加锁成功
- MCS锁的节点状态blocked的改变是由其前驱节点触发改变的
- 加锁时会更新链表的末节点并完成链表结构的维护
- 释放锁的时候由于链表结构建立的时滞(getAndSet原子方法和链表建立整体而言并非原子性),可能存在多线程的干扰,需要使用忙等待保证链表结构就绪
那么还有没有更轻量的自旋锁方案呢?有!CLH锁。实际上在AbstractQueuedSynchronizer中利用的就是它的一种变体来完成线程之间的排队和同步。
本文就来介绍它的原理和相应实现。
CLH锁
同MCS自旋锁一样,CLH也是一种基于单向链表(隐式创建)的高性能、公平的自旋锁,申请加锁的线程只需要在其前驱节点的本地变量上自旋,从而极大地减少了不必要的处理器缓存同步的次数,降低了总线和内存的开销。
先上实现代码,然后在分析重点:
public class CLHLockV2
/**
* CLH锁节点状态 - 每个希望获取锁的线程都被封装为一个节点对象
*/
private static class CLHNodeV2
/**
* 默认状态为true - 即处于等待状态或者加锁成功(换言之,即此节点处于有效的一种状态)
*/
volatile boolean active = true;
/**
* 隐式链表最末等待节点
*/
private volatile CLHNodeV2 tail = null;
/**
* 线程对应CLH节点映射
*/
private ThreadLocal<CLHNodeV2> currentThreadNode = new ThreadLocal<>();
/**
* 原子更新器
*/
private static final AtomicReferenceFieldUpdater UPDATER = AtomicReferenceFieldUpdater
.newUpdater(
CLHLockV2.class,
CLHNodeV2.class,
"tail");
/**
* CLH加锁
*/
public void lock()
CLHNodeV2 cNode = currentThreadNode.get();
if (cNode == null)
cNode = new CLHNodeV2();
currentThreadNode.set(cNode);
// 通过这个操作完成隐式链表的维护,后继节点只需要在前驱节点的locked状态上自旋
CLHNodeV2 predecessor = (CLHNodeV2) UPDATER.getAndSet(this, cNode);
if (predecessor != null)
// 自旋等待前驱节点状态变更 - unlock中进行变更
while (predecessor.active)
// 没有前驱节点表示可以直接获取到锁,由于默认获取锁状态为true,此时可以什么操作都不执行
// 能够执行到这里表示已经成功获取到了锁
/**
* CLH释放锁
*/
public void unlock()
CLHNodeV2 cNode = currentThreadNode.get();
// 只有持有锁的线程才能够释放
if (cNode == null || !cNode.active)
return;
// 从映射关系中移除当前线程对应的节点
currentThreadNode.remove();
// 尝试将tail从currentThread变更为null,因此当tail不为currentThread时表示还有线程在等待加锁
if (!UPDATER.compareAndSet(this, cNode, null))
// 不仅只有当前线程,还有后续节点线程的情况 - 将当前线程的锁状态置为false,因此其后继节点的lock自旋操作可以退出
cNode.active = false;
/**
* 用例
*
* @param args
*/
public static void main(String[] args)
final CLHLockV2 lock = new CLHLockV2();
for (int i = 1; i <= 10; i++)
new Thread(generateTask(lock, String.valueOf(i))).start();
private static Runnable generateTask(final CLHLockV2 lock, final String taskId)
return () ->
lock.lock();
try
Thread.sleep(3000);
catch (Exception e)
System.out.println(String.format("Thread %s Completed", taskId));
lock.unlock();
;
节点定义以及锁拥有的字段
节点定义
同MCS锁一样的是,首先需要定义一个节点对象。一个节点即代表了一个申请加锁的线程对象,节点的active属性表示的是该节点是否处于活跃状态,关于这个是否活跃状态,需要满足下面2个条件中的任意一个即可:
- 正在排队等待加锁
- 已经获取到了锁
一旦一个节点被创建出来,由于它最终都会取代tail字段的位置,因此它总能够满足上面的两个条件,所以将active属性默认设置为true是合理的。
那么什么时候它会被设置为false呢?要回答这个问题,需要首先明确CLH锁的自旋方式,这也是它和MCS锁之间最大的一点差异:
CLH锁是在前驱节点的active属性上自旋。
因此当前驱节点释放了锁之后,其对应的active属性就会被设置为false,此时它的后继节点就能够退出自旋并成功地获取到锁。从字面意思上来说,当active被设置为false之后,即表示该节点已经完成:等待加锁 - 加锁成功 - 执行业务 - 释放锁成功 这一标准的流程,节点可以被释放了,因此也就变成了不活跃状态,等待垃圾回收。
另外,CLH节点中并没有指向其后继节点的next属性。但是这并不代表CLH锁不依赖链表这种数据结构,毕竟作为一种公平的自旋锁,CLH还是需要仰仗链表的。只不过这个链表是隐式维护的,通过原子更新器的getAndSet方法在更新tail时,可以在Set的同时获取到原来的tail节点。这也从侧面反映了,为什么CLH锁是在前驱节点的active属性上自旋。每个节点只了解它直接前驱节点的状态,不需要显式地去维护一个完整的链表结构。
锁属性
然后,锁本身的实现有三个属性:
- 节点类型tail:表示当前隐式链表的尾部,每个新加入排队的线程都会被放到这个位置
- ThreadLocal类型的currentThreadNode:保存的是从线程对象到节点对象实例的映射关系
- 针对tail字段的原子更新器AtomicReferenceFieldUpdater:通过AtomicReferenceFieldUpdater对tail字段操作的一层包装,任何操作都不会直接施加在tail上,而是通过它,它提供了一些CAS操作来保证原子性
最重要的就是其中的lock和unlock方法了。简单分析一下实现方法:
lock方法
简单提炼一下此方法的操作步骤和要点:
- 获取当前线程和对应的节点对象(不存在则初始化)
- 将tail通过getAndSet这一原子操作更新为第一步中得到的节点对象,返回可能存在的前驱节点,如果前驱存在跳转到Step 3;不存在则lock方法直接执行结束:这里的逻辑是隐式链表只有目前一个节点等待进入,因此锁处于可用状态,认为加锁成功
- 当前线程开始在前驱节点对象的active字段上自旋等待(等待前驱节点进行释放锁的操作,使其active属性变为false),变为false之后自旋结束,此时认为加锁成功,lock方法执行完毕
unlock方法
简单提炼一下此方法的操作步骤和要点:
- 获取当前线程和对应的节点对象;如果节点不存在或者节点active属性为false的话直接返回,因为只有处于active状态的节点才有资格进行释放锁的操作
- 清空当前线程对应的节点信息
- 利用原子更新器的CAS操作尝试将tail设置为null,此时预期的tail会指向当前节点。如果设置成功的话表示当前节点为链表最末节点,再无后继节点,因此可以直接结束unlock方法的执行,注意到此时连active状态都不需要设置,因为当前节点没有后继节点在其上自旋,也再无其它引用,可以被GC;如果执行不成功的话,表示此时tail指向的并非当前节点,当前节点是存在后继节点的在其active属性上自旋的,因此此时倒需要将active属性设置为false,从而能够通知到后继节点停止自旋
以上加锁和释放锁的每个步骤都有比较详细的注释,相信仔细读的话看懂并不是难事。
main方法
针对CLH锁实现的一个用例,模拟了10个线程抢锁的场景。
总结
实现的代码量相比MCS锁少了很多,也简洁了不少。
需要把握的几个重点:
- CLH锁的节点对象只有一个active属性,关于其含义前面已经详细讨论过
- CLH锁的节点属性active的改变是由其自身触发的
- CLH锁是在前驱节点的active属性上进行自旋
众所周知,AbstractQueuedSynchronizer是Java并发包的基石之一,而CLH锁的原理和思想则是AbstractQueuedSynchronizer的基石之一。理解清楚CLH锁的原理和实现对后面学习和理解AbstractQueuedSynchronizer是非常必要的。另外需要注意的是,以上CLH锁的实现是一种不可重入的独占锁。
参考资料
- https://coderbee.net/index.php/concurrent/20131115/577
- https://en.wikipedia.org/wiki/Spinlock
- https://www.slideshare.net/snakebbf/clh-spinlock
以上是关于CLH锁的原理和实现的主要内容,如果未能解决你的问题,请参考以下文章