Java6及以上版本对synchronized的优化
一、概述
在多线程并发编程中 synchronized 一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着 Java SE 1.6 对 synchronized 进行了各种优化之后,有些情况下它就并不那么重了。
本文详细介绍 Java SE 1.6 中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。
二、实现同步的基础
Java 中的每个对象都可以作为锁,具体变现为以下3中形式:
-
对于普通同步方法,锁是当前实例对象
-
对于静态同步方法,锁是当前类的 Class 对象
-
对于同步方法块,锁是 synchronized 括号里配置的对象
一个线程试图访问同步代码块时,必须获取锁,在退出或者抛出异常时,必须释放锁。
三、实现方式
JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步,但是两者的实现细节不一样。
-
代码块同步:通过使用 monitorenter 和 monitorexit 指令实现的
-
同步方法:ACC_SYNCHRONIZED 修饰
monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 指令是在编译后插入到同步代码块的结束处或异常处,对于同步方法,个人觉得也是类似的原理,进入方法前添加一个 monitorenter 指令,退出方法后条件一个 monitorexit 指令。
为了证明 JVM 的实现方式,下面通过反编译代码来证明:
public class Demo {
public void f1() {
synchronized (Demo.class) {
System.out.println("Hello World.");
}
}
public synchronized void f2() {
System.out.println("Hello World.");
}
}
编译之后的字节码如下(只摘取了方法的字节码):
public void f1();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: ldc #2 // class me/snail/base/Demo
2: dup
3: astore_1
4: monitorenter
5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #4 // String Hello World.
10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: aload_1
14: monitorexit
15: goto 23
18: astore_2
19: aload_1
20: monitorexit
21: aload_2
22: athrow
23: return
Exception table:
from to target type
5 15 18 any
18 21 18 any
LineNumberTable:
line 6: 0
line 7: 5
line 8: 13
line 9: 23
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 18
locals = [ class me/snail/base/Demo, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
public synchronized void f2();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #4 // String Hello World.
5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 12: 0
line 13: 8
先说 f1() 方法,发现其中一个 monitorenter 对应了两个 monitorexit,这是不对的。但是仔细看 #15: goto 语句,直接跳转到了 #23: return 处,再看 #22: athrow 语句发现,原来第二个 monitorexit 是保证同步代码块抛出异常时锁能得到正确的释放而存在的,这就理解了。
综上:发现同步代码块是通过 monitorenter 和 monitorexit 来实现的,同步方法是加了一个 ACC_SYNCHRONIZED 修饰来实现的。
四、Java对象头(存储锁类型)
在 HotSpot 虚拟机中,对象在内存中的布局分为三块区域:对象头,实例数据和对齐填充。
对象头中包含两部分:MarkWord 和 类型指针。如果是数组对象的话,对象头还有一部分是存储数组的长度。
多线程下 synchronized 的加锁就是对同一个对象的对象头中的 MarkWord 中的变量进行CAS操作。
1、MarkWord
Mark Word 用于存储对象自身的运行时数据,如 HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等等。
占用内存大小与虚拟机位长一致(32位JVM -> MarkWord是32位,64位JVM -> MarkWord是64位)。
2、类型指针
虚拟机通过这个指针确定该对象是哪个类的实例。
3、对象头的长度
长度 | 内容 | 说明 |
---|---|---|
32/64bit | MarkWord | 存储对象的hashCode或锁信息等 |
32/64bit | Class Metadada Address | 存储对象类型数据的指针 |
32/64bit | Array Length | 数组的长度(如果当前对象是数组) |
如果是数组对象的话,虚拟机用3个字宽(32/64bit + 32/64bit + 32/64bit)存储对象头,如果是普通对象的话,虚拟机用2字宽存储对象头(32/64bit + 32/64bit)。
五、优化后synchronized锁的分类
级别从低到高依次是:
-
无锁状态
-
偏向锁状态
-
轻量级锁状态
-
重量级锁状态
锁可以升级,但不能降级。即:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁是单向的。
下面看一下每个锁状态时,对象头中的 MarkWord 这一个字节中的内容是什么。
以32位系统为例:
1、无锁状态
25bit | 4bit | 1bit(是否是偏向锁) | 2bit(锁标志位) |
---|---|---|---|
对象的hashCode | 对象分代年龄 | 0 | 01 |
这里的 hashCode 是 Object#hashCode 或者 System#identityHashCode 计算出来的值,不是用户覆盖产生的 hashCode。
2、偏向锁状态
23bit | 2bit | 4bit | 1bit | 2bit |
---|---|---|---|---|
线程ID | epoch | 对象分代年龄 | 1 | 01 |
这里 线程ID 和 epoch 占用了 hashCode 的位置,所以,如果对象如果计算过 identityHashCode 后,便无法进入偏向锁状态,反过来,如果对象处于偏向锁状态,并且需要计算其 identityHashCode 的话,则偏向锁会被撤销,升级为重量级锁。
epoch:
对于偏向锁,如果 线程ID = 0 表示未加锁。
什么时候会计算 HashCode 呢?比如:将对象作为 Map 的 Key 时会自动触发计算,List 就不会计算,日常创建一个对象,持久化到库里,进行 json 序列化,或者作为临时对象等,这些情况下,并不会触发计算 hashCode,所以大部分情况不会触发计算 hashCode。
Identity hash code是未被覆写的 java.lang.Object.hashCode() 或者 java.lang.System.identityHashCode(Object) 所返回的值。
3、轻量级锁状态
30bit | 2bit |
---|---|
指向线程栈锁记录的指针 | 00 |
这里指向栈帧中的 Lock Record 记录,里面当然可以记录对象的 identityHashCode。
4、重量级锁状态
30bit | 2bit |
---|---|
指向锁监视器的指针 | 10 |
这里指向了内存中对象的 ObjectMonitor 对象,而 ObectMontitor 对象可以存储对象的 identityHashCode 的值。
六、锁的升级
1、偏向锁
偏向锁是针对于一个线程而言的,线程获得锁之后就不会再有解锁等操作了,这样可以省略很多开销。假如有两个线程来竞争该锁话,那么偏向锁就失效了,进而升级成轻量级锁了。
为什么要这样做呢?因为经验表明,其实大部分情况下,都会是同一个线程进入同一块同步代码块的。这也是为什么会有偏向锁出现的原因。
如果支持偏向锁(没有计算 hashCode),那么在分配对象时,分配一个可偏向而未偏向的对象(MarkWord的最后 3 位为 101,并且 Thread Id 字段的值为 0)。
a、偏向锁的加锁
- 偏向锁标志是未偏向状态,使用 CAS 将 MarkWord 中的线程ID设置为自己的线程ID,
- 如果成功,则获取偏向锁成功。
- 如果失败,则进行锁升级。
- 偏向锁标志是已偏向状态
- MarkWord 中的线程 ID 是自己的线程 ID,成功获取锁
- MarkWord 中的线程 ID 不是自己的线程 ID,需要进行锁升级
偏向锁的锁升级需要进行偏向锁的撤销。
b、偏向锁的撤销
- 对象是不可偏向状态
- 不需要撤销
- 对象是可偏向状态
- MarkWord 中指向的线程不存活
- 允许重偏向:退回到可偏向但未偏向的状态
- 不允许重偏向:变为无锁状态
- MarkWord 中的线程存活
- 线程ID指向的线程仍然拥有锁
- 升级为轻量级锁,将 mark word 复制到线程栈中
- 不再拥有锁
- 允许重偏向:退回到可偏向但未偏向的状态
- 不允许重偏向:变为无锁状态
- 线程ID指向的线程仍然拥有锁
- MarkWord 中指向的线程不存活
小结: 撤销偏向的操作需要在全局检查点执行。我们假设线程A曾经拥有锁(不确定是否释放锁), 线程B来竞争锁对象,如果当线程A不在拥有锁时或者死亡时,线程B直接去尝试获得锁(根据是否 允许重偏向(rebiasing
),获得偏向锁或者轻量级锁);如果线程A仍然拥有锁,那么锁 升级为轻量级锁,线程B自旋请求获得锁。
偏向锁的撤销流程
2、轻量级锁
之所以是轻量级,是因为它仅仅使用 CAS 进行操作,实现获取锁。
a、加锁流程
如果线程发现对象头中Mark Word已经存在指向自己栈帧的指针,即线程已经获得轻量级锁,那么只需要将0存储在自己的栈帧中(此过程称为递归加锁);在解锁的时候,如果发现锁记录的内容为0, 那么只需要移除栈帧中的锁记录即可,而不需要更新Mark Word。
加锁前:
加锁后:
线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录(Lock Record
)的指针, 如上图所示。如果成功,当前线程获得轻量级锁,如果失败,虚拟机先检查当前对象头的 Mark Word 是否指向当前线程的栈帧,如果指向,则说明当前线程已经拥有这个对象的锁,则可以直接进入同步块 执行操作,否则表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。当竞争线程的自旋次数 达到界限值(threshold
),轻量级锁将会膨胀为重量级锁。
b、撤销流程
轻量级锁解锁时,如果对象的Mark Word仍然指向着线程的锁记录,会使用CAS操作, 将Dispalced Mark Word替换到对象头,如果成功,则表示没有竞争发生。如果失败, 表示当前锁存在锁竞争,锁就会膨胀为重量级锁。
3、重量级锁
重量级锁(heavy weight lock
),是使用操作系统互斥量(mutex
)来实现的传统锁。 当所有对锁的优化都失效时,将退回到重量级锁。它与轻量级锁不同竞争的线程不再通过自旋来竞争线程, 而是直接进入堵塞状态,此时不消耗CPU,然后等拥有锁的线程释放锁后,唤醒堵塞的线程, 然后线程再次竞争锁。但是注意,当锁膨胀(inflate
)为重量锁时,就不能再退回到轻量级锁。
七、总结
首先要明确一点是引入这些锁是为了提高获取锁的效率, 要明白每种锁的使用场景, 比如偏向锁适合一个线程对一个锁的多次获取的情况; 轻量级锁适合锁执行体比较简单(即减少锁粒度或时间), 自旋一会儿就可以成功获取锁的情况.
要明白MarkWord中的内容表示的含义.
参考文献
座右铭:不要因为知识简单就忽略,不积跬步无以至千里。
版权声明:自由转载-非商用-非衍生-保持署名。
本作品采用知识共享署名 4.0 国际许可协议进行许可。
----------------------------------------------------------------------