volatile / synchronized / final:三大特性(原子性/可见性/有序性)到底谁保证了什么?

「Java 进阶之路」系列 Day04

写在前面

上一篇讲完 JMM 和 happens-before,这篇顺理成章地把 volatilesynchronizedfinal 这三个并发关键字,和"原子性、可见性、有序性"三大特性对应起来,讲清楚谁解决了什么问题、谁又管不了什么------这是面试里最常被拿来连环追问的一组知识点,背熟表格不如真的理解底层原理。


一、先把三大特性的含义钉死

性质 含义 被破坏的典型场景
原子性 一个操作要么全做,要么全不做,不能被中途打断 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 不是一上来就用最重的操作系统级锁,而是有一条升级路径:

stateDiagram-v2 [*] --> 无锁 无锁 --> 偏向锁: 只有一个线程访问 偏向锁 --> 轻量级锁: 出现第二个线程竞争 轻量级锁 --> 重量级锁: 竞争激烈/自旋失败 重量级锁 --> [*] note right of 偏向锁 对象头记录持锁线程 ID 无需 CAS,几乎零开销 end note note right of 轻量级锁 CAS 自旋尝试获取 不挂起线程 end note note right of 重量级锁 线程挂起进 EntryList 需要 OS 内核介入调度 end note

注意:锁只能升级,不能降级(偏向锁被撤销的特殊情况除外)。这也是为什么"低并发场景下 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)**实现两件事:

  1. 可见性 :写 volatile 变量会立即刷新到主内存;读 volatile 变量会跳过工作内存缓存,直接从主内存重新加载。
  2. 禁止指令重排
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 具体做了两件事:

  1. 写 final 字段的重排序规则 :构造函数里对 final 字段的赋值,不允许被重排序到构造函数之外(JVM 在构造函数末尾插入 StoreStore 屏障)。
  2. 读 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
  • 需要原子性,或者是"判断+更新"这种复合操作 → 用 synchronizedLock
  • 需要高性能的计数器/累加器 → 用 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> listlist = otherList 不允许,但 list.add("x") 完全合法。


下一篇预告

Day05 深入 synchronized 的锁升级细节------偏向锁、轻量级锁、重量级锁在字节码和对象头层面到底发生了什么,以及为什么现在很多场景已经不推荐依赖偏向锁。

相关推荐
巴勒个啦1 小时前
从需求到上线:记录一次完全由 AI 辅助完成的小产品全流程
java·前端
网易云信1 小时前
销售为什么是企业 AI 落地的"最佳突破口"?
人工智能·后端·agent
名字还没想好☜1 小时前
Spring @Async 不生效排查:自调用失效、默认线程池坑与异步方法里的异常去哪了
java·python·spring·异步
wechatbot8881 小时前
解决企业微信官方 API 短板:iPad 协议全功能对接方案
java·汇编·windows·http·微信·企业微信
Alan_751 小时前
文件下载中文文件名乱码终极方案
后端
枫叶V1 小时前
请求可以重试,业务只能生效一次:我们的幂等架构实践
后端
Ai拆代码的曹操1 小时前
K8s 调度器 predicate 阶段揭秘:为什么你的 Pod 总是堆在同一台机器
后端·容器
Tim_101 小时前
【C++】020、野指针&悬空指针
java·开发语言
Ai拆代码的曹操1 小时前
手摸手排查:Pod CrashLoopBackOff 日志丢失问题,3 层兜底方案
后端·容器