前言
摘自《深入理解Java虚拟机》一书
互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。而在很多应用上,共享数据的锁定状态只会持续很短的一段时间。若实体机上有多个处理器,能让两个以上的线程同时并行执行,我们就可以让后面请求锁的那个线程原地自旋(不放弃CPU时间),看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是自旋锁。
如果锁长时间被占用,则浪费处理器资源,因此自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了(默认10次)。
JDK1.6引入自适应的自旋锁:自旋时间不再固定,由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。
一、定义
自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。
自旋锁是专为防止多处理器并发而引入的一种锁,它在内核中大量应用于中断处理等部分(对于单处理器来说,防止中断处理中的并发可简单采用关闭中断的方式,即在标志寄存器中关闭/打开中断标志位,不需要自旋锁)。
何谓自旋锁?它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。
二、原理
跟互斥锁一样,一个执行单元要想访问被自旋锁保护的共享资源,必须先得到锁,在访问完共享资源后,必须释放锁。如果在获取自旋锁时,没有任何执行单元保持该锁,那么将立即得到锁;如果在获取自旋锁时锁已经有保持者,那么获取锁操作将自旋在那里,直到该自旋锁的保持者释放了锁。由此我们可以看出,自旋锁是一种比较低级的保护数据结构或代码片段的原始方式,这种锁可能存在两个问题:
- 死锁。试图递归地获得自旋锁必然会引起死锁:递归程序的持有实例在第二个实例循环,以试图获得相同自旋锁时,不会释放此自旋锁。在递归程序中使用自旋锁应遵守下列策略:递归程序决不能在持有自旋锁时调用它自己,也决不能在递归调用时试图获得相同的自旋锁。此外如果一个进程已经将资源锁定,那么,即使其它申请这个资源的进程不停地疯狂“自旋”,也无法获得资源,从而进入死循环。
- 过多占用cpu资源。如果不加限制,由于申请者一直在循环等待,因此自旋锁在锁定的时候,如果不成功,不会睡眠,会持续的尝试,单cpu的时候自旋锁会让其它process动不了. 因此,一般自旋锁实现会有一个参数限定最多持续尝试次数. 超出后, 自旋锁放弃当前time slice. 等下一次机会。
由此可见,自旋锁比较适用于锁使用者保持锁时间比较短的情况。正是由于自旋锁使用者一般保持锁时间非常短,因此选择自旋而不是睡眠是非常必要的,自旋锁的效率远高于互斥锁。信号量和读写信号量适合于保持时间较长的情况,它们会导致调用者睡眠,因此只能在进程上下文使用,而自旋锁适合于保持时间非常短的情况,它可以在任何上下文使用。如果被保护的共享资源只在进程上下文访问,使用信号量保护该共享资源非常合适,如果对共享资源的访问时间非常短,自旋锁也可以。但是如果被保护的共享资源需要在中断上下文访问(包括底半部即中断处理句柄和顶半部即软中断),就必须使用自旋锁。自旋锁保持期间是抢占失效的,而信号量和读写信号量保持期间是可以被抢占的。自旋锁只有在内核可抢占或SMP(多处理器)的情况下才真正需要,在单CPU且不可抢占的内核下,自旋锁的所有操作都是空操作。
上面简要介绍了自旋锁的基本原理,以下将给出具体的例子,进一步阐释自旋锁在实际系统中的应用。上面我们已经讲过自旋锁只有在内核可抢占或SMP(多处理器)的情况下才真正需要,下面我们就以SMP为例,来说明为什么要使用自旋锁,以及自旋锁实现的基本算法。
根据上次线程持有资源的时长,决定多线程下次竞争锁的状态,既是否从内核态,全部转换到用户态进行阻塞挂起。 如果不需要,这些线程进就用自旋状态,等有线程释放了锁立即可获取锁,从而避免用户线程和内核态切换的消耗。
java实现一个自旋锁
public class SpinLockTest { AtomicReference<Thread> reference = new AtomicReference<>();// 此时构造方法未传入值,此时原子线程默认是null public void lock() { Thread thread = Thread.currentThread(); System.out.println(Thread.currentThread().getName() + " come in"); // 自旋的精髓是循环(没有拿到锁的线程,会进入到循环里面,即通过循环等待来获取锁,很耗性能) while (!reference.compareAndSet(null, thread)) {// // System.out.println(Thread.currentThread().getName() + " 循环尝试获取锁"); } } public void unlock() { Thread thread = Thread.currentThread(); System.out.println(Thread.currentThread().getName() + " unlock"); reference.compareAndSet(thread, null);// 原子操作+期望值吻合+释放 } public static void main(String[] args) { SpinLockTest spinLock = new SpinLockTest(); new Thread(() -> { spinLock.lock();// aa获取锁 try { TimeUnit.SECONDS.sleep(5);// aa持有锁5s } catch (Exception e) { e.printStackTrace(); } spinLock.unlock();// aa释放锁 }, "aa").start(); try { TimeUnit.SECONDS.sleep(1);// 保证线程aa先获取到锁 } catch (Exception e) { e.printStackTrace(); } new Thread(() -> { spinLock.lock();// bb获取锁 System.out.println("bb终于得到锁了"); spinLock.unlock();// bb释放锁 }, "bb").start(); } }
执行流程:
- 当aa线程获取主内存的共享变量reference的副本到自己的工作内存且reference=null时,null为aa线程的期望值,若为null,aa线程在自己的工作内存中将reference=null改成reference=aa线程;
- aa线程执行时间为5s(在5s过程中,bb线程启动),5s后aa线程调unlock方法将reference=aa改为reference=null,并将reference=null写回主内存中;
- bb线程在aa线程启动的1s后,不断获取主存中reference的值,但在aa线程没执行完之前,主内存的reference始终=aa线程,故bb执行while方法不断循环获取主内存的reference,直到aa线程执行5s完毕,主存的reference=null;
- 线程bb如愿以偿的获取到了锁,打印了“bb终于得到锁了”这句话,后释放了锁,将reference恢复到初始值:reference=null,也就是最初的代码AtomicReference<Thread> reference = new AtomicReference<>();
lock()方法利用的CAS,当第一个线程A获取锁的时候,能够成功获取到,不会进入while循环,如果此时线程A没有释放锁,另一个线程B又来获取锁,此时由于不满足CAS,所以就会进入while循环,不断判断是否满足CAS,直到A线程调用unlock方法释放了该锁。
总结:自旋锁其实就是线程aa获取到锁,bb线程在获取锁的时候不是处于阻塞状态在等待队列中一直等,而是不断循环的获取锁,但是如果说aa执行时间特别长,bb还是在不断获取,因为每次获取都需要一点内存来执行,这样会大量耗费cpu的资源,影响性能,应根据情况使用。
三、自旋锁存在的问题
使用自旋锁会有以下一个问题:
1. 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
2. 上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。
自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)
四、自旋锁的优缺点
在自旋状态下,当一个线程A尝试进入同步代码块,但是当前的锁已经被线程B占有时,线程A不进入阻塞状态,而是不停的空转,等待线程B释放锁。如果锁的线程能在很短时间内释放资源,那么等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞状态,只需自旋,等持有锁的线程释放后即可立即获取锁,避免了用户线程和内核的切换消耗。
自旋等待最大时间:线程自旋会消耗cpu,若自旋太久,则会让cpu做太多无用功,因此要设置自旋等待最大时间。
优点:
开启自旋锁后能减少线程的阻塞,在对于锁的竞争不激烈且占用锁时间很短的代码块来说,能提升很大的性能,在这种情况下自旋的消耗小于线程阻塞挂起的消耗。
- 减少线程用户态和内核态的切换消耗
- 锁资源处理时间非常短代码块,性能极大提升。
缺点:
在线程竞争锁激烈,或持有锁的线程需要长时间执行同步代码块的情况下,使用自旋会使得cpu做的无用功太多
- 不适合锁资源处理时间长的场景
- 自旋锁状态既CPU空转消耗
五、可重入的自旋锁和不可重入的自旋锁
文章开始的时候的那段代码,仔细分析一下就可以看出,它是不支持重入的,即当一个线程第一次已经获取到了该锁,在锁释放之前又一次重新获取该锁,第二次就不能成功获取到。由于不满足CAS,所以第二次获取会进入while循环等待,而如果是可重入锁,第二次也是应该能够成功获取到的。
而且,即使第二次能够成功获取,那么当第一次释放锁的时候,第二次获取到的锁也会被释放,而这是不合理的。
为了实现可重入锁,我们需要引入一个计数器,用来记录获取锁的线程数。
public class ReentrantSpinLock { private AtomicReference cas = new AtomicReference(); private int count; public void lock() { Thread current = Thread.currentThread(); if (current == cas.get()) { // 如果当前线程已经获取到了锁,线程数增加一,然后返回 count++; return; } // 如果没获取到锁,则通过CAS自旋 while (!cas.compareAndSet(null, current)) { // DO nothing } } public void unlock() { Thread cur = Thread.currentThread(); if (cur == cas.get()) { if (count > 0) {// 如果大于0,表示当前线程多次获取了该锁,释放锁通过count减一来模拟 count--; } else {// 如果count==0,可以将锁释放,这样就能保证获取锁的次数与释放锁的次数是一致的了。 cas.compareAndSet(cur, null); } } } }