REJAVA / LEARNING NOTES
synchronized(内置同步关键字)
判断锁住哪个对象、哪些访问会等待,再把监视器规则连接到可见性。
根据课程与真实学习交流整理;不是逐字稿
本次更新说明
- 补充 javap(字节码查看工具)的中文释义。
- 整理为公开学习笔记,保留技术内容与练习。
- 清理个人分析,完善阅读导航。
学完这一课,你能做到
- 说清 synchronized(内置同步关键字) 三种写法 各锁的是哪个对象
- 用
javap(字节码查看工具) 指出它编译成了什么,并解释为什么有 两个monitorexit(退出监视器指令) - 用 happens-before(先行发生关系) 解释 synchronized(内置同步关键字) 的可见性,顺便彻底想通「DCL(双重检查锁定) 为什么还要 volatile(可见性关键字)」
- 给出 2026 版 的锁升级答案:不背已经被移除的偏向锁,还能讲出老系统升级 JDK 21 时的真实坑
热身:先回忆第 3 课(2 分钟)
进入新主题前,先用 retrieval practice(主动回忆练习) 回看上一课:不看正文尝试回答,再展开核对。
1. volatile(可见性关键字) 的两个语义是什么?它不保证什么?
可见性 :对 volatile(可见性关键字) 变量的写 happens-before(先行发生关系) 后续对它的读。 禁止重排序 :约束与该变量读写相关的重排序。 不保证原子性。
2. volatile(可见性关键字) 变量上的 i++ 为什么还是不安全?
i++ 是「读—改—写」三步复合操作,中间可能被其他线程插进来,导致丢失更新。
3. 「A happens-before(先行发生关系) B」到底是什么意思?
A 的结果对 B 可见 —— 不是「A 在时间上先发生」。
4. DCL(双重检查锁定) 单例里的 volatile(可见性关键字) 防的是什么?
防的是 对象初始化与引用赋值之间的重排序 ,避免别的线程拿到一个「半初始化」的对象。(本课第四节会从另一个角度再看它一次。)
回忆不稳时标记具体缺口,可先读本课相关解释;不以热身全答对作为继续条件。也可以把上一课的60秒表达与本课的回答放在同一份笔记中。
一、为什么学这个
本课成果:在一个共享对象场景中判断谁会互斥、哪些写入可见,区分方法与代码块的同步实现,再完成90秒表达。先会读简单方法调用即可;旧版本优化作为背景,不影响先掌握同锁协议。
- 它是高频题 :JavaGuide 把「synchronized(内置同步关键字) 底层原理」「synchronized(内置同步关键字) 和 volatile(可见性关键字) 的区别」都标成了 ⭐️ 重点题[1],后一题正是第 3 课「追问」里的第 2 题。
- 它补上 volatile(可见性关键字) 的短板 :volatile(可见性关键字) 不保证原子性,synchronized(内置同步关键字) 两者都能保证[1]。
- 它有一个 2026 时效陷阱 :很多资料仍把「无锁 → 偏向锁 → 轻量级锁 → 重量级锁」当标准答案。本课程选择 Java 17/21 作为学习基线,具体行为须按实际运行版本核对,偏向锁在 JDK 17 默认关闭,到 JDK 21 连启动参数都不认了[5]。照着旧答案背,讲的其实是 JDK 8/11 时代的 JVM(Java 虚拟机)。
二、用法:先搞清「锁的是谁」
synchronized(内置同步关键字) 解决的是多个线程访问资源的同步问题:被它修饰的方法或代码块, 竞争同一对象的监视器时,任意时刻只能由一个线程持有该监视器并执行受保护代码 [1]。它靠的是 监视器 :
监视器(monitor(监视器)) :每个 Java 对象都有自己的监视器,可以先理解成「对象自带的一把锁」。JDK 官方文档写得很直白: 同一时刻只有一个线程能持有一个对象的监视器 [3]。
所以学 synchronized(内置同步关键字) 的第一步,是看清每种写法用的是 哪个对象 的监视器。JDK 文档列出了线程成为监视器持有者的三种方式[3]:
| 写法 | 用的是哪个对象的监视器 | 俗称 |
|---|---|---|
synchronized void m()实例方法 | 调用它的那个实例(this(当前实例)) | 对象锁 |
static synchronized void m()静态方法 | 这个类的 Class(类对象类型) 对象(Foo.class,也叫「类对象」) | 类锁 |
synchronized (obj) { … }代码块 | 括号里的那个对象 | 看括号里是谁 |
三个由此而来的高频追问(JavaGuide[1]):
- 静态同步方法和实例同步方法互斥吗? 不互斥。一个用的是
Class(类对象类型) 对象的监视器,一个用的是实例的监视器 —— 两把不同的锁。 - 为什么别写
synchronized("某个字符串")? JavaGuide 给的理由是字符串常量池有缓存功能:内容相同的字符串字面量可能是同一个对象,你以为锁的是自己的字符串,实际可能和别处的代码抢同一把锁。 - synchronized(内置同步关键字) 可重入吗? 可以。已经持有某个对象锁的线程,可以再次获取这把锁,所以一个 synchronized(内置同步关键字) 方法里调用同一对象的另一个 synchronized(内置同步关键字) 方法,不会自己把自己锁死。这里要分清:this(当前实例) 指向接收调用的对象,执行线程是另一回事;可重入依据当前线程是否已经持有监视器,与线程优先级无关。在这种嵌套调用中,内层返回而外层尚未退出时,外层仍持有监视器,其他竞争同一监视器的线程仍须等待。
💡 互斥只发生在「抢同一个监视器」的不同线程之间。 如果多个线程访问同一份共享状态,却使用不同的锁,就无法靠这些同步建立彼此的互斥。所以必须确认相关访问是否遵守同一把锁的规则。
三、底层:用 javap(字节码查看工具) 亲眼看一看
风险说明:以下只分析锁的获取与释放;异常退出不自动回滚业务修改。下面是本机用 JDK 21 编译、再用 javap(字节码查看工具) 反编译得到的真实输出[5],只截了关键行,← 后面是我加的注释。先看 同步代码块 :
public void method() {
synchronized (this) {
System.out.println("synchronized block");
}
}
演示/验证: 将片段置于类中编译,再查看下面对应字节码;只打印演示信息,不修改外部资源。
3: monitorenter ← 进入同步块:获取 this 的监视器
4: getstatic #7 // System.out
7: ldc #13 // "synchronized block"
9: invokevirtual #15 // println
12: aload_1
13: monitorexit ← 正常路径:释放监视器
14: goto 22
17: astore_2 ← 异常处理器从这里开始
18: aload_1
19: monitorexit ← 异常路径:同样释放监视器
20: aload_2
21: athrow ← 再把异常原样抛出去
22: return
Exception table:
from to target type
4 14 17 any ← 4~14 之间抛出任何异常,都跳到 17
17 20 17 any
演示/验证: 这是编译输出片段,不是待运行源码。沿正常和异常两条路径各跟踪一次监视器退出,不把条数推广为所有编译结果。
再看 同步方法 :方法本身的同步语义不靠 monitorenter(进入监视器指令) / monitorexit(退出监视器指令) 实现(方法内另写同步块时仍可能出现它们),只在方法标志上多了一个 ACC_SYNCHRONIZED(同步方法访问标志):
public synchronized void method();
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED ← 只多了这个标志
演示/验证: 这是方法签名与访问标志输出。对照实例同步方法和静态同步方法,二者使用同类访问标志但锁对象不同。
从输出里读出的四个结论
- 同步代码块 → 编译成
monitorenter(进入监视器指令) /monitorexit(退出监视器指令) 指令[1][5]。 - 为什么有两个
monitorexit(退出监视器指令) :本例一个在正常路径,一个在异常处理器里;指令条数并非所有编译结果固定相同。正常或异常退出时都会释放已获取的锁[1][5]。对比一下:ReentrantLock(可重入锁) 要靠你自己在try/finally里调unlock()[1],而 synchronized(内置同步关键字) 的释放由编译器兜底。 - 同步方法 → 方法上带
ACC_SYNCHRONIZED(同步方法访问标志) 标志,JVM(Java 虚拟机) 调用时自动加锁、解锁:实例方法锁实例,静态方法锁Class(类对象类型) 对象[1]。 - 本质相同 :两种写法都是在获取对象的监视器[1]。
💡 面试一句话: 「代码块靠
monitorenter(进入监视器指令) /monitorexit(退出监视器指令) 指令,方法靠ACC_SYNCHRONIZED(同步方法访问标志) 标志,本质都是获取对象的监视器;编译器还会在异常路径上生成monitorexit(退出监视器指令),所以抛异常也会释放锁。」
🛠️ 动手复现(5 分钟,推荐)
把上面的代码存成 SynchronizedDemo.java(外面套上 public class SynchronizedDemo { … }),在终端执行:
javac SynchronizedDemo.java
javap -c -v SynchronizedDemo.class
演示/验证: 在单独的示例目录编译与反编译,命令生成本地类文件;核对方法标志和代码块指令,不以输出长度判断掌握。
找到 monitorenter(进入监视器指令) 和两个 monitorexit(退出监视器指令)。能说一句「我自己用 javap(字节码查看工具) 看过」,比只背结论更扛得住追问。
四、它怎么保证三大特性(接上第 3 课的 happens-before(先行发生关系))
| 特性 | synchronized(内置同步关键字) 怎么保证 |
|---|---|
| 原子性 | 同一时刻只有一个线程能持有监视器,所以同步块里的「读—改—写」不会被其他 抢同一把锁 的线程打断[1][3]。 |
| 可见性 | 监视器锁规则 :对一个监视器的解锁 happens-before(先行发生关系) 随后对同一个监视器的加锁[2][4]。 |
| 有序性 | 同步块内部仍然可能重排序(第 3 课 DCL(双重检查锁定) 的 1→3→2 就发生在同步块里[1])。但拿同一把锁的线程只能在你解锁之后进来,看到的是完整结果[4]。 |
可见性是怎么「传」过去的
JDK 官方文档(java.util.concurrent 包说明)原话: 对一个监视器的解锁(退出 synchronized(内置同步关键字) 块或方法)happens-before(先行发生关系) 随后对同一个监视器的每一次加锁;又因为 happens-before(先行发生关系) 可以传递,一个线程解锁之前的所有操作,都 happens-before(先行发生关系) 任何线程随后加锁之后的所有操作 [4]。画成一条链:
A:count = 1 → 程序顺序 → A:解锁 m → 监视器锁规则 → B:加锁 m → 程序顺序 → B:读 count
三段首尾相接,按传递性:A 的 count = 1 happens-before(先行发生关系) B 的读,在此例没有其他写入的前提下, B 读到 1 。
回到第 3 课:DCL(双重检查锁定) 明明有 synchronized(内置同步关键字),为什么还要 volatile(可见性关键字)?
if (uniqueInstance == null) { // ① 第一次检查:在锁外面!
synchronized (Singleton.class) {
if (uniqueInstance == null) { // ② 第二次检查:在锁里面
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
演示/验证: 这是快速读取片段,不是完整类。核对引用声明与首次判空位置;安全发布的证明见后面的先行发生关系链。
关键在 ①:它 没有加锁 。监视器锁规则只在「解锁 → 随后对同一个监视器加锁」之间建立 happens-before(先行发生关系)[4],在 ① 处看到非空、直接返回的线程根本没加锁,这条规则保护不到它。第 3 课讲过,同步块里的 new Singleton() 可能被重排成「先赋值引用、后初始化」[1],于是 ① 可能看到一个 非空但没初始化完 的对象。volatile(可见性关键字) 补上的正是这条缺失的 happens-before(volatile(可见性关键字) 变量规则)。
💡 复述要点: 「synchronized(内置同步关键字) 只保护拿同一把锁的线程。DCL(双重检查锁定) 的第一次检查在锁外,所以还得靠 volatile(可见性关键字)。」
volatile(可见性关键字) 怎样补上这条关系: DCL(双重检查锁定) 希望对象创建后,读取线程在第一次检查见非空时可以直接返回。把实例引用声明为 volatile(可见性关键字),就可以利用“对 volatile(可见性关键字) 变量的写 happens-before(先行发生关系) 随后对同一变量的读”[4]:
创建者:初始化字段 → 程序顺序 → 创建者:写 volatile(可见性关键字) 引用 → volatile(可见性关键字) 变量规则 → 读取者:读同一引用 → 程序顺序 → 读取者:读取字段
安全发布 在这里指:读取者通过这个 volatile(可见性关键字) 引用拿到对象时,也能看到创建者在发布引用之前完成的初始化写入。它保证的不只是引用非空;由程序顺序和传递性,前面的初始化结果也可见[4]。这个结论针对发布前的写入,对象发布之后的其他修改仍需各自满足同步要求。
可见性保证不靠等待: 例如构造时将普通字段 port 设为 8080,再通过 volatile(可见性关键字) 引用发布,且此后不再修改该字段,那么读取者通过 volatile(可见性关键字) 读取拿到这个对象后,第一次读取 port 就保证得到 8080。依据是上面的 happens-before(先行发生关系) 链[4];缺少同步关系时,单纯等待一段时间也不提供这种保证。
happens-before(先行发生关系) 常用规则一览(第 3 课预告的内容)
| 规则 | 内容 |
|---|---|
| 程序顺序规则 | 同一个线程里,写在前面的操作 happens-before(先行发生关系) 写在后面的操作 |
| 监视器锁规则 | 对一个监视器的解锁 happens-before(先行发生关系) 随后对同一个监视器的加锁 (本课主角) |
| volatile(可见性关键字) 变量规则 | 对一个 volatile(可见性关键字) 变量的写 happens-before(先行发生关系) 随后对同一个变量的读(第 3 课) |
| 线程启动规则 | 调用线程的 start() happens-before(先行发生关系) 这个新线程里的每一个动作 |
| 线程终止规则 | 一个线程里的所有动作 happens-before(先行发生关系) 其他线程从它的 join() 成功返回 |
| 传递性 | A happens-before(先行发生关系) B,且 B happens-before(先行发生关系) C,那么 A happens-before(先行发生关系) C |
依据 JDK 官方文档[4] 与 JavaGuide[2]。这份表不是全部:JavaGuide 提醒还有线程中断等规则;java.util.concurrent 里的并发容器、线程池、Future(异步结果句柄) 等也各自提供了 happens-before(先行发生关系) 保证,讲线程池时会用到。
五、锁升级:2026 版答法
为什么要「升级」:重量级锁很贵
早期的 synchronized(内置同步关键字) 是 重量级锁 :监视器依赖操作系统的互斥量(Mutex Lock(显式锁接口)),挂起、唤醒线程都要请操作系统出手,得从用户态切换到内核态,这个切换比较耗时[1]。所以从 JDK 6 开始,JVM(Java 虚拟机) 为 synchronized(内置同步关键字) 加了一系列优化来减少锁操作的开销[1]。先认识两个会用到的词:
- CAS(Compare And Swap,比较并交换) :拿「预期值」和变量当前值比较,相等才写入新值。整个动作靠一条 CPU(处理器) 原子指令完成;失败的线程不会被挂起,可以重试[1]。
- 自旋 :抢锁失败后先原地循环重试一会儿,而不是马上挂起,用一点 CPU(处理器) 换掉昂贵的挂起和唤醒。但如果长时间抢不到,空转会白白消耗大量 CPU(处理器)[1]。
JDK 8 时代的经典答案:四种状态
JavaGuide 的描述:锁有四种状态,依次是 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 ,会随着竞争变激烈而逐渐升级[1]。
| 状态 | 适用情况 | 大致做法 |
|---|---|---|
| 无锁 | 没有线程持有 | — |
| 偏向锁 | 一直只有一个线程反复进入 | 提升单线程反复进入同步块的性能[1][6] |
| 轻量级锁 | 有竞争但很轻 | 用 CAS(比较并交换) 加锁,不用让操作系统挂起线程[1] |
| 重量级锁 | 竞争激烈 | 依赖操作系统互斥量,抢不到锁的线程会被挂起[1] |
JavaGuide 自己也说锁升级的细节「面试也很少问到」[1],所以本课只要求讲清状态和版本变化,不抠底层数据结构。
2026 的现实:偏向锁已经没了
| JDK 版本 | 偏向锁 | 证据 |
|---|---|---|
| 8 / 11 | 默认开启(UseBiasedLocking = true) | 本机实测[5] |
| 15 | 默认关闭,并标记为废弃(JEP 374,OpenJDK 的官方改进提案) | JavaGuide[1] · JEP 374[7] |
| 17 | 默认关闭;手动打开仍会生效,但 JVM(Java 虚拟机) 警告「deprecated in version 15.0」 | 本机实测[5] · JDK 17 手册[6] |
| 18 | 彻底废弃,命令行也打不开了 | JavaGuide[1] |
| 21 / 25 | 连参数都不认了 :带上 -XX:+UseBiasedLocking,JVM(Java 虚拟机) 报 Unrecognized VM option,直接启动失败 | 本机实测[5] · JDK 21 手册[6] |
✅ 在 JDK 17 默认配置和 JDK 21 上,可按无锁、轻量级锁、重量级锁理解这组状态 ,JDK 17 仍允许显式启用偏向锁,JDK 21 已移除它[1][5]。
为什么去掉偏向锁
JEP 374 官方原文给出了两方面理由(已存档并核对)[7]:
- 收益变小了 :偏向锁适合同一个线程反复对同一个对象进行无竞争同步,减少后续获取监视器时的原子指令开销。过去受益明显的
Hashtable(旧式同步哈希表)、Vector等老集合使用模式减少;现代应用更多选择适合单线程或多线程场景的新集合,原子指令的成本也发生变化。出现竞争时,撤销偏向又有额外成本,因此性能收益已不像过去明显。 - 维护成本太高 :偏向锁往 JVM(Java 虚拟机) 的同步子系统里塞了大量复杂代码,还侵入其他组件,难以理解和重构。
面试话术:面试官用旧说法提问时
「经典教材把 JDK 8 的锁状态概括为无锁、偏向锁、轻量级锁、重量级锁。偏向锁从 JDK 15 起默认关闭,JDK 18 移除;原因是现代应用收益下降、竞争时撤销有额外开销,而且 JVM(Java 虚拟机) 实现复杂、维护成本高。JDK 17 默认配置和 JDK 21 上,按无锁、轻量级锁、重量级锁回答;JDK 17 仍可手动启用偏向锁。」
先完整给出经典答案(说明你学过),再补一句版本变化(说明你跟得上)。不必去纠正面试官, 把时间线讲清楚 就够了。
能落到项目:JDK 升级的真实坑
若项目准备从旧版本升级,需要核对启动参数兼容性。本机实测:启动参数里带着 -XX:+UseBiasedLocking 或 -XX:BiasedLockingStartupDelay=0 的老系统,在 JDK 17 上只是打印废弃警告;换到 JDK 21, JVM(Java 虚拟机) 直接启动失败 [5]。
六、synchronized(内置同步关键字) 和 volatile(可见性关键字) 的区别(第 3 课追问第 2 题)
JavaGuide 的定性是:两者 互补 ,不是对立[1]。JDK 官方文档说得更精确: volatile(可见性关键字) 读写有与进出监视器相似的内存效果,但不涉及互斥加锁 [4]。
| volatile(可见性关键字) | synchronized(内置同步关键字) | |
|---|---|---|
| 能用在哪 | 修饰字段,不用于局部变量 | 方法、代码块 |
| 复合操作原子性 | 不自动保证 | 相关访问遵守同一锁协议时可保证 |
| 可见性 | 保证(volatile(可见性关键字) 变量规则) | 保证(监视器锁规则) |
| 有序性 | 约束相关重排序 | 对拿同一把锁的线程有保证 |
| 竞争时会阻塞吗 | 不会 | 会;竞争激烈时频繁阻塞和唤醒 |
| 开销 | 成本依赖硬件、访问模式与优化,需测量 | 获取与释放监视器有成本,竞争程度会影响性能 |
表格内容依据 JavaGuide[1]。
怎么选 [1]:只需要可见性(状态标志位、DCL(双重检查锁定) 里的实例引用)→ volatile(可见性关键字);需要复合操作的原子性(i++、先检查后执行)→ synchronized(内置同步关键字)、Lock(显式锁接口) 或原子类。
七、检验:7 道题,先回答,再展开核对
先别往上翻,凭记忆作答。第 4 题混进了第 3 课的内容:新旧知识交叉着练,记得更牢。
static synchronized方法用的是哪个对象的监视器?
- A. 调用它的实例对象
- B. 当前执行它的线程
- C. 该类对应的类对象
- D. 方法区里的字节码
查看答案与解释
答案:C
静态同步方法用的是这个类的 Class(类对象类型) 对象的监视器(JDK 文档:For objects of type Class(类对象类型), by executing a static synchronized(内置同步关键字) method of that class)。所以它和实例同步方法 不互斥 :两把不同的锁。
- 同步代码块编译后,为什么有两个
monitorexit(退出监视器指令)?
- A. 异常时也能释放锁
- B. 支持同一线程重入
- C. 释放实例锁和类锁
- D. 唤醒两个等待线程
查看答案与解释
答案:A
一个在正常路径(javap(字节码查看工具) 输出里标号 13 那条),一个在异常处理器里(标号 19 那条:异常表的 any 捕获所有异常 → 释放锁 → athrow 重新抛出)。可重入是真的,但和「两个 monitorexit(退出监视器指令)」无关。
- 线程 A 在
synchronized (m)里改了count,退出后线程 B 进入同一个m的同步块。B 一定能读到新值,关键靠的是哪条规则?
- A. 程序顺序规则
- B. 监视器锁规则
- C. 线程启动规则
- D. 线程终止规则
查看答案与解释
答案:B
解锁 happens-before(先行发生关系) 随后对同一个监视器的加锁。程序顺序规则只管同一个线程内部;它和传递性一起,把「A 的写 → A 解锁 → B 加锁 → B 的读」连成一条链,但跨线程的那一环靠的是监视器锁规则。
- DCL(双重检查锁定) 单例里已经有 synchronized(内置同步关键字) 了,为什么实例引用还必须加 volatile(可见性关键字)?
- A. 同步块不保证原子
- B. 为了减少加锁次数
- C. 防止反射破坏单例
- D. 第一次检查在锁外
查看答案与解释
答案:D
第一次 if 没有加锁,监视器锁规则保护不到它;同步块里的 new 可能被重排成「先赋值、后初始化」,锁外的线程就可能拿到半初始化的对象。「减少加锁次数」是 第一次检查本身 的目的,不是 volatile(可见性关键字) 的目的。
- 在 JDK 21(默认配置)上,synchronized(内置同步关键字) 的锁可能处于哪几种状态?
- A. 无锁、轻量级、重量级
- B. 偏向、轻量级、重量级
- C. 无锁、偏向锁、重量级
- D. 只有重量级这一种状态
查看答案与解释
答案:A
偏向锁 JDK 15 默认关闭、JDK 18 移除,JDK 21 上只剩无锁 → 轻量级锁 → 重量级锁。b、c 都带着偏向锁,那是 JDK 8/11 的情况。
- 把一个启动参数里带
-XX:+UseBiasedLocking的老系统直接放到 JDK 21 上启动,会怎样?
- A. 打印警告后正常启动
- B. 静默忽略该参数启动
- C. 偏向锁照常开启生效
- D. 虚拟机报错启动失败
查看答案与解释
答案:D
本机实测:JDK 21 报 Unrecognized VM option 'UseBiasedLocking',JVM(Java 虚拟机) 创建失败,退出码 1。a 是 JDK 17 的表现(打印废弃警告后正常启动),同一个参数,版本不同结果完全不同。
- OpenJDK 去掉偏向锁,主要理由是什么?
- A. 有线程安全漏洞且太慢
- B. 收益变小且维护成本高
- C. 被虚拟线程完全取代了
- D. 与新垃圾收集器冲突了
查看答案与解释
答案:B
JEP 374 官方原文的理由:① 现代应用的收益下降,竞争时还有撤销偏向的额外成本;② 同步子系统及其他 HotSpot 组件被复杂实现牵连,维护与后续设计改动困难。撤销某个对象的偏向状态与 JDK 默认禁用、最终移除整个机制是不同层面的事。
八、把它讲出来:90 秒脱稿演练
面试原题: 「说说 synchronized(内置同步关键字) 的锁对象、实现形式、可重入和可见性边界;升级版本时应注意什么?」 合上资料,在自己的笔记中记录回答(或先录音、再整理成文字),写完再对照要点自评。
参考结构(记顺序和关键词,别背逐字稿):
① 作用和用法:互斥;三种写法分别锁谁
② 底层:对象监视器;代码块用monitorenter(进入监视器指令) /monitorexit(退出监视器指令),方法用ACC_SYNCHRONIZED(同步方法访问标志)
③ 三大特性:原子性靠互斥,可见性靠监视器锁规则
④ 锁升级(2026 版):历史优化与版本兼容:偏向锁 Java 15 默认关闭、Java 18 移除;Java 17 显式开启仍可能生效,Java 21 不接受旧启用参数
⑤ 落到项目:JDK 升级时的偏向锁参数坑,或 DCL(双重检查锁定) 为什么还要 volatile(可见性关键字)
✍️ 写下你的回答
练习:请在自己的笔记中写出答案,再展开参考内容核对。
- 说出三种写法分别锁哪个对象(实例 / Class(类对象类型) 对象 / 括号里的对象)
- 说出底层是对象的监视器,同一时刻只有一个线程能持有
- 讲清字节码:代码块的进入与退出监视器指令(含异常路径),以及同步方法访问标志
- 用监视器锁规则(解锁 happens-before(先行发生关系) 后续加锁)解释可见性
- 说明偏向锁的适用场景与版本变化,区分 Java 17 默认配置和显式启用情况
- 主动落到实际:JDK 升级时的偏向锁参数坑,或 DCL(双重检查锁定) 为什么还需要 volatile(可见性关键字)
作业复盘:先说准“锁谁、约束谁”(3 分钟)
复习时重点检查锁对象、同锁访问范围、可重入与可见性。下面用对照表与反例把这些边界连起来,再尝试新的场景。
| 写法 | 锁哪个对象 | 字节码层面 |
|---|---|---|
| 实例同步方法 | 接收调用的实例,即 this(当前实例) | 方法访问标志 ACC_SYNCHRONIZED(同步方法访问标志) |
| 静态同步方法 | 声明该方法的类对应的 Class(类对象类型) 对象 | 方法访问标志 ACC_SYNCHRONIZED(同步方法访问标志) |
| 同步代码块 | 括号中表达式指向的对象 | monitorenter(进入监视器指令) / monitorexit(退出监视器指令) 指令 |
方法通过访问标志让 JVM(Java 虚拟机) 处理同步;代码块通过进入/退出监视器的指令处理同步。对象对应关系见 JDK 文档[3],两种字节码形式见本机 javap(字节码查看工具) 实验[5]。示例检查:若代码块写作 synchronized (lock),应指出锁的是 lock 指向的对象,并在字节码中找监视器指令。
- 锁谁: synchronized(内置同步关键字) 用于实例方法、静态方法和代码块,不能修饰变量。代码块括号中的表达式要指向对象,取得的是该对象的监视器[3]。
- 约束谁: 同一时刻只有一个线程持有同一个对象的监视器。其他不拿这把锁的线程仍可能访问字段;锁没有把变量变成“谁都不能改”[3]。
- 谁能再次进入: 可重入指 当前持有监视器的线程,可以再次获取同一个监视器 。同一线程嵌套调用同一实例的同步方法就是一个例子;关键是线程身份与锁对象,不是“实例调用方法”,也不是获取锁更快[1]。
- 谁能看见: 一次解锁 happens-before(先行发生关系) 随后对 同一监视器的每次加锁 ,不只下一个线程;这里保证先前写入的可见性,不表示获取锁更快,也不表示数据此后不会再被修改[4]。
关键反例:A 已经持锁,C 不加这把锁,仍然能修改共享字段
锁约束获取同一监视器的线程,不会自动拦截所有字段访问。 要靠这把锁保证复合操作的原子性,相关访问必须遵守同一把锁的协议[3]。
- A 进入同步块,持有监视器,将 count 设为 1。
- C 不获取这个监视器,直接把 count 改成 2。
- C 通知修改完成时,A 仍在同步块内持锁;修改已经发生。
可运行 Demo 与内置验证:在项目根目录执行 java reference/MonitorScopeDemo.java。本机 JDK 25.0.2 验证通过:A 仍持有监视器为 true,同时 count 已是 2;等待超过 5 秒或结果不符会报错。
Demo 使用一个“完成信号”(CountDownLatch(倒计时协调器))安排顺序并保证结果可观察,等待信号不会释放 A 持有的监视器。本实验验证该监视器不会阻挡 C 的无锁写入;它不表示普通字段在没有其他同步措施时也有可见性保证。
修复练习: 要让 C 的修改也受 A 所用的锁约束,应把 C 的修改放在哪里?先回答,再回来让我检查。
不同方法共用锁的例子: 同一个 Counter 实例中,若 increment() 已是实例同步方法,get() 也可以声明为实例同步方法,两者都会获取这个实例的 this(当前实例) 监视器[3]:
public synchronized int get() {
return count;
}
Demo/验证思路: 两个线程共享同一个 Counter 实例;A 已进入 increment() 且尚未释放 this(当前实例) 的监视器时,C 进入 get() 必须等待。将两个方法合并不是必要条件;这里是两处相关访问各自遵守同一把锁的规则。
迁移练习:A 持有对象 m 的监视器时,B 要进入以 m 为锁的同步块,C 要进入以另一个对象 n 为锁的同步块。谁因 A 持有 m 而无法进入?
B 无法取得 m 的监视器,须等 A 释放。C 不会因为 m 被 A 持有而受阻;它能否取得 n,要看 n 自身的持有情况。判断互斥先比较锁对象的身份,不看变量名或代码是否相似。
隔日复测:A 在 m 的同步块里写入 count = 1 后退出;B、C 此后依次取得 m 并读取 count。没有其他写入时,谁能读到 1?
B、C 都能。A 的解锁 happens-before(先行发生关系) 随后对 m 的每次加锁,再由程序顺序和传递性把写入与读取连起来;可见性不是只传给下一个线程。
主读:JDK Object(对象类) 的三种监视器持有方式,以及 Memory Consistency Properties 的监视器规则。对应存档见 官方摘录 C、D 节;复习用 并发速查表。记录判断理由,再按需要回看字节码与锁优化。
九、面试官接着会追问什么(提前打个照面)
- synchronized(内置同步关键字) 是可重入的吗? (本课第二节已讲)
wait()/notify()为什么必须在 synchronized(内置同步关键字) 里调用? 提示:JDK 文档要求调用线程必须是该对象监视器的持有者,否则抛IllegalMonitorStateException[1][3]。- synchronized(内置同步关键字) 和 ReentrantLock(可重入锁) 有什么区别? JavaGuide 的 ⭐️ 题:等锁时可以被中断、可以设成公平锁(有竞争时优先让等得最久的线程拿锁)、能分组唤醒等待的线程、支持超时获取[1]。之后讲 ReentrantLock(可重入锁) 的底层原理时展开。
- JDK 6 还做了哪些锁优化? JavaGuide 还列了锁消除、锁粗化、适应性自旋[1]。本课没有展开,因为还没找到能存档的官方定义,已记入待补清单。
⏩ 拓展:后续专题:虚拟线程遇上 synchronized(内置同步关键字)
虚拟线程 是 JDK 21 正式提供的轻量级线程:由 Java 运行时(而不是操作系统)调度,通常跑在一小组平台线程上,这些平台线程叫 载体线程 (carrier thread(线程))[9]。
本机实测[5]:只给 1 个载体线程,让 10 个虚拟线程各自在 自己的 锁里 sleep 100 ms(互相之间没有锁竞争):
| synchronized(内置同步关键字) | ReentrantLock(可重入锁) | |
|---|---|---|
| JDK 21 | ≈ 1054–1061 ms (10 个线程被排成了一队) | ≈ 117–118 ms |
| JDK 25 | ≈ 115–119 ms | ≈ 115–116 ms |
也就是说,在 JDK 21 上,虚拟线程在 synchronized(内置同步关键字) 里阻塞时会占着载体线程不放,这叫 钉住 (pinning)。JDK 21 源码里,虚拟线程无法让出载体线程的原因之一就写着 MONITOR(持有监视器)[9]。到 JDK 25 这个现象消失了;具体机制和从哪个版本开始修复,待核实官方 JEP(Java 增强提案)后再展开。 现在只需要知道有这回事。
课后复习:第4、5课对话复盘(34个去重问答),含技术结论、理解转折与易混点。
十、主来源
- 主读 :Java 21 Object(对象类)的监视器持有规则,只读三种持有方式及单持有者说明;再读下方官方内存一致性规则。官方摘录C、D节有中文对照。JavaGuide 用于补充例子,正式保证以官方来源为准。
- 官方原文 :JDK
java.util.concurrent包说明 · Memory Consistency Properties[4]。happens-before(先行发生关系) 规则最权威的简明版本,一屏就能读完。 - 速查表 :JMM(Java 内存模型) · volatile(可见性关键字) · synchronized(内置同步关键字) 速查(第 3–4 课浓缩版,可打印,面试前 10 分钟过一遍)。
引用
JavaGuide《Java 并发常见面试题总结(中)》:synchronized(内置同步关键字) 关键字、synchronized(内置同步关键字) vs volatile(可见性关键字)、ReentrantLock(可重入锁)、CAS(比较并交换)、偏向锁为什么被废弃。存档
reference/javaguide-concurrent-questions-02.mdJavaGuide《JMM(Java 内存模型)详解》:happens-before(先行发生关系) 原则与常见规则。存档
reference/javaguide-jmm.mdJDK 21 API 文档 ·
Object.notify():监视器的持有者、三种持有方式、IllegalMonitorStateException。摘自本机 JDK 21src.zip,存档reference/jdk-official-docs-excerpts.mdC 节JDK 21 API 文档 ·
java.util.concurrent· Memory Consistency Properties:happens-before(先行发生关系) 规则官方原文。存档同上 D 节本机实验记录(2026-09-28)(本地资料):javap(字节码查看工具) 字节码、偏向锁参数在 JDK 8/11/17/21/25 上的表现、虚拟线程钉住对比。存档
reference/jdk-lab-synchronized-2026-09-28.mdJDK
java命令手册:JDK 17 版(Deprecated Java Options ·-XX:+UseBiasedLocking)、JDK 21 版(Removed Java Options 的定义)。摘自本机 JDK 的man/man1/java.1,存档同 [3] A、B 节JEP 374: Deprecate and Disable Biased Locking:OpenJDK 官方提案,2026-09-28 已核对并存档正文。支持 JDK 15 默认禁用并废弃选项、收益下降与维护复杂的理由;最终移除版本另见 JavaGuide [1],JDK 17/21 参数表现见实验 [5]
JDK 21 API 文档 ·
Thread(Virtual threads),以及 JDK 21 源码jdk.internal.vm.Continuation.Pinned。存档同 [3] F、G 节
下一课: 线程池:7 个参数、执行流程、拒绝策略与参数配置。
相关: 第 3 课 volatile(可见性关键字) 与 JMM(Java 内存模型) · 学习导读:通用学习路线 · 学习路线速查 · 并发速查表
课后问答:理解是怎样变化的
问题与结论默认可见。展开后可看理解过程;以下根据真实交流整理,并非逐字稿。
三种写法分别锁谁?
最终结论: 实例同步方法锁接收调用的 this(当前实例);静态同步方法锁声明该方法的 Class(类对象);同步代码块锁括号表达式求值得到的对象。
展开理解过程与易混点
纠正与解释: 锁属于对象,不属于变量名、方法名或线程;“修饰变量”不是这三种写法之一。
最终结论: 实例同步方法锁接收调用的 this(当前实例);静态同步方法锁声明该方法的 Class(类对象);同步代码块锁括号表达式求值得到的对象。
现有复盘未逐条保留中间追问,本页不补写对话。
同一个方法,不同实例调用,要互相等待吗?
最终结论: 若是实例同步方法,且两个实例确实不同,仅凭方法相同不需要互相等待。两个不同引用指向同一个对象,则仍竞争同一个 monitor(监视器)。
展开理解过程与易混点
最初理解(归纳): 曾把锁的单位理解成变量;需要改为判断引用指向的对象。
纠正与解释: 变量名相同不代表同锁;变量名不同也不代表不同锁。
最终结论: 若是实例同步方法,且两个实例确实不同,仅凭方法相同不需要互相等待。两个不同引用指向同一个对象,则仍竞争同一个 monitor(监视器)。
现有复盘未逐条保留中间追问,本页不补写对话。
静态同步方法和锁住同一类对象的代码块,是否互斥?
最终结论: 不同执行线程会竞争同一个 monitor(监视器),所以需要互斥。锁对象由表达式或方法种类决定,字节码形式由“方法还是代码块”决定。
展开理解过程与易混点
纠正与解释: synchronized(内置同步关键字)代码块即使锁 Class(类对象),也不会因此变成同步方法的字节码形式。
最终结论: 不同执行线程会竞争同一个 monitor(监视器),所以需要互斥。锁对象由表达式或方法种类决定,字节码形式由“方法还是代码块”决定。
现有复盘未逐条保留中间追问,本页不补写对话。
同步方法和代码块,字节码有何区别?
最终结论: 实例与静态同步方法都使用 ACC_SYNCHRONIZED(同步方法访问标志)。同步代码块使用 monitorenter(进入监视器指令)和 monitorexit(退出监视器指令)。
展开理解过程与易混点
最初理解(归纳): 曾把实例同步方法归到同步代码块使用的指令。
纠正与解释: 不能把实例同步方法归到代码块指令;访问标志也不是源代码关键字。方法内若另写同步块,仍可出现对应指令。
最终结论: 实例与静态同步方法都使用 ACC_SYNCHRONIZED(同步方法访问标志)。同步代码块使用 monitorenter(进入监视器指令)和 monitorexit(退出监视器指令)。
现有复盘未逐条保留中间追问,本页不补写对话。
抛异常后会释放吗?为什么示例有两个退出指令?
最终结论: 正常退出和异常退出都要释放已获取的 monitor(监视器)。本课简单同步块编译示例分别为正常路径和异常路径生成 monitorexit(退出监视器指令)。
展开理解过程与易混点
纠正与解释: 不是一个执行路径连续释放两次,也不能把“两条指令”当成所有同步块固定不变的数量。
最终结论: 正常退出和异常退出都要释放已获取的 monitor(监视器)。本课简单同步块编译示例分别为正常路径和异常路径生成 monitorexit(退出监视器指令)。
现有复盘未逐条保留中间追问,本页不补写对话。
reentrancy(可重入)究竟是谁重新进入?
最终结论: 当前持有 monitor(监视器)的同一个 thread(线程)可以再次获取它。内层返回而外层仍持有时,其他竞争者仍须等待。
展开理解过程与易混点
最初理解(归纳): 曾用“同一实例优先”和效率解释可重入。
纠正与解释: 关键是“同一线程、同一监视器”,不是“同一实例优先”,也不是为减少开销而给别的线程插队。
最终结论: 当前持有 monitor(监视器)的同一个 thread(线程)可以再次获取它。内层返回而外层仍持有时,其他竞争者仍须等待。
现有复盘未逐条保留中间追问,本页不补写对话。
我持有锁,能阻止完全不加锁的访问吗?
最终结论: 不能。互斥约束的是竞争同一 monitor(监视器)的访问。另一个执行者直接访问共享字段,不会因为你持锁就自动被拦住。
展开理解过程与易混点
最初理解(归纳): 曾认为持锁能阻止所有访问,包括不加锁的访问。
纠正与解释: 修复要求相关访问遵守共同同步协议,不要求放在同一个方法里。例:同一实例的读方法和写方法都同步。
最终结论: 不能。互斥约束的是竞争同一 monitor(监视器)的访问。另一个执行者直接访问共享字段,不会因为你持锁就自动被拦住。
现有复盘未逐条保留中间追问,本页不补写对话。
可见性只保证紧接着进入的一个线程吗?
最终结论: 不是。对一个 monitor(监视器)的解锁 happens-before(先行发生于)随后对同一监视器的每次加锁。结合程序顺序与传递性,可建立写入到后续读取的可见性关系。
展开理解过程与易混点
最初理解(归纳): 曾把可见性理解为更快获取锁。
纠正与解释: 它不是“更快获取锁”。换一把锁且无其他同步关系,不能照搬这一保证;若中间还有更新,不能机械地说永远读到第一次写入的值。
最终结论: 不是。对一个 monitor(监视器)的解锁 happens-before(先行发生于)随后对同一监视器的每次加锁。结合程序顺序与传递性,可建立写入到后续读取的可见性关系。
现有复盘未逐条保留中间追问,本页不补写对话。
DCL(双重检查锁定)为什么还需要 volatile(可见性关键字)?
最终结论: 第一次判空在锁外;看到非空直接返回的一方没有获取初始化方释放的锁。引用需要安全发布,让读取方同时看到发布之前的初始化结果。volatile(可见性关键字)的写读关系补上这条可见性链。
展开理解过程与易混点
纠正与解释: 不只是“读到最新引用”,还包括初始化状态。缺少同步保证,等待一会儿也不会自动变成有保证。
最终结论: 第一次判空在锁外;看到非空直接返回的一方没有获取初始化方释放的锁。引用需要安全发布,让读取方同时看到发布之前的初始化结果。volatile(可见性关键字)的写读关系补上这条可见性链。
现有复盘未逐条保留中间追问,本页不补写对话。
spin(自旋)为什么有时划算,有时浪费?
最终结论: 短暂等待时,保持执行并反复尝试可能省去挂起与唤醒的成本;等待太久会持续占用 CPU(处理器)时间。是否合适取决于竞争和等待情况。
展开理解过程与易混点
纠正与解释: 不能把自旋说成免费,也不能用它解释所有锁优化。
最终结论: 短暂等待时,保持执行并反复尝试可能省去挂起与唤醒的成本;等待太久会持续占用 CPU(处理器)时间。是否合适取决于竞争和等待情况。
现有复盘未逐条保留中间追问,本页不补写对话。
biased locking(偏向锁)优化什么,为什么退出历史舞台?
最终结论: 面向同一个执行线程反复获取同一对象锁的情况,在满足条件时减少重复同步操作。收益随工作负载变化,加上撤销等机制与维护复杂度,促使其禁用并移除。Java 15 默认禁用并弃用;Java 17 可显式启用但有弃用警告;Java 18 移除,Java 21 的旧启用参数不再被接受。
展开理解过程与易混点
纠正与解释: 不是竞争激烈后“等工作结束再把这个功能关掉”的简单流程。版本兼容风险与锁竞争机制要分开讲。
最终结论: 面向同一个执行线程反复获取同一对象锁的情况,在满足条件时减少重复同步操作。收益随工作负载变化,加上撤销等机制与维护复杂度,促使其禁用并移除。Java 15 默认禁用并弃用;Java 17 可显式启用但有弃用警告;Java 18 移除,Java 21 的旧启用参数不再被接受。
现有复盘未逐条保留中间追问,本页不补写对话。