详细介绍各种锁
前言
通常情况下,每学习一块知识点我都会先找相应的文章来作铺垫,今天轮到锁相关介绍了,发现了一篇好文章,本想着直接贴个链接就完事了,想想自己还是好好总结下,下面的内容可能大部分摘自该文章锁的详细介绍,对本文没兴趣的读者直接移至该链接即可,速速开始吧。
进入正题
Java中有好几种锁,什么悲观锁、乐观锁、自旋锁等,第一次看的时候觉得这是什么乱七八糟的,实际上每种锁在被设计时就考虑了其使用场景,也就是说锁的特性决定了它用于哪种场景下的效率会最高,所以只要理解了使用场景的理念,基本上掌握锁是没什么问题了。在上面提供的链接中作者结合了Java源码进行介绍,这边的话也会适当性的加入一些,接下来通过一张表格来对锁进行分组归类:

乐观锁 VS 悲观锁
悲观锁:同一个数据的操作,悲观锁认为自己在使用数据的时候一定有其他线程在修改数据,所以要确保数据的准确性必须要先加锁。Java中,synchronized关键字和lock的实现类都是悲观锁。
乐观锁:同一个数据的操作,乐观锁认为自己在使用数据的时候不会有其他线程在修改数据,所以不会加锁,只是在更新数据时判断之前有没有其他线程更新了数据,如果数据没有被更新,当前线程将自己修改的数据成功写入,如果数据已经被其他线程更新过,则根据不同的实现方式执行不同的操作,如报错或重试。Java中,乐观锁使用无锁编程来实现,最常采用的是CAS算法。


根据其特性,我们可知:
-
悲观锁适合写操作多的场景,加锁可以保证数据的正确性
-
乐观锁适合读操作多的场景,不加锁能够使读操作的性能大幅提升
悲观锁是显式的加锁后才开始操作同步资源的,而乐观锁却是直接操作同步资源。那么,为何乐观锁能够做到不锁定同步资源也可以正确地实现线程同步呢?说到底就是要理解CAS算法是如何实现的,接下来是笔者自己总结的知识点。
CAS操作利用了CPU提供的
cmpxchg指令,被该指令操作的内存区域会加锁,也就是说多个CPU同时访问该内存区域的话只会有一个CPU能成功访问,当将其值回写到内存区域时会利用缓存一致性机制使其他CPU对应的缓存行无效,从而保证了原子性操作,严格上来说是保证了更新的原子性。CAS操作的底层代码的变量使用volatile来修饰是为了禁止该指令与读写指令重排序。
在简单说下CAS操作的使用:比较旧值(预期值)是否发生变化,若没有发生变化则更新成新值,若发生了变化则直接返回,通常情况下,需要循环执行CAS操作直到成功为止。CAS操作虽然很高效,但也存在一些问题:
-
ABA问题:CAS操作要先检查旧值是否发生变化,那么就要先获取旧值,假设旧值是a,线程A获取到了旧值还没开始进行比较,线程B突然将其值由a变成了b,在变成a,那么线程A在进行比较时会发现值并没有变化,继而就更新了,这导致了线程B的操作相当于是没有一样,很明显这是逻辑上的错误。解决该问题的方式是在变量前面添加版本号,每次更新变量的时候都把版本号加一,比如由原来的a -> b -> a变成了1a -> 2b -> 3a,或者使用JDK提供的AtomicStampedReference。
-
循环时间开销长:CAS操作如果长时间不成功,会导致一直循环,给CPU带来非常大的执行开销。
-
只能保证一个共享变量的原子操作:对一个共享变量执行操作时,CAS操作能保证原子性,但是对多个共享变量操作时,CAS操作无法保证操作的原子性。这种情况下可以使用锁,JDK提供了AtomicReference类可以把多个变量放在一个对象里进行操作。
自旋锁 VS 适应性自旋锁
自旋锁:阻塞或唤醒一个线程都会进行一次上下文切换,如果同步代码块的内容过于简单,那么频繁的上下文切换所带来的开销可能会使得系统的性能下降,显得因小失大了。在多处理器下,为了使未能获取到同步资源的线程不阻塞,即不让其放弃CPU的执行时间,允许线程发生
自旋,如果在短时间内成功获取到锁,就可以避免切换线程的开销,而如果长时间未获取到锁,那就白白浪费了CPU资源,所以自旋的次数必须要有一定的限制,如果自旋次数超过了指定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)还未获得锁,就应当挂起线程。自旋锁是在JDK1.4中引入的,使用-XX:+UseSpinning来开启,JDK6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。
适应性自旋锁:一种更加聪明的自旋锁。自适应意味着自旋的次数不在固定,线程通过自旋成功获取到锁,那么下次自旋的次数会增加,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,所以它就允许自旋等待持续的次数更多。反之,如果线程通过自旋很少获取到锁,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费资源。
还有一些自旋锁的变体,如TicketLock、CLHLock、MCSLock,简单介绍下。
-
TicketLock:对于多个线程发生自旋来说,无法满足等待时间最长的线程优先获取锁,可能会存在线程饥饿问题。为了解决公平性问题,引出了TicketLock。每当有线程在获取锁的时候,就给该线程分配一个递增的标识,称之为排队号,同时锁对应一个服务号,每当有线程释放锁,服务号会递增,此时如果服务号与排队号一致,那么该线程就获到锁,由于排队号是递增的,所以就保证了最先请求获取锁的线程可以优先获取到,实现了公平性。可以想象成银行办理业务排队,排队的每一个客户都代表着一个请求锁的线程,银行窗口表示锁,每当服务完一个客户就把服务号加1,此时在排队中的所有客户中,只有服务号与排队号一致的客户才能得到服务。服务号对于排队中的所有线程来说必须是已知的,也就是说但凡一个线程修改了服务号,其他的线程必须重新从主内存获取同步资源,这导致了内存压力上升。
-
CLHLock:基于链表的可扩展、高性能、公平的自旋锁,每个请求锁的线程对应一个节点,按照请求顺序关联上下节点,所以每个线程在请求锁时只需要关注上一个节点是否释放锁了,若释放了则可以获取到锁了,这避免了TicketLock会造成内存压力的问题。
-
MCSLock:同CLHLock类似,只不过它的重心是在当前节点上。
无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁
这四种锁是指锁的状态,之前在理解synchronized关键字时提到过。早起的synchronized效率较低,是因为在简单的同步方法中频繁的进行上下文切换所带来的开销会降低系统的性能,所以引入了偏向锁和轻量级锁。
无锁:所有的线程都能并发访问同一个资源,但同时只有一个线程能修改成功,而其他线程会不断的重试修改直到成功为止。上面介绍的CAS原理便是无锁的实现。
偏向锁:HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获取,在第一次获取到锁时会存储当前线程的ID,以后该线程在进入和退出同步方法时不需要CAS操作进行加锁或解锁,只需要简单测试下对象头里是否存储了指向当前线程的偏向锁。偏向锁在遇到其他线程尝试竞争时会发生偏向锁的撤销,之所以称之为撤销是因为偏向锁并不存在真正意义上的释放锁的操作。偏向锁的撤销,需要等待全局安全点(没有任何线程执行的时刻),首先会暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否还活着,个人理解是同步代码是否执行完毕,若已执行完毕说明偏向锁已经过期无效,注意,是只有在出现其他线程竞争的情况下才会发生偏向锁的撤销,而没有竞争的情况下线程是不会主动撤销偏向锁,以便于下次获取锁时可直接进入到同步代码中,若还未执行同步代码说明偏向锁仍然有效,此时升级到轻量级锁状态。实际上偏向锁的撤销还远不止于此,还有偏向锁的重新偏向,没办法,我不懂...
轻量级锁:线程在执行同步代码之前,JVM会先在当前线程的栈帧(每个线程都有自己独立的内存空间,栈帧就是其中一部分)中创建用于存储锁记录的空间,并将对象头的Mark Word复制到锁记录中,官方称之为Displaced Mark Word(个人理解此时的锁记录被称之为该名称,但不敢保证正确性)。然后线程尝试使用CAS操作将对象头中的Mark Word替换为指向锁记录的指针,如果成功,当前线程获得锁,如果失败则当前线程便尝试使用自旋来获取锁。轻量级锁在解锁时,会使用CAS操作将Mark Word替换成原值,即使用Displaced Mark Word替换,如果成功,则表示没有竞争发生,如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。关于轻量级锁的理论摘自《并发编程的艺术》书籍,书中所写的内容表述不够准确清晰,故加上了自己的理解,并不敢保证其正确性。
重量级锁:升级为重量级锁时,对象头中的Mark Word存储的是指向互斥量(重量级锁)的指针,未获取到锁的其他线程都会进入到阻塞状态。
此小节中不太确定的理论,笔者只是简单地加上了自己的理解,再次强调不敢保证其正确性,这里也为大家贴上一篇更为详细的文章,此篇文章的作者是从源码的角度进行分析的。突然发现我看源码只是站在Java层面上看,别人那才是大牛,直接到C了...以下是锁的优缺点比较:
| 锁 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步资源 |
| 轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 始终得不到锁的线程使用自旋会消耗CPU | 追求响应时间,同步代码执行速度非常快 |
| 重量级锁 | 竞争的线程不使用自旋直接阻塞,不会消耗CPU | 线程阻塞,响应时间缓慢 | 同步代码执行速度较长 |
公平锁 VS 非公平锁
公平锁:是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。优点是队列中的线程不会出现饿死,缺点是整体吞吐率相对非公平锁低,队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁:多个线程直接尝试获取锁,未获取到的线程才会进入到队列中等待。如果此时锁刚好可用,那么当前线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取到锁的场景。优点是可以减少要唤醒的线程,整体的吞吐率较高,因为线程有几率不阻塞直接获取到锁,缺点是队列中的线程可能会饿死,或者说等很久才能获取到锁。
JDK中ReentrantLock类就提供了公平锁与非公平锁,会另外新起文章讲解ReentrantLock的源码,这里就不作展开了。
可重入锁 VS 不可重入锁
可重入锁:又名递归锁,指同一个线程在外层方法成功获取到锁后,而后又进入到内层方法会自动获取锁,前提是锁是同一个对象或者class,不会因为之前获取到的锁还未释放就阻塞。Java中ReentrantLock和synchronized都是可重入锁,优点是可一定程度上避免死锁。
不可重入锁:指同一个线程在外层方法成功获取到锁后,而后又进入到内层方法还要再次获取锁,容易出现死锁的情况。
public class Test {
public synchronized void A() {
System.out.println("A");
B();
}
public synchronized void B() {
System.out.println("B");
}
}
因为synchronized是可重入锁,所以同一个线程在获取到锁后,接着调用B方法时可直接获得锁进行操作。如果是一个不可重入锁,那么当前线程在调用B之前需要先释放调用A时获取到的锁,其实锁已经被当前线程所持有,且无法释放,所以会出现死锁。
独享锁 VS 共享锁
独享锁:又可以叫排他锁、独占锁、互斥锁,指一个锁只能被一个线程锁持有,获得该锁的线程既能读取又能修改数据,ReentrantLock是独享锁。
共享锁:指一个锁可被多个线程同时持有,获得该锁的线程只能读数据,不能修改数据,否则就出问题了。Java中ReentrantReadWriteLock是共享锁,内部中有两个锁,一个读锁,一个写锁,读锁是共享锁,写锁是独享锁,读写是互斥的,也就是说当有线程先获取了读锁,那么写锁自然不能被其他线程获取,如果有线程是先获取了写锁,那么读锁自然要被阻塞。
这里就不从源码的角度进行后续的分析了。
结束语
Java中的很多锁都是基于AQS来实现的,了解了其特性后,最好能够去看看对应的源码实现帮助更加深入地理解。
浙公网安备 33010602011771号