文章目录
一、概述
在多线程并发编程中 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中的内容表示的含义.
八、简单版总结
以下是32位的对象头描述
| 锁状态 | 25 bit | 4bit | 1bit | 2bit |
|---|---|---|---|---|
| 23bit | 2bit | 是否偏向锁 | 锁标志位 | |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 | ||
| 重量级锁 | 指向互斥量(重量级锁)的指针 | 10 | ||
| GC标记 | 空 | 11 | ||
| 偏向锁 | 线程ID | Epoch | 对象分代年龄 | 1 |
synchronized锁的膨胀过程:
当线程访问同步代码块。首先查看当前锁状态是否是偏向锁(可偏向状态)
- 1、如果是偏向锁:
- 检查当前mark word中记录是否是当前线程id,如果是当前线程id,则获得偏向锁执行同步代码块。
- 如果不是当前线程id,cas操作替换线程id,替换成功获得偏向锁(线程复用),替换失败,锁撤销,升级轻量锁(同一类对象多次撤销升级达到阈值20,则批量重偏向,这个点可以稍微提一下,详见下面的注意)
- 2、升级轻量锁:
- 升级轻量锁对于当前线程,分配栈帧锁记录lock_record(包含mark word和object-指向锁记录首地址),对象头mark word复制到线程栈帧的锁记录,mark word存储的是无锁的hashcode(里面有重入次数问题)
- 3、重量级锁(纯理论可结合源码)
- CAS自旋达到一定次数升级为重量级锁(多个线程同时竞争锁时)
- 存储在ObjectMonitor对象,里面有很多属性ContentionList、EntryList 、WaitSet、 owner。当一个线程尝试获取锁时,如果该锁已经被占用,则该线程封装成ObjectWaiter对象插到 ContentionList队列的对首,然后调用park挂起。该线程锁时方式会从ContentionList或EntryList挑一个唤醒。线程获得锁后调用Object的wait方法,则会加入到WaitSet集合中(当前锁或膨胀为重量级锁)
注意:
- 1.偏向锁在JDK1.6以上默认开启,开启后程序启动几秒后才会被激活
- 2.偏向锁撤销是需要在safe_point,也就是安全点的时候进行,这个时候是stop the word的,所以说偏向锁的撤销是开销很大的,如果明确了项目里的竞争情况比较多,那么关闭偏向锁可以减少一些偏向锁撤销的开销
- 3.以class为单位,为每个class维护一个偏向锁撤销计数器。每一次该class的对象发生偏向撤销操作时(这个时候进入轻量级锁),该计数器+1,当这个值达到重偏向阈值(默认20,也就是说前19次进行加锁的时候,都是假的轻量级锁,当第20次加锁的时候,就会走批量冲偏向的逻辑)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。每个class对象也会有一个对应的epoch字段,每个处于偏向锁状态对象的mark word中也有该字段,其初始值为创建该对象时,class中的epoch值。每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的站,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获取锁时,发现当前对象的epoch值和class不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其mark word的Thread Id改为当前线程ID
本文详细介绍了Java1.6中synchronized的优化,包括偏向锁、轻量级锁和重量级锁的状态及升级过程,以及锁在对象头中的存储结构。同步方法和代码块的实现分别基于monitorenter和monitorexit指令以及ACC_SYNCHRONIZED修饰。锁的状态从无锁到重量级锁逐级升级,不可逆。偏向锁适用于单线程频繁访问,轻量级锁通过CAS操作避免阻塞,而重量级锁则依赖于操作系统互斥量。
288

被折叠的 条评论
为什么被折叠?



