本文介绍下几种常见自旋锁的Java实现,包括CLH、MCS等队列锁
非公平自旋锁 实现一个自旋锁最朴素的思路就是直接利用CAS来保证加锁、解锁的原子性。具体到Java中,则可以利用原子类来实现。下面即是非公平自旋锁的实现,可以看到其实现过程非常简单。为了进一步支持锁重入,还提供了一个count变量记录锁重入的次数。但该实现的缺点也很明显,首先加锁线程需要不停自旋来检测锁的状态。此举会明显浪费CPU,当然这也是自旋锁方案的通用弊端;其次该实现方式是一个非公平的自旋锁,即无法保证公平性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 public class SpinLock { private AtomicReference<Thread> owner = new AtomicReference <>(); private volatile int count = 0 ; public void lock () { Thread current = Thread.currentThread(); if ( current == owner.get() ) { count++; return ; } while ( !owner.compareAndSet(null , current) ); } public void unlock () { Thread current = Thread.currentThread(); if ( current == owner.get() ) { if ( count>0 ) { count--; } else { owner.set(null ); } } } }
公平自旋锁 为了解决实现自旋锁的公平性,我们需要额外引入一个排队机制。具体地,分别用两个原子变量表示排队号码、当前服务号码(即持有锁的号码)。基本原理类似于银行服务窗口的取号、叫号机制。加锁过程:会先给当前线程分配一个排队号码,然后该线程开始自旋。直到被它叫到号才退出自旋,即它的排队号码等于当前服务号码。解锁过程:计算、更新当前服务号码的值。以便下一个线程能够结束自旋、获取到锁。下面即是一个基于排队机制的公平自旋锁实现,可以看到每次调用requestTicketNum方法即可获得一个单调递增的排队号码,并通过一个ThreadLocal类型的threadLocalNum变量来保存各线程所获得的排队号码。进一步地,这里还增加了一个count变量以实现锁重入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 public class TicketLock { private AtomicInteger serviceNum = new AtomicInteger (0 ); private volatile int count = 0 ; private AtomicInteger ticketNum = new AtomicInteger (0 ); private static ThreadLocal<Integer> threadLocalNum = new ThreadLocal <>(); public void lock () { Integer num = threadLocalNum.get(); if ( num!=null && num==serviceNum.get() ) { count++; return ; } num = requestTicketNum(); threadLocalNum.set( num ); while ( num != this .serviceNum.get() ); } public void unlock () { Integer num = threadLocalNum.get(); if ( num!=null && num==serviceNum.get() ) { if ( count>0 ) { count--; } else { threadLocalNum.remove(); serviceNum.set( num+1 ); } } } private int requestTicketNum () { return ticketNum.getAndIncrement(); } }
队列锁 SMP 与 NUMA 在进一步介绍队列锁之前,我们先来了解下计算机中常见的两种体系结构:SMP、NUMA。SMP(Symmetric Multi Processing)对称多处理器结构,所谓对称指的就是多个CPU的地位是平等的、一致性的。它们共享全部的资源,包括内存、IO、总线等。所以如果多个CPU同时访问某个资源(例如某部分内存),就需要锁机制来解决资源争用的问题。事实上,我们日常所使用的计算机大部分都是SMP结构的
而在NUMA(Non-Uniform Memory Access)非一致存储访问结构下,其基本特征是含有多个CPU模块。而每个CPU模块则由多个CPU、独立的本地内存、IO组成,各CPU模块则通过所谓的互联模块进行连接。这样一个CPU访问其自身CPU模块的本地内存的速度将远远高于访问其他CPU模块的本地内存,即所谓访问存储表现上具有非一致性
CLH队列锁 队列锁较为常见的是CLH队列锁,其由Craig、Landin 和 Hagersten三位发明,并以他们的名字缩写进行组合而命名。Java中的AQS就是使用了CLH队列锁的变体。在之前的两种自旋锁实现SpinLock、TicketLock中,实际上存在一个明显性能问题。各线程均在同一个共享变量上进行自旋,竞争非常激烈时,其会导致非常繁重的总线、内存流量。因为当一个线程释放锁、更新共享变量时,会引发其他CPU中该共享变量对应的Cache Line缓存行失效,并重新经由总线、内存读取
而CLH队列锁则是将各个加锁线程包装为Node节点并构建为一个隐式链表。各Node不断自旋检查其前驱节点的状态,当某线程进行解锁操作时,则会修改自身Node节点的状态以让其后继节点结束自旋、获取到锁。其实现过程如下所示,之所以称之为隐式链表,是因为所谓的链表事实上是通过每个线程利用ThreadLocal类型的prevNode变量保存其前驱节点来维护的。由前文可知,各线程是在其前驱节点的status变量上进行自旋的,故在构建CLH队列锁实例的过程中首先向隐式链表中添加了一个哨兵节点,以便处理第一个加锁线程。值得一提的是,在解锁过程时,执行currentNode.remove() 是必要的。其目的是为了保证该线程在下一次加锁调用lock()时,能够通过 currentNode.get() 获取到一个新的Node实例,防止死锁。至此可以看出CLH队列锁是一个公平的自旋锁实现,其次由于各线程自旋在不同Node实例的status变量上避免了繁重的总线、内存流量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 public class CLHLock { private AtomicReference<Node> tail; private ThreadLocal<Node> currentNode; private ThreadLocal<Node> prevNode; public CLHLock () { tail = new AtomicReference <>( new Node () ); currentNode = ThreadLocal.withInitial( Node::new ); prevNode = new ThreadLocal <>(); } public void lock () { Node node = currentNode.get(); node.status = true ; Node prev = tail.getAndSet( node ); prevNode.set( prev ); while ( prev.status ); } public void unlock () { Node node = currentNode.get(); node.status = false ; prevNode.remove(); currentNode.remove(); } private static class Node { private volatile boolean status = false ; } }
MCS队列锁 前面提到了CLH队列锁的各种好处,看上去好像一切都很完美。其实不然,CLH在SMP结构性下非常有效。但在NUMA结构中,如果其前驱节点不是位于该CPU自身模块的本地内存中,而是位于其他CPU模块的本地内存中,则会导致效率大幅降低。 故John M. Mellor-Crummey、Michael L. Scott提出、发明了MCS队列锁。其与CLH队列锁的最大不同在于,MCS队列锁中各加锁线程是在自身的Node节点上进行自旋。这一改进使得其在NUMA结构下也具有很好的性能表现。具体地,其同样是先将各加锁线程包装为Node节点,然后通过Node的next变量指向其后继节点来显式地维护链表。当某线程进行解锁操作时,则会通过next指针找到当前节点的后继节点,并修改后继节点的状态以让其后继节点结束自旋、获取到锁。实现过程如下所示,当然MCS队列锁同样是一个公平的自旋锁
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 public class MCSLock { private AtomicReference<Node> tail; private ThreadLocal<Node> currentNode; public MCSLock () { tail = new AtomicReference <>(null ); currentNode = ThreadLocal.withInitial( Node::new ); } public void lock () { Node node = currentNode.get(); node.status = true ; Node prev = tail.getAndSet( node ); if ( prev!=null ) { prev.next = node; while ( node.status ); } } public void unlock () { Node node = currentNode.get(); if ( node.next==null ) { if ( !tail.compareAndSet(node, null ) ) { while ( node.next==null ); } } if ( node.next!=null ) { node.next.status = false ; node.next = null ; } currentNode.remove(); } private static class Node { private volatile boolean status = false ; private volatile Node next = null ; } }
Note 本文为了行文简便、实现清晰,故在队列锁实现中均未实现锁重入这一特性。事实上,可以通过添加额外的辅助变量来拓展、实现