写了3年CRUD,volatile和synchronized的区别还是答不清——直到我画出了这张三性两锁边界图

Java 并发编程(一):可见性/原子性/有序性 + volatile + synchronized

volatile 和 synchronized 的真正区别,不是"一个保可见性、一个保原子性"这种面试八股------是 volatile 修饰的计数器,编译通过、逻辑正确、但每次跑出来的数值都不一样。根因在 CPU 缓存一致性(MESI 协议)、JMM 内存屏障、对象头 Mark Word 的锁标记位。理解这三层,比背概念有用 10 倍。这篇文章从一次线上 Bug 出发------一个 flag 永远读不到 true------把从 CPU 到 JVM 的整条链路拆给你。

阅读约 14 分钟 | 系列第 6/17 篇


一、可见性、原子性、有序性------三个概念用同一个场景讲清

这三个概念是并发编程的根基。用一个生活场景一次讲清------两个人同时操作一个银行账户

css 复制代码
账户余额 = 100 元
线程A(你老婆在柜台取钱)      线程B(你在ATM取钱)

原子性------"一个操作要么全做完,要么全不做"

arduino 复制代码
// 取钱操作:余额 = 余额 - 取款金额
// 一行代码分三步:
// ① 从主内存读余额 → 100
// ② 计算 100 - 取款金额 → 新余额
// ③ 把新余额写回主内存
ini 复制代码
时间线(没有原子性保护时):
​
老婆(线程A)                你(线程B)
① 读余额 → 100
                             ① 读余额 → 100   ← 也读到100!
② 100 - 80 = 20
                             ② 100 - 50 = 50   ← 基于旧值计算!
③ 写回 20
                             ③ 写回 50          ← 覆盖!老婆取的80块丢了

结果:取了 80+50=130,但余额剩 50。银行亏了 30。 "读→改→写"三步被另一个线程插入了。

如何保证原子性synchronizedLockAtomicInteger/CAS(硬件层面的"比较并交换")。❌ volatile 不能保证原子性 ------volatile int i = 0; i++; 两个线程同时 ++ 照样丢数据。

可见性------"一个线程改了,另一个线程能看到吗"

css 复制代码
老婆(线程A)                你(线程B)
① 取完钱,余额 = 20
   存在自己CPU缓存里
   主内存还是 100!
                             ① 读余额 → 100   ← 读到旧值!
                             ② 基于100继续操作   ← 账全乱了

为什么出这个问题? 每个 CPU 核心有自己的 L1/L2 高速缓存,线程修改变量先改缓存,不会立刻写回主内存。其他线程读的是自己那份缓存。

如何保证可见性volatile(写后立即刷主内存,读前强制从主内存重读)、synchronizedLock

有序性------"代码会按我写的顺序执行吗"

CPU 和 JVM 为了性能会指令重排------在不影响单线程最终结果的前提下,调整指令执行顺序:

arduino 复制代码
// 你写的顺序:
① 分配内存空间
② 在内存上初始化对象(调用构造方法)
③ 把内存地址赋值给变量 instance
​
// CPU可能重排成:
① 分配内存空间
③ 把内存地址赋值给变量 instance   ← 先赋值了!
② 在内存上初始化对象              ← 还没初始化完!
​
// 另一个线程在①和③之间、②还没执行时读 instance:
// → instance 非空(地址有了),但对象属性全是默认值 → "半成品"对象 → NPE

为什么会重排? CPU 执行一条指令要几百个时钟周期,等待期间它可以先执行后面不依赖当前结果的指令------这叫"指令级并行"。但 CPU 判断"有没有依赖"时不知道多线程的存在,它只看当前线程。

什么问题 生活类比 谁保证
原子性 多步操作被插入,数据错乱 你写到一半,别人也在同一格写字 synchronized / Lock / CAS
可见性 改了别人看不到 你改了本地 Excel,没上传共享盘 volatile / synchronized / Lock
有序性 代码执行顺序被打乱,中间态暴露 房子地址挂出去了,墙还没砌 volatile / final

二、volatile 的底层实现:内存屏障

volatile 解决两件事

(1)可见性:volatile 变量修改后立即刷到主内存,同时让其他线程的缓存失效,下次读必须从主内存重读。

(2)有序性(禁止指令重排) :volatile 通过四种内存屏障禁止指令重排:

屏障 作用 生活类比
StoreStore 上面的普通写刷完,才能 volatile 写 data="开会"写完了 → 才能举旗 flag=true
StoreLoad volatile 写完了,下面的读才能开始(最重 旗子举起来、所有人都看到了 → 才能读公告栏
LoadLoad volatile 读完了,下面的读才能开始 flag 读到 true 了 → 才能读 data
LoadStore volatile 读完了,下面的写才能开始 基于最新 flag 值 → 再做后续修改

StoreLoad 为什么最重? 因为"写完成"≠"所有 CPU 都看到了"。StoreLoad 要等 CPU 的写缓冲区全部刷到主内存------这是唯一需要等待全局可见的屏障,其他三个只管本地执行顺序。

CPU 物理实现:x86 vs ARM

arduino 复制代码
x86 处理器(Intel/AMD)------强内存模型:
  只允许"Store-Load"重排,其他三种天然不乱序
  → StoreStore/LoadLoad/LoadStore 可能编译成 0 条指令(空操作)
  → StoreLoad 需要 mfence(清空读写缓冲区)
​
ARM(手机芯片/苹果M系列)------弱内存模型:
  四种重排都可能发生
  → 每种屏障都要编译成真正的 dmb(Data Memory Barrier)指令

Java 的"Write Once, Run Anywhere"------JVM 定义了四种逻辑屏障,在不同 CPU 上翻译成不同指令。x86 上空操作,ARM 上 dmb。

屏障即给 CPU 下了一条"强制执行顺序"的死命令。volatile 写 = 前面的都做完 + 当前写刷主存;volatile 读 = 从主存重取 + 后面的基于最新值做。

经典 Bug:DCL 单例为什么必须 volatile?

csharp 复制代码
// ❌ 没有 volatile
private static Singleton instance;
​
public static Singleton getInstance() {
    if (instance == null) {                  // 第一次检查(无锁)
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();  // 💥 这行不是原子的!
            }
        }
    }
    return instance;
}

instance = new Singleton() 分三步:① 分配内存 → ② 初始化对象 → ③ 赋值给 instance。CPU 可能重排成 ①→③→②------地址先赋了,对象还没初始化。另一个线程在第一次 if(instance==null) 时看到地址非空,直接返回"半成品"对象 → NPE。

arduino 复制代码
// ✅ volatile 禁止重排:步骤②一定在步骤③之前
private static volatile Singleton instance;

能亲眼看到可见性 Bug(10 行代码)

arduino 复制代码
public class VolatileDemo {
    private static boolean running = true;  // 不加 volatile

    public static void main(String[] args) throws Exception {
        Thread reader = new Thread(() -> {
            int count = 0;
            while (running) { count++; }
            System.out.println("读线程退出!count=" + count);
        });

        Thread writer = new Thread(() -> {
            Thread.sleep(2000);
            running = false;
            System.out.println("写线程:running 已改为 false");
        });

        reader.start(); writer.start();
        reader.join(5000);

        if (reader.isAlive()) {
            System.out.println("💥 5秒后读线程还活着!running=false 没被看到!");
        }
    }
}

没加 volatile:JIT 把 running 的读取提升到循环外------非 volatile 变量在无同步的循环中,编译器判断"循环内没人改它",只读一次局部缓存 → 死循环。加了 volatile:写完刷主内存 + 广播"缓存作废" → 下次读从主内存重取 → 退出。(注意:此行为依赖 JIT 充分编译优化,非 100% 可复现------循环内有同步操作如 System.out.println 时也可能读到更新值)

volatile 的核心能力可概括为"可见禁重不保证原子"------保证可见性、禁止指令重排,但不保证原子性。


三、synchronized 锁升级:Mark Word 的四次变脸

四种锁状态

bash 复制代码
无锁态:              偏向锁:              轻量级锁:            重量级锁:
┌──────────┬────┐    ┌──────────┬────┐    ┌──────────┬────┐    ┌──────────┬────┐
│ hash/空  │ 01 │    │ 线程A的ID │1_01│    │→LockRec指针│ 00 │    │→Monitor指针│ 10 │
└──────────┴────┘    └──────────┴────┘    └──────────┴────┘    └──────────┴────┘
  标志=01 偏向=0       标志=01 偏向=1        标志=00              标志=10

一、无锁 → 偏向锁

ini 复制代码
CAS 期望值:Mark Word == "空 | 0_01"(无锁态)
CAS 新值:Mark Word = "线程A的ID | Epoch | age | 1_01"

CAS 成功 → 线程A 持有偏向锁。
后续线程A 重入:看一眼 ID 是自己,直接进,一次 CAS 都不做!

这就是偏向锁的设计初衷------大多数锁从头到尾只有一个线程用,把线程 ID 烙上去,以后免检。

二、偏向锁 → 轻量级锁

线程B 来抢,发现 ID 不是自己。JVM 到达安全点暂停线程A:

css 复制代码
线程A 栈帧创建 Lock Record,存 Mark Word 旧值("A的ID | 1_01")
然后 CAS 把对象 Mark Word 从偏向锁改成轻量级锁:

  期望值:"线程A的ID | 1_01"
  新值:"指向A的Lock Record | 00"

三、轻量级锁的自旋

线程B 也在栈帧创建 Lock Record,CAS 尝试把 Mark Word 从指向 A 改成指向自己:

objectivec 复制代码
第1次 CAS → 失败(A 还持有着)→ 空转几十个 CPU 周期
第2次 CAS → 失败 → 再空转(指数退避,越等越长)
...
第N次 → 还失败 → 不转了,升级!

为什么自旋? 大部分临界区就几行代码,A 很快释放。与其把 B 切进 BLOCKED 等 OS 调度(一次切换几万条指令开销),不如让 B 原地转几圈等 A 释放------转圈的代价比线程切换小得多。

四、轻量级锁 → 重量级锁

线程B 自旋 N 次失败 → 在堆上创建 Monitor 对象,CAS 膨胀 Mark Word:

ini 复制代码
Monitor 对象:
  _owner  = 线程A        ← 当前持有者
  _EntryList = [B, C...] ← BLOCKED 的线程在这里排队
  _WaitSet = [...]       ← 调了 wait() 的线程放这里

CAS 膨胀:
  期望值:"指向A的Lock Record | 00"
  新值:  "指向Monitor对象的指针 | 10"

CAS 成功 → 锁膨胀完毕 → B 把自己放进 _EntryList → park() 挂起

四种状态 CAS 总结

状态变化 CAS 期望值 CAS 新值 失败后动作
无锁→偏向 MarkWord 空 线程ID+偏向标志 说明有人抢先了
偏向→轻量级 旧线程ID+偏向标志 Lock Record指针+00 安全点暂停原线程
轻量级自旋 当前持有者指针+00 自己指针+00 空转重试N次
轻量级→重量级 Lock Record指针+00 Monitor指针+10 park进_EntryList

一句话:锁升级过程,CAS 比较的始终是对象 Mark Word 的二进制值------无锁时是空,偏向时是线程 ID,轻量级时是 Lock Record 指针,重量级时是 Monitor 指针。CAS 不管语义------它只看比特位,比特位对上了就换。


四、synchronized vs Lock:什么时候用哪个

可见性 原子性 有序性 额外能力
volatile ---
synchronized 部分 自动释放,代码简单
Lock 部分 tryLock、超时、可中断、公平锁、Condition 精准唤醒

快速决策

arduino 复制代码
需要线程同步?
  → 只是开关标志位,一写多读?→ volatile ✅
  → 需要原子性?
      → 需要 tryLock/超时/可中断/公平锁/精准唤醒?→ Lock ✅
      → 否则 → synchronized ✅(代码更简单,自动释放,不会忘 unlock)

日常工作大部分靠 synchronized 就够了。遇到 tryLock 或超时放弃场景才用 Lock。volatile 只在标志位和 DCL 单例用过。


核心要点回顾

可见性、原子性、有序性 是并发编程的三个根基概念,分别对应不同层面的问题:可见性源于 CPU 多级缓存------一个核心修改了变量但未写回主内存,其他核心读的是自己的缓存副本;原子性源于"读→改→写"三步中间可被其他线程插入;有序性源于 CPU 和 JIT 为性能而进行的指令重排------在单线程视角下结果不变,但在多线程视角下暴露了中间态。volatile 通过四种内存屏障(StoreStore/StoreLoad/LoadLoad/LoadStore)同时解决可见性和有序性问题------写 volatile 变量时前面的写操作全部刷到主内存,读 volatile 变量时后续的读操作必须从主内存重取,但 volatile 不保证原子性(i++ 依然丢数据)。x86 的强内存模型下仅 StoreLoad 需要 mfence 指令,ARM 的弱内存模型下每种屏障都需要翻译成 dmb 指令。DCL 单例instance = new Singleton() 三步(分配内存→初始化对象→赋值引用)可能被重排为 1→3→2,另一个线程在第一层 if(instance==null) 时看到非空但未初始化的"半成品"对象------volatile 禁止该重排,保证步骤②一定在步骤③之前完成。

synchronized 锁升级路径体现了 JVM 对"大部分锁只被一个线程持有"的乐观假设:偏向锁将持有线程的 ID 烙在对象头的 Mark Word 中,后续重入无需任何 CAS------看一眼 ID 是自己就直接进;当第二个线程来竞争时,偏向锁撤销并升级为轻量级锁,各线程在自己的栈帧中创建 Lock Record,通过 CAS 将 Mark Word 从指向对方的指针改为指向自己;自旋 N 次失败后升级为重量级锁------在堆上创建 Monitor 对象,通过 park/unpark 将线程挂起到 _EntryList。整个过程中 CAS 比较的始终是 Mark Word 的 64 位二进制值------无锁时是空值+01 标志位,偏向时是线程 ID+1_01 标志位,轻量级时是 Lock Record 指针+00 标志位,重量级时是 Monitor 指针+10 标志位。日常使用中,标志位场景用 volatile,普通同步用 synchronized(自动释放、代码简洁),需要 tryLock/超时/公平锁/Condition 精准唤醒时用 Lock。


三性两锁的边界这张表,面试和线上排查都直接翻出来对照。收藏下来,下次写 DCL 单例别再漏 volatile 了。
下一篇 :《并发编程(二):背了"CPU核数+1"还是被面试官问倒?线程池7参数联动推导链》 系列合集掘金Java合集

相关推荐
暮暮祈安2 小时前
Celery 新手入门指南
java·数据库·python·flask·httpx
AI人工智能+电脑小能手2 小时前
【大白话说Java面试题 第190题】【08_Kafka篇】第6题:消息队列有什么作用?
java·kafka·消息队列·系统设计·分布式架构
蚰蜒螟2 小时前
一次 Spring AOP 与定时任务引发的死锁排查实录
java·spring·firefox
TeamDev2 小时前
JxBrowser 9.3.1 版本发布啦!
java·前端·javascript·c#·混合应用·jxbrowser·浏览器控件
程序猿乐锅3 小时前
【数据结构与算法 | 第六篇】力扣1109,1094差分数组
java·算法·leetcode
yeflx3 小时前
速腾Airy雷达使用记录
java·服务器·网络
乐之者v3 小时前
AI编程 -- Agents.md 的简单示例
java·ai编程
无风听海4 小时前
Claude Agent Skills 的四种设计模式;从渐进式披露到最小权限
java·算法·设计模式
geovindu4 小时前
java: Facade Pattern
java·开发语言·后端·外观模式·结构型模式