1. synchronized 锁优化的背景
- 用锁能够实现数据的安全性,但是会带来性能下降。
- 无锁能够基于线程并行提升程序性能,但是会带来安全性下降。
为求平衡将synchronized优化为不在是无所和重锁两个状态,新增偏向锁和轻量级锁来平衡安全性和性能问题
synchronized锁:由对象头中的Mark Word
根据锁标志位的不同而被复用及锁升级策略
2. Synchronized的性能变化
2.1 java5以前-重量级锁
只有Synchronized,这个是操作系统级别的重量级操作,重量级锁,假如锁的竞争比较激烈的话,性能急剧下降
2.1.1 重量级锁为什么比较消耗性能?
重量级锁涉及到用户态
和内核态
之间的切换
java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock
来实现的,挂起线程和恢复线程都需要转入内核态去完成,阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长”,时间成本相对较高,这也是为什么早期的synchronized效率低的原因
2.1.1.1 为什么每一个对象都可以成为一个锁?
C++源码: markOop.hpp
Monitor可以理解为一种同步工具,也可理解为一种同步机制,常常被描述为一个Java对象。Java对象是天生的Monitor
,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。
Monitor的本质是依赖于底层操作系统的==Mutex Lock
==实现,操作系统实现线程之间的切换需要从用户态到内核态的转换,成本非常高。
2.1.1.2 Monitor与java对象以及线程是如何关联 ?
- 如果一个java对象被某个线程锁住,则该java对象的Mark Word字段中LockWord指向monitor的起始地址
- Monitor的Owner字段会存放拥有相关联对象锁的线程id
2.1 Java 6之后优化Synchronized-引入了轻量级锁和偏向锁
- Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁
- 需要有个逐步升级的过程,别一开始就捅到重量级锁
3. synchronized锁种类及升级步骤
3.1 多线程访问情况,4种
根据这三种情况使用不同的锁
- 所有线程都可以来访问 ---->无锁
- 只有一个线程来访问,有且唯一Only One —>偏向锁
- 有2个线程A、B来交替访问 —>轻量锁
- 竞争激烈,多个线程来访问 —>重锁
3.1.1 锁升级总流程
3.1.1.1 锁的指向
- 偏向锁:MarkWord存储的是偏向的线程ID
- 轻量锁:MarkWord存储的是指向线程栈中Lock Record的指针
- 重量锁:MarkWord存储的是指向堆中的monitor对象的指针
3.2 无锁
3.2.1 示例及对象头阅读规则
package site.zhourui.juc.synchronizedUpgrade;
import org.openjdk.jol.info.ClassLayout;
public class NoLock {
public static void main(String[] args)
{
Object o = new Object();
System.out.println("10进制hash码:"+o.hashCode());
System.out.println("16进制hash码:"+Integer.toHexString(o.hashCode()));
System.out.println("2进制hash码:"+Integer.toBinaryString(o.hashCode()));
System.out.println( ClassLayout.parseInstance(o).toPrintable());
}
}
执行结果:
锁标志位001代表无锁
3.3 偏向锁 (白学警告-jdk15后将移除)
Hotspot 的作者经过研究发现,大多数情况下:
- 多线程的情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,
- 偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能。
- 在实际应用运行过程中发现,“锁总是同一个线程持有,很少发生竞争”,也就是说锁总是被第一个占用他的线程拥有,这个线程就是锁的
偏向线程
。 - 那么只需要在锁第一次被拥有的时候,记录下偏向线程ID。这样偏向线程就一直持有着锁(后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁)。
如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了,**直到竞争发生才释放锁。**以后每次同步,检查锁的偏向线程ID与当前线程ID是否一致,如果一致直接进入同步。无需每次加锁解锁都去CAS更新对象头。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。 - 假如不一致意味着发生了竞争,锁已经不是总是偏向于同一个线程了,这时候可能需要升级变为轻量级锁,才能保证线程间公平竞争锁。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。
3.3.1 主要作用
当一段同步代码一直被同一个线程多次访问,由于只有一个线程那么该线程在后续访问时便会自动获得锁
3.3.2 对象头中存储数据
- 当前线程
- 通过CAS方式修改markword中的线程ID
- 分代年龄
- 锁标志位—>101–>标志位理解就是无锁状态偏向锁标志位从0改为1
3.3.3 技术实现
一个synchronized方法被一个线程抢到了锁时,那这个方法所在的对象就会在其所在的Mark Word中将偏向锁修改状态位,同时还会有占用前54位来存储线程指针作为标识。
若该线程再次访问同一个synchronized方法时,该线程只需去对象头的Mark Word 中去判断一下是否有偏向锁指向本身的ID,无需再进入 Monitor 去竞争对象了。
个人理解:
- 因为加锁需要用户态到内核态的转换,所以我们先在加锁前拦截一层如果每次都是同一线程来操作那么我们就不需要加锁,这样就没有用户态到内核态的转换
- JVM不用和操作系统协商设置Mutex(争取内核),它只需要记录下线程ID就标示自己获得了当前锁,不用操作系统接入。
- 而且Hotspot 的作者经过研究发现,大多数情况下:多线程的情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,所以这种情况很多
- 除非第二个线程来争抢才会开始加锁升级为轻量级锁.
- 偏向锁几乎没有额外开销,性能极高。
3.3.4 偏向锁JVM命令–查看JVM默认启动参数
3.3.4.1 Linux命令
windows上可以用git bash执行
java -XX:+PrintFlagsInitial |grep BiasedLock*
执行结果:
发现JVM启动时默认会启动偏向锁,但是会有4秒的延迟
所以需要添加参数
-XX:BiasedLockingStartupDelay=0
,让其在程序启动时立刻启动。
3.3.4.2 Windows命令
java -XX:+PrintFlagsInitial |find /i "BiasedLock"
执行结果和上面相同:
3.3.5 开启/关闭偏向锁
3.3.5.1 开启
通过3.3.4我们发现:发现JVM启动时默认会启动偏向锁,但是会有4秒的延迟
因为UseBiasedLocking默认就等于true,只需要添加参数-XX:BiasedLockingStartupDelay=0
,让其在程序启动时立刻启动。
3.3.5.2 关闭
关闭偏向锁:关闭之后程序默认会直接进入 轻量级锁状态。
-XX:-UseBiasedLocking
3.3.5.3 示例
package site.zhourui.juc.synchronizedUpgrade;
import org.openjdk.jol.info.ClassLayout;
public class BiasedLockDemo {
public static void main(String[] args)
{
Object o = new Object();
new Thread(() -> {
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
},"t1").start();
}
}
3.3.5.3.1 不添加任何参数
执行结果:
锁标志为000,表示程序启动时为轻量级锁
3.3.5.3.2 关闭延迟-填入参数-XX:BiasedLockingStartupDelay=0
执行结果:
锁标志为101,表示程序启动时为偏向锁
3.3.5.3.3 关闭偏向锁-填入参数-XX:-UseBiasedLocking
执行结果
锁标志为000,表示为轻量级锁
3.3.5.3.4 不添加任何参数-当程序启动后睡5秒钟等偏向锁延时启动成功
执行结果:
证实了延时启动
3.3.6 对偏向锁线程ID的理解
偏向锁前54位为线程id
3.3.6.1 示例1-对象未用synchronized加锁
package site.zhourui.juc.synchronizedUpgrade;
import org.openjdk.jol.info.ClassLayout;
public class BiasedLockDemo2 {
public static void main(String[] args)
{
try { Thread.sleep( 5000 ); } catch (InterruptedException e) { e.printStackTrace(); }
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
执行结果:
锁标志为101,为偏向锁但是前54位全是0没有任何信息
是因为o对象未用synchronized加锁,所以线程id为空.
3.3.6.2 示例2-对象用synchronized加锁
结果参考3.3.5.3.2
发现线程id不再全是0
3.3.7 偏向锁的撤销–开始有第2个线程来抢夺了
-
偏向锁使用一种等到竞争出现才释放锁的机制,只有当其他线程竞争锁时,持有偏向锁的原来线程才会被撤销。
-
竞争线程尝试CAS更新对象头失败,**会等待到全局安全点(此时不会执行任何代码,类似JVM垃圾搜集的STW)撤销偏向锁。**同时检查持有偏向锁的线程是否还在执行
- 第一个线程正在执行synchronized方法(处于同步块),它还没有执行完,其它线程来抢夺,该偏向锁会被取消掉并出现
锁升级
。- 此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。
- 第一个线程执行完成synchronized方法(退出同步块),则将对象头设置成无锁状态并撤销偏向锁,重新偏向 。
- 第一个线程正在执行synchronized方法(处于同步块),它还没有执行完,其它线程来抢夺,该偏向锁会被取消掉并出现
3.3.7.1 偏向锁升级与撤销流程–(红线部分)
3.3.8 偏向锁在Java15后逐步废除偏向锁
java15后就将不会默认开启偏向锁了
废除原因:
-
性能影响
在过去,Java 应用通常使用的都是 HashTable、Vector 等比较老的集合库,这类集合库大量使用了 synchronized 来保证线程安全。
如果在单线程的情景下使用这些集合库就会有不必要的加锁操作,从而导致性能下降。
而偏向锁可以保证即使是使用了这些老的集合库,也不会产生很大的性能损耗,因为 JVM 知道访问临界区的线程始终是同一个,也就避免了加锁操作。
这一切都很美好,但是随着时代的变化,新的 Java 应用基本都已经使用了无锁的集合库,比如 HashMap、ArrayList 等,这些集合库在单线程场景下比老的集合库性能更好。
即使是在多线程场景下,Java 也提供了 ConcurrentHashMap、CopyOnWriteArrayList 等性能更好的线程安全的集合库。
综上,对于使用了新类库的 Java 应用来说,偏向锁带来的收益已不如过去那么明显,而且在当下多线程应用越来越普遍的情况下,偏向锁带来的锁升级操作反而会影响应用的性能。
-
代码侵入
在废弃偏向锁的提案 JEP374 中还提到了与 HotSpot 相关的一点
Biased locking introduced a lot of complex code into the synchronization subsystem and is invasive to other HotSpot components as well.
简单翻译就是偏向锁为整个「同步子系统」引入了大量的复杂度,并且这些复杂度也入侵到了 HotSpot 的其它组件。
这导致了系统代码难以理解,难以进行大的设计变更,降低了子系统的演进能力,
总结下来其实就是 ROI (投资回报率)太低了,考虑到兼容性,所以决定先废弃该特性,最终的目标是移除它。
3.4 轻量级锁 -自旋锁(CAS)
3.4.1 主要作用
- 有线程来参与锁的竞争,但是获取锁的冲突时间极短
- 本质就是自旋锁(CAS)
- 轻量级锁是为了在线程近乎交替执行同步块时提高性能。
- 主要目的: 在没有多线程竞争的前提下,通过CAS减少重量级锁使用操作系统互斥量产生的性能消耗,说白了先自旋再阻塞。
- 升级时机: 当关闭偏向锁功能或多线程竞争偏向锁会导致偏向锁升级为轻量级锁
3.4.2 升级为轻量级演示
参考3.3.5.3.3 关闭偏向锁-填入参数-XX:-UseBiasedLocking
3.4.3 轻量级锁的加锁与释放
3.4.3.1 轻量级锁的加锁
JVM会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,官方成为Displaced Mark Word
。若一个线程获得锁时发现是轻量级锁,会把锁的MarkWord
复制到自己的Displaced Mark Word
里面。然后线程尝试用CAS
将锁的MarkWord替换为指向锁记录的指针。
- 如果成功,当前线程获得锁,
- 如果失败,表示
Mark Word
已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。
3.4.3.2 轻量级锁的释放
在释放锁时,当前线程会使用CAS
操作将Displaced Mark Word
的内容复制回锁的Mark Word
里面。
- 如果没有发生竞争,那么这个复制的操作会成功。
- 如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么
CAS
操作会失败,此时会释放锁并唤醒被阻察的线程。
3.4.4 步骤流程图示–(红线部分)
3.4.5 轻量级锁升级为重锁
3.4.5.1 升级条件–自旋达到一定次数和程度
3.4.5.1.1 java6之前–固定场景(了解)
- 默认启用,默认情况下自旋的次数是 10 次
-XX:PreBlockSpin=10
来修改
- 或者自旋线程数超过cpu核数一半
3.4.5.1.2 Java6之后–自适应场景
- 自适应意味着自旋的次数不是固定不变的
- 而是根据:
- 同一个锁上一次自旋的时间。
- 拥有锁线程的状态来决定。
3.4.6 轻量锁与偏向锁的区别和不同
- 争夺轻量级锁失败时,自旋尝试抢占锁
- 轻量级锁每次退出同步块都需要释放锁,而偏向锁是在竞争发生时才释放锁
3.5 重锁
-
有大量的线程参与锁的竞争,冲突性很高
-
Java中synchronized的重量级锁,是基于进入和退出Monitor对象实现的。在编译时会将同步块的开始位置插入
monitor enter
指令,在结束位置插入monitor exit
指令。 -
当线程执行到
monitor enter
指令时,会尝试获取对象所对应的Monitor所有权,如果获取到了,即获取到了锁,会在Monior
的owner中存放当前线程的id,这样它将处于锁定状态,除非退出同步块,否则其他线程无法获取到这个Monitor。
3.5.1 示例
4. 锁升级hashCode去那了?
锁升级为轻量级或重量级锁后,Mark Word中保存的分别是线程栈帧里的锁记录指针和重量级锁指针,已经没有位置再保存哈希码,GC年龄了,那么这些信息被移动到哪里去了呢?
-
在无锁状态下,Mark Word中可以存储对象的identity hash code值。当对象的hashCode()方法第一次被调用时,JVM会生成对应窋identity hash code值并将该值存储到Mark Word中。
-
对于偏向锁,在线程获取偏向锁时,会用Thread ID和epoch值覆盖identity hash code所在的位置。**如果一个对象的hashCode()方法已经被调用过一次之后,这个对象不能被设置偏向锁。**因为如果可以的化,那Mark Word中的identity hash code必然会被偏向线程ld给覆盖,这就会造成同一个对象前后两次调用hashCode()方法得到的结果不一致。
-
当一个对象已经计算过identity hashcode,它就无法进入偏向锁状态,跳过偏向锁,直接升级轻量级锁
-
偏向锁过程中遇到一致性哈希计算请求,立马撤销偏向模式,膨胀为重量级锁
-
-
升级为轻量级锁时,JVM会在当前线程的栈帧中创建一个锁记录(Lock Record)空间,用于存储锁对象的Mark Word拷贝,该拷贝中可以包含identity hash code,所以轻量级锁可以和identity hash code共存,哈希码和GC年龄自然保存在此,释放锁后会将这些信息写回到对象头。
-
升级为重量级锁后,Mark Word保存的重量级锁指针,代表重量级锁的Object Monitor类里有字段记录非加锁状态下的Mark Word,锁释放后也会将信息写回到对象头。
5. 各种锁优缺点、synchronized锁升级和实现原理
6. JIT编译器对锁的优化
Just In Time Compiler,一般翻译为即时编译器
6.1 锁消除
锁消除:
从JIT角度看相当于无视它,synchronized (o)不存在了,这个锁对象并没有被共用扩散到其它线程使用,极端的说就是根本没有加这个锁对象的底层机器码,消除了锁的使用
package site.zhourui.juc.synchronizedUpgrade;
/**
* 锁消除
* 从JIT角度看相当于无视它,synchronized (o)不存在了,这个锁对象并没有被共用扩散到其它线程使用,
* 极端的说就是根本没有加这个锁对象的底层机器码,消除了锁的使用
*/
public class LockClearUPDemo {
static Object objectLock = new Object();//正常的
public void m1()
{
//锁消除,JIT会无视它,synchronized(对象锁)不存在了。不正常的
Object o = new Object();
synchronized (o)
{
System.out.println("-----hello LockClearUPDemo"+"\t"+o.hashCode()+"\t"+objectLock.hashCode());
}
}
public static void main(String[] args)
{
LockClearUPDemo demo = new LockClearUPDemo();
for (int i = 1; i <=10; i++) {
new Thread(() -> {
demo.m1();
},String.valueOf(i)).start();
}
}
}
执行结果:
其实就是每个线程使用的锁对象都不一样(每次加锁都是用的新new 的对象),只对一个线程加锁没事作用;
然后JIT编译器看到你这种脑残行为给你把锁去掉了:)
6.2 锁粗化
锁粗化
- 假如方法中首尾相接,前后相邻的都是同一个锁对象,那JIT编译器就会把这几个synchronized块合并成一个大块,加粗加大范围,一次申请锁使用即可,避免次次的申请和释放锁,提升了性能
package site.zhourui.juc.synchronizedUpgrade;
/**
* 锁粗化
* 假如方法中首尾相接,前后相邻的都是同一个锁对象,那JIT编译器就会把这几个synchronized块合并成一个大块,
* 加粗加大范围,一次申请锁使用即可,避免次次的申请和释放锁,提升了性能
*/
public class LockBigDemo
{
static Object objectLock = new Object();
public static void main(String[] args)
{
new Thread(() -> {
synchronized (objectLock) {
System.out.println("11111");
}
synchronized (objectLock) {
System.out.println("22222");
}
synchronized (objectLock) {
System.out.println("33333");
}
},"a").start();
new Thread(() -> {
synchronized (objectLock) {
System.out.println("44444");
}
synchronized (objectLock) {
System.out.println("55555");
}
synchronized (objectLock) {
System.out.println("66666");
}
},"b").start();
}
}
执行结果:
前后相邻的都是同一个锁对象,那JIT编译器就会把这几个synchronized块合并成一个大块,加粗加大范围,一次申请锁使用即可,避免次次的申请和释放锁,提升了性能