自旋锁是什么 自旋锁与互斥量 (注意这里不是互斥锁)类似,用于多线程同步的一种锁。但他不是休眠使线程阻塞,而是在获取锁之前一直处于忙等(自旋)阻塞状态。基本的实现是通过线程反复检查锁特定变量是否可用。由于线程在这一过程中保持执行,因此是一种忙等待。一旦获取了自旋锁,线程会一直保持该锁,直至显式释放自旋锁。
自旋锁通常被作为底层原语用于实现其他类型的锁。根据它们所基于与的系统体系结构,可以通过使用测试并设置指令(CAS)有效的实现。当然这里说的有效也还是会导致CPU资源的浪费:当线程自旋等待锁变为可用时,CPU不能做其它的事情,这也是自旋锁只能够持有一段时间的原因。
有了这一层了解,自旋锁的优势和劣势,以及其适用场景也就一目了然了。
优势:
没有线程阻塞,也就没有了线程上下文切换带来的开销
自旋操作更加直观,无需分析什么情况下会导致线程阻塞
劣势: 最大的问题就是由于需要一直循环检测锁的状态,因此会浪费CPU Cycles
适用场景: 结合上述的优劣,自旋锁在锁的临界区很小并且锁争抢排队不是非常严重的情况下是非常合适的:
临界区小,因此每个使用锁的线程占用锁的时间不会很长,自旋等待的线程能够快速地获取到锁。
所争抢排队不严重,因此锁的自旋时间也是可控的,不会有大量线程处于自旋等待的状态中,从而增加浪费CPU Cycles。
2. 自旋锁实现 首先需要明确的一点是,对于加锁和释放锁的操作,需要是原子性的。这是能够继续讨论的基石。对于现代处理器,一般通过CAS(Compare And Set)操作来保证原子性。它的原理其实很简单,就是将“对比-设置”这一个流程原子化,保证在符合某种预期的前提下,完成一次写操作。
对应到Java语言层面,就是那一大票的AtomicXXX类型。比如在下面的非公平自旋锁的实现中,会借助AtomicReference类型提供的CAS操作来完成加锁和释放锁的操作。
1. 简单的自旋锁 直接上代码
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 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 package JUC.spinLock;import java.util.ArrayList;import java.util.List;import java.util.concurrent.CountDownLatch;import java.util.concurrent.atomic.AtomicReference;public class SpinLock { private AtomicReference<Thread> owner = new AtomicReference <>(); public void lock () { Thread currentThread = Thread.currentThread(); while (!owner.compareAndSet(null , currentThread)) { } } public void unLock () { Thread currentThread = Thread.currentThread(); owner.compareAndSet(currentThread, null ); } static CountDownLatch countDownLatch = new CountDownLatch (10 ); static List<String> list = new ArrayList <>(); public static void main (String[] args) throws InterruptedException { final SpinLock lock = new SpinLock (); for (int i = 0 ; i < 10 ; i++) { new Thread (generateTask(lock, String.valueOf(i), list)).start(); } countDownLatch.await(); System.out.println(list); } private static Runnable generateTask (final SpinLock lock, final String taskId, final List<String> list) { return () -> { lock.lock(); try { Thread.sleep(300 ); list.add(taskId); } catch (Exception e) { } String s = list.toString(); System.out.println(String.format("Thread %s Completed %s" , taskId, s)); lock.unLock(); countDownLatch.countDown(); }; } }
这里的关键就是加锁和释放锁中的两个CAS操作:
加锁过程:将CAS操作置于一个while循环中,来实现自旋的语义。由于CAS操作成功与否是成功取决于它的boolean返回值,因此当CAS操作失败的情况下,while循环将不会退出,会一直尝试CAS操作直到成功为止,此即所谓的自旋(忙等待)。
释放锁过程:此时不需要循环操作,但是仍然会考虑到只有当前拥有锁的线程才有资格释放锁。这一点还是通过CAS操作来保证。
这里用AtomicReference是为了使用它的原子性的compareAndSet方法(CAS操作),解决了多线程并发操作导致数据不一致的问题,确保其他线程可以看到锁的真实状态。
运行结果 :
1 2 3 4 5 6 7 8 9 10 11 Thread 0 Completed [0] Thread 9 Completed [0, 9] Thread 3 Completed [0, 9, 3] Thread 8 Completed [0, 9, 3, 8] Thread 4 Completed [0, 9, 3, 8, 4] Thread 6 Completed [0, 9, 3, 8, 4, 6] Thread 5 Completed [0, 9, 3, 8, 4, 6, 5] Thread 7 Completed [0, 9, 3, 8, 4, 6, 5, 7] Thread 1 Completed [0, 9, 3, 8, 4, 6, 5, 7, 1] Thread 2 Completed [0, 9, 3, 8, 4, 6, 5, 7, 1, 2] [0, 9, 3, 8, 4, 6, 5, 7, 1, 2]
从上可以看出,加锁和释放锁正确的实现,和预期的也是一样的,这是一个非公平的自旋锁,不是按照先来先获取锁的方式实现。
缺点
CAS操作需要硬件的配合;
保证各个CPU的缓存(L1、L2、L3、跨CPU Socket、主存)的数据一致性,通讯开销很大,在多处理器系统上更严重;
没法保证公平性,不保证等待进程/线程按照FIFO顺序获得锁。
2. Ticket Lock Ticket Lock 是为了解决上面的公平性问题,类似于现实中银行柜台的排队叫号:锁拥有一个服务号,表示正在服务的线程,还有一个排队号;每个线程尝试获取锁之前先拿一个排队号,然后不断轮询锁的当前服务号是否是自己的排队号,如果是,则表示自己拥有了锁,不是则继续轮询。
当线程释放锁时,将服务号加1,这样下一个线程看到这个变化,就退出自旋。
具体代码如下:
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 63 64 65 public class TicketLock { private AtomicInteger serviceNum = new AtomicInteger (); private AtomicInteger ticketNum = new AtomicInteger (); public int lock () { int myTicketNum = ticketNum.getAndIncrement(); while (serviceNum.get() != myTicketNum) { } return myTicketNum; } public void unLock (int myTicketNum) { int next = myTicketNum + 1 ; serviceNum.compareAndSet(myTicketNum, next); } static CountDownLatch countDownLatch = new CountDownLatch (10 ); static List<String> list = new ArrayList <>(); public static void main (String[] args) throws InterruptedException { final TicketLock lock = new TicketLock (); for (int i = 0 ; i < 10 ; i++) { new Thread (generateTask(lock, String.valueOf(i), list)).start(); } countDownLatch.await(); System.out.println(list); } private static Runnable generateTask (final TicketLock lock, final String taskId, final List<String> list) { return () -> { int myTicketNum = lock.lock(); try { Thread.sleep(300 ); list.add(taskId); } catch (Exception e) { } String s = list.toString(); System.out.println(String.format("Thread %s Completed %s" , taskId, s)); lock.unLock(myTicketNum); countDownLatch.countDown(); }; } }
加锁和释放锁两个操作的过程如下:
加锁过程。获取一个排队号,当排队号和当前的服务号不相等时自旋等待。
释放锁过程。当前正被服务的线程释放锁,计算下一个服务号并设置。
这里的AtomicInteger是为了保证服务号和等待号的原子性
运行结果如下
1 2 3 4 5 6 7 8 9 10 11 Thread 0 Completed [0] Thread 1 Completed [0, 1] Thread 2 Completed [0, 1, 2] Thread 3 Completed [0, 1, 2, 3] Thread 4 Completed [0, 1, 2, 3, 4] Thread 5 Completed [0, 1, 2, 3, 4, 5] Thread 6 Completed [0, 1, 2, 3, 4, 5, 6] Thread 7 Completed [0, 1, 2, 3, 4, 5, 6, 7] Thread 8 Completed [0, 1, 2, 3, 4, 5, 6, 7, 8] Thread 9 Completed [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
从运行结果可以看出,确实是按照FIFO的顺序来获取锁,实现了公平性。
不过上面的代码有一个问题,在释放锁的时候需要外部传进来排队号,这样就会带来一个隐患,外部传进来的排队号是可以修改的,也就是线程A获取的排队号是5,但是释放锁的时候却传进来6。这样就有可能导致死锁等问题。下面这个是通过ThreadLocal来改进上面的代码
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 63 64 65 public class TicketLockImprove { private AtomicInteger serviceNum = new AtomicInteger (); private AtomicInteger ticketNum = new AtomicInteger (); private ThreadLocal<Integer> threadLocalTicketNum = new ThreadLocal <>(); public void lock () { int myTicketNum = ticketNum.getAndIncrement(); threadLocalTicketNum.set(myTicketNum); while (serviceNum.get() != myTicketNum) { } } public void unLock () { int myTicketNum = threadLocalTicketNum.get(); int next = myTicketNum + 1 ; serviceNum.compareAndSet(myTicketNum, next); } static CountDownLatch countDownLatch = new CountDownLatch (10 ); static List<String> list = new ArrayList <>(); public static void main (String[] args) throws InterruptedException { final TicketLockImprove lock = new TicketLockImprove (); for (int i = 0 ; i < 10 ; i++) { new Thread (generateTask(lock, String.valueOf(i), list)).start(); } countDownLatch.await(); System.out.println(list); } private static Runnable generateTask (final TicketLockImprove lock, final String taskId, final List<String> list) { return () -> { lock.lock(); try { Thread.sleep(300 ); list.add(taskId); } catch (Exception e) { } String s = list.toString(); System.out.println(String.format("Thread %s Completed %s" , taskId, s)); lock.unLock(); countDownLatch.countDown(); }; } }
这里是通过ThreadLocal来保存每个线程的排队号,这样就不会出现排队号被私自修改问题。其他的和上面的代码一致。
缺点 Ticket Lock 虽然解决了公平性的问题,但是多处理器系统上,每个进程/线程占用的处理器都在读写同一个变量serviceNum,每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能。
所以,需要有一种办法能够让执行线程不再在同一个共享变量上自旋,避免过高频率的缓存同步操作。下面介绍的CLH锁和MCS锁都是为了解决这个问题的。
MCS来自于其发明人名字的首字母: John Mellor-Crummey和Michael Scott。
CLH的发明人是:Craig,Landin and Hagersten。
3. MCS 锁 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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 public class MCSLock { public static class MCSNode { volatile MCSNode next; volatile boolean isBlock = true ; } volatile MCSNode tail; private static final AtomicReferenceFieldUpdater<MCSLock, MCSNode> UPDATER = AtomicReferenceFieldUpdater.newUpdater(MCSLock.class, MCSNode.class, "tail" ); ThreadLocal<MCSNode> currentThreadNode = new ThreadLocal <>(); public void lock () { MCSNode mcsNode = currentThreadNode.get(); if (mcsNode == null ) { mcsNode = new MCSNode (); currentThreadNode.set(mcsNode); } MCSNode predecsessor = UPDATER.getAndSet(this , mcsNode); if (predecsessor != null ) { predecsessor.next = mcsNode; while (mcsNode.isBlock) { } } else { mcsNode.isBlock = false ; } } public void unLock () { MCSNode mcsNode = currentThreadNode.get(); if (mcsNode == null && mcsNode.isBlock) { return ; } if (mcsNode.next == null ) { if (UPDATER.compareAndSet(this , mcsNode, null )) { return ; } else { while (mcsNode.next == null ) { } } } mcsNode.next.isBlock = false ; mcsNode.next = null ; currentThreadNode.remove(); } public static void main (String[] args) throws InterruptedException { final MCSLock lock = new MCSLock (); for (int i = 0 ; i < 10 ; i++) { new Thread (generateTask(lock, String.valueOf(i), list)).start(); } countDownLatch.await(); System.out.println(list); } static CountDownLatch countDownLatch = new CountDownLatch (10 ); static List<String> list = new ArrayList <>(); private static Runnable generateTask (final MCSLock lock, final String taskId, final List<String> list) { return () -> { lock.lock(); try { Thread.sleep(300 ); list.add(taskId); } catch (Exception e) { } String s = list.toString(); System.out.println(String.format("Thread %s Completed %s" , taskId, s)); lock.unLock(); countDownLatch.countDown(); }; } }
实现的代码量虽然不多,但是lock和unlock的设计思想还是有些微妙之处,想要实现正确也并不容易。
需要把握的几个重点:
MCS锁的节点对象需要有两个状态,next用来维护单向链表的结构,blocked用来表示节点的状态,true表示处于自旋中;false表示加锁成功
MCS锁的节点状态blocked的改变是由其前驱节点触发改变的
加锁时会更新链表的末节点并完成链表结构的维护
释放锁的时候由于链表结构建立的时滞(getAndSet原子方法和链表建立整体而言并非原子性),可能存在多线程的干扰,需要使用忙等待保证链表结构就绪
4. CLH锁 同MCS自旋锁一样,CLH也是一种基于单向链表(隐式创建)的高性能、公平的自旋锁,申请加锁的线程只需要在其前驱节点的变量上自旋,从而极大地减少了不必要的处理器缓存同步的次数,降低了总线和内存的开销。
实现代码
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 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 public class CLHLock { public static class CLHNode { private boolean isLocked = true ; } @SuppressWarnings("unused") private volatile CLHNode tail; private ThreadLocal<CLHNode> currentThreadNode = new ThreadLocal <>(); public void lock () { CLHNode clhNode = currentThreadNode.get(); if (clhNode == null ) { clhNode = new CLHNode (); currentThreadNode.set(clhNode); } CLHNode preNode = UPDATER.getAndSet(this , clhNode); if (preNode != null ) { while (preNode.isLocked) { try { Thread.sleep(100 ); } catch (InterruptedException e) { e.printStackTrace(); } } } } public void unLock () { CLHNode clhNode = currentThreadNode.get(); if (clhNode == null || !clhNode.isLocked) { return ; } currentThreadNode.remove(); if (!UPDATER.compareAndSet(this , clhNode, null )) { clhNode.isLocked = false ; } } private static final AtomicReferenceFieldUpdater<CLHLock, CLHNode> UPDATER = AtomicReferenceFieldUpdater.newUpdater(CLHLock.class, CLHNode.class, "tail" ); static CountDownLatch countDownLatch = new CountDownLatch (10 ); public static void main (String[] args) throws InterruptedException { final CLHLock lock = new CLHLock (); List<String> list = new ArrayList <>(); for (int i = 0 ; i < 10 ; i++) { new Thread (generateTask(lock, String.valueOf(i), list)).start(); } countDownLatch.await(); System.out.println(list); } private static Runnable generateTask (final CLHLock lock, final String taskId, final List<String> list) { return () -> { lock.lock(); try { Thread.sleep(300 ); list.add(taskId); } catch (Exception e) { } System.out.println(String.format("Thread %s Completed %s" , taskId, list)); lock.unLock(); countDownLatch.countDown(); }; } }
实现的代码量相比MCS锁少了很多,也简洁了不少。
需要把握的几个重点:
CLH锁的节点对象只有一个isLocked属性,关于其含义前面已经详细讨论过
CLH锁的节点属性isLocked的改变是由其自身触发的
CLH锁是在前驱节点的isLocked属性上进行自旋
众所周知,AbstractQueuedSynchronizer是Java并发包的基石之一,而CLH锁的原理和思想则是AbstractQueuedSynchronizer的基石之一。理解清楚CLH锁的原理和实现对后面学习和理解AbstractQueuedSynchronizer是非常必要的。
在Doug Lea 写的The java.util.concurrent Synchronizer Framework 文章中有这么一句话 CLH锁显然比MCS锁更合适。因为CLH锁可以更容易地去实现“取消(cancellation)”和“超时”功能,因此我们选择了CLH锁作为实现的基础。 其实只要实现了取消功能,那么超时就比较容易实现,因为超时功能就是在取消的功能基础上加了一个时间的设置。为什么CLH更容易实现取消功能呢。首先在CLH各节点之间只有一条隐形的链表存在,而排队的节点是观察前面节点的信息来判断是否可以获取锁,因此在取消排队节点时,是不需要修改前继节点,而后继节点只需要简单的进行一次自旋看是否可以获取锁,此时因为是取消节点,而不是释放锁,所以后继节点是获取不了,接着后继节点就继续自旋,因此对取消节点,既不需要更改后继节点,也不需要修改前继节点,所以相对来说需要操作的少,因此就相对与MCS自旋锁要简单点。如果MCS自旋锁要取消,则需要改变此节点的自旋状态,改变后继节点在链表中的位置,也就是取代此节点,变成此节点前继节点的后继节点,如果此时前继节点正在拿到锁运行,那此时更改锁就不是那么容易的,而且链表又是单链表,因此更改起来更加麻烦。
总结 下面我们来比较一下MCS和CLH锁
首先我们先补充一点知识SMP和NUMA架构
SMP(Symmetric Multi-Processor):对称多处理器结构,指服务器中多个 CPU 对称工作,每个 CPU 访问内存地址所需时间相同。其主要特征是共享,包含对 CPU,内存,I/O 等进行共享。SMP 能够保证内存一致性,但这些共享的资源很可能成为性能瓶颈,随着 CPU 数量的增加,每个 CPU 都要访问相同的内存资源,可能导致内存访问冲突,可能会导致 CPU 资源的浪费。常用的 PC 机就属于这种。
NUMA(Non-Uniform Memory Access):非一致存储访问,将 CPU 分为 CPU 模块,每个 CPU 模块由多个 CPU 组成,并且具有独立的本地内存、I/O 槽口等,模块之间可以通过互联模块相互访问,访问本地内存的速度将远远高于访问远地内存 (系统内其它节点的内存) 的速度,这也是非一致存储访问的由来。NUMA 较好地解决 SMP 的扩展问题,当 CPU 数量增加时,因为访问远地内存的延时远远超过本地内存,系统性能无法线性增加。
CLH 队列锁的优点是空间复杂度低(如果有 n 个线程,L 个锁,每个线程每次只获取一个锁,那么需要的存储空间是 O(L+n),n 个线程有 n 个myNode,L 个锁有 L 个 tail),CLH 的一种变体被应用在了 JAVA 并发框架中 (AbstractQueuedSynchronizer.Node)。CLH 在 SMP 系统结构下该法是非常有效的。但在 NUMA 系统结构下,每个线程有自己的内存,如果前趋结点的内存位置比较远,自旋判断前趋结点的 locked 域,性能将大打折扣,一种解决 NUMA 系统结构的思路是 MCS 队列锁。
MSC 与 CLH 最大的不同并不是链表是显示还是隐式,而是线程自旋的规则不同:CLH 是在前趋结点的 locked 域上自旋等待,而 MCS 是在自己的结点的 locked 域上自旋等待。正因为如此,它解决了 CLH 在 NUMA 系统架构中获取 locked 域状态内存过远的问题。
参考
简单的非公平自旋锁以及基于排队的公平自旋锁的实现
CLH锁的原理和实现
MCS锁的原理和实现
面试必备之深入理解自旋锁
Java自旋锁、排队自旋锁、MCS锁、CLH锁
CLH锁与MCS锁