Java中的自旋锁与适应性自旋锁的区别
作者:我用漂亮的押韵形容被掠夺一空的爱情
自旋锁
1、概念:
当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人获取(占用),那么此线程就无法获取到这把锁,该线程将会等待,间隔一段时间后会再次尝试获取。这种采用循环加锁 -> 等待的机制被称为自旋锁(spinlock)
2、提出背景
由于在多处理器环境中某些资源的有限性,有时需要互斥访问(mutual exclusion),这时候就需要引入锁的概念,只有获取了锁的线程才能够对资源进行访问,由于多线程的核心是CPU的时间分片,所以同一时刻只能有一个线程获取到锁。那么就面临一个问题,那么没有获取到锁的线程应该怎么办?
通常有两种处理方式:一种是没有获取到锁的线程就一直循环等待判断该资源是否已经释放锁,这种锁叫做自旋锁,它不用将线程阻塞起来(NON-BLOCKING);还有一种处理方式就是把自己阻塞起来,等待重新调度请求,这种叫做互斥锁。
3、自旋锁的原理
自旋锁的原理比较简单,如果持有锁的线程能在短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞状态,它们只需要等一等(自旋),等到持有锁的线程释放锁之后即可获取,这样就避免了用户进程和内核切换的消耗。
因为自旋锁避免了操作系统进程调度和线程切换,所以自旋锁通常适用在时间比较短的情况下。由于这个原因,操作系统的内核经常使用自旋锁。但是,如果长时间上锁的话,自旋锁会非常耗费性能,它阻止了其他线程的运行和调度。线程持有锁的时间越长,则持有该锁的线程将被 OS(Operating System) 调度程序中断的风险越大。如果发生中断情况,那么其他线程将保持旋转状态(反复尝试获取锁),而持有该锁的线程并不打算释放锁,这样导致的是结果是无限期推迟,直到持有锁的线程可以完成并释放它为止。
解决上面这种情况一个很好的方式是给自旋锁设定一个自旋时间,等时间一到立即释放自旋锁。自旋锁的目的是占着CPU资源不进行释放,等到获取锁立即进行处理。但是如何去选择自旋时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用 CPU 资源,进而会影响整体系统的性能。因此自旋的周期选的额外重要!JDK在1.6 引入了适应性自旋锁,适应性自旋锁意味着自旋时间不是固定的了,而是由前一次在同一个锁上的自旋时间以及锁拥有的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间。
4、 自旋锁的优缺点
优点:自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!
缺点:但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用 cpu 做无用功,占着 XX 不 XX,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要 cpu 的线程又不能获取到 cpu,造成 cpu 的浪费。所以这种情况下我们要关闭自旋锁。
5、自旋锁开启
虽然在JDK1.4.2的时候就引入了自旋锁,但是需要使用“-XX:+UseSpinning”参数来开启。在到了JDK1.6以后,就已经是默认开启了。
举个栗子:
public class SpinLockTest { /** * 持有锁的线程,null表示锁未被线程持有 */ private AtomicReference<Thread> ref = new AtomicReference<>(); public void lock(){ Thread currentThread = Thread.currentThread(); while(!ref.compareAndSet(null, currentThread)){ //当ref为null的时候compareAndSet返回true,反之为false //通过循环不断的自旋判断锁是否被其他线程持有 } } public void unLock() { Thread cur = Thread.currentThread(); if(ref.get() != cur){ //exception ... } ref.set(null); } } //自旋锁测试 public class SpinLockTestTest { static int count = 0; @Test public void spinLockTest() throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(100); CountDownLatch countDownLatch = new CountDownLatch(100); SpinLockTest2 spinLockTest2 = new SpinLockTest2(); for (int i = 0; i < 100; i++) { executorService.execute(new Runnable() { @Override public void run() { spinLockTest2.lock(); ++count; spinLockTest2.unLock(); countDownLatch.countDown(); } }); } countDownLatch.await(); System.out.println (count); } }
通过上面的代码可以看出,自旋就是在循环判断条件是否满足,那么会有什么问题吗?如果锁被占用很长时间的话,自旋的线程等待的时间也会变长,白白浪费掉处理器资源。因此在JDK中,自旋操作默认10次,我们可以通过参数“-XX:PreBlockSpin”来设置,当超过来此参数的值,则会使用传统的线程挂起方式来等待锁释放。
自适应自旋锁
随着JDK的更新,在1.6的时候,又出现了一个叫做“自适应自旋锁”的玩意。它的出现使得自旋操作变得聪明起来,不再跟之前一样死板。所谓的“自适应”意味着对于同一个锁对象,线程的自旋时间是根据上一个持有该锁的线程的自旋时间以及状态来确定的。例如对于A锁对象来说,如果一个线程刚刚通过自旋获得到了锁,并且该线程也在运行中,那么JVM会认为此次自旋操作也是有很大的机会可以拿到锁,因此它会让自旋的时间相对延长。但是如果对于B锁对象自旋操作很少成功的话,JVM甚至可能直接忽略自旋操作。因此,自适应自旋锁是一个更加智能,对我们的业务性能更加友好的一个锁。
总结
自旋锁是为了提高资源的使用频率而出现的一种锁,自旋锁说的是线程获取锁的时候,如果锁被其他线程持有,则当前线程将循环等待,直到获取到锁。
自旋锁在等待期间不会睡眠或者释放自己的线程。自旋锁不适用于长时间持有CPU的情况,这会加剧系统的负担,为了解决这种情况,需要设定自旋周期,那么自旋周期的设定也是一门学问。
在自旋锁中 另有三种常见的锁形式:TicketLock、CLHlock和MCSlock
这里直接举栗子:
- TicketLock 是一种同步机制或锁定算法,它是一种自旋锁,它使用ticket 来控制线程执行顺序。
/** * @Description TicketLock 是一种同步机制或锁定算法,它是一种自旋锁,它使用ticket 来控制线程执行顺序。 * * TicketLock 是基于先进先出(FIFO) 队列的机制。 * 它增加了锁的公平性,其设计原则如下:TicketLock 中有两个 int 类型的数值, * 开始都是0,第一个值是队列ticket(队列票据), 第二个值是 出队(票据)。 * 队列票据是线程在队列中的位置,而出队票据是现在持有锁的票证的队列位置。 * 可能有点模糊不清,简单来说,就是队列票据是你取票号的位置,出队票据是你距离叫号的位置。 * * * @Author zhoumm * @Version V1.0.0 * @Since 1.0 * @Date 2020-05-29 */ //这个设计是有问题的,因为获得自己的号码之后,是可以对号码进行更改的,这就造成系统紊乱,锁不能及时释放。 //需要有一个能确保每个人按会着自己号码排队办业务的角色 public class TicketLock { // 队列票据(当前排队号码) private AtomicInteger queueNum = new AtomicInteger(); // 出队票据(当前需等待号码) private AtomicInteger dueueNum = new AtomicInteger(); // 获取锁:如果获取成功,返回当前线程的排队号 public int lock(){ int currentTicketNum = dueueNum.incrementAndGet(); while (currentTicketNum != queueNum.get()){ // doSomething... } return currentTicketNum; } // 释放锁:传入当前排队的号码 public void unLock(int ticketNum){ queueNum.compareAndSet(ticketNum,ticketNum + 1); } //改进后,这就不再需要返回值,办业务的时候,要将当前的这一个号码缓存起来,在办完业务后,需要释放缓存的这条票据。 //缺点:虽然解决了公平性的问题,但是多处理器系统上,每个进程/线程占用的处理器都在读写同一个变量queueNum , // 每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能。 //为了解决这个问题,MCSLock 和 CLHLock 应运而生 public class TicketLock2 { // 队列票据(当前排队号码) private AtomicInteger queueNum = new AtomicInteger(); // 出队票据(当前需等待号码) private AtomicInteger dueueNum = new AtomicInteger(); //线程内部的存储类,可以在指定线程内存储数据,数据存储以后,只有指定线程可以得到存储数据 private ThreadLocal<Integer> ticketLocal = new ThreadLocal<>(); public void lock(){ int currentTicketNum = dueueNum.incrementAndGet(); // 获取锁的时候,将当前线程的排队号保存起来 ticketLocal.set(currentTicketNum); while (currentTicketNum != queueNum.get()){ // doSomething... } } // 释放锁:从排队缓冲池中取 public void unLock(){ Integer currentTicket = ticketLocal.get(); queueNum.compareAndSet(currentTicket,currentTicket + 1); } } }
- TicketLock 是基于队列的,那么 CLHLock 就是基于链表设计的
/** * @Description * TicketLock 是基于队列的,那么 CLHLock 就是基于链表设计的 * * CLH 是一种基于链表的可扩展,高性能,公平的自旋锁,申请线程只能在本地变量上自旋, * 它会不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。 * * * @Author zhoumm * @Version V1.0.0 * @Since 1.0 * @Date 2020-05-29 */ public class CLHLock { public static class CLHNode{ private volatile boolean isLocked = true; } // 尾部节点 private volatile CLHNode tail; private static final ThreadLocal<CLHNode> LOCAL = new ThreadLocal<>(); private static final AtomicReferenceFieldUpdater<CLHLock,CLHNode> UPDATER = AtomicReferenceFieldUpdater.newUpdater(CLHLock.class,CLHNode.class,"tail"); public void lock(){ // 新建节点并将节点与当前线程保存起来 CLHNode node = new CLHNode(); LOCAL.set(node); // 将新建的节点设置为尾部节点,并返回旧的节点(原子操作),这里旧的节点实际上就是当前节点的前驱节点 CLHNode preNode = UPDATER.getAndSet(this,node); if(preNode != null){ // 前驱节点不为null表示当锁被其他线程占用,通过不断轮询判断前驱节点的锁标志位等待前驱节点释放锁 while (preNode.isLocked){ } preNode = null; LOCAL.set(node); } // 如果不存在前驱节点,表示该锁没有被其他线程占用,则当前线程获得锁 } public void unlock() { // 获取当前线程对应的节点 CLHNode node = LOCAL.get(); // 如果tail节点等于node,则将tail节点更新为null,同时将node的lock状态职位false,表示当前线程释放了锁 if (!UPDATER.compareAndSet(this, node, null)) { node.isLocked = false; } node = null; } }
- MCS Spinlock 是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,直接前驱负责通知其结束自旋,从而极大地减少了不必要的处理器缓存同步的次数,降低了总线和内存的开销
/** * @Description * MCS Spinlock 是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋, * 直接前驱负责通知其结束自旋,从而极大地减少了不必要的处理器缓存同步的次数,降低了总线和内存的开销。 * * 总结: * 1.都是基于链表,不同的是CLHLock是基于隐式链表,没有真正的后续节点属性,MCSLock是显示链表,有一个指向后续节点的属性。 * 2.将获取锁的线程状态借助节点(node)保存,每个线程都有一份独立的节点,这样就解决了TicketLock多处理器缓存同步的问题。 * @Author zhoumm * @Version V1.0.0 * @Since 1.0 * @Date 2020-05-29 */ public class MCSLock { public static class MCSNode { volatile MCSNode next; volatile boolean isLocked = true; } private static final ThreadLocal<MCSNode> NODE = new ThreadLocal<>(); // 队列 @SuppressWarnings("unused") private volatile MCSNode queue; private static final AtomicReferenceFieldUpdater<MCSLock,MCSNode> UPDATE = AtomicReferenceFieldUpdater.newUpdater(MCSLock.class,MCSNode.class,"queue"); public void lock(){ // 创建节点并保存到ThreadLocal中 MCSNode currentNode = new MCSNode(); NODE.set(currentNode); // 将queue设置为当前节点,并且返回之前的节点 MCSNode preNode = UPDATE.getAndSet(this, currentNode); if (preNode != null) { // 如果之前节点不为null,表示锁已经被其他线程持有 preNode.next = currentNode; // 循环判断,直到当前节点的锁标志位为false while (currentNode.isLocked) { } } } public void unlock() { MCSNode currentNode = NODE.get(); // next为null表示没有正在等待获取锁的线程 if (currentNode.next == null) { // 更新状态并设置queue为null if (UPDATE.compareAndSet(this, currentNode, null)) { // 如果成功了,表示queue==currentNode,即当前节点后面没有节点了 return; } else { // 如果不成功,表示queue!=currentNode,即当前节点后面多了一个节点,表示有线程在等待 // 如果当前节点的后续节点为null,则需要等待其不为null(参考加锁方法) while (currentNode.next == null) { } } } else { // 如果不为null,表示有线程在等待获取锁,此时将等待线程对应的节点锁状态更新为false,同时将当前线程的后继节点设为null currentNode.next.isLocked = false; currentNode.next = null; } } }
到此这篇关于Java中的自旋锁与适应性自旋锁的区别的文章就介绍到这了,更多相关自旋锁与适应性自旋锁区别内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!