「Java 进阶之路」系列 Day04
写在前面
上一篇讲完 JMM 和 happens-before,这篇顺理成章地把 volatile、synchronized、final 这三个并发关键字,和"原子性、可见性、有序性"三大特性对应起来,讲清楚谁解决了什么问题、谁又管不了什么------这是面试里最常被拿来连环追问的一组知识点,背熟表格不如真的理解底层原理。
一、先把三大特性的含义钉死
| 性质 | 含义 | 被破坏的典型场景 |
|---|---|---|
| 原子性 | 一个操作要么全做,要么全不做,不能被中途打断 | i++(读-改-写三步)、复合的"判断再更新" |
| 可见性 | 一个线程改了值,其他线程能立刻看到最新值 | CPU 多级缓存,导致各线程读到自己缓存里的旧值 |
| 有序性 | 代码实际执行顺序与编写顺序一致(或效果等效) | 编译器/CPU 为了优化对指令做重排序 |
接下来逐个关键字看它们分别解决了这三者中的哪几个。
二、synchronized:三大特性全都要
底层原理:Monitor(管程)
synchronized 依赖对象头里的 Monitor(监视器锁) 实现互斥,JVM 通过 monitorenter/monitorexit 两条字节码指令获取和释放锁。Monitor 内部维护三个关键结构:
- Owner:当前持锁的线程
- EntryList :抢锁失败、排队等待的线程(对应线程
BLOCKED状态) - WaitSet :调用了
wait()被挂起的线程(对应线程WAITING状态)
三种使用方式
java
// ① 修饰实例方法 ------ 锁是当前实例 this
public synchronized void method() { ... }
// ② 修饰静态方法 ------ 锁是当前 Class 对象
public static synchronized void staticMethod() { ... }
// ③ 同步块 ------ 锁是括号内指定的对象(推荐,粒度最小,性能最好)
synchronized (lock) { ... }
锁升级:从"免费"到"昂贵"
JDK 6 之后,synchronized 不是一上来就用最重的操作系统级锁,而是有一条升级路径:
注意:锁只能升级,不能降级(偏向锁被撤销的特殊情况除外)。这也是为什么"低并发场景下 synchronized 性能其实还不错"------大部分时候根本升级不到重量级锁那一步。
可重入性
同一个线程可以重复获取自己已经持有的锁,Monitor 内部靠一个计数器记录重入次数:
java
synchronized (this) {
synchronized (this) { // 不会死锁,计数器 +1,退出时再 -1
...
}
}
synchronized 为什么三大特性全占
- 原子性:同步块内的代码在任意时刻只有一个线程能执行,不会被打断。
- 可见性:释放锁前,会把工作内存的修改刷回主内存;获取锁后,会从主内存重新加载最新值。
- 有序性 :happens-before 的"管程锁定规则"(上一篇 Day03 提到的规则 2)保证了
unlock一定 happens-before 后续的lock。
最容易踩的坑:锁错对象
java
// ❌ 字符串常量池可能被别处共享,锁不住
String lock = "abc";
synchronized (lock) { ... }
// ❌ 每次都是新对象,每个线程各锁各的,完全没保护效果
synchronized (new Object()) { ... }
// ✅ 锁住共享的、不会变的对象
private final Object lock = new Object();
synchronized (lock) { ... }
三、volatile:只管可见性和有序性,不管原子性
volatile 是比 synchronized 轻量得多的同步手段,靠**内存屏障(Memory Barrier)**实现两件事:
- 可见性 :写
volatile变量会立即刷新到主内存;读volatile变量会跳过工作内存缓存,直接从主内存重新加载。 - 禁止指令重排:
arduino
写 volatile 变量:
[前面的普通写] → StoreStore 屏障 → [volatile 写] → StoreLoad 屏障
读 volatile 变量:
[volatile 读] → LoadLoad 屏障 → LoadStore 屏障 → [后面的普通读/写]
反复强调的一个坑:volatile 不保证原子性
java
volatile int i = 0;
// ❌ i++ 实际是"读 → 加 → 写"三步,不是原子操作
// 两个线程并发执行 i++,依然可能丢失更新(比如都读到 5,都写回 6,实际应该是 7)
i++;
// ✅ 需要原子性,换 AtomicInteger(CAS 实现)
AtomicInteger i = new AtomicInteger(0);
i.incrementAndGet();
典型使用场景
| 场景 | 说明 |
|---|---|
| 状态标志位 | volatile boolean running = true;,用来控制线程安全退出 |
| DCL 单例 | 防止 new 对象时的指令重排(Day03 讲过的经典坑) |
| 一写多读 | 只有一个线程写、多个线程只读,volatile 足够,不需要加锁 |
volatile vs synchronized 一张表看清区别
volatile |
synchronized |
|
|---|---|---|
| 原子性 | ❌ | ✅ |
| 可见性 | ✅ | ✅ |
| 有序性 | ✅(内存屏障) | ✅(happens-before) |
| 会不会挂起线程 | 不会 | 竞争激烈时会 |
| 性能开销 | 轻量 | 相对更重 |
| 适用场景 | 简单标志位、状态变量 | 复合操作、临界区保护 |
四、final:管可见性,靠的是"安全发布"
final 的并发语义容易被忽略------它保证的是安全发布(Safe Publication) :对象构造完成后,它的 final 字段对所有线程都是可见的、完整初始化好的,不会被别的线程读到"半成品"。
JMM 具体做了两件事:
- 写 final 字段的重排序规则 :构造函数里对
final字段的赋值,不允许被重排序到构造函数之外(JVM 在构造函数末尾插入StoreStore屏障)。 - 读 final 字段的重排序规则:第一次读取"持有 final 字段的对象引用"和第一次读取"该 final 字段",这两者不能重排序。
java
class SafeObject {
final int x;
int y; // 非 final,没有这层保证
SafeObject() {
x = 1; // final 写,构造完成前必须刷出到主内存
y = 2; // 普通写,没有这个保证
}
}
// 线程 A
SafeObject obj = new SafeObject();
sharedRef = obj; // 发布对象给其他线程
// 线程 B
SafeObject o = sharedRef;
if (o != null) {
int a = o.x; // ✅ 一定是 1,final 保证
int b = o.y; // ⚠️ 可能是 0,没有 volatile/synchronized 保护,有可见性风险
}
用 final 做不可变对象,天然线程安全
java
// ✅ 所有字段都是 final,一旦构造完成,任何线程读到的都是完整、一致的对象
class ImmutablePoint {
final int x, y;
ImmutablePoint(int x, int y) { this.x = x; this.y = y; }
}
但要注意一个常见误解:final 引用不等于引用指向的对象内容不可变:
java
final List<String> list = new ArrayList<>();
list.add("hello"); // 合法!final 只保证 list 这个引用不能再指向别的对象,但对象内部内容照样能改
五、三大特性 × 三个关键字,总表收尾
| 关键字 | 原子性 | 可见性 | 有序性 |
|---|---|---|---|
volatile |
❌ | ✅ | ✅ |
synchronized |
✅ | ✅ | ✅ |
final |
❌ | ✅(安全发布) | ❌ |
AtomicXxx(Day07 会细讲) |
✅ | ✅ | 部分 |
选型口诀:
- 只需要可见性 + 有序性,单个变量的简单读写 → 用
volatile - 需要原子性,或者是"判断+更新"这种复合操作 → 用
synchronized或Lock - 需要高性能的计数器/累加器 → 用
AtomicInteger等 CAS 原子类 - 需要构造一个天然线程安全的不可变对象 → 字段全部加
final
六、面试追问
Q1:volatile 和 synchronized 的核心区别是什么?
volatile 只保证可见性和有序性,不保证原子性,且不会让线程阻塞挂起;synchronized 是互斥锁,原子性、可见性、有序性三者全保证,但竞争激烈时会挂起线程,开销更大。简单说:volatile 管"看没看到最新值",synchronized 还多管了"操作会不会被打断"。
Q2:为什么 volatile 修饰的 i++ 依然不是线程安全的?
因为 i++ 底层是"读取当前值 → 加一 → 写回"三步操作,volatile 只保证每一步单独读写时的可见性和禁止重排,但不能保证这三步作为一个整体不被其他线程插队。两个线程可能同时读到相同的旧值,各自加一后写回,导致一次更新被覆盖丢失。
Q3:synchronized 的锁升级过程是怎样的,能不能降级?
从无锁开始,只有一个线程访问时是偏向锁(对象头记录线程 ID,几乎零开销);出现第二个线程竞争时升级为轻量级锁(CAS 自旋尝试获取,不挂起线程);如果竞争依然激烈、自旋也拿不到锁,就升级为重量级锁(线程挂起进入 EntryList,需要操作系统介入调度)。锁只能升级不能降级(偏向锁被撤销是特例,不算真正的"降级")。
Q4:final 字段是怎么保证可见性的,这和 volatile 有什么不同?
final 靠"安全发布"机制:JVM 保证构造函数里对 final 字段的写不会被重排序到构造函数之外,所以对象一旦被其他线程拿到引用,它的 final 字段一定是构造完成后的值。但这个保证只覆盖"构造完成那一刻"的可见性,如果 final 字段之后又通过其他手段被间接修改(比如引用指向的是可变对象),后续的可见性就不再有保证了,这点和能持续保证读写可见性的 volatile 不同。
Q5:什么时候应该用 final 修饰字段?final 修饰的集合还能不能修改内容?
用于构造完成后不再需要改变引用指向的字段,尤其是设计不可变对象时(所有字段都 final)。需要注意 final 只锁定"引用不能再指向别的对象",并不代表引用指向的对象内容不可变------比如 final List<String> list,list = otherList 不允许,但 list.add("x") 完全合法。
下一篇预告
Day05 深入 synchronized 的锁升级细节------偏向锁、轻量级锁、重量级锁在字节码和对象头层面到底发生了什么,以及为什么现在很多场景已经不推荐依赖偏向锁。