【Java】synchronized关键字详解:从字节码到对象头与锁升级

【Java】synchronized关键字详解:从字节码到对象头与锁升级



👨‍💻程序员三明治个人主页
🔥 个人专栏: 《设计模式精解》《重学数据结构》

🤞先做到 再看见!


目录

synchronized底层原理(总结版)

synchronize底层使用的是minitor,Monitor 被翻译为监视器,是由jvm提供,c++语言实现。

使用javap -v xxx.class反编译一段代码可以看到机器指令

  • monitorenter 上锁开始的地方
  • monitorexit 解锁的地方
  • 其中被monitorenter和monitorexit包围住的指令就是上锁的代码
  • 第二个monitorexit是为了防止锁住的代码抛异常后不能及时释放锁

monitor主要就是跟这个对象产生关联,如下图

Monitor内部具体的存储结构:

  • Owner:存储当前获取锁的线程,只能有一个线程可以获取
  • EntryList:关联没有抢到锁的线程,处于Blocked状态的线程
  • WaitSet:关联调用了wait方法的线程,处于Waiting状态的线程

具体的流程:

  • 进入synchorized代码块时,先让lock(对象锁)关联monitor,然后判断Owner是否有线程持有
  • 如果没有线程持有,则让当前线程持有,表示该线程获取锁成功
  • 如果有线程持有,则让当前线程进入entryList进行阻塞,如果Owner持有的线程已经释放了锁,在EntryList中的线程去竞争锁的持有权(非公平)
  • 如果代码块中调用了wait()方法,则会进去WaitSet中进行等待

参考回答:

  • Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】
  • 它的底层由monitor实现的,monitor是jvm级别的对象( C++实现),线程获得锁需要使用对象(锁)关联monitor
  • 在monitor内部有三个属性,分别是owner、entrylist、waitset
  • 其中owner是关联的获得锁的线程,并且只能关联一个线程;entrylist关联的是处于阻塞状态的线程;waitset关联的是处于Waiting状态的线程好的,我们来详细、深入地剖析一下 synchronized 在 JVM 中的底层实现原理。这对于理解 Java 并发编程至关重要。

synchronized 底层原理(详解版)

synchronized 的底层原理可以从三个层面来看:字节码层面JVM 底层实现硬件层面。我们逐层深入。

1. 字节码层面:monitorenter 和 monitorexit

当我们使用 synchronized 关键字时,无论是修饰代码块还是方法,在编译后的字节码中都会生成对应的指令。

  • 同步代码块
    对于 synchronized(object) { ... },编译器会在同步代码块的前后分别生成 monitorentermonitorexit 指令。
publicvoidmethod(){synchronized(obj){// 同步代码块System.out.println("hello");}}

编译后的字节码大致如下:

public void method(); Code: 0: aload_0 1: getfield #2 // 获取对象引用 obj 4: dup 5: astore_1 6: monitorenter // 进入同步块,尝试获取锁 7: getstatic #3 // 获取 System.out 10: ldc #4 // 加载 "hello" 12: invokevirtual #5 // 调用 println 15: aload_1 16: monitorexit // 正常退出同步块,释放锁 17: goto 25 20: astore_2 21: aload_1 22: monitorexit // 异常退出同步块,释放锁 (确保在异常情况下也能释放锁) 23: aload_2 24: athrow 25: return 

关键点

- 可以看到有两个 `monitorexit` 指令,第一个用于正常退出,第二个用于处理异常情况(隐藏在 `finally` 语义中),这确保了即使同步块内抛出异常,锁也能被正确释放。 
  • 同步方法
    对于 synchronized 修饰的方法,方法常量池中会设置 ACC_SYNCHRONIZED 标志。
publicsynchronizedvoidmethod(){// 方法体}
- 当方法调用时,调用指令(如 `invokevirtual`)会检查这个标志。如果设置了,执行线程会先尝试获取锁(对于实例方法是 `this`,对于静态方法是该类的 `Class` 对象),再执行方法体。方法执行完毕后,无论是正常返回还是异常抛出,都会自动释放锁。 

小结:从字节码看,synchronized 的实现依赖于 monitorentermonitorexit 这一对指令,或者方法的 ACC_SYNCHRONIZED 标志。

2. JVM 底层实现:对象头与 Monitor

monitorentermonitorexit 指令背后的具体实现,是 JVM 的核心。其关键在于 Java 对象头Monitor

2.1 Java 对象头(Mark Word)

在 HotSpot 虚拟机中,每个 Java 对象在内存中存储的布局分为三部分:对象头、实例数据、对齐填充

其中,对象头 是理解锁的关键。它包含两部分信息:

  1. Mark Word:存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID 等。它是实现锁的“主战场”。
  2. Klass Pointer:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

为了在极小的空间内存储尽可能多的信息,Mark Word 被设计成一个非固定的动态数据结构。它会根据对象的状态复用自己的存储空间。下图清晰地展示了 32 位 JVM 下 Mark Word 在不同状态下的结构:

(在 64 位 JVM 下,结构类似,只是空间更大。)

关键点:注意最后 2 位(lock),它标识了对象的锁状态。锁的升级过程就体现在这 2 位的变化上。

2.2 Monitor(管程/监视器锁)

JVM 为每个对象都关联了一个内置的 Monitor(管程)。monitorenter 指令的本质就是尝试去获取这个对象对应的 Monitor。

一个 Monitor 由以下部分组成:

  • Owner:当前持有该 Monitor 的线程。初始为 null
  • EntryList:处于 Blocked 状态的、等待锁的线程队列。当 Owner 释放锁时,JVM 会从 EntryList 中挑选一个线程来成为新的 Owner。
  • WaitSet:处于 Waiting 状态的、调用了 Object.wait() 方法的线程队列。这些线程在等待其他线程的通知(notify/notifyAll)。

工作流程

  1. 当线程执行到 monitorenter 指令时,会尝试进入(enter)该对象的 Monitor。
  2. 如果 Monitor 的 Owner 为 null,则该线程成功成为 Owner,并将锁的计数器 +1。
  3. 如果该线程已经是 Owner(可重入锁),它再次进入,锁计数器再次 +1。
  4. 如果 Owner 是其他线程,则当前线程会进入 EntryList,进入 BLOCKED 状态,直到 Owner 线程释放锁。
  5. 当线程执行 monitorexit 指令时,锁计数器 -1。当计数器减到 0 时,线程释放 Monitor,不再担任 Owner。然后,EntryList 中的线程会开始竞争锁。
3. 锁的升级与优化

在 Java 6 之前,synchronized 是一个重量级锁,性能较差,因为它依赖于操作系统的 Mutex Lock(互斥锁),需要进行用户态到内核态的切换,耗时较长。

为了减少这种性能开销,Java 6 引入了锁升级机制。synchronized 的锁状态从低到高分为四种,升级路径是单向的:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

3.1 偏向锁
  • 目的:在没有竞争的情况下,消除整个同步操作。假设在大多数情况下,锁不仅不存在竞争,而且总是由同一线程多次获得。
  • 原理
    • 当一个线程访问同步块时,会在对象头和栈帧中的锁记录里存储偏向的线程 ID
    • 以后该线程再次进入和退出同步块时,不需要进行 CAS 操作来加锁和解锁,只需简单测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。
    • 如果测试成功,表示线程已经获得了锁。
  • 撤销:一旦出现另一个线程来尝试竞争锁,偏向模式就宣告结束。持有偏向锁的线程会被挂起,JVM 会撤销偏向锁,然后升级为轻量级锁。
注意:在 Java 15 之后,偏向锁被标记为废弃并默认禁用,因为维护其带来的收益已不如从前。但理解其原理依然重要。
3.2 轻量级锁
  • 目的:在竞争不激烈(“近交替执行”)的情况下,避免直接使用重量级锁带来的性能消耗。
  • 加锁过程
    1. 在当前线程的栈帧中创建一个名为 锁记录 的空间。
    2. 将对象头的 Mark Word 复制到锁记录中(称为 Displaced Mark Word)。
    3. 线程尝试使用 CAS 操作将对象头的 Mark Word 替换为指向锁记录的指针。
      • 如果成功,当前线程获得锁。并将对象 Mark Word 的最后 2 位设置为 00,表示轻量级锁状态。
      • 如果失败,表示存在竞争(另一个线程也修改了 Mark Word)。
  • 解锁过程
    • 使用 CAS 操作将 Displaced Mark Word 替换回对象头。
    • 如果成功,则同步过程顺利完成。
    • 如果失败,说明锁已经升级,需要释放锁的同时唤醒被挂起的线程。
3.3 重量级锁
  • 触发条件:当轻量级锁竞争失败后,会自旋尝试获取锁一定次数(自旋锁)。如果自旋后依然失败,锁就会膨胀为重量级锁。
  • 特点
    • 此时 Mark Word 中存储的是指向重量级锁(Monitor)的指针。
    • 等待锁的线程都会进入 EntryList,进入 BLOCKED 状态。
    • 依赖于操作系统底层的 Mutex Lock,需要进行用户态到内核态的切换,成本最高。

4. 硬件层面:内存屏障与 CAS

synchronized 的语义保证了原子性、可见性和有序性

  • 可见性与有序性:是通过在编译器和处理器层面插入 内存屏障 来实现的。在同步块开始时加 Load Barrier,在同步块结束时加 Store Barrier,强制将工作内存中的修改刷新到主内存,并禁止指令重排序。
  • 原子性:对于简单的 monitorenter/monitorexit,由 Monitor 保证。对于锁升级过程中的状态变更(如轻量级锁的获取),则是通过 CAS 操作实现的。CAS 是一条 CPU 原子指令(cmpxchg),它保证了“比较-交换”操作的原子性。

JVM锁升级是什么?

Monitor实现的锁属于重量级锁,你了解过锁升级吗?

  • Monitor实现的锁属于重量级锁,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
  • 在JDK 1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。
对象的内存结构
MarkWord

我们可以通过lock的标识,来判断是哪一种锁的等级

  • 后三位是001表示无锁
  • 后三位是101表示偏向锁
  • 后两位是00表示轻量级锁
  • 后两位是10表示重量级锁
再说Monitor重量级锁

每个对象的markword都可以设置monoitor的指针,让对象与monitor产生关联

轻量级锁

**加锁的流程 **

1.在线程栈中创建一个Lock Record,将其obj字段指向锁对象。

2.通过CAS指令将Lock Record的地址存储在对象头的mark word中(数据进行交换),如果对象处于无锁状态代表该线程获得了轻量级锁。

3.如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用。

4.如果CAS修改失败,说明发生了竞争,需要膨胀为重量级锁。

解锁过程

1.遍历线程栈,找到所有obj字段等于当前锁对象的Lock Record。

2.如果Lock Record的Mark Word为null,代表这是一次重入,将obj设置为null后continue。

3.如果Lock Record的 Mark Word不为null,则利用CAS指令将对象头的mark word 恢复成为无锁状态。如果失败则膨胀为重量级锁。

偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。

Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有.

**加锁的流程 **

1.在线程栈中创建一个Lock Record,将其obj字段指向锁对象。

2.通过CAS指令将Lock Record的线程id存储在对象头的mark word中,同时也设置偏向锁的标识为101,如果对象处于无锁状态则修改成功,代表该线程获得了偏向锁

3.如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用。与轻量级锁不同的时,这里不会再次进行cas操作,只是判断对象头中的线程id是否是自己,因为缺少了cas操作,性能相对轻量级锁更好一些

解锁流程参考轻量级锁

如果我的内容对你有帮助,请辛苦动动您的手指为我点赞,评论,收藏。感谢大家!!

在这里插入图片描述


我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=h70g0sv71wz

Read more

Java 注解与反射实战:手把手实现自定义日志与参数校验注解

Java 注解与反射实战:手把手实现自定义日志与参数校验注解

前言:为什么需要自定义注解? 在日常开发中,我们经常遇到两类重复工作: 日志记录:每个重要方法都要写 "开始执行"、"参数是 xxx"、"执行结束" 的代码;参数校验:判断输入是否为 null、年龄是否在合理范围、手机号格式是否正确等。 这些工作机械且冗余,而注解 + 反射正是解决这类问题的 "银弹"—— 用注解标记需要处理的地方,用反射自动执行逻辑,实现 "一次定义,多处复用"。 本文将带你从零实现两个实用案例: 1. 自定义日志注解@Log:自动记录方法调用细节; 2. 自定义参数校验注解@NotNull、@Range:自动校验方法参数合法性。 全程实战,代码可直接运行,搭配图解帮你吃透底层逻辑。 案例一:自定义日志注解@

By Ne0inhk
【Java String】类深度解析:从原理到高效使用技巧

【Java String】类深度解析:从原理到高效使用技巧

🎁个人主页:User_芊芊君子 🎉欢迎大家点赞👍评论📝收藏⭐文章 🔍系列专栏:【Java】内容概括 【前言】 在 Java 编程中,String 类是使用频率最高的类之一,也是初学者接触最早的引用类型之一。但正是因为其基础且常用,很多开发者往往忽略了它的底层原理和高级特性。本文将从 String 类的底层实现、核心方法到性能优化、常见误区,全方位解析 Java String 类,帮你彻底搞懂这一基础却关键的类。 文章目录: * 一、String类本质特征 * 1.String类定义 * 2.字符串创建及内存机制 * 3.字符串常量池(StringTable) * 二、String 类核心方法 * 1.字符串方法比较 * 1.1“==”比较是否引用同一个对象 * 1.2 equals

By Ne0inhk

Picked up JAVA_TOOL_OPTIONS: -Dfile.encoding=GBK 新版IDEA编码格式GBK问题 maven命令Picked up JAVA_TOOL_OPTION

📋 问题概述 问题现象 在使用新版IDEA执行 Maven 构建项目时,控制台输出警告信息: Picked up JAVA_TOOL_OPTIONS: -Dfile.encoding=GBK 🔍 问题排查过程 第一阶段:初步判断与假设 初始假设:系统环境变量设置了 Java 编码为 GBK 第二阶段:环境变量验证 cmd # 检查环境变量 echo %JAVA_TOOL_OPTIONS% # 输出:%JAVA_TOOL_OPTIONS%(表示变量未显式设置) 排查结果:系统环境中并未手动设置 JAVA_TOOL_OPTIONS 变量 第三阶段:深入排查IDEA配置 怀疑方向:IDEA内部设置或配置文件指定了GBK编码 检查项包括: 1. IDEA VM Options:

By Ne0inhk

java下载安装教程(附安装包)JDK超详细图文安装教程

文章目录 * 下载JDK安装包 * java安装 * 配置Java环境变量 * IntelliJ IDEA开发工具JDK配置 * 新建项目时配置JDK * 已有项目调整JDK版本 * 通过Maven控制JDK版本 * Java开发环境常见问题解决 * 环境变量配置后java命令仍然无法识别 * 多版本JDK共存技巧 * 深入理解Java版本选择策略 本文提供最新JDK完整安装教程,从下载安装包到环境变量配置的详细流程。包含Java开发工具包的完整部署步骤,附带官方安装包下载链接,适合Java开发初学者和编程学习者快速搭建JDK开发环境。 下载JDK安装包 官网下载渠道 Java Downloads |Oracle 中国 https://www.oracle.com/cn/java/technologies/downloads/#jdk17-windows 国内高速下载链接: 如果官网下载速度慢,可以试试这个国内镜像: https://pan.quark.cn/s/296349c7d9b5 java安装 在当前目录地址栏

By Ne0inhk