AQS的前菜—详解CLH队列锁

什么是CLH队列锁

CLH锁其实就是一种基于逻辑队列非线程饥饿的一种自旋公平锁。当多线程竞争一把锁时,获取不到锁的线程,会排队进入CLH队列的队尾,然后自旋等待,直到其前驱线程释放锁。由于是 Craig、Landin 和 Hagersten三位大佬的发明,因此命名为CLH锁。

自旋锁与互斥锁的区别

由于CLH锁是一种自旋锁,那么我们先来看看自旋锁是什么?

自旋锁说白了也是一种互斥锁,只不过没有抢到锁的线程会一直自旋等待锁的释放,处于busy-waiting的状态,此时等待锁的线程不会进入休眠状态,而是一直消耗CPU判断是否有资格获取锁。因此自旋锁适用于锁占用时间短的场合。

这里,我们可以总结出,CLH队列锁,如果队列过长(等待锁的线程过多),势必会给CPU带来极大的压力,因此CLH锁的使用场景就被局限住了。

Java中ReentrantLock重入锁中的AQS队列排队策略,是基于CLH队列的一种变种实现。原始的CLH队列,一般用于实现自旋锁。而AQS队列的实现,是获取不到锁的线程,先进行一小段时间的自旋,然后进入Park挂起状态。【同样的,ZK中的分布式锁,也使用了类似方式,获取不到锁的线程值监听前一个节点】。因此AQS解决了CLH每个线程无限自旋的问题,应用场景也更加广泛。

这里谈到了自旋锁,那么我们也顺便说下互斥锁。这里的互斥锁说的是传统意义的互斥锁,就是多个线程并发竞争锁的时候,没有抢到锁的线程会进入休眠状态即sleep-waiting,当锁被释放的时候,处于休眠状态的一个线程会再次获取到锁。缺点就是这一些列过程需要线程切换,需要执行很多CPU指令,同样需要时间。如果CPU执行线程切换的时间比锁占用的时间还长,那么可能还不如使用自旋锁。因此互斥锁适用于锁占用时间长的场合。

CLH锁原理

  • 首先有一个尾节点指针,通过这个尾结点指针来构建等待线程的逻辑队列,当有新的节点加入队列时,尾节点指针会指向这个新加入的节点,并将原本的尾节点变为当前新加入节点的前驱节点。因此能确保线程线程先到先服务的公平性,尾指针可以说是构建逻辑队列的桥梁;此外这个尾节点指针是原子引用类型,避免了多线程并发操作的线程安全性问题;
  • 通过等待锁的每个线程在自己的某个变量上自旋等待,这个变量指向自己的前驱节点中的变量,通过不断地自旋,感知到前驱节点的变化后成功获取到锁。

CLH锁的优点

  1. 没有惊群效应。假设有1000个线程等待获取锁,锁释放后,只会通知队列中的第一个线程去竞争锁,避免了同时唤醒大量线程 在瞬间争抢CPU资源,避免了惊群效应。(此处仅仅是不会对锁过度的争抢,也就是公平锁的好处。但是自旋锁的实现方式依然消耗CPU)
  2. CLH队列锁的长处是空间复杂度低(假设有n个线程。L个锁,每一个线程每次仅仅获取一个锁,那么须要的存储空间是O(L+n),n个线程有n个myNode。L个锁有L个tail)。

CLH锁的缺点

在NUMA系统结构下性能稍差。在这样的系统结构下,每一个线程有自己的内存,假设前趋结点的内存位置比較远。自旋推断前趋结点的locked域,性能将大打折扣,在SMP结构下还是非常有效的。【CLH自旋在前驱节点上,访问的是其他线程的变量值,在NUMA架构下,其他线程变量有可能是对端CPU的高速缓存,因此更适合SMP架构】

深入CLH队列源码

那么,下面我们先来看CLH锁实现代码,然后通过一步一图来详解CLH锁。

// CLHLock.java
 
public class CLHLock {
    /**
     * CLH锁节点
     */
    private static class CLHNode {
        // 锁状态:默认为false,表示线程没有获取到锁;true表示线程获取到锁或正在等待
        // 为了保证locked状态是线程间可见的,因此用volatile关键字修饰
        volatile boolean locked = false;
    }
    // 尾结点,总是指向最后一个CLHNode节点
    // 【注意】这里用了java的原子系列之AtomicReference,能保证原子更新
    private final AtomicReference tailNode;
    // 当前节点的前继节点
    private final ThreadLocal predNode;
    // 当前节点
    private final ThreadLocal curNode;
 
    // CLHLock构造函数,用于新建CLH锁节点时做一些初始化逻辑
    public CLHLock() {
        // 初始化时尾结点指向一个空的CLH节点
        tailNode = new AtomicReference<>(new CLHNode());
        // 初始化当前的CLH节点
        curNode = new ThreadLocal() {
            @Override
            protected CLHNode initialValue() {
                return new CLHNode();
            }
        };
        // 初始化前继节点,注意此时前继节点没有存储CLHNode对象,存储的是null
        predNode = new ThreadLocal();
    }
 
    /**
     * 获取锁
     */
    public void lock() {
        // 取出当前线程ThreadLocal存储的当前节点,初始化值总是一个新建的CLHNode,locked状态为false。
        CLHNode currNode = curNode.get();
        // 此时把lock状态置为true,表示一个有效状态,
        // 即获取到了锁或正在等待锁的状态
        currNode.locked = true;
        // 当一个线程到来时,总是将尾结点取出来赋值给当前线程的前继节点;
        // 然后再把当前线程的当前节点赋值给尾节点
        // 【注意】在多线程并发情况下,这里通过AtomicReference类能防止并发问题
        // 【注意】哪个线程先执行到这里就会先执行predNode.set(preNode);语句,因此构建了一条逻辑线程等待链
        // 这条链避免了线程饥饿现象发生
        CLHNode preNode = tailNode.getAndSet(currNode);
        // 将刚获取的尾结点(前一线程的当前节点)付给当前线程的前继节点ThreadLocal
        // 【思考】这句代码也可以去掉吗,如果去掉有影响吗?
        predNode.set(preNode);
        // 【1】若前继节点的locked状态为false,则表示获取到了锁,不用自旋等待;
        // 【2】若前继节点的locked状态为true,则表示前一线程获取到了锁或者正在等待,自旋等待
        while (preNode.locked) {
            System.out.println("线程" + Thread.currentThread().getName() + "没能获取到锁,进行自旋等待。。。");
        }
        // 能执行到这里,说明当前线程获取到了锁
        System.out.println("线程" + Thread.currentThread().getName() + "获取到了锁!!!");
    }
 
    /**
     * 释放锁
     */
    public void unLock() {
        // 获取当前线程的当前节点
        CLHNode node = curNode.get();
        // 进行解锁操作
        // 这里将locked至为false,此时执行了lock方法正在自旋等待的后继节点将会获取到锁
        // 【注意】而不是所有正在自旋等待的线程去并发竞争锁
        node.locked = false;
        System.out.println("线程" + Thread.currentThread().getName() + "释放了锁!!!");
        // 小伙伴们可以思考下,下面两句代码的作用是什么??
        CLHNode newCurNode = new CLHNode();
        curNode.set(newCurNode);
 
        // 【优化】能提高GC效率和节省内存空间,请思考:这是为什么?
        // curNode.set(predNode.get());
    }
}

CLH锁的初始化逻辑

通过上面代码,我们缕一缕CLH锁的初始化逻辑先:

首先定义了一个CLHNode节点(在代码中以内部类的形式呈现),里面有一个locked属性,表示线程线程是否获得锁,默认为false。false表示线程没有获取到锁或已经释放锁;true表示线程获取到了锁或者正在自旋等待。

AQS的前菜—详解CLH队列锁_第1张图片

这个locked也就是我们每个节点轮询判断前驱节点是否释放锁的关键变量。但我们知道这个变量是跨线程获取的,为了保证locked属性线程间可见,该属性被volatile修饰。

接下来,我们看CLHLock类中有三个重要的成员变量:

AQS的前菜—详解CLH队列锁_第2张图片

CLHLock有三个重要的成员变量尾节点指针tailNode,当前线程的前继节点preNode和当前节点curNode。其中tailNode是AtomicReference类型,目的是为了保证尾节点的线程安全性;此外,preNode和curNode都是ThreadLocal类型,即线程本地变量类型,可见这些节点都是线程级别的。用来保存每个线程的前驱CLHNode和当前CLHNode节点。

接着,到我们的构造方法,看看构造方法都干了些什么事。

AQS的前菜—详解CLH队列锁_第3张图片

给尾指针tailNode和当前节点curNode初始化一个locked状态为false的CLHNode节点,此时前继节点preNode存储的是null。配合上我们之前说的,当节点为false时表示线程没有获取到锁或已经释放锁,因此当新增节点时,必然会直接获取锁。

CLH加锁过程

我们再来看看CLH锁的加锁过程,下面再贴一遍加锁lock方法的代码:

AQS的前菜—详解CLH队列锁_第4张图片

第一步:CLHNode currNode = curNode.get();

由于在构造函数中对每一个线程的curNode都做了初始化,所以直接获取当前线程的当前节点curNode,并且每次获取的CLHNode节点的locked状态都为false;

AQS的前菜—详解CLH队列锁_第5张图片

第二步:currNode.locked = true;

设置当前节点的状态位true。表示等待锁或者正在执行锁。意图就是阻塞它的后继节点获取锁。

第三步:CLHNode preNode = tailNode.getAndSet(currNode);

使用尾节点tailNode,将当前节点线程安全的进入队列尾部,并且返回原本的尾节点。

第四步:predNode.set(preNode);

将当原本的尾节点,设置为当前节点的前驱节点。

第五步:轮循检测前驱节点locked的状态

image.png

当前驱节点检测变为了false时,就成功获取到了锁。这也说明locked这种跨线程变量,必须要使用voliate修饰保证可见性。

但是此处我们发现,每一个节点(线程)都在自旋获取锁。因此如果在任务较长较多的情况下,CLH锁对CPU的性能消耗不言而喻。因此在未来的AQS中,对此处进行了优化。

画图解释

第一个节点加入

我们假设线程A是第一个加入队列的线程,我们都知道它的前驱节点是一个null,那么如何给它的前驱节点赋值呢?答案就是tailNode,在初始化的时候对taileNode中的locked成员也设为了false。因此当线程A通过taileNode获取队列中第一个元素的前驱节点时,也不会获取为null。

AQS的前菜—详解CLH队列锁_第6张图片

AQS的前菜—详解CLH队列锁_第7张图片

经过代码分析,我们得出了第一个线程加入队列后的状态图。

AQS的前菜—详解CLH队列锁_第8张图片

第二个节点加入

 

此时,第二个线程B加入,我们的队列会发生什么变化呢?假设线程A一直持有锁不释放。当线程B调用lock方法进入队列时,队列发生了哪些变化。

AQS的前菜—详解CLH队列锁_第9张图片

AQS的前菜—详解CLH队列锁_第10张图片

第三个节点加入

后续的流程就和第二个节点加入流程一模一样了。

AQS的前菜—详解CLH队列锁_第11张图片

CLH解锁过程

我们再来看看CLH锁的加锁过程,下面再贴一遍解锁unlock方法的代码:

AQS的前菜—详解CLH队列锁_第12张图片

由于我们的加锁逻辑设置的非常巧妙,解锁逻辑反而非常简单明了了。

首先,获取当前线程的CLHNode。(为什么是当前线程?因为肯定是需要解锁的线程调用unlock方法,此处是一个逻辑对应关系)。然后将locked状态置为false即释放了锁;locked因为被volitile关键字修饰,此时后面自旋等待的线程的局部变量preNode.locked也为false,因此后面自旋等待的线程结束while循环即结束自旋等待,此时也获取到了锁。这一步骤也在异步进行着。

然后给当前线程的表示当前节点的线程本地变量重新赋值为一个新的CLHNode。这一步就很莫名其妙了,这么做的目的是什么呢?我们留到后面再说。

现在线程A释放锁,线程B获取锁的流程图如下:

AQS的前菜—详解CLH队列锁_第13张图片

解锁流程中莫名其妙的两行代码

AQS的前菜—详解CLH队列锁_第14张图片

就是这两行代码,看得人莫名其妙。那么这两行代码取消了会有什么问题么?别急,这就给你上个例子。

首先,假设队列里存储的都是不同的线程的节点,那么即时注销掉代码,当前线程释放锁,其他线程抢到锁的流程如下图所示:

AQS的前菜—详解CLH队列锁_第15张图片

这是没有任何问题的,而问题出现在下面这种场景:

此时,CLH队列锁中只有一个线程在反复的添加节点。那么效果就是线程A执行完毕执行unlock方法,紧接着由它再次执行lock方法。现在假设我们这两行莫名其妙的代码注销掉,来看看流程图会出现什么问题。

注销掉两行代码出现问题

AQS的前菜—详解CLH队列锁_第16张图片

第一步:线程A执行lock

AQS的前菜—详解CLH队列锁_第17张图片

此时状态如上图。

第二步:线程A执行unlock

AQS的前菜—详解CLH队列锁_第18张图片

第三步:线程A再次执行lock

接下来,就是问题出现的关键了。此时的获取锁中:CLHNode preNode = tailNode.getAndSet(currNode);这段代码,根据tailNode获取前驱节点,此时的当前线程的前驱节点和当前节点都代表着同一个节点。那么当每次默认给当前节点的locked赋值为true时,就会将原本前驱节点locked值为false的节点又改成true。好家伙,不用说,之后的轮循就会无限等待前驱节点的锁。

AQS的前菜—详解CLH队列锁_第19张图片

反应到图中的效果如下:

AQS的前菜—详解CLH队列锁_第20张图片    此时,我们就发现死循环的问题了。说到底,就是因为taileNode指向了同一个节点导致的。那么该如何解决呢?

添加这两行代码解决问题

AQS的前菜—详解CLH队列锁_第21张图片

第一步:线程A执行unlock

我们可以看到,在添加这两行代码后,线程A的在队列中的状态图变成了这样:

AQS的前菜—详解CLH队列锁_第22张图片

诶?这样,操作一下,tailNode就不会再指向ThreadA中的当前节点了。

第二步:线程A再次执行lock

AQS的前菜—详解CLH队列锁_第23张图片

可以看到,再次执行CLHNode preNode = tailNode.getAndSet(currNode);时,获取到的前驱节点就是一个已经被弃用,但必然是false的节点。从而解决了我们连续向CLH队列添加相同线程节点出现的死循环问题。

优化GC减少内存空间的优化

AQS的前菜—详解CLH队列锁_第24张图片

我们知道。我们之前的那两行代码,就是为了让我们当前线程持有的一个不和后继节点重复的locked值为false的CLHNode节点。那么我们每次新建对象必然会给内存,GC带来消耗。此时已经存在的前驱节点必然locked值为false,正好让我们拿来继续利用,避免过多无用对象的创建。

小结

我们在的CLH队列其实属于我们学习AQS的前菜。但是只有深入研究后,才知道CLH存在什么问题(CLH每一个线程都是一个自旋锁,非常消耗CPU),以及AQS在CLH的基础上做了哪些优化。

我们可以看到公平锁就是最初的实现理念就是CLH队列。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

你可能感兴趣的:(多线程,java,队列)