首页天道酬勤redissonlock,前菜菜单

redissonlock,前菜菜单

张世龙 05-04 17:59 125次浏览

CLH队列锁定什么是CLH锁定,实际上是基于逻辑队列非线程饥饿的自旋公平锁定。 当多线程竞争锁定时,未能获得锁定的线程排列在CLH队列的末尾,并旋转等待,直到其前线程解除锁定。 因为是Craig、Landin、Hagersten三大人物的发明,所以被命名为CLH摇滚。

自旋锁和排他锁的区别在于CLH锁是自旋锁,所以我们先来看看什么是自旋锁。

自旋锁说白了也是排他锁。 但是,未获得锁定的线程将旋转并等待锁定释放,进入3358www.Sina.com/的状态。 此时,等待锁定的线程不会进入休眠状态,而是判断是否有资格消耗CPU获得锁定。busy-waiting

其中,如果队列太长(等待锁定的线程太多),则CLH队列锁定会给CPU带来很大压力,总结出CLH锁定的使用场景有限。

Java的ReentrantLock被锁定的AQS队列排队策略是一种基于CLH队列的变种实现。 原始CLH队列通常用于实现旋转锁定。 另一方面,AQS队列的实现是未锁定的线程在自旋一段时间后进入Park挂起状态。 【同样地,ZK中的分布式锁定也使用同样的方式,无法获取锁定的线程值监听前面的节点】。 因此,AQS解决了CLH每个线程的无限自旋问题,应用场景也更加广泛。

我们在这里谈了自旋锁,我们也顺便谈互斥锁。 其中,3358www.Sina.com/是指传统意义上的排他锁。 这意味着,当多个线程同时竞争锁定时,未获得锁定的线程将进入休眠状态3358www.Sina.com/,当锁定释放时,处于休眠状态的线程将再次获得锁定。 缺点是这些列进程需要线程切换,需要执行许多CPU指令,同样需要时间。 如果CPU需要比锁定更长的时间来执行线程切换,则可能最好使用旋转锁定。因此自旋锁适用于锁占用时间短的场合。

CLH锁定的原理首先是端点指针,通过该端点指针构建线程等待逻辑队列,当新节点加入队列时,端点指针指向这个新加入的节点,而原始端点指针因此,可以确保线程先到先得服务的公平性,尾指针可以说是构建逻辑队列的桥梁; 此外,该末端节点指针是原子引用型的,通过针对每个线程用自己的某个变量旋转等待避免多线程同时操作的线程安全问题的锁定,该变量指向自己的前体节点中的变量,通过依次旋转来进行前体节点的旋转CLH锁定的优点没有惊讶群效应。 假设有1,000个线程正在等待获取锁定。 释放锁定后,它会通知队列中的第一个线程只争用锁定,同时唤醒大量线程以避免瞬间争夺CPU资源,从而避免死机。 这里只是没有过度的锁争夺,也就是公平的锁的好处。 但是,假设自旋锁的实现方式仍然消耗CPU (clh队列锁的优点在于空间复杂度低() ),有n个线程。 对于每个线程一次仅获取一个锁定的l个锁定,所需的存储区域是o(ln ),并且n个线程具有n个myNode。 l个锁有l个tail )。 CLH锁定的缺点是在NUMA系统结构中性能稍差。 在这种系统配置中,假设每个线程都有其自己的存储器,并且先前节点的存储器位置较远。 自旋推测前趋结点的locked域,性能大幅下降,在SMP结构中非常有效。 【CLH自旋位于前驱节点,访问其他线程的变量值。 在NUMA体系结构中,其他线程变量可能是端到端CPU缓存,因此非常适合SMP体系结构】

让我们深入挖掘CLH队列的源代码。 首先,我们先查看CLH锁定实现代码,然后一步一步地详细检查CLH锁定。

//clhlock.javapublicclassclhlock (/* * clh锁定节点)/privatestaticclassclhnode )//锁定状态:默认值为false,表示线程正在获取锁定true指示线程已获取锁定或正在等待以确保锁定状态在线程之间可见,用volatile关键字限定volatile boolean locked=false; //结尾节点始终指向最后一个CLHNode节点//【注意】此处使用java原子序列的AtomicReference,表示原子更新了专用字符序列//当前节点专用性readlocalclhnodecurnode; //CLH创建新锁定节点时初始化逻辑公共clhlock ()//初始化时终结节点为空的clh节点tail node=newatomicreference (newclhnode ) () ) )

@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表示线程获取到了锁或者正在自旋等待。

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

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

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

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

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

CLH加锁过程

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

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

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

第二步:currNode.locked = true;

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

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

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

第四步:predNode.set(preNode);

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

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

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

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

画图解释

第一个节点加入

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

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

第二个节点加入

 

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

第三个节点加入

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

CLH解锁过程

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

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

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

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

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

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

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

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

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

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

注销掉两行代码出现问题

第一步:线程A执行lock

此时状态如上图。

第二步:线程A执行unlock

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

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

反应到图中的效果如下:

    此时,我们就发现死循环的问题了。说到底,就是因为taileNode指向了同一个节点导致的。那么该如何解决呢?

添加这两行代码解决问题

第一步:线程A执行unlock

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

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

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

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

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

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

小结

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

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

 

 

 

 

 

 

 

 

 

 

 

 

 

 

同步队列,队列的存储结构