锁升级
锁升级
什么是锁升级?
本质就是JVM对synchronized
关键字的优化,通过减少用户态进入内核态的切换次数,让程序在java程序内就能获得锁,而不用进入操作系统,使得synchronized
关键字可以更高效。
注意:是JVM对synchronized
的优化
synchronized 的作用:
synchronized
关键字解决的是多个线程之间访问资源的同步性,synchronized
关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
另外,在 Java 早期版本中,synchronized
属于 重量级锁,效率低下。
为什么效率低下呢?
因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock
来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对 synchronized
较大优化,所以现在的 synchronized
锁效率也优化得很不错了。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
所以,你会发现目前的话,不论是各种开源框架还是 JDK 源码都大量使用了 synchronized
关键字。
如何优化synchronized?
通过一步步升级锁,实现锁的升级,在锁竞争不激烈的情况下,直接通过偏向、轻量级锁实现即可实现,而不是直接去获取重量级锁进入内核态。
锁升级过程
实现
先了解Java对象的布局,因为锁的具体实现信息在对象头中
对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充数据
对象头
对象头:对象头 = Mark Word + 类型指针(未开启指针压缩的情况下)
Mark Word:Mark Word = HashCode + GC分代年龄 + 锁状态标志 + 线程持有的锁 + 偏向线程ID + 偏向时间戳等等,
在 64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:
偏向锁
概念:
偏向锁是JDK 6中的重要引进,因为HotSpot作者经过研究实践发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。
偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的线程ID,以后该线程进入和退出同步块时只需要检查是否为偏向锁、锁标志位以及ThreadID即可。
适用情况:
偏向锁是在只有一个线程执行同步块时进一步提高性能,适用于一个线程反复获得同一锁的情况。偏向锁可以提高带有同步但无竞争的程序性能。
实现过程:
- 虚拟机将会把对象头中的标志位设为“01”,即偏向模式。
- 同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中 ,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作,偏向锁的效率高。
- 它同样是一个带有效益权衡性质的优化,也就是说,它并不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同的线程访问比如线程池,那偏向模式就是多余的。
撤销偏向锁:
- 偏向锁的撤销动作必须等待全局安全点(我的理解是运行完当前synchronized代码块内的内容)
- 暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态
- 撤销偏向锁,恢复到无锁(标志位为 01)或轻量级锁(标志位为 00)的状态
轻量级锁
概念:
轻量级锁是JDK 6之中加入的新型锁机制,它名字中的“轻量级”是相对于使用monitor的传统锁而言的,因此传统的锁机制就称为“重量级”锁。首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的(不是所有的时候开销都比较小,只是在一定的情况下能够减少消耗)。
适用情况:
在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗,但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁
实现过程:
- 判断当前对象是否处于无锁状态(hashcode、0、01),如果是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),将对象的Mark Word复制到栈帧中的Lock Record中,将Lock Reocrd中的owner指向当前对象。
- JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果成功表示竞争到锁,则将锁标志位变成00,执行同步操作。
- 如果失败则判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态。
轻量级锁的同步流程可以总结为:使用 CAS 操作,在线程栈帧与锁对象建立双向的指针。
撤销轻量级锁:
轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:
- 取出在获取轻量级锁保存在Displaced Mark Word中的数据。
- 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功。
- 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要将轻量级锁需要膨胀升级为重量级锁
注意:在没有线程竞争的情况下,轻量级锁使用 CAS 自旋操作避免了使用互斥量的开销,提高了效率。但如果存在锁竞争,除了互斥量的开销外,还额外发生了 CAS 操作。因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
重量级锁
概念:
通过操作系统的 Mutex Lock 来实现同步的。而操作系统的 Mutex Lock 是操作系统级别的方法,需要切换到内核态来执行。这就需要从用户态转换到内核态中,因此我们说 synchronized 同步是重量级的操作。它与轻量级锁不同竞争的线程不再通过自旋来竞争线程, 而是直接进入堵塞状态,此时不消耗CPU,然后等拥有锁的线程释放锁后,唤醒堵塞的线程, 然后线程再次竞争锁。但是注意,当锁膨胀(inflate
)为重量锁时,就不能再退回到轻量级锁。
适用情况:
锁竞争加剧,多个线程在同一时刻进入临界区。
实现过程:
JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步,但是两者的实现细节不一样。
- synchronized修饰在同步代码块:通过使用 monitorenter 和 monitorexit 指令实现的
- synchronized修饰在同步方法:ACC_SYNCHRONIZED 修饰
总结
在偏向锁和轻量级锁的阶段进入同步体,其实并没有进入操作系统的内核态,而是使用自旋锁在外面等待一下,进而避免进入操作系统的阻塞队列。
现状 | 锁名称 | 收益 | 使用场景 |
---|---|---|---|
大多数情况下,锁同步期间没有线程竞争 | 轻量级锁 | 与自旋锁相比,减少了自旋时间 | 没有线程竞争锁 |
大多数情况下,锁同步期间没有线程竞争 | 偏向锁 | 与轻量级锁相比,减少了多余的对象复制操作 | 没有线程竞争锁 |
从上面表格可以看到,自旋锁、轻量级锁、偏向锁,他们的优化是逐渐深入的。
- 轻量级锁,是自旋锁再 Java 内存模型里的直接应用,其同样是减少了内核态与用户态的切换开销。
- 偏向锁,相对于轻量级锁来说,减少了多余的对象复制操作,因此效率更高一些。
题外(锁的其他优化)
自旋锁
提出背景:
没有获得锁的进程怎么办?
通常有2种处理方式。
- 一种是没有获得锁的调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,这就是自旋锁,他不用将线城阻塞起来(NON-BLOCKING);
- 另一种是没有获得锁的进程就阻塞(BLOCKING)自己,请求OS调度另一个线程上处理器,这就是互斥锁。
线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。
所以引入自旋锁。
何谓自旋锁?
所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起(就是不让前来获取该锁(已被占用)的线程立即阻塞),看持有锁的线程是否会很快释放锁。
怎么等待呢?
**执行一段无意义的循环即可(自旋)。**
自旋等待不能替代阻塞,先不说对处理器数量的要求(多核,貌似现在没有单核的处理器了),虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好;反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。
所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整。
自适应自旋锁
JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
它怎么做呢?
线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。
锁消除
为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。
如果不存在竞争,为什么还需要加锁呢?
所以锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?
我们虽然没有显示使用锁,但是我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。
比如StringBuffer的append()方法,Vector的add()方法:
public void vectorTest(){
Vector<String> vector = new Vector<String>();
for(int i = 0 ; i < 10 ; i++){
vector.add(i + "");
}
System.out.println(vector);
}
在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除。
锁粗化
我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小,仅在共享数据的实际作用域中才进行同步。这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
在大多数的情况下,上述观点是正确的,LZ也一直坚持着这个观点。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。
那什么是锁粗化?
就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。
附图
偏向锁
轻量级锁
问题
CAS、自旋锁是什么
CAS即Compareand Swap,是一种比较并交换算法
自旋锁是一种基于CAS的锁,获取锁的线程不会被阻塞,而是循环的去获取锁
自旋锁跟轻量级锁的关系是什么?
锁自旋是一种锁竞争机制,假设在一段时间内比如一个时钟周期内能获得锁,因此虚拟机会进行一次赌博,在这段时间类一直尝试加锁。
而轻量级锁是一种状态
根据上面的附图可知,偏向锁和轻量级锁在面对锁竞争时,是会出现CAS的过程的,这就是自旋锁。
所以我认为,轻量级锁 = 自旋锁(CAS) + Mark Word 。为了获取轻量级锁,本身就需要CAS,要防止竞争锁失败的线程进入阻塞状态,所以这时候还需要加上自旋锁,让竞争失败的线程不放弃cpu的使用权,不进入synchronized的阻塞队列。
参考:
https://www.cnblogs.com/chanshuyi/p/deep-insight-of-synchronized.html
https://javaguide.cn/java/concurrent/java-concurrent-questions-02.html#synchronized-关键字
本文作者:bourbonbote
本文链接:https://www.cnblogs.com/bourbonbote/p/16534628.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步