在上一篇文章 看完你就应该能明白的悲观锁和乐观锁 中咱们已经学习到了什么是悲观锁和乐观锁、悲观锁和乐观锁的实现、优缺点分别是什么。其中乐观锁的实现之一 CAS 算法中提到了一个自旋锁的概念,为了全面理解 CAS 算法就首先须要了解一下自旋锁
是什么,自旋锁的适用场景和优缺点分别是什么,别着急,下面为你一一列举。html
因为在多处理器环境中某些资源的有限性,有时须要互斥访问(mutual exclusion),这时候就须要引入锁的概念,只有获取了锁的线程才可以对资源进行访问,因为多线程的核心是CPU的时间分片,因此同一时刻只能有一个线程获取到锁。那么就面临一个问题,那么没有获取到锁的线程应该怎么办?java
一般有两种处理方式:一种是没有获取到锁的线程就一直循环等待判断该资源是否已经释放锁,这种锁叫作自旋锁,它不用将线程阻塞起来(NON-BLOCKING);还有一种处理方式就是把本身阻塞起来,等待从新调度请求,这种叫作互斥锁
。node
自旋锁的定义:当一个线程尝试去获取某一把锁的时候,若是这个锁此时已经被别人获取(占用),那么此线程就没法获取到这把锁,该线程将会等待,间隔一段时间后会再次尝试获取。这种采用循环加锁 -> 等待的机制被称为自旋锁(spinlock)
。算法
自旋锁的原理比较简单,若是持有锁的线程能在短期内释放锁资源,那么那些等待竞争锁的线程就不须要作内核态和用户态之间的切换进入阻塞状态,它们只须要等一等(自旋),等到持有锁的线程释放锁以后便可获取,这样就避免了用户进程和内核切换的消耗。缓存
由于自旋锁避免了操做系统进程调度和线程切换,因此自旋锁一般适用在时间比较短的状况下。因为这个缘由,操做系统的内核常用自旋锁。可是,若是长时间上锁的话,自旋锁会很是耗费性能,它阻止了其余线程的运行和调度。线程持有锁的时间越长,则持有该锁的线程将被 OS(Operating System)
调度程序中断的风险越大。若是发生中断状况,那么其余线程将保持旋转状态(反复尝试获取锁),而持有该锁的线程并不打算释放锁,这样致使的是结果是无限期推迟,直到持有锁的线程能够完成并释放它为止。多线程
解决上面这种状况一个很好的方式是给自旋锁设定一个自旋时间,等时间一到当即释放自旋锁。自旋锁的目的是占着CPU资源不进行释放,等到获取锁当即进行处理。可是如何去选择自旋时间呢?若是自旋执行时间太长,会有大量的线程处于自旋状态占用 CPU 资源,进而会影响总体系统的性能。所以自旋的周期选的额外重要!JDK在1.6 引入了适应性自旋锁,适应性自旋锁意味着自旋时间不是固定的了,而是由前一次在同一个锁上的自旋时间以及锁拥有的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间。性能
自旋锁尽量的减小线程的阻塞,这对于锁的竞争不激烈,且占用锁时间很是短的代码块来讲性能能大幅度的提高,由于自旋的消耗会小于线程阻塞挂起再唤醒的操做的消耗,这些操做会致使线程发生两次上下文切换!学习
可是若是锁的竞争激烈,或者持有锁的线程须要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,由于自旋锁在获取锁前一直都是占用 cpu 作无用功,占着 XX 不 XX,同时有大量线程在竞争一个锁,会致使获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操做的消耗,其它须要 cpu 的线程又不能获取到 cpu,形成 cpu 的浪费。因此这种状况下咱们要关闭自旋锁。this
下面咱们用Java 代码来实现一个简单的自旋锁spa
public class SpinLockTest {
private AtomicBoolean available = new AtomicBoolean(false);
public void lock(){
// 循环检测尝试获取锁
while (!tryLock()){
// doSomething...
}
}
public boolean tryLock(){
// 尝试获取锁,成功返回true,失败返回false
return available.compareAndSet(false,true);
}
public void unLock(){
if(!available.compareAndSet(true,false)){
throw new RuntimeException("释放锁失败");
}
}
}复制代码
这种简单的自旋锁有一个问题:没法保证多线程竞争的公平性。对于上面的SpinlockTest,当多个线程想要获取锁时,谁最早将available
设为false
谁就能最早得到锁,这可能会形成某些线程一直都未获取到锁形成线程饥饿
。就像咱们下课后蜂拥的跑向食堂,下班后蜂拥地挤向地铁,一般咱们会采起排队的方式解决这样的问题,相似地,咱们把这种锁叫排队自旋锁(QueuedSpinlock)。计算机科学家们使用了各类方式来实现排队自旋锁,如TicketLock,MCSLock,CLHLock。接下来咱们分别对这几种锁作个大体的介绍。
在计算机科学领域中,TicketLock 是一种同步机制或锁定算法,它是一种自旋锁,它使用ticket
来控制线程执行顺序。
就像票据队列管理系统同样。面包店或者服务机构(例如银行)都会使用这种方式来为每一个先到达的顾客记录其到达的顺序,而不用每次都进行排队。一般,这种地点都会有一个分配器(叫号器,挂号器等等都行),先到的人须要在这个机器上取出本身如今排队的号码,这个号码是按照自增的顺序进行的,旁边还会有一个标牌显示的是正在服务的标志,这一般是表明目前正在服务的队列号,当前的号码完成服务后,标志牌会显示下一个号码能够去服务了。
像上面系统同样,TicketLock 是基于先进先出(FIFO) 队列的机制。它增长了锁的公平性,其设计原则以下:TicketLock 中有两个 int 类型的数值,开始都是0,第一个值是队列ticket(队列票据)
, 第二个值是 出队(票据)
。队列票据是线程在队列中的位置,而出队票据是如今持有锁的票证的队列位置。可能有点模糊不清,简单来讲,就是队列票据是你取票号的位置,出队票据是你距离叫号的位置。如今应该明白一些了吧。
当叫号叫到你的时候,不能有相同的号码同时办业务,必须只有一我的能够去办,办完后,叫号机叫到下一我的,这就叫作原子性
。你在办业务的时候不能被其余人所干扰,并且不可能会有两个持有相同号码的人去同时办业务。而后,下一我的看本身的号是否和叫到的号码保持一致,若是一致的话,那么就轮到你去办业务,不然只能继续等待。上面这个流程的关键点在于,每一个办业务的人在办完业务以后,他必须丢弃本身的号码,叫号机才能继续叫到下面的人,若是这我的没有丢弃这个号码,那么其余人只能继续等待。下面来实现一下这个票据排队方案
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);
}
}复制代码
每次叫号机在叫号的时候,都会判断本身是否是被叫的号,而且每一个人在办完业务的时候,叫号机根据在当前号码的基础上 + 1,让队列继续往前走。
可是上面这个设计是有问题的,由于得到本身的号码以后,是能够对号码进行更改的,这就形成系统紊乱,锁不能及时释放。这时候就须要有一个能确保每一个人按会着本身号码排队办业务的角色,在得知这一点以后,咱们从新设计一下这个逻辑
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 虽然解决了公平性的问题,可是多处理器系统上,每一个进程/线程占用的处理器都在读写同一个变量queueNum ,每次读写操做都必须在多个处理器缓存之间进行缓存同步,这会致使繁重的系统总线和内存的流量,大大下降系统总体的性能。
为了解决这个问题,MCSLock 和 CLHLock 应运而生。
上面说到TicketLock 是基于队列的,那么 CLHLock 就是基于链表设计的,CLH的发明人是:Craig,Landin and Hagersten,用它们各自的字母开头命名。CLH 是一种基于链表的可扩展,高性能,公平的自旋锁,申请线程只能在本地变量上自旋,它会不断轮询前驱的状态,若是发现前驱释放了锁就结束自旋。
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 是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,直接前驱负责通知其结束自旋,从而极大地减小了没必要要的处理器缓存同步的次数,下降了总线和内存的开销。MCS 来自于其发明人名字的首字母: John Mellor-Crummey和Michael Scott。
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;
}
}
}复制代码
此篇文章咱们主要讲述了自旋锁的提出背景,自旋锁是为了提升资源的使用频率而出现的一种锁,自旋锁说的是线程获取锁的时候,若是锁被其余线程持有,则当前线程将循环等待,直到获取到锁。
自旋锁在等待期间不会睡眠或者释放本身的线程。自旋锁不适用于长时间持有CPU的状况,这会加重系统的负担,为了解决这种状况,须要设定自旋周期,那么自旋周期的设定也是一门学问。
还提到了自旋锁自己没法保证公平性,那么为了保证公平性又引出了TicketLock ,TicketLock 是采用排队叫号的机制来实现的一种公平锁,可是它每次读写操做都必须在多个处理器缓存之间进行缓存同步,这会致使繁重的系统总线和内存的流量,大大下降系统总体的性能。
因此咱们又引出了CLHLock和MCSLock,CLHLock和MCSLock经过链表的方式避免了减小了处理器缓存同步,极大的提升了性能,区别在于CLHLock是经过轮询其前驱节点的状态,而MCS则是查看当前节点的锁状态。
文章参考:
https://blog.csdn.net/qq_34337272/article/details/81252853
http://www.blogjava.net/jinfeng_wang/archive/2016/12/14/432088.html
https://blog.hufeifei.cn/ 关于自旋锁的文章
https://en.wikipedia.org/wiki/Ticket_lock
下面为本身作个宣传,欢迎关注公众号 Java建设者
,号主是Java技术栈,热爱技术,喜欢阅读,热衷于分享和总结,但愿能把每一篇好文章分享给成长道路上的你。关注公众号回复 002
领取为你特地准备的大礼包,你必定会喜欢并收藏的。
本文由博客一文多发平台 OpenWrite 发布!