大家好,我是此林。
不知道大家有没有这样一种感觉,网上对于一些 Java 框架和类的原理实现众说纷纭,看了总是不明白、不透彻。常常会想:真的是这样吗?
今天我们就从 HotSpot 源码级别去看 synchronized 的实现原理。全文以问题-解答的模式来展开讲述,方便大家理解。
1. 修饰代码块和修饰方法在字节码层面有什么不同?
synchronized 关键字可以修饰在三个地方:代码块、实例方法、静态方法。
但 synchronized 本质上是作用在对象上。
**修饰在代码块:**作用于括号里的对象
**修饰在实例方法:**作用于当前 this 实例对象
**修饰在静态方法:**作用于当前 Class 对象
1.1. 修饰在代码块
java
public class A {
public static void main(String[] args) {
}
public void test() {
synchronized (this) {
System.out.println("test");
}
}
}
上面这段代码用 IDEA 中的 jclasslib 插件反编译看下字节码。

执行 monitorenter 代表去抢占 monitor 对象,抢到了 monitor 对象就代表持有了锁。
monitorexit 也就很好理解了,是释放锁的意思。
为什么 monitorexit 要执行两次呢?
因为代码如果出现异常了,也需要解锁,否则就死锁了。
从字节码的角度,我们也就可以知道为什么 synchronized 不需要手动解锁了。
因为编译器生成的字节码里已经给我们考虑好了,异常情况也考虑到了。
1.2. 修饰在方法上
java
public class A {
public static void main(String[] args) {
}
public synchronized void test() {
System.out.println("test");
}
}
同样的,这段代码我们再反编译一下。

不过,这一次好像没有自动加 monitorenter 和 monitorexit 指令啊。
别急,你看看当前方法的访问标志。这里是 public synchronized 。

这样 JVM 就知道这个方法是被 synchronized标记的,在进入方法前后会进行加锁解锁操作。
对比一下之前修饰代码块的访问标志。

所以 synchronized 修饰代码块和修饰方法在字节码层面是不一样的,修饰代码块会自动加上 monitorenter 和 monitorexit 指令,修饰方法时会在方法的访问标志上做标记。
2. Java 对象结构是怎么样的?
下面给一张图,对 Java 对象布局有个直观的了解。

上图可知,Java 对象结构分为 对象头、实例数据、对齐填充。
在 HotSpot 源码里,Java 对象结构的代码在 src\share\vm\oops 里,instanceOop、instanceKlass、oop
几个C++的文件描述了对象的定义(有兴趣的小伙伴可以自行去研究)。
笔者用的 openjdk 8。
而对象头又分为:MarkWord、Klass Pointer(类型指针)、数组长度(只有数组有)。

我们现在关注锁,所以重点放在 MarkWord 上,各种锁操作都和 MarkWord 有强关联。下面是 MarkWord的内部结构。

从图中可以看到,当为重量级锁的时候,对象头的锁标志位为 10 ,并且会有一个指针指向这个 Monitor 对象。所以 java对象和 Monitor 就是这么关联上的。
**疑点解答:**每个对象都有一个 monitor 对象 (C++实现)和它关联。
其实不是这样的。
看上表可以知道,
当 synchronized 为偏向锁的时候,锁对象和线程ID关联
当 synchronized 为轻量级锁的时候,锁对象和lockRecord关联
当 synchronized 为重量级级锁的时候,锁对象和monitor对象关联
也就是说,只有当 synchronized 升级为重量级级锁的时候,锁对象的对象头的markword才会指向monitor对象。
3. synchronized 锁升级流程是怎么样的?
先说整体流程,无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
下面这张表很重要!敲黑板!
|---------|-------------------------------------------------------|
| 锁类型 | 用途场景 |
| 偏向锁 | 只有一个线程来抢锁。如果后续又来了一个线程,那么偏向锁会被立即撤销,升级为轻量级锁。 |
| 轻量级锁 | 有两个线程来抢锁,但这两个线程不会同时抢锁,交替执行。如果出现同时抢锁场景,轻量级锁会立即升级为重量级锁。 |
| 重量级锁 | 多个线程同时来抢锁,也就是我们常说的互斥锁。 |
来,直接上 JVM 源码!

3.1. 偏向锁
3.1.1. 偏向锁是什么?
偏向锁是什么呢?
在 只有一个线程的情况下,没有其他线程来竞争锁,所以频繁 CAS 会造成性能开销。
所以 JVM 开发者们弄出了偏向锁,就是偏向一个线程,下次这个线程来可以直接获取锁。
再看下这张图。

举个例子:
比如有个 synchronized (obj){}
1. 时间点:9:00:00
线程A来了,通过 CAS 把obj锁对象的对象头的 markword 指向线程A的ID。
2. 时间点:9:00:05
线程A又来了,发现obj锁对象的 markword 指向线程A的ID,那么线程A直接放行,无需再次 CAS ,相当于无锁的性能。
3. 时间点:9:00:10
线程B来了,那么偏向锁直接撤销,升级为轻量级锁。
(注:如果在 时间点:9:00:00 - 9:00:05 之间,线程B来了,那么偏向锁也会直接撤销,升级为轻量级锁)
对象头里会记录持有偏向锁的线程id,并把最后三个比特位设置为 101,第一个1代表是偏向锁。
之后有线程请求获取这把锁,只需要判断对象头的 markword 的后三位是不是 101,线程ID是否和当前线程相等。
3.1.2. 如何开启偏向锁?
这个就是 JVM 参数调优了。
可以通过参数 -XX:+UseBiasedLocking 来开启。
可以通过参数 -XX:-UseBiasedLocking 来关闭。
在高并发应用中,建议关闭偏向锁;在低并发应用中,可以考虑开启偏向锁。
3.1.3. 为什么在在高并发应用中,建议关闭偏向锁?
偏向锁只适合一个线程抢锁的场景。在只有一个线程的场景下,只需要第一次 CAS 把对象头的markword 指向当前线程ID,后续只需要比对线程ID,无需重复 CAS,实现几乎无锁的性能。
但是一旦有其他线程来抢锁,偏向锁会立刻撤销,而撤销会消耗大量的资源。
具体来说,偏向锁的撤销需要等待全局安全点(safepoint) ,需要 STW (Stop The World), 遍历所有线程栈,检查偏向线程是否还存活并且持有锁。如果偏向线程存活且持有锁,升级为轻量级锁。

上源码(偏向锁升级为轻量级锁)。
之前也说过了,轻量级锁时,锁对象的对象头的 markword 指向 lockRecord(BasicObjectLock)对象。
所以说,不同级别锁的本质是靠锁对象头的markword来区分关联的。
3.1.4. 代码执行完了,偏向锁会释放吗?
先说答案,不会。
在 HotSpot 虚拟机中,偏向锁的释放并不是在代码执行完(同步块退出)时立即触发的。偏向锁的设计目标是 无竞争场景下的性能优化,因此即使线程退出同步块,只要没有其他线程竞争,对象头仍会保持偏向模式,偏向锁不会主动释放。
那偏向锁的释放(撤销)触发时机呢?
当其他线程尝试获取已被偏向的锁时,JVM 会触发偏向锁的撤销(Revoke Bias),将对象头升级为轻量级锁。
3.1.5. 偏向锁有什么优化吗?
偏向锁在有竞争的时候是要执行撤销操作的,其实就是要升级成轻量级锁。
而当一个对象撤销的次数过多,经常被撤销,次数到了一定阈值(XX:BiasedLockingBulkRebiasThreshold,默认为 20) 就会把当代的偏向锁废弃,把 Klass 对象 的 epoch 加一。

看见了对象头的markword还有个 Epoch 吧?
所以当 Klass对象和 实例锁对象的 epoch 值不等的时候,当前线程可以将该锁重偏向至自己,因为前一代偏向锁已经废弃了。
当撤销次数超过另一个阈值(XX:BiasedLockingBulkRevokeThreshold,默认值为 40),则废弃此类的偏向功能,也就是说这个类都无法偏向了(永久废弃)。
3.2. 轻量级锁
3.2.1. 轻量级锁是什么?
还记得我们之前说过的这个表格吗?
|---------|-------------------------------------------------------|
| 锁类型 | 用途场景 |
| 偏向锁 | 只有一个线程来抢锁。如果后续又来了一个线程,那么偏向锁会被立即撤销,升级为轻量级锁。 |
| 轻量级锁 | 有两个线程来抢锁,但这两个线程不会同时抢锁,交替执行。如果出现同时抢锁场景,轻量级锁会立即升级为重量级锁。 |
| 重量级锁 | 多个线程同时来抢锁,也就是我们常说的互斥锁。 |
轻量级锁应用场景 :多个线程都是在不同的时间段来请求同一把锁,此时根本就用不需要阻塞线程,连 monitor 对象都不需要,所以就引入了轻量级锁这个概念,避免了系统调用,减少了开销。
3.2.2. 轻量级锁时,对象头的markword指向lockRecord?
前面我们说过,轻量级锁时,锁对象的对象头的markword指向lockRecord。
那这个lockRecord又是什么?
lockRecord 本质上就是 BasicObjectLock 对象,不过它不是分配在堆上的,是分配在线程栈上的,也就是线程私有,每个线程都有自己的 BasicObjectLock对象。
看到这里,再问一句:那重量级锁的 monitor 对象呢?
monitor 对象本质上是一个 C++ 实现的 ObjectMonitor 对象,它分配在堆上,全局唯一,所有线程共享。因为它全局唯一共享,所以 ObjectMonitor 会有个 owner 字段,用来标识当前哪个线程占有了 monitor。
3.2.3. 说说轻量级锁的加锁流程?
看下图源码吧!

其实本质上就是通过 CAS 把锁对象对象头的markword指向当前线程栈上私有的BasicLock。
3.2.4. 那轻量级锁的可重入逻辑怎么实现的?
前面已经说过了轻量级锁的加锁逻辑,如果无锁,直接把锁对象对象头的markword指向当前线程栈上私有的BasicLock。
如果已经有锁,先断言判断一下 markword 的 BasicLock 和当前线程的BasicLock是否相等,如果相等,那么就执行可重入逻辑。
下面一张图应该很清晰了。

可以看到,每个 lockRecord 里拷贝了锁对象的markword,
加锁流程如下:
-
每次加锁时,线程栈都会入栈一个 lockRecord。
-
先检查锁对象的 markword 是否已经指向了 lockRecord,如果没有,说明第一次加锁,lockRecord 拷贝一份 原始无锁态的markword的副本到字段_displaced_header,并且通过 CAS 让 markword 指向这个 lockRecord。
-
如果锁对象的 markword 已经指向了 lockRecord 了,并且发现这个 lockRecord 属于当前线程栈,lockRecord 里的字段 _displaced_header 设置为 NULL。
解锁流程如下:
-
解锁时,若发现 _displaced_header 为 NULL,说明是重入的,直接 return 返回,lockRecord 弹栈。
-
若发现 _displaced_header 不为 NULL,那就 CAS 把现在markword 换成 原始无锁态的markword,这也就是为什么 lockRecord 要拷贝一份markword副本的原因。
来看 JVM 轻量级锁解锁代码。


3.3. 重量级锁
3.3.1. 重量级锁是什么?
前面已经说过,重量级锁本质上就是锁对象头的markword指向一个堆空间上分配的、全局唯一的 ObjectMonitor 对象,这个 ObjectMonitor 对象有个属性 owner(标识哪个线程持有锁),recursions(锁重入次数),object(锁对象)。

至于 _WaitSet、_cxq、_EntryList 三个列表,_cxq 和 _EntryList 用于存放竞争锁失败被 park() 阻塞的线程。_WaitSet 里是存储已经获取到锁的线程,但是主动调用 wait() 的线程。
|---------------|-------------------------------------|---------------------------|----------------------------------------------|
| | LockSupport.park() | Thread.sleep() | Object.wait() |
| 是否释放锁 | 不会释放锁 | 不会释放锁 | 会释放锁, 无论重入几次(线程必须持有锁才能调用) |
| 阻塞方式 | 无限期阻塞, 直到 unpark() | 休眠到固定时间, 或 interrupt() | 无限期阻塞, 进入 waitSet, 直到 notify() 或 notifyAll() |
| interrupt() 时 | 不会抛异常,但 Thread.interrupted() 变 true | 会抛 InterruptedException异常 | 会抛 InterruptedException异常 |
| 使用场景 | 线程池线程挂起 | 定时任务,休眠 | 生产者-消费者,线程通信 |
3.3.2. 重量级锁加锁流程?
下面贴一张之前说的轻量级锁加锁流程:

在这之后,slow_enter() 方法最后,如果轻量级锁加锁失败,则 inflate,直接升级为重量级锁。

可以看到,轻量级锁加锁失败,是直接升级为重量级锁的(锁对象头markword指向ObjectMonitor 对象),并没有先进行自旋操作。
至于说自旋优化,那也是在升级为重量级锁之后的操作。inflate方法是升级为重量级锁,enter方法是抢锁逻辑。来看enter方法。

好,下面重点来了,如果抢锁失败了呢?

如果 Knob_SpinEarly 开启(默认为1,开启),先TrySpin() 自适应自旋一波。
自适应自旋 可以理解为多次CAS,它会通过一系列算法按之前的经验 动态调整等待时间,次数等。

重点看 EnterI() 方法。


所以总的流程如下:
先再尝试一下获取锁,不行的话就自适应自旋,还不行就包装成 ObjectWaiter 对象加入到 _cxq 这个单向链表之中,挣扎一下还是没抢到锁的话,那么就要阻塞了,所以下面还有阻塞逻辑。

至此,重量级锁的加锁逻辑到此结束了。总结一下,偷个懒,贴一张别人的图。

3.3.3. 重量级锁的解锁流程?
解锁流程在 exit() 方法里:

recursions 减到0的时候,还会唤醒其他线程,这里有几种模式。
1. Qmode == 2

2. Qmode == 3

3. Qmode == 4

总结一下,网图,侵删。

3.3.4. 说说 wait() 和 notify() 方法?
再看下之前的表格:
|---------------|-------------------------------------|---------------------------|----------------------------------------------|
| | LockSupport.park() | Thread.sleep() | Object.wait() |
| 是否释放锁 | 不会释放锁 | 不会释放锁 | 会释放锁, 无论重入几次(线程必须持有锁才能调用) |
| 阻塞方式 | 无限期阻塞, 直到 unpark() | 休眠到固定时间, 或 interrupt() | 无限期阻塞, 进入 waitSet, 直到 notify() 或 notifyAll() |
| interrupt() 时 | 不会抛异常,但 Thread.interrupted() 变 true | 会抛 InterruptedException异常 | 会抛 InterruptedException异常 |
| 使用场景 | 线程池线程挂起 | 定时任务,休眠 | 生产者-消费者,线程通信 |
线程必须持有 synchronized 锁才能调用 wait() 方法。
wait() 逻辑很简单,就是将当前线程加入到 _waitSet 这个双向链表中,然后再执行 ObjectMonitor::exit
方法来释放锁。
notify() 逻辑也不难,就是从 _waitSet 头部拿节点,然后根据策略选择是放在 cxq 还是 EntryList 的头部或者尾部,并且进行唤醒。
现在再来看下这个图,应该心里很有数了。

3.3.5. 为什么会有_cxq 和 _EntryList 两个列表来放线程?
因为会有多个线程会同时竞争锁,竞争失败了先存在 _cxq 这个单向链表,在每次唤醒的时候搬迁一些线程节点到_EntryList 这个双向链表,降低 _cxq 的头部入队竞争。
3.3.6. 重量级锁开销大的原因?
阻塞和唤醒依赖于底层的操作系统实现,系统调用存在用户态与内核态之间的切换,所以有较高的开销,因此称之为重量级锁。
所以又引入了自适应自旋机制,来提高锁的性能。
我是此林,关注我吧!带你看不一样的世界!