尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

synchronized与volatile深度解析:原子性、可见性、有序性一次讲透

synchronized与volatile深度解析:原子性、可见性、有序性一次讲透 多线程并发这几年几乎成了后端开发的“默认复杂度”而说到并发就绕不开两个关键字synchronized和volatile。如果你去翻各种技术博客会发现讲这两个关键字的文章多如牛毛但真正能把原子性、可见性、有序性这三大特性讲透、再把两者对比讲明白的其实很少。很多文章要么堆砌术语要么只给结论不给原理读者看完好像懂了一写代码或一被面试官追问就露馅。这篇文章我就用一线开发的实际视角把 synchronized 如何支撑三大特性、 volatile 又到底能承诺什么、不能承诺什么一次说清楚。内容会涉及 JMMJava 内存模型、锁升级过程、内存屏障、指令重排序等底层细节也会给出实实在在的代码案例和避坑经验。适合正在准备面试的 Java 工程师也适合在并发场景下踩过坑、想系统理清思路的后端开发人员。1. 并发 Bug 产生的根源三大特性缺失1.1 为什么程序会出问题从一次加班事故说起先讲一个我早年经历过的线上事故。某个系统里有一个配置缓存 Map多个业务线程启动时会读它另有一个后台线程每隔几分钟刷新一次。当时觉得“读多写少反正数据量小”就用了普通的 HashMap 加一个 volatile 引用结果线上频繁出现读到不完整甚至错乱数据的现象。排查了很久才发现问题不是容量不够而是并发修改导致的可见性、有序性双双失效。这种问题在单线程世界里完全不存在——代码一行一行执行变量的值写进内存就写进去了。但多线程环境下每个线程都有自己独立的工作内存CPU 寄存器或缓存线程对变量的操作不会直接改主内存而是先改自己的工作内存副本。这就带来了一个核心矛盾线程 A 的修改线程 B 到底什么时候能看到JMMJava 内存模型就是为了解决这个矛盾而定义的规范。它规定了一系列规则告诉我们什么情况下一个线程对共享变量的写入对另一个线程可见什么情况下不可见。如果规则不满足程序就可能出现三种典型的错误脏读、死循环、对象状态异常。而这三种错误分别对应三大特性原子性、可见性、有序性。1.2 原子性一个操作要么全做要么全不做原子性指一个操作是不可分割的整体在执行过程中不允许被其他线程打断。打个比方银行转账从你账户扣钱和往对方账户加钱必须绑定在一起要么两个都成功要么两个都不发生。如果中间有人插队看到“扣完钱但没加钱”的中间状态那账就乱了。在 Java 中最简单的非原子操作就是i。它看起来是一行代码但编译后其实是三步指令读取i的值、把值加 1、把新值写回。三步之间任意一步都可能被其他线程打断。两个线程同时执行i理论上应该加 2实际结果可能只加了 1这就是典型的原子性缺失。这里有个坑要注意int、long 等基本类型的赋值在 64 位 JVM 上通常是原子的JMM 允许非原子地处理 long/double但主流 HotSpot JVM 的 64 位实现已经保证原子。所以“赋值是原子的”不等于“所有操作是原子的”关键要看操作的指令粒度。1.3 可见性线程对变量的修改何时被看见可见性问题最容易在纯读写的代码上暴露。请看这个经典死循环例子public class VisibilityDemo { private static boolean flag false; public static void main(String[] args) throws InterruptedException { new Thread(() - { while (!flag) { // 空循环 } System.out.println(线程退出); }).start(); Thread.sleep(100); flag true; // 主线程修改 flag System.out.println(主线程修改完成); } }你运行这段代码极大概率看到主线程打印了修改完成但子线程永远不退出。原因是子线程的循环里反复读取工作内存中的flag副本主线程修改的是主内存的值副本没有刷新子线程感知不到。这是可见性缺失的典型症状一个线程修改了共享变量其他线程不能及时看到最新值。JMM 中并没有规定普通变量什么时候把工作内存的值回写主内存、什么时候从主内存重新读取也就是说普通变量的可见性是不可预期的。1.4 有序性编译器/CPU 带来的假顺序有序性指程序执行顺序要符合代码的语义顺序。现代编译器和 CPU 为了提升性能会对指令做重排序reordering只要重排序后单线程执行结果不变就行。比如代码先写 a 再写 b编译器完全可能先写 b 再写 a——单线程下没问题多线程下就可能导致另一个线程读到b 已经写了但 a 还没写的中间状态。下面是经典的指令重排序问题例子public class ReorderDemo { private static int x 0, y 0; private static int a 0, b 0; public static void main(String[] args) throws InterruptedException { for (int i 0; i 100000; i) { x 0; y 0; a 0; b 0; Thread t1 new Thread(() - { a 1; x b; }); Thread t2 new Thread(() - { b 1; y a; }); t1.start(); t2.start(); t1.join(); t2.join(); // 如果绝不重排序x和y不可能同时为0 if (x 0 y 0) { System.out.println(发生了重排序); } } } }这个例子我在课上讲过很多次。两个线程各执行两个赋值如果不重排序最终要么 x1、要么 y1、要么两个都是 1绝不可能同时为 0。但实际运行你就会看到发生了重排序这就是编译器或 CPU 改变指令顺序导致的直接结果。2. synchronized从使用到底层的一次完整剖析2.1 三种用法与锁的持有对象synchronized 的使用姿势有三种对应锁住不同的对象。最容易出问题的是第二种——用错了锁对象等于没加锁。修饰实例方法锁当前实例对象this。同一个对象的不同 synchronized 方法之间互斥。修饰静态方法锁当前类的 Class 对象。注意它和实例方法锁的不是同一个对象锁两者互不排斥。修饰代码块锁的是括号里指定的对象最灵活。常见写法是synchronized (this)或者锁一个专门的锁对象。锁对象必须始终是同一个对象才是互斥的。如果两个线程一个锁this一个锁 class 对象它们可以同时进入临界区。还有一个极容易踩的坑锁字符串常量和包装类型值。比如synchronized (userId)你以为锁住了单个用户但 Integer 在 -128 到 127 之间是缓存的两个不同的 userId 可能对应同一个 Integer 对象直接锁范围扩大牺牲所有无关线程的并发度。2.2 synchronized 如何保证原子性原子性对 synchronized 来说是最容易解释的被它包裹的代码块在同一时刻只允许一个线程执行。线程要进入 synchronized 块必须先获得对应的 monitor监视器锁没有拿到锁的线程就阻塞等待。只要锁不释放其他线程就无法进入临界区自然就不可能发生指令交错。我把这个过程类比成公共厕所的单间一个人进去锁了门其他人只能在门口等着等他出来释放锁才能进下一个。临界区里的所有操作对当前线程来说就是一个不可分割的整体从外部视角看这些操作要么完全执行完了要么完全没开始线程被阻塞没进去不存在中间态。注意synchronized 保证的原子性是基于互斥实现的。也就是说它不是让操作本身变成单条指令而是通过同一时刻只有一个线程能执行这段代码这个约束来避免交错。如果代码块内包含多次变量读写其他线程也无法插入所以原子性天然成立。2.3 synchronized 如何保证可见性很多开发知道 synchronized 能保证互斥却不知道它还顺带保证了可见性。JMM 对 synchronized 的可见性规则是这样规定的线程释放锁时必须把工作内存中对共享变量的修改刷新到主内存。线程获取锁时必须把主内存中共享变量的最新值加载到工作内存。这就意味着线程 A 在 synchronized 块里改了变量释放锁时这些修改会同步到主内存线程 B 进入同一个锁保护的 synchronized 块时会从主内存重新加载最新值。这样就能保证 B 看到 A 的修改。实际上 Java 的 happens-before 规则里有一条monitor 规则对一个锁的解锁 happens-before 于后续对这个锁的加锁。翻译成人话就是锁释放之前的写入对之后抢到同一个锁的线程可见。这条规则是 synchronized 可见性保证的理论根基。所以哪怕你只是synchronized (lock) { flag true; }其他线程synchronized (lock) { if (flag) ... }也能看到 flag 的最新值——前提是双方必须使用同一个锁。2.4 synchronized 如何保证有序性synchronized 对有序性的保证很多资料一笔带过其实它包含两层意思。第一层是临界区内的指令不会跑到临界区外。锁的获取和释放在 JMM 里有明确的 happens-before 关系加锁点之后的代码不可能在加锁前执行解锁点之前的代码不可能在解锁后执行。这在字节码层面就有体现synchronized 块编译后进入时有monitorenter指令正常退出和异常退出时都有monitorexit指令JVM 不允许重排序越过这两个边界。第二层是串行化本身就是有序性。多线程并发时如果没有 synchronized两个线程的指令会交错执行从全局角度看顺序是乱的。加锁后同一时刻只有一个线程执行从结果上看这些线程的临界区代码是被排队执行的相当于人为规定了顺序。虽然这个顺序不确定是哪个线程先抢到锁但至少不存在交错。需要特别提醒synchronized 块内部的指令在单线程执行视角下编译器依然可以进行优化重排只要不改变当前线程的语义。但这对其他线程没有影响——因为其他线程进不来。这就是互斥带来的隔离性让内部重排变得无害。2.5 底层原理与锁升级过程偏向锁、轻量级锁、重量级锁聊 synchronized 的原理绕不开对象头和 monitor。每个 Java 对象在内存中有一个对象头Mark Word它里面存储了 hashcode、GC 分代年龄和锁状态信息。synchronized 用的锁标识位就在 Mark Word 里。jdk1.6 之前 synchronized 是纯重量级锁性能很差线程竞争不到锁会进入操作系统内核态的阻塞和唤醒上下文切换开销巨大。所以早期很多文章劝人避开 synchronized 改用 Lock。但 JDK 1.6 之后HotSpot 做了一系列锁优化让 synchronized 的性能大幅提升。锁升级路径是无锁 → 偏向锁 → 轻量级锁 → 重量级锁。偏向锁的设计思路是大部分情况下锁不仅不存在多线程竞争而且总是由同一个线程多次获得。那干脆让第一个获得锁的线程把锁偏向自己Mark Word 记录线程 ID之后该线程再次进入不需要任何 CAS 操作直接执行。如果另一个线程来竞争偏向锁就撤销升级为轻量级锁。轻量级锁的做法是线程在自己的栈帧中分配锁记录空间用 CAS 尝试把对象头中的 Mark Word 替换为指向锁记录的指针。如果成功就拿到了锁如果失败说明有竞争膨胀为重量级锁。轻量级锁的执行路径不需要操作系统介入所以性能好但如果竞争激烈自旋和 CAS 也会白白消耗 CPU。重量级锁依赖于操作系统的互斥量mutex线程没有获得锁时会被挂起阻塞进入内核态调度。阻塞和唤醒的开销很大但这是保证公平性和可靠性的底牌。这里有一个常见误区偏向锁不是一上来就有的。JVM 默认有个延迟JDK 8 里默认是 4 秒因为刚启动时 JVM 内部有大量多线程操作避免偏向锁频繁撤销。用-XX:BiasedLockingStartupDelay0可以关闭这个延迟。另外 JDK 15 之后偏向锁已经默认被废弃因此如果你的项目跑在较新的 JDK 上分析锁升级时不要再纠结于偏向锁重心放在轻量级锁和重量级锁的切换上。3. volatile 的承诺与边界3.1 volatile 保证可见性的底层逻辑volatile 是比 synchronized 更轻量的选择。它保证可见性的核心机制是每次读写变量时都强制走主内存写 volatile 变量时JVM 会立即把工作内存中的值刷新到主内存读 volatile 变量时JVM 会直接读主内存的副本。这就避免了线程读到过期的缓存值。但底层实现不是简单的绕过缓存。在 x86 平台上volatile 写操作会通过一个Lock 前缀指令这个指令有两个作用一是让 CPU 缓存行中的数据立即写回到主内存二是触发总线锁定或缓存一致协议让其他 CPU 核心里缓存的行失效下次读取时必须重新从主内存加载。这么说有点抽象。编译后的汇编代码中volatile 写操作对应类似lock addl $0x0,(%rsp)这样的指令——注意这个 add 操作本身是加了 0没用真正的目的是lock前缀它充当了一个内存屏障。这个屏障保证了写操作之前的读写不会被重排到写之后同时使前面的写入对其他核心可见。3.2 volatile 禁止重排序的底层逻辑volatile 的另一个核心能力是禁止指令重排序。JMM 围绕 volatile 设计了四个内存屏障规则按操作类型分为 LoadLoad、LoadStore、StoreLoad、StoreStore 四种在每个 volatile 写操作的前面插入 StoreStore 屏障禁止普通写与后面的 volatile 写重排。在每个 volatile 写操作的后面插入 StoreLoad 屏障禁止 volatile 写与后面可能的 volatile 读/写重排。在每个 volatile 读操作的后面插入 LoadLoad 屏障禁止 volatile 读与后面的普通读重排。在每个 volatile 读操作的后面插入 LoadStore 屏障禁止 volatile 读与后面的普通写重排。这套规则翻译成业务语言就是volatile 写之前的普通写不能在 volatile 写之后执行volatile 读之后的普通读和普通写不能跑到 volatile 读之前去。这里最经典的落地场景是双重检查锁DCL单例模式。创建单例时instance new Singleton()在字节码层面不是原子的它分三步分配内存、调用构造器初始化对象、把引用指向内存。如果重排序后第 3 步先于第 2 步执行另一个线程就会判断 instance 不为 null直接返回一个尚未初始化完成的对象。加 volatile 之后写操作前面的 StoreStore 屏障保证了 init 一定在赋值前完成禁止了这个危险的重排序。3.3 volatile 不保证原子性的典型翻车现场这是 volatile 最容易让开发栽跟头的地方。volatile 不保证复合操作的原子性最典型的例子就是volatile int count配合countpublic class VolatileIncrDemo { private static volatile int count 0; public static void main(String[] args) throws InterruptedException { Thread[] threads new Thread[10]; for (int i 0; i 10; i) { threads[i] new Thread(() - { for (int j 0; j 1000; j) { count; } }); threads[i].start(); } for (Thread t : threads) { t.join(); } System.out.println(count count); } }预期是 10000实际几乎不可能到 10000。原因就是 3.1 节说的count在字节码层面是读、加、写三步操作。volatile 保证了每次读都是最新的、每次写都能立即被其他线程看到但无法阻止两个线程都执行读了 100 → 加 1 → 写回 101的流程。线程 A 写回 101 后线程 B 在稍早时已经读过 100它还是会写回 101。这个读改写过程不原子最后的结果就是丢更新。volatile 能保证的是单个读和单个写的原子性比如flag true、读取面板上的状态值。而count这种依赖旧值计算的复合操作必须用 synchronized 或AtomicInteger这类并发原子类。4. synchronized 与 volatile 的深度对比4.1 能力对比总表把两者的能力差异放到一张表里一眼就能看明白对比维度synchronizedvolatile原子性保证临界区内完全互斥等价于原子性只保证单次读写原子性复合操作不安全可见性通过锁的释放与获取保证每次读写直接主内存保证有序性通过互斥隔离实现串行化通过内存屏障禁止特定重排序使用成本较重可能阻塞线程有锁升级机制很轻无阻塞开销小可修饰对象方法、代码块仅共享变量实例字段、静态字段阻塞行为部分锁实现会阻塞线程完全不阻塞适用场景复合操作、临界区、需要互斥的状态修改单一标志位、状态开关、发布不可变对象这张表说明了两个工具的定位完全不同synchronized 是一个大而全的互斥工具能一次性解决三大特性的问题volatile 是一个小而专的可见性工具只在特定场景下有效。选型之前先想清楚你到底缺哪个特性。4.2 双重检查锁为什么两者缺一不可双重检查锁Double-Checked LockingDCL是检验对两者理解的最佳案例也几乎是 Java 面试的必考题。来看标准写法public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }这里有两层判空第一层是为了避免每次都抢锁提升并发度第二层是在抢到锁之后重新检查防止重复创建。很多人疑惑synchronized 已经保证互斥了为什么 instance 还要加 volatile关键就在instance new Singleton()这一行的指令重排序风险。正常这三步是memory allocate() 分配内存instanceVar init(memory) 调用构造器singleton memory 让引用指向对象。如果 JVM 把第 2 步和第 3 步重排先让引用指向未初始化的内存此时线程 B 进入 getInstance()看到 instance 不为 null直接返回——它拿到的是一个半初始化的对象里面的字段都还是默认值。如果 instance 加了 volatile第 3 步的写操作前会插入 StoreStore 屏障保证 init 在赋值引用之前完成同时写操作后还有 StoreLoad 屏障防止它为后续读提供错误的重排。这样其他线程看到的 instance 只可能是 null 或者完全构造好的对象不会出现中间态。这个案例里synchronized 负责保证只有一个线程能创建实例原子性和互斥volatile 负责保证实例发布时的安全可见性和有序性。两者缺一不可这就是对比维度落地的经典模板。4.3 性能考量与选型建议性能上volatile 几乎没成本x86 上只是加一条 lock 前缀指令synchronized 在无竞争时也很便宜重量级锁才贵。所以选型不该单看锁很重这个刻板印象而要看具体场景。我的建议很直接如果是状态标志、开关量——比如线程是否停止配置是否已刷新一个 volatile boolean 就够了。这种场景写操作是单线程的读操作是并发的不存在复合更新。如果是多个线程同时改一个计数值——用AtomicInteger或者干脆用 synchronized 包上。volatile 在这里是陷阱。如果是需要严格互斥的临界区——逻辑上就属于任意时刻只允许一个人进门这活 volatile 干不了必须上 synchronized 或 Lock。如果读多写少且数据量小通常用 volatile 不可变对象组合利用 volatile 的可见性保证安全发布。如果写多读少synchronized 反而比锁加读多路这类复杂方案容易维护。还有一个常见的性能误解是synchronized 一定比 ReentrantLock 慢。现代 JVM 的锁升级机制让 synchronized 在低竞争下与 Lock 不相上下而且 synchronized 是非公平锁吞吐量在某些场景下反而更好。大部分业务系统遇到的热点代码瓶颈往往不在锁本身而在临界区做太多 IO 或计算。别把性能问题一股脑甩给 synchronized。5. 实战经验与常见坑5.1 从真实线上事故总结的 3 个教训这部分没有代码框架的条条框框纯粹是我个人在工作和帮同事排查问题时积累的实战经验每一条都对应过真实的线上事故或压测数据。教训一不要对普通 volatile 变量抱有原子幻想。有次同事用 volatile 变量做多线程统计请求数压测单机 2000 QPS 时统计值偏差接近 15%。排查后定位到count这个复合操作改成LongAdder后数据才对齐。这个坑在面试中也高频出现属于知道 vs 做到之间最容易暴露的一个点。教训二锁对象必须是你以为的那个对象。线上出现过用synchronized (某个实体 ID 的 String)做互斥结果因为字符串常量池的同一性让毫无业务关联的两类请求被同一个锁串行化。排查的方式是给目标方法加线程转储thread dump发现两个线程 wait 在同一个 monitor 地址。解决方案是改用锁对象池比如 ConcurrentHashMap 维护 per-key 锁。教训三可见性问题比原子性问题更难复现。我见过最隐蔽的一次系统每天随机出现一次配置没更新的问题加日志根本打不出中间态。后来发现是某个工具类里用普通 boolean 做配置开关读线程长期停留在旧副本上。解决方式就是把变量改成 volatile一行代码的事但排查花了整整两天。可见性问题的特点是偶尔发生、不规律、与环境相关一旦怀疑到它直接上 volatile 或加锁往往立竿见影。5.2 关于锁性能的误解很多人一提到 synchronized 就想到性能差这是历史包袱。JDK 1.6 之前重量级锁的性能确实不好但现代 HotSpot 引入了偏向锁、轻量级锁、自旋优化和锁粗化synchronized 在低竞争场景下开销已经很小。压测数据我自己跑过单线程简单计数器加锁与不加锁两者的吞吐差异在小数点后一到两位级别根本构不成性能瓶颈。锁的代价飙升只出现在竞争激烈时——大量线程抢同一把锁就会导致线程被挂起、唤醒内核态切换成本增加。你要做的不是恐慌性地用 Lock 替代 synchronized而是在设计上减少锁的粒度。比如分段锁读写分离无锁化数据结构往往比换 Lock 实现带来的提升大得多。我从工作里总结的经验是先看代码结构再看锁机制。有一个细节值得提synchronized 可以通过 JVM 参数调整锁升级的阈值但日常开发中我几乎不调这些参数。默认行为经过足够多的实际应用验证大多数场景下是合理的。如果你有强烈的局部热点优先考虑减小锁粒度而不是调参数。5.3 面试和工作中常见的追问点如果你准备面试下面这些问题是我面别人时喜欢追的大家可以根据这些点自测问 synchronized 静态方法和实例方法锁的是不是同一个锁不是。静态方法锁 Class 对象实例方法锁 this。两者互不影响。问 volatile 一定比 synchronized 快吗不一定。在写操作多的场景 volatile 并不合适而且 volatile 不能用做复合操作的原子性保证。问 volatile 能保证有序性吗能保证 volatile 操作附近的相对顺序但要求比较精细。它通过内存屏障限制重排不能保证你写的所有代码整体有序。问锁升级过程中对象头 Mark Word 的角色是什么Mark Word 记录锁状态是锁升级判定的依据也是轻量级锁 CAS 替换的目标。这些细节最能区分背过答案和真懂原理。问 happens-before 规则到底有什么用它是 JMM 对程序员的承诺只要满足规则就能保证可见性和有序性。monitor 规则对应 synchronizedvolatile 规则对应 volatile。问日常开发中什么时候选 volatile状态开关、单一发布不可变对象、可见性比原子性更重要的读多写少场景。多个线程同时改同一个值选 volatile 就是给自己埋坑。我写这么多核心想表达的是synchronized 和 volatile 不是对立的两个选择而是并发工具箱里的两种不同工具。关键是先理解你的并发场景在缺什么特性再选合适的工具——或者更常见的是像 DCL 单例那样组合使用。我见过大量并发 bug根因都不是工具选得不够高端而是对原子性、可见性、有序性这三件最基本的事情缺乏足够清晰的认识。把这三个概念想明白Java 并发的很多可靠性问题就解决了一半。
返回列表