从 JVM 源码(HotSpot)看 synchronized 原理

news/2025/3/5 14:04:03/

大家好,我是此林。

不知道大家有没有这样一种感觉,网上对于一些 Java 框架和类的原理实现众说纷纭,看了总是不明白、不透彻。常常会想:真的是这样吗?

今天我们就从 HotSpot 源码级别去看 synchronized 的实现原理。全文以问题-解答的模式来展开讲述,方便大家理解。

1. 修饰代码块和修饰方法在字节码层面有什么不同?

synchronized 关键字可以修饰在三个地方:代码块、实例方法、静态方法。

但 synchronized 本质上是作用在对象上。

修饰在代码块:作用于括号里的对象

修饰在实例方法:作用于当前 this 实例对象

修饰在静态方法:作用于当前 Class 对象

1.1. 修饰在代码块

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. 修饰在方法上

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,

加锁流程如下:

1. 每次加锁时,线程栈都会入栈一个 lockRecord。

2. 先检查锁对象的 markword 是否已经指向了 lockRecord,如果没有,说明第一次加锁,lockRecord 拷贝一份 原始无锁态的markword的副本 到字段_displaced_header,并且通过 CAS 让 markword 指向这个 lockRecord。

3. 如果锁对象的 markword 已经指向了 lockRecord 了,并且发现这个 lockRecord 属于当前线程栈,lockRecord 里的字段 _displaced_header 设置为 NULL。

解锁流程如下:

1. 解锁时,若发现 _displaced_header 为 NULL,说明是重入的,直接 return 返回,lockRecord 弹栈。

2. 若发现 _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. 重量级锁开销大的原因?

阻塞和唤醒依赖于底层的操作系统实现,系统调用存在用户态与内核态之间的切换,所以有较高的开销,因此称之为重量级锁。

所以又引入了自适应自旋机制,来提高锁的性能。

我是此林,关注我吧!带你看不一样的世界!


http://www.ppmy.cn/news/1576831.html

相关文章

MyBatis-Plus 逻辑删除实现

在很多企业级应用中,数据删除操作通常采用 逻辑删除 的方式,而不是物理删除。逻辑删除指的是通过更新字段(例如 is_deleted 或 status)来标记数据为删除状态,而不是真的从数据库中删除记录。这样做的好处是保留数据的历…

网线水晶头接法

目录 介绍 排线标准 连接方法 直连互联法 交叉互联法 操作步骤 介绍 网线:双绞线,有4对8条芯线,分别为白绿色、绿色、白橙色、蓝色、白蓝色、橙色、白棕色、棕色。 水晶头:也被称为RJ45连接器,是一种用于网络连接…

【应急响应工具教程】一款自动化分析网络安全应急响应工具--FindAll

1、工具介绍 FindAll 是一款安全团队开发的轻量化蓝队工具,专为应急响应场景设计,主打信息收集与威胁情报联动,尤其适合团队快速排查多台主机的安全风险。同时FindAll采用客户端-服务器(CS)架构,特别适用于…

AI赋能校园安全:科技助力预防与应对校园霸凌

校园本应是学生快乐学习、健康成长的地方,然而,校园霸凌却成为威胁学生身心健康的隐形“毒瘤”。近年来,随着人工智能(AI)技术的快速发展,AI在校园安全领域的应用逐渐成为解决校园霸凌问题的新突破口。通过…

YOLOv12 ——基于卷积神经网络的快速推理速度与注意力机制带来的增强性能结合

概述 实时目标检测对于许多实际应用来说已经变得至关重要,而Ultralytics公司开发的YOLO(You Only Look Once,只看一次)系列一直是最先进的模型系列,在速度和准确性之间提供了稳健的平衡。注意力机制的低效阻碍了它们在…

如何使用 Jenkins 实现 CI/CD 流水线:从零开始搭建自动化部署流程

如何使用 Jenkins 实现 CI/CD 流水线:从零开始搭建自动化部署流程 在软件开发过程中,持续集成(CI)和持续交付(CD)已经成为现代开发和运维的标准实践。随着代码的迭代越来越频繁,传统的手动部署方式不仅低效,而且容易出错。为了提高开发效率和代码质量,Jenkins作为一款…

Deepseek的缺陷

Deepseek什么都略懂,但答案很难让人满意。 内容蜻蜓点水,什么都有点,但浅薄的很。让玩家很无语。 一些软件类的答案貌似比较详细,但距离能用还差很远。 ----------------- 一些问题的答案如下: 《b4》是一款经典的生…

NUMA架构介绍

NUMA 架构详解 NUMA(Non-Uniform Memory Access,非统一内存访问) 是一种多处理器系统的内存设计架构,旨在解决多处理器系统中内存访问延迟不一致的问题。与传统的 UMA(Uniform Memory Access,统一内存访问…