CLH锁的原理和实现

Posted dm_vincent

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了CLH锁的原理和实现相关的知识,希望对你有一定的参考价值。

前情回顾

上一篇文章中主要讨论了MCS自旋锁的特点和其适用场景,并分析了其原理和实现细节。

MCS锁存在的问题

MCS锁解决了简单自旋锁的一个最大痛点:频繁地缓存同步操作会导致繁重的系统总线和内存的流量,从而大大降低了系统整体的性能。

解决这个问题的思路是将自旋操作限制在一个本地变量上,从而在根本上避免了频繁地多CPU之间的缓存同步。但是MCS锁的实现并不简单,需要注意的事项主要有以下几点:

  1. MCS锁的节点对象需要有两个状态,next用来维护单向链表的结构,blocked用来表示节点的状态,true表示处于自旋中;false表示加锁成功
  2. MCS锁的节点状态blocked的改变是由其前驱节点触发改变的
  3. 加锁时会更新链表的末节点并完成链表结构的维护
  4. 释放锁的时候由于链表结构建立的时滞(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个条件中的任意一个即可:

  1. 正在排队等待加锁
  2. 已经获取到了锁

一旦一个节点被创建出来,由于它最终都会取代tail字段的位置,因此它总能够满足上面的两个条件,所以将active属性默认设置为true是合理的。

那么什么时候它会被设置为false呢?要回答这个问题,需要首先明确CLH锁的自旋方式,这也是它和MCS锁之间最大的一点差异:

CLH锁是在前驱节点的active属性上自旋。

因此当前驱节点释放了锁之后,其对应的active属性就会被设置为false,此时它的后继节点就能够退出自旋并成功地获取到锁。从字面意思上来说,当active被设置为false之后,即表示该节点已经完成:等待加锁 - 加锁成功 - 执行业务 - 释放锁成功 这一标准的流程,节点可以被释放了,因此也就变成了不活跃状态,等待垃圾回收。

另外,CLH节点中并没有指向其后继节点的next属性。但是这并不代表CLH锁不依赖链表这种数据结构,毕竟作为一种公平的自旋锁,CLH还是需要仰仗链表的。只不过这个链表是隐式维护的,通过原子更新器的getAndSet方法在更新tail时,可以在Set的同时获取到原来的tail节点。这也从侧面反映了,为什么CLH锁是在前驱节点的active属性上自旋。每个节点只了解它直接前驱节点的状态,不需要显式地去维护一个完整的链表结构。

锁属性

然后,锁本身的实现有三个属性:

  1. 节点类型tail:表示当前隐式链表的尾部,每个新加入排队的线程都会被放到这个位置
  2. ThreadLocal类型的currentThreadNode:保存的是从线程对象到节点对象实例的映射关系
  3. 针对tail字段的原子更新器AtomicReferenceFieldUpdater:通过AtomicReferenceFieldUpdater对tail字段操作的一层包装,任何操作都不会直接施加在tail上,而是通过它,它提供了一些CAS操作来保证原子性

最重要的就是其中的lock和unlock方法了。简单分析一下实现方法:

lock方法

简单提炼一下此方法的操作步骤和要点:

  1. 获取当前线程和对应的节点对象(不存在则初始化)
  2. 将tail通过getAndSet这一原子操作更新为第一步中得到的节点对象,返回可能存在的前驱节点,如果前驱存在跳转到Step 3;不存在则lock方法直接执行结束:这里的逻辑是隐式链表只有目前一个节点等待进入,因此锁处于可用状态,认为加锁成功
  3. 当前线程开始在前驱节点对象的active字段上自旋等待(等待前驱节点进行释放锁的操作,使其active属性变为false),变为false之后自旋结束,此时认为加锁成功,lock方法执行完毕

unlock方法

简单提炼一下此方法的操作步骤和要点:

  1. 获取当前线程和对应的节点对象;如果节点不存在或者节点active属性为false的话直接返回,因为只有处于active状态的节点才有资格进行释放锁的操作
  2. 清空当前线程对应的节点信息
  3. 利用原子更新器的CAS操作尝试将tail设置为null,此时预期的tail会指向当前节点。如果设置成功的话表示当前节点为链表最末节点,再无后继节点,因此可以直接结束unlock方法的执行,注意到此时连active状态都不需要设置,因为当前节点没有后继节点在其上自旋,也再无其它引用,可以被GC;如果执行不成功的话,表示此时tail指向的并非当前节点,当前节点是存在后继节点的在其active属性上自旋的,因此此时倒需要将active属性设置为false,从而能够通知到后继节点停止自旋

以上加锁和释放锁的每个步骤都有比较详细的注释,相信仔细读的话看懂并不是难事。

main方法

针对CLH锁实现的一个用例,模拟了10个线程抢锁的场景。

总结

实现的代码量相比MCS锁少了很多,也简洁了不少。

需要把握的几个重点:

  1. CLH锁的节点对象只有一个active属性,关于其含义前面已经详细讨论过
  2. CLH锁的节点属性active的改变是由其自身触发的
  3. CLH锁是在前驱节点的active属性上进行自旋

众所周知,AbstractQueuedSynchronizer是Java并发包的基石之一,而CLH锁的原理和思想则是AbstractQueuedSynchronizer的基石之一。理解清楚CLH锁的原理和实现对后面学习和理解AbstractQueuedSynchronizer是非常必要的。另外需要注意的是,以上CLH锁的实现是一种不可重入的独占锁。

参考资料

  1. https://coderbee.net/index.php/concurrent/20131115/577
  2. https://en.wikipedia.org/wiki/Spinlock
  3. https://www.slideshare.net/snakebbf/clh-spinlock

以上是关于CLH锁的原理和实现的主要内容,如果未能解决你的问题,请参考以下文章

算法:CLH锁的原理及实现

多图详解CLH锁的原理与实现,附带学习经验

多图详解CLH锁的原理与实现,先睹为快

多图详解CLH锁的原理与实现,你掌握了多少?

Java面试题中高级,多图详解CLH锁的原理与实现

Java并发编程实战—– AQS:CLH同步队列