常见锁的简单整理(初稿)

1,451 阅读13分钟

初识锁

首先,使用锁的场景。
在并发编程中,多线程访问一个共享资源,为维护数据的一致性,需要给资源上锁,并顺序访问。

java中的锁——synchronized关键字 和 Lock的实现类。

其次,锁的策略。
悲观锁乐观锁,这也是最常见的锁的宏观分类。

悲观锁(Pessimistic Lock)

悲观锁就是在访问数据的时候上锁,其他线程阻塞,在用完后将资源还给其他线程。
关系型数据库中的行锁,读锁,写锁都是这种策略。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。

乐观锁(Optimistic Lock)

首先明确乐观锁不会上锁,只是在更新数据的时候判断读写数据的时间内,数据有没有发生改变。常见的是CAS算法。

相较而言,乐观锁适用于多读场景下,很少发生冲突,可以减少锁的开销,而多读的场景中,面对多冲突情况,适合使用悲观锁。
借用一句:悲观锁阻塞事务,乐观锁回滚重试

乐观锁

CAS算法

CAS算法(compare and swap),还有一些文章中解释为 compare and set,本质上没区别。既然是乐观锁策略,主要思路就是更新前比较数据是否一致。

CAS算法是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数
需要读写的内存值 V
进行比较的值 A
拟写入的新值 B
当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。

一般使用CAS算法都有一个自旋的操作,既回滚重试,这就引出自旋锁

CAS算法常见问题

  1. ABA 问题
    再看上面的例子,在写入新值B时,只判断了当前值是否是A,无法判断A有没有发生过变化,例如 A 被更新为B,但又被更新回A,发生了两次update,但是CAS无法判断出来。这个问题被称为CAS操作的 "ABA"问题。
    解决方案:加入预期标识,类似版本号的定义,读写操作的标识相同时,才会更新数据,并更新标识,java AtomicStampedReference有实现

  2. 线程循环导致的开销问题
    这是一个自旋锁机制导致的问题,在回滚重试期间,线程不断的进行循环重试,会浪费资源增加开销。解决方案在下面自旋锁部分看一下。

  3. 多个共享变量的原子操作
    CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。java AtomicReference类可以用来保证引用对象之间的原子性,也可以将多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。

自旋锁

自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。

自旋锁对比互斥锁,本质上都是保护共享资源,保证只有一个线程能够拥有该资源。区别在于对于互斥锁,如果资源已经被占用,资源的其他申请者只能进入睡眠状态进行等待。但是自旋锁不会眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环判断保持者是否已经释放。

1.自旋锁的优缺点

优点:不改变线程状态,不会使线程阻塞,减少不必要的上下文切换。

缺点:如果有一个线程长时间锁住资源,会导致其他线程循环等待,造成资源浪费,CPU使用率增高。还有,最简单的自旋锁是非公平锁。

这里引出两个相互对立的锁的概念
1、公平锁 ———— 非公平锁
2、可重入锁 ———— 不可重入锁

(在文章最后一部分介绍)

2.自旋锁的延伸

2.1 TicketLock

TicketLock简单来说就是解决自旋锁公平性问题。

在线程请求锁的时候,分配给线程一个顺序id,每当有线程释放锁的时候,按顺序匹配线程,达到先到先得的公平性。

TicketLock的问题:多线程都在监听这个锁的顺序变量,每次读写操作都会有很大的开销。为解决这个问题,引出以下两种锁。

2.1 CLHLock

CLHLock = Craig, Landin, and Hagersten locks 指的是三个人的名字缩写

CLH锁是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。

当一个线程需要获取锁时:

1.创建一个的QNode,将其中的locked设置为true表示需要获取锁

2.线程对tail域调用getAndSet方法,使自己成为队列的尾部,同时获取一个指向其前趋结点的引用myPred

3.该线程就在前趋结点的locked字段上旋转,直到前趋结点释放锁

4.当一个线程需要释放锁时,将当前结点的locked域设置为false,同时回收前趋结点

2.1 MCSLock

MCSLock = John M. Mellor-Crummey and Michael L. Scott 也是三个人

MSC与CLH最大的不同并不是链表是显示还是隐式,而是线程自旋的规则不同:CLH是在前趋结点的locked域上自旋等待,而MCS是在自己的

结点的locked域上自旋等待。正因为如此,它解决了CLH在NUMA系统架构中获取locked域状态内存过远的问题。

MCS队列锁的具体实现如下:

a. 队列初始化时没有结点,tail=null

b. 线程A想要获取锁,于是将自己置于队尾,由于它是第一个结点,它的locked域为false

c. 线程B和C相继加入队列,a->next=b,b->next=c。且B和C现在没有获取锁,处于等待状态,所以它们的locked域为true,

尾指针指向线程C对应的结点

d. 线程A释放锁后,顺着它的next指针找到了线程B,并把B的locked域设置为false。这一动作会触发线程B获取锁

(这一段详细内容可以查看 www.cnblogs.com/yuyutianxia…

3.Java8 CAS 的优化

场景:大量的线程请求一个锁,全都在不停的自旋,浪费资源。

Java 8推出了一个新的类,LongAdder,使用分段CAS以及自动分段迁移的方式来大幅度提升多线程高并发执行CAS操作的性能。

在LongAdder的底层实现中,首先有一个base值,刚开始多线程来不停的累加数值,都是对base进行累加的,比如刚开始累加成了base = 5。

接着如果发现并发更新的线程数量过多,就会开始施行分段CAS的机制,也就是内部会搞一个Cell数组,每个数组是一个数值分段。

这时,让大量的线程分别去对不同Cell内部的value值进行CAS累加操作,这样就把CAS计算压力分散到了不同的Cell分段数值中了!

这样就可以大幅度的降低多线程并发更新同一个数值时出现的无限循环的问题,大幅度提升了多线程并发更新数值的性能和效率!

而且他内部实现了自动分段迁移的机制,也就是如果某个Cell的value执行CAS失败了,那么就会自动去找另外一个Cell分段内的value值进行CAS操作。

这样也解决了线程空旋转、自旋不停等待执行CAS操作的问题,让一个线程过来执行CAS时可以尽快的完成这个操作。

最后,如果你要从LongAdder中获取当前累加的总值,就会把base值和所有Cell分段数值加起来返回给你。

4.自适应自旋锁

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。

如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

悲观锁

这里主要看一下java中的synchronized。

synchronized的底层实现主要依靠 Lock-Free 的队列,基本思路是 自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。

由于悲观锁的线程阻塞特质,synchronized被定义为重量锁。
而在java SE 1.6中对锁进行了优化,从而引出了四种锁状态————无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率

偏向锁

大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

偏向锁默认只有一个线程,不需要同步,如果有另外的线程尝试获取时,升级为轻量级锁。

轻量级锁

线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

偏向锁和轻量级锁的差别,偏向锁在第一个线程拿到锁之后,将把线程ID 存储在对象头中,后面的所有操作都不是同步的,相当于无锁。而轻量级锁,每次获取锁的时候还是需要使用CAS来修改对象头的记录,在没有线程竞争的情况下,这个操作是很轻量的,不需要使用操作系统的互斥机制。

对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢;

重量级锁

重量级锁通过对象内部监听器来实现,操作系统线程之间切换需要从用户态到内核态的切换。

锁消除

锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。

锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。

锁粗化

锁粗化就是将多个连续的加锁、解锁操作连到一起,扩展成更多范围的锁。

JVM检测到同一个对象有连续的加锁、解锁操作,会合并成为一个更大范围的加锁、解锁操作,例如将加锁解锁操作移到for循环外面。

其他纬度定义的锁

公平锁、非公平锁

简单来说,在多线程申请同一把锁的时候,锁释放后,锁分配的策略是怎样的。

公平锁:先到先得,现申请先分配,符合请求的绝对时间顺序,也就是FIFO。
非公平锁:随机分配或按照其他优先级排序。

补充:java ReentrantLock可以指定公平或非公平,而ReentrantLock、ReadWriteLock默认都是非公平模式。原因,如果在释放锁的时候,正好有一个线程来请求,而队列头部的线程没有被唤醒,则后来的线程先获得锁,这样可以减少线程上下文切换,减少开销。非公平锁减少了线程挂起的几率,后来的线程有一定几率逃离被挂起的开销。

可重入锁、不可重入锁

场景:当前线程已经获得锁,这时,当前线程的另一个方法,再次请求锁,是否会成功呢,这就区分了可重入锁,和不可重入锁。如果是最简单的自旋锁,线程再次请求,不满足CAS算法,会循环等待,即为不可重入锁。如果判断是同一个线程就请求成功,则为可重入锁

java线程是基于“每线程(per-thread)”,而不是基于“每调用(per-invocation)”的(java中线程获得对象锁的操作是以每线程为粒度的,per-invocation互斥体获得对象锁的操作是以每调用作为粒度的),所以Lock实现类,synchronized关键字锁都是可重入的。不可重入锁可以自行实现。

可中断锁、不可中断锁

顾名思义就是能否中断线程的锁。

Java中提供了中断机制,但是不能直接终止线程。synchronized就是不可中断锁,而Lock的实现类都是可中断锁

参考引用

www.cnblogs.com/yuyutianxia…

ifeve.com/java-synchr…