-So-easy! 多图详解 CLH 锁的原理与实现,轻松把握 AQS,nginx 原理及应用
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());}}
4.1 CLH 锁的初始化逻辑
通过上面代码,我们缕一缕 CLH 锁的初始化逻辑先:
定义了一个
CLHNode
节点,里面有一个locked
属性,表示线程线程是否获得锁,默认为false
。false
表示线程没有获取到锁或已经释放锁;true
表示线程获取到了锁或者正在自旋等待。
注意,为了保证
locked
属性线程间可见,该属性被volatile
修饰。
CLHLock
有三个重要的成员变量尾节点指针tailNode
,当前线程的前继节点preNode
和当前节点curNode
。其中tailNode
是AtomicReference
类型,目的是为了保证尾节点的线程安全性;此外,preNode
和curNode
都是ThreadLocal
类型即线程本地变量类型,用来保存每个线程的前继CLHNode
和当前CLHNode
节点。最重要的是我们新建一把
CLHLock
对象时,此时会执行构造函数里面的初始化逻辑。此时给尾指针tailNode
和当前节点curNode
初始化一个locked
状态为false
的CLHNode
节点,此时前继节点preNode
存储的是null
。
4.2 CLH 锁的加锁过程
我们再来看看 CLH 锁的加锁过程,下面再贴一遍加锁lock
方法的代码:
// CLHLock.java
/**
获取锁*/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) {try {Thread.sleep(1000);} catch (Exception e) {
}System.out.println("线程" + Thread.curr
entThread().getName() + "没能获取到锁,进行自旋等待。。。");}// 能执行到这里,说明当前线程获取到了锁 System.out.println("线程" + Thread.currentThread().getName() + "获取到了锁!!!");}
虽然代码的注释已经很详细,我们还是缕一缕线程加锁的过程:
首先获得当前线程的当前节点
curNode
,这里每次获取的CLHNode
节点的locked
状态都为false
;然后将当前
CLHNode
节点的locked
状态赋值为true
,表示当前线程的一种有效状态,即获取到了锁或正在等待锁的状态;因为尾指针
tailNode
的总是指向了前一个线程的CLHNode
节点,因此这里利用尾指针tailNode
取出前一个线程的CLHNode
节点,然后赋值给当前线程的前继节点predNode
,并且将尾指针重新指向最后一个节点即当前线程的当前CLHNode
节点,以便下一个线程到来时使用;根据前继节点(前一个线程)的
locked
状态判断,若locked
为false
,则说明前一个线程释放了锁,当前线程即可获得锁,不用自旋等待;若前继节点的 locked 状态为 true,则表示前一线程获取到了锁或者正在等待,自旋等待。
为了更通俗易懂,我们用一个图里来说明。
**假如有这么一个场景:**有四个并发线程同时启动执行 lock 操作,假如四个线程的实际执行顺序为:threadA<--threadB<--threadC<--threadD
第一步,线程 A 过来,执行了 lock 操作,获得了锁,此时locked
状态为true
,如下图:
第二步,线程 B 过来,执行了 lock 操作,由于线程 A 还未释放锁,此时自旋等待,locked
状态也为true
,如下图:
第三步,线程 C 过来,执行了 lock 操作,由于线程 B 处于自旋等待,此时线程 C 也自旋等待(因此 CLH 锁是公平锁),locked
状态也为true
,如下图:
第四步,线程 D 过来,执行了 lock 操作,由于线程 C 处于自旋等待,此时线程 D 也自旋等待,locked
状态也为true
,如下图:
这就是多个线程并发加锁的一个过程图解,当前线程只要判断前一线程的locked
状态如果是true
,那么则说明前一线程要么拿到了锁,要么也处于自旋等待状态,所以自己也要自旋等待。而尾指针tailNode
总是指向最后一个线程的CLHNode
节点。
4.3 CLH 锁的释放锁过程
前面用图解结合代码说明了 CLH 锁的加锁过程,那么,CLH 锁的释放锁的过程又是怎样的呢? 同样,我们先贴下释放锁的代码:
// CLHLock.java
/**
释放锁*/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 锁的过程代码比加锁简单多了,下面同样缕一缕:
首先从当前线程的线程本地变量中获取出当前
CLHNode
节点,同时这个CLHNode
节点被后面一个线程的preNode
变量指向着;然后将
locked
状态置为false
即释放了锁;
注意:
locked
因为被volitile
关键字修饰,此时后面自旋等待的线程的局部变量preNode.locked
也为false
,因此后面自旋等待的线程结束while
循环即结束自旋等待,此时也获取到了锁。这一步骤也在异步进行着。
然后给当前线程的表示当前节点的线程本地变量重新赋值为一个新的
CLHNode
。
思考:这一步看上去是多余的,其实并不是。请思考下为什么这么做?我们后续会继续深入讲解。
我们还是用一个图来说说明 CLH 锁释放锁的场景,接着前面四个线程加锁的场景,假如这四个线程加锁后,线程 A 开始释放锁,此时线程 B 获取到锁,结束自旋等待,然后线程 C 和线程 D 仍然自旋等待,如下图:
以此类推,线程 B 释放锁的过程也跟上图类似,这里不再赘述。
4.4 考虑同个线程加锁释放锁再次正常获取锁的情况
在前面 4.3 小节讲到释放锁unLock
方法中有下面两句代码:
CLHNode newCurNode = new CLHNode();curNode.set(newCurNode);
这两句代码的作用是什么?这里先直接说结果:若没有这两句代码,若同个线程加锁释放锁后,然后再次执行加锁操作,这个线程就会陷入自旋等待的状态。这是为啥,可能有些下伙伴也没明白,劲越也是搞了蛮久才搞明白,嘿嘿。
下面我们同样通过一步一图的形式来分析这两句代码的作用。 假如有下面这样一个场景:线程 A 获取到了锁,然后释放锁,然后再次获取锁。
第一步: 线程 A 执行了 lock 操作,获取到了锁,如下图:
上图的加锁操作中,线程 A 的当前CLHNode
节点的locked
状态被置为true
;然后tailNode
指针指向了当前线程的当前节点;最后因为前继节点的locked
状态为false
,不用自旋等待,因此获得了锁。
第二步: 线程 A 执行了 unLock 操作,释放了锁,如下图:
上图的释放锁操作中,线程 A 的当前CLHNode
节点的locked
状态被置为false
,表示释放了锁;然后新建了一个新的CLHNode
节点newCurNode
,线程 A 的当前节点线程本地变量值重新指向了newCurNode
节点对象。
第三步: 线程 A 再次执行 lock 操作,重新获得锁,如下图:
上图的再次获取锁操作中,首先将线程 A 的当前CLHNode
节点的locked
状态置为true
;然后首先通过tailNode
尾指针获取到前继节点即第一,二步中的curNode
对象,然后线程 A 的前继节点线程本地变量的值重新指向了重新指向了curNode
对象;然后tailNode
尾指针重新指向了新创建的CLHNode
节点newCurNode
对象。最后因为前继节点的locked
状态为false
,不用自旋等待,因此获得了锁。
扩展: 注意到以上图片的
preNode
对象此时没有任何引用,所以当下一次会被 GC 掉。前面是通过每次执行unLock
操作都新建一个新的CLHNode
节点对象newCurNode
,然后让线程 A 的当前节点线程本地变量值重新指向newCurNode
。因此这里完全不用重新创建新的CLHNode
节点对象,可以通过curNode.set(predNode.get());
这句代码进行优化,提高 GC 效率和节省内存空间。
4.5 考虑同个线程加锁释放锁再次获取锁异常的情况
现在我们把unLock
方法的CLHNode newCurNode = new CLHNode();
和curNode.set(newCurNode);
这两句代码注释掉,变成了下面这样:
// CLHLock.java
public void unLock() {CLHNode node = curNode.get();node.locked = false;System.out.println("线程" + Thread.currentThread().getName() + "释放了锁!!!");/CLHNode newCurNode = new CLHNode();curNode.set(newCurNode);/}
那么结果就是线程 A 通过加锁,释放锁后,再次获取锁时就会陷入自旋等待的状态,这又是为什么呢?我们下面来详细分析。
第一步: 线程 A 执行了 lock 操作,获取到了锁,如下图:
上图的加锁操作中,线程 A 的当前CLHNode
节点的locked
状态被置为true
;然后tailNode
指针指向了当前线程的当前节点;最后因为前继节点的locked
状态为false
,不用自旋等待,因此获得了锁。这一步没有什么异常。
第二步: 线程 A 执行了 unLock 操作,释放了锁,如下图:
现在已经把unLock
方法的CLHNode newCurNode = new CLHNode();
和curNode.set(newCurNode);
这两句代码注释掉了,因此上图的变化就是线程 A 的当前CLHNode
节点的locked
状态置为false
即!可。
第三步: 线程 A 再次执行 lock 操作,此时会陷入一直自旋等待的状态,如下图:
通过上图对线程 A 再次获取锁的lock
方法的每一句代码进行分析,得知虽然第二步中将线程 A 的当前CLHNode
的locked
状态置为false
了,但是在第三步线程 A 再次获取锁的过程中,将当前CLHNode
的locked
状态又置为true
了,且尾指针tailNode
指向的依然还是线程 A 的当前当前CLHNode
节点。又因为每次都是将尾指针tailNode
指向的CLHNode
节点取出来给当前线程的前继CLHNode
节点,之后执行while(predNode.locked) {}
语句时,此时因为predNode.locked = true
,因此线程 A 就永远自旋等待了。
5 测试 CLH 锁
下面我们通过一个 Demo 来测试前面代码实现的 CLH 锁是否能正常工作,直接上测试代码:
评论