一、Synchronized的性能优化
在JDK 1.5之前,synchronized锁机制是基于monitor对象(也称之为管程或者监视器锁)实现的,每个对象都存在一个monitor对象与之关联,对象头中有一块专门的内存区域用于存储与之关联的monitor对象的地址。 在HotSpot中,monitor是由ObjectMonitor实现的,其主要数据结构如下(源码ObjectMonitor.hpp文件,C++实现):
//只列举出部分关键字段
ObjectMonitor() {
_object; = NULL; //当前monitor关联的锁对象
_header = NULL; //当前monitor关联的锁对象的原始对象头
_count = 0; //抢占该monitor的线程数
_owner = NULL; //占用当前monitor的线程
_WaitSet = NULL; //处于wait状态的线程,会被加入到该列表
_EntryList = NULL ; //处于block状态的线程,会被加入到该列表
}
monitor锁机制依赖于底层操作系统的Mutex Lock实现,挂起线程和恢复线程都需要从用户态切换到内核态去完成,状态转换耗费的成本非常高,所以synchronized是Java语言中的一个重量级操作。
Java 1.6中,为了减少获得锁和释放锁所带来的性能消耗,引入了偏向锁和轻量级锁的概念,在并发场景下锁存在逐步升级的过程,不会一开始就使用monitor级别的重锁。
在锁升级的过程中,对象头Mark Word存储的数据会随之变化,以64位虚拟机为例,其Mark Word共占8字节(64Bit),升级过程中各阶段存储的具体数据如下:
重量级锁中线程的挂起和唤醒需要从用户态切换到内核态进行,而偏向锁和轻量级锁的加锁、释放锁流程基于CAS操作即可完成,不依赖于内核空间对线程状态进行管理。
二、无锁状态
引入JOL的API用于分析对象的内存布局:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
这里介绍下如何分析控制台打印出来的布局内容,后面不做赘述:
- 前两行共8个字节,64bit,标识对象头的MarkWord,排列顺序的规则:字节间倒序排列,bit间正序排列。对比图1.1中的Mark Word布局,绿色部分是3bit的偏向标识+锁标识,黄色部分是31bit的hash值
上述的布局信息对应的代码为:
public static void main(String[] args) {
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
System.out.println("==========================================");
System.out.println(o.hashCode());
System.out.println("==========================================");
System.out.println( ClassLayout.parseInstance(o).toPrintable());
}
- 无锁状态下,偏向标识+锁标识始终为:001
- 只有显示调用对象的hashCode()方法之后,MarkWord中才会存储对象的hash值
三、偏向锁
在实际应用中研究发现,“锁总是由同一个线程持有,很少发生竞争”(例如StringBuffer和Vector中的一些sync方法),这个线程可以理解为锁的偏向线程。偏向锁的出现就是为了在只有一个线程执行同步代码块的场景下提高性能。
3.1 偏向锁的获取
参考图3.1总结偏向锁的获取流程:
-
线程T执行到synchronized临界区代码的时候,JVM通过CAS操作把T的线程ID记录到Mark Word当中,并修改偏向标识,线程T成功持有偏向锁(也可以说锁成功偏向于线程T),开始执行临界区的代码逻辑
-
线程T退出临界区代码后,并不会将Mark Word中的线程ID置空(既不会释放偏向锁)。后续线程T再次尝试执行到临界区代码时,检查锁偏向的线程ID与当前线程ID一致,直接获取锁,无需再通过CAS更新对象头。
-
如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
上述的布局信息对应的代码为:
//-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
//启用偏向锁 并且需要关闭偏向锁启动延迟
public static void main(String[] args) {
Object o = new Object();
new Thread(() -> {
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
},"t1").start();
}
- 偏向锁的状态下,偏向标识+锁标识始终为:101
- Mark Word中占用前54位来存储锁偏向的线程ID
3.2 偏向锁的撤销
从图3.1中偏向锁的撤销流程可以看出,偏向锁使用一种等到竞争出现才释放锁的机制,只有当其他线程竞争锁时,原来持有偏向锁的线程才会被撤销。
-
CAS修改锁偏向的线程ID时,expectedValue为“null | 0 | 01”,newValue为“currTId | 1 | 01”(currTId为当前竞争偏向锁的线程ID)
-
出现竞争时,对象头中已经存储了偏向的线程Id(即原来持有偏向锁的线程Id),因此当前线程的CAS操作就会失败,一旦CAS失败就会启动偏向锁的撤销流程
JVM中设有专门的VM Thread,该线程会源源不断地从VMOperationQueue中取出请求,比如GC请求。
假设线程B尝试CAS失败,启动偏向锁的撤销。此时会将撤销操作push到VMOperationQueue中,并带上safe-point的标识。到达全局安全点(safe-point)后,VM Thread将会执行撤销操作。撤销过程需要检查持有偏向锁的线程(假设为线程A,此时已暂停)的状态
- 如果线程A已经从同步代码块退出(或者不处于活动状态),VM Thread会将Mark Word置为无锁状态(此时处于stop-the-world状态,无需CAS),随后恢复工作线程,线程B重新尝试获取偏向锁
- 如果线程A尚未从同步代码块退出,偏向锁升级为轻量级锁,由线程A继续持有,执行其同步代码,当前正在竞争的线程会进入自旋等待获得该轻量级锁
3.3 JDK 15中的偏向锁
- 偏向锁在单线程反复获取锁的场景下性能很高,但细想便知生产环境中高并发的场景下很难有这种场景
- 而且对于偏向锁来说,在多线程竞争时的撤销操作十分复杂且带来了额外的性能消耗(需要等到safe point,并STW)
- JDK 15 之前,偏向锁默认是 开启的,从 JDK 15 开始,默认就是关闭的了,需要显式打开(-XX:+UseBiasedLocking)
四、轻量级锁
轻量级锁也称自旋锁,是为了在多线程近乎交替执行同步块的场景下提高性能,基于CAS+自旋获取锁的机制,减少重量级锁使用操作系统互斥量以及切换内核态挂起和释放线程产生的性能消耗。
轻量级锁的应用时机有两种情况:
- 关闭偏向锁(-XX:-UseBiasedLocking)时,线程执行到代码临界区则会尝试获取轻量级锁
- 多线程竞争偏向锁时,会导致偏向锁升级为轻量级锁
轻量级锁的获取和释放完整流程如下:
图4.1 轻量级锁的获取与释放
4.1 轻量级锁的获取
线程执行同步块前,JVM会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,官方成为Displaced Mark Word。
参考图4.1总结轻锁的加锁流程:
- 线程尝试获取轻量级锁前,会把锁对象头中的Mark Word复制到栈帧的Displaced Mark Word中。
- 偏向锁升级为轻锁的场景中,假设原持有偏向锁的线程栈帧中Displaced Mark Word的地址为P_Origin
- 线程在无锁状态下尝试获取轻锁或者参与竞争轻锁的场景中,线程栈帧中Displaced Mark Word的地址为P_CURR
- 偏向锁升级轻锁时,无需CAS操作,原持有偏向锁的线程直接获取轻锁,VM Thread会直接将对象头Mark Word修改为“P_Origin | 00”
- 在无锁状态下尝试获取轻锁或者参与竞争轻锁的场景中,线程尝试通过CAS操作将锁的MarkWord替换为指向锁记录的指针(栈帧中Displaced Mark Word的地址)。
- 如果成功,当前线程获得轻锁锁
- 如果失败,说明Mark Word已经被其他线程修改,当前线程尝试使用CAS+自旋获取锁。
上述的布局信息对应的代码为:
//-XX:-UseBiasedLocking
//关闭偏向锁
public static void main(String[] args) {
Object o = new Object();
new Thread(() -> {
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
},"t1").start();
}
轻锁的状态下,锁标识始终为:00 Mark Word中占用前62位来存储指向栈帧锁记录的指针(Displaced Mark Word的地址)
4.2 轻量级锁的释放和升级
参考图4.1总结轻锁的升级流程:
- 当某个尝试获取轻锁的线程自旋超过一定次数时,轻锁将升级为重量级锁
- 1.6之前是固定次数(可配置),超过自旋次数或者自旋线程数超过cpu核数一半,就升级重锁
- 1.6之后,自适应调整自旋次数,自旋成功会增加次数,反之减少
- 升级重锁时,JVM会定位到与锁对象关联的monitor对象,设置原持有轻锁的线程为当前monitor的_owner线程,同时将尝试获取锁的线程添加到_EntryList进行排队
- 将对象头的MarkWord中前62bit修改为指向monitor对象的指针
- 参考图4.1总结轻锁的释放流程:
假设当前持有轻锁的线程栈帧中Displaced Mark Word的地址为P_Owner,Displaced Mark Word的内容为Prev_MW(获取轻锁前复制的内容),该线程尝试通过CAS(期望值为P_Owner | 00 新值为Prev_MW)释放轻锁时:
- CAS成功,则当前线程成功释放轻锁,Mark Word变为 Prev_MW
- CAS失败,说明轻锁已经升级为重锁,此时执行重锁的释放流程。
4.3 轻量级锁和偏向锁的区别
**线程争夺轻量级锁失败时,会通过CAS+自旋尝试抢占锁
轻量级锁每次退出同步块都需要释放锁,而偏向锁是在竞争发生时才释放锁**
五、重量级锁
synchronized的重量级锁,是基于Monitor对象实现的。在编译时会将同步块的开始位置插入monitorenter指令,在结束位置插入monitorexit指令。
JVM基于Monitor对象中_owner状态和_EntryList的队列管理,实现了monitorenter(获取锁)和 monitorexit(释放锁)的语义。本文主要解析锁的升级流程,关于monitor的实现细节这里不赘述。
上述的布局信息对应的代码为:
public static void main(String[] args) {
Object o = new Object();
new Thread(() -> {
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
},"t1").start();
new Thread(() -> {
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
},"t2").start();
}
重锁的状态下,锁标识始终为:10 Mark Word中占用前62位来存储指向monitor的指针
六、再谈hashcode
最开始接触锁升级的流程时,一直有这样的疑问,除了无锁状态,其他状态的Mark Word中并没有位置存储hashcode,但是对象的hashcode其实是一直都能取到的,那么在非无锁状态下,对象的hashcode到底存在哪里了?
-
在无锁状态下,当对象的hashCode()方法第一次被调用时,JVM会生成对应的hash值并将该值存储到Mark Word中。
-
偏向锁无法和hashcode共存,如果一个对象的hashCode()方法己经被调用过一次之后,这个对象不能被设置偏向锁,会直接尝试获取轻量级锁。
//-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
//启用偏向锁 并且需要关闭偏向锁启动延迟
public static void main(String[] args) {
Object o = new Object();
int hash = o.hashCode();
synchronized (o){
System.out.println("Not biased lock");
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
-
轻量级锁的MarkWord存储了栈帧中Displaced Mark Word的地址,而栈帧中Displaced Mark Word复制了加锁前的Mark Word内容(包括hashcode和gc年龄等内),释放锁后会将这些信息写回到对象头,所以轻量级锁可以和hashcode共存。
-
如果对象处于偏向锁状态,此时收到计算hash code的请求,会即刻撤销偏向模式,膨胀为重量级锁
//-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
//启用偏向锁 并且需要关闭偏向锁启动延迟
public static void main(String[] args) {
Object o = new Object();
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
int hash = o.hashCode();
System.out.println("Compute hash code :" + hash);
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
- 升级为重量级锁后,Mark Word中前62bit存储了指向monitor对象的指针,monitor对象中记录了非加锁状态下的Mark Word(_header字段是当前monitor关联的锁对象的原始对象头,自然包含hash code和gc年龄等内容),锁释放后也会将这些信息写回到对象头,因此重量级锁可以和hashcode共存。
七、总结
- JDK1.6之前synchronized使用的是重量级锁
- JDK1.6之后进行了优化,拥有了无锁->偏向锁->轻量级锁->重量级锁的升级过程。 这里引用一下《Java并发编程的艺术》一书中关于锁的优缺点的对比:
评论( 0 )