【Java 并发】好多锁啊!偏向锁、轻量级锁、重量级锁、自旋锁、乐观锁、悲观锁 ......

相信你和我一样,一开始学习这些锁的时候晕头转向,各种各样锁层出不穷,所以在我学习了这些知识后,特意来总结自己的学习记录,供大家参考学习。今天讲解:偏向锁、轻量级锁、重量级锁、自旋锁、乐观锁、悲观锁、公平锁、非公平锁、读写锁、可重入锁

一、锁优化

在JDK1.5 - JDK1.6中对高效并发有了很大的改进,其中一个重要的改进就是优化了很多锁相关的技术。包括适应性自旋、缩小出、锁粗化、轻量级锁和偏向锁等,都是为了更加高效的解决并发问题。

1. 自旋锁🔒

来源
对于之前使用的互斥同步方式进行加锁,每一次线程竞争失败时,都会进行阻塞,获取到锁的线程执行结束后又会唤醒阻塞的线程。这个线程阻塞唤醒的行为每次都是需要转到内核态来进行的,这会给操作系统带来很大压力。对于一些在同步代码块中执行很快的代码就需要频繁的阻塞唤醒线程,为了执行很短时间的代码而将线程阻塞唤醒很不值得,于是自旋锁就出现了。
自旋锁
自旋锁在实现上就是一种“我先啥也不干等等锁”的态度,首先竞争锁,如果没有竞争到,就自己进行循环直到枪锁成功,可以看作时以下代码:

while (抢锁(lock) == 失败) {}

自旋锁避免了线程切换的开销,但它需要占据处理器的时间。所以,如果锁被占用的时间较短,可以使用自旋锁,反之,如果被占用的时间很长,只会让自旋空转白白消耗处理器资源,带来性能上的浪费。
因此,自旋等待也是有限度的,如果时间过长都没获得锁就应该将线程挂起了。自旋的默认次数是10次

2. 自适应自旋🔒

本质上还是自旋,是自旋的 “ 加强版 ”。自适应自旋使得自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间以及锁的拥有者来决定。也就是说,如果在同一个锁对象中,自旋等待刚成功过,那这一次也很可能会成功,那可以适当的增加自旋的时间,比如加到100次,反之如果对于某一个锁而言,自旋很少成功过,那以后获取这个锁将有可能直接省去自旋的过程,以避免浪费处理器。
可以看出,有了自适应自旋,虚拟机对程序锁状况的预测就会越来越准确了,也提高了性能效率。

3.锁消除

锁消除 指的是虚拟机编译器运行时,虽然一些代码上有同步的要求,但检测到对于这些同步代码的共享数据不存在竞争,那么虚拟机就会将这个同步代码块的锁 “消除”,也就是这个线程不需要竞争锁,而可以直接进如代码块执行。这也是虚拟机对锁的一种优化,使得并发更加高效。

4. 锁粗化

我们在写代码时会被推荐使用细粒度锁,就是将同步块的作用范围限制的尽量小,这样是为了使得需要同步的操作数量尽可能变小,就算有线程竞争,每一个线程也可以尽快拿到锁。
锁粗化则恰恰相反,它是将加锁的区域放大。大多数场景都是使用更细粒度的锁,而锁粗化的场景就是:当有一系列的连续操作都是对于同一个对象反复的加锁解锁,甚至是加到循环体内的锁,这样的话即使没有线程之间的竞争,频繁的进行互斥同步操作也会导致不必要的性能损耗。
此时,就会使用锁粗化,虚拟机探测到上述情况,就会把加锁同步的范围扩展到整个操作的外部,这样只需要加一次锁就好了。

二、从无锁到重量级锁

JDK1.6后为了减少获得锁和释放锁带来的性能损耗,就引入了 偏向锁 和 轻量级锁。锁的状态一共有四种,级别从高到低依次是无锁—> 偏向锁 —> 轻量级锁 —> 重量级锁,这几个状态会随着竞争的情况逐渐升级,注意!! 锁可以升级,不可以降级,以下的锁也是synchronized关键字优化后的锁状态。

1. 对象头

在了解这些锁之前,先来认识下对象头。对于每一个对象,都有一个对象头来保存这个对象的相关信息。一个非数组类型的对象头中会有两种信息:

  1. Mark Word —— 存储对象锁信息和hashCode等信息
  2. Class Metadata Address —— 存储到对象类型数据的指针

所以,对于锁的信息,就要要研究 Mark Word 相关信息,以下是32位系统的Mark Word默认存储结构:

锁状态 25bit 4bit 1bit是否是偏向锁 2bit锁标志位
无锁状态 对象的hashCode 对象分代年龄 0 01

其中 ,锁标志位就是表示上述的锁的情况:

轻量级锁 无锁(偏向锁) 重量级锁 GC标记
00 01 10 11

那对于无锁和偏向锁的区别就是使用Mark Word中的1bit来表示了:如果为0 就是无锁状态,如果为1 就是偏向锁状态。

2. 偏向锁🔒

来源
经过研究发现,大多数情况下,锁不仅不存在多线程之间的竞争,而且还总是由同一个线程多次获得,所以在这种情况下,为了降低线程获得锁的代价,引入了偏向锁的概念。也就是偏向锁是用于没有竞争的情况下的同步操作。

偏向锁的获得

当锁对象第一次被线程获取的时候,虚拟机将对象头中的标志位设置为“01”,同时使用CAS操作把获取到这个线程的ID记录在对象的Mark Word中如果CAS成功,则线程持有偏向锁,以后再次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。

依次,对于一个线程进入同步代码块后:首先会测试以下对象头中的Mark Word里是否存储着指向当前线程的偏向锁。

  1. 此时如果测试成功,表示线程已经获得了锁,执行同步代码
  2. 如果测试失败,则再看看对象头中表示 是否偏向锁的标志位 是否为1(是否偏向)
    (1)如果还不是可偏向,那就可以使用CAS竞争获得锁
    (2)如果已经是偏向锁,那说明出现了线程之间的竞争。此时可能就要根据另外线程的情况,可能是重新偏向,也有可能是做偏向撤销,但大部分情况下就是升级成轻量级锁了。

偏向锁的撤销
偏向锁会等到竞争出现才释放锁,当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放
偏向锁的撤销首先会等到全局安全点(没有字节码执行)才执行:

  1. 首先暂停拥有偏向锁的线程,检查线程是否“活着”
  2. 如果线程已经不活动了,就将线程设置为“无锁状态”
  3. 如果线程还活着,遍历偏向对象的所记录,栈中的所记录和对象头的Mark Word 要么重新偏向于其它线程,要么恢复到无锁状态或不适合偏向锁状态
  4. 唤醒所有暂停的线程

关闭偏向锁
如果确认程序中所有的锁通常情况下都处于竞争状态,就可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking = false,系统就会进入轻量级锁状态。

(图源自《Java并发编程的艺术》)
在这里插入图片描述

3.轻量级锁🔒

加锁
锁撤销升级为轻量级锁之后,那么对象的Mark Word也会进行相应的的变化。线程执行同步块时,过程如下:

  1. 线程在自己的栈桢中创建锁记录 LockRecord。并将锁对象的对象头中Mark Word复制到线程的刚刚创建的锁记录中。(官方称之为 Displaced Mark Word)
  2. 使用CAS将锁对象的对象头的MarkWord替换为指向锁记录的指针。将锁记录中的Owner指针指向锁对象。

如果上面操作成功,则当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程就尝试使用自旋来获取锁。
(图来源于《深入理解Java虚拟机》)
在这里插入图片描述
解锁
解锁时,会使用CAS将Displaced Mark Word 替换回对象头,如果成功表示没有竞争发生,反之,如果失败,表示就会由竞争发生,锁就会膨胀为重量级锁。
(图源自《Java并发编程的艺术》)
在这里插入图片描述

4.重量级锁🔒

因为自旋会消耗CPU,所以为了避免无用的自旋每一单锁升级为重量级锁,就不会恢复到轻量级锁。在重量级锁的状态下,其它线程试图获取锁时就会被阻塞住,等到持有锁的线程释放锁后才会唤醒这些线程,被唤醒的线程就会继续竞争锁。
重量级锁也称为互斥锁、悲观锁

所以,Synchronized关键字其实在优化后,也变得效率高起来了,会逐渐从偏向锁到轻量级锁来膨胀。

5.锁的优缺点

优点 缺点 使用场景
偏向锁 加锁和解锁不需要额外的资源消耗,执行同步方法很快 如果线程之间有竞争,会有额外的锁撤销消耗 适用于只有一个线程访问同步代码块的场景
轻量级锁 竞争线程不会阻塞,提高了程序相应程度 如果始终得不到锁,竞争的线程会自旋消耗CPU 追求响应时间,同步块执行的时间很快
重量级锁 线程竞争不适用自旋,不消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量,同步块执行时间较长

三、常见锁策略

以下介绍的都是一些锁的策略,基于这些策略下也实现了一些其它锁。

1.乐观锁🔒

乐观锁 假设认为数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。之前讲到的CAS就属于乐观锁。

2. 悲观锁🔒

悲观锁 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。Synchronized关键字和ReentrantLock都是一种悲观锁

3. 读写锁🔒

多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
读写锁(readers-writer lock):看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,读读之间并不互斥,而读写和写写都要求与任何人互斥。

4. 可重入锁🔒

可重入锁 :的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的

5. 公平锁🔒

公平锁 :多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。

优点:所有的线程都能得到资源,不会“饿死”在队列中。
缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。

6. 非公平锁🔒

非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。不需要按照顺序获得。

优点:减少CPU唤醒线程的数量以及开销,提高吞吐量
缺点:队列中有的线程可能一直获取不到锁或者长时间获取不到锁,导致“饿死”。

唠唠叨叨:
这一次的文章有各种各样不同的锁,希望今天自己将这些不同的锁讲明白些了,当然这些锁也相应的也有延申出来的各种各样的知识,以后会逐渐都写出来。文章参考了《深入理解Java虚拟机》以及《Java并发编程的艺术》都是很好的书,大家也可以去看看,当然本人的知识深度广度都有限,文章如果有什么问题欢迎大家提出指正,以后一定会继续写各种各样学到的知识,欢迎点赞关注一起进步。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章