资讯动态

volatile与synchronized深度解析:从Java内存模型到并发选型实战

发布时间:2026/10/24 1:28:28 来源:尧图企业网站定制
先抛一个我经常被问到的问题volatile和synchronized到底有什么区别这个问题几乎每个做过Java并发开发的人都会遇到。网上答案很多但大多停留在“volatile保证可见性synchronized保证原子性”这种层面。说实话这句话背起来容易真到排查线上并发问题、决定用哪个关键字的时候很多人还是拿不准。这篇文章我打算把这两个关键字从问题根源、JVM实现、典型场景到常见坑点完整串一遍。适合正在系统学习并发编程的开发者也适合那种“代码里用了volatile问题还在”的同行作为排查参考。读完你可以回答的不再是“volatile能干嘛”而是“当前这个并发场景到底该靠谁”。1. 并发问题的三个维度为什么需要这两个关键字1.1 从硬件缓存到Java内存模型想真正理解volatile和synchronized得先看它们到底在解决什么问题。现代CPU早就是多核架构每个核心都有自己的高速缓存。程序运行时变量不会每次都去内存读而是先复制到CPU缓存改完再刷回内存。硬件这么做是为了速度但也带来了一个副作用某个线程修改了共享变量的值另一个线程看到的不一定是最新的因为它的执行核心可能还停留在自己缓存的旧副本上。Java跨平台不能直接依赖某一种CPU的具体缓存协议所以Java语言定义了一层抽象规则这就是Java内存模型JMM。JMM规定所有共享变量存在主内存中每个线程有自己的工作内存可以理解为线程私有的缓存抽象。线程对变量的所有操作都是先在“工作内存”里进行再整体同步回主内存。可以这样类比主内存是公司的公告栏每个线程是各自工位上的员工。员工A改了公告栏上的排班表但员工B工位上贴的还是昨天那份旧表格。如果B不主动去看公告栏他永远拿着过期信息干活。volatile和synchronized本质上就是强迫线程放弃“只看自己工位旧消息”的偷懒行为去跟主内存对齐。1.2 可见性、原子性、有序性对应哪些痛点并发编程有三个经典维度可见性、原子性、有序性。三者要分开说因为很多bug就是因为把它们混成了一回事。可见性说的是一个线程修改共享变量后另一个线程能不能马上看到最新值。缓存和指令优化都会导致看不见。硬件层面有缓存一致性协议Java层面则靠volatile、synchronized、final等机制来实现。原子性说的是一组动作要么全部执行完要么全部不执行中间不能被其他线程插入。比如count实际上包含“读取count、加1、写回count”三个步骤。多人同时做这件事最终结果可能比预期小得多。原子性需要靠锁或者原子类来保证。有序性说的是编译器和CPU为了优化可能会调整指令执行顺序。单线程内怎么调整都不影响结果但多线程共享数据的场景下调整顺序可能让另一个线程看到一个“违反直觉”的中间状态。volatile通过内存屏障禁掉关键重排序synchronized通过锁的互斥让临界区内代码不可能被其他线程并发看到中间状态。这三个维度不是独立的。synchronized能同时保证原子性、可见性和临界区内的有序性volatile只保证可见性和部分有序性不保证原子性。理解这个能力边界后面选型就不容易出圈。2. volatile轻量级的可见性通知机制2.1 volatile保证什么可见性与禁止重排序的实现volatile最核心的两个能力一个是可见性一个是有序性。可见性实现很直接对volatile变量的写必须立即刷新到主内存对volatile变量的读必须从主内存重新读取不能直接用工作内存里的旧副本。所以当一个线程改了volatile标志位其他线程读它时一定能看到最新值。有序性则依赖内存屏障。JVM在生成指令时会在volatile写操作前后插入内存屏障在volatile读操作后面也插入合适的屏障。这里不要求你背全所有屏障名字但理解机制有助于排错。简单说volatile写之前的普通写操作不允许重排序到volatile写之后volatile读之后的普通读操作不允许重排序到volatile读之前。这样就能保证“先更新数据、再更新标志位”的顺序不被编译器打破。Java内存模型还给出了happens-before规则对一个volatile变量的写happens-before于之后再对这个变量的读。换句话说只要线程B在线程A写完volatile变量之后去读它就能看到线程A在写volatile之前做过的所有其他修改。这正是很多“状态发布”场景能用volatile的原因。2.2 volatile不保证原子性count的经典失败现场volatile最容易被误解的地方就是它看上去无所不能但面对count就会露馅。看一段很常见的代码public class Counter { private volatile int count 0; public void increment() { count; } public int get() { return count; } }假设8个线程同时调用1000次increment最后count一定等于8000吗答案是几乎不可能。原因在于count不是单条原子指令它等价于读count当前值、把值加1、把新值写回。即使count是volatile也只能保证每一步读写都从主内存来、写回主内存去但无法阻止多个线程同时读到同一个旧值然后各自加1再写回最终互相覆盖。画个时间线你可能立刻就懂了。线程A读到count5线程B也读到count5A先计算并写回6B后计算也写回6。本应加两次变成7结果还是6有一次更新白白丢失。这就是典型的“丢失更新”。要解决它要么用AtomicInteger这种CAS原子类要么用synchronized把increment整段包起来保证每次“读-加-写”之间没有别人插队。这也是volatile和synchronized最本质的分界线volatile管的是“一个线程写完另一个立刻知道”synchronized管的是“这段代码同时只能一个人进来执行完”。2.3 volatile真正够用的三类场景虽然volatile不保证原子性但在一些特定场景里它足够而且更轻量。第一类是状态标志位。比如后台线程的停止开关private volatile boolean stopped false; // 某个工作线程循环里判断 while (!stopped) { doWork(); } // 其他线程调用 public void stop() { stopped true; }这里写操作不依赖当前值不需要复合运算volatile完美胜任。第二类是对象引用的安全发布。如果某个对象在构建完成后把引用赋值给一个volatile字段那么其他线程通过这个字段拿到对象后能看到对象构造过程中的所有字段赋值。因为volatile写和读之间存在happens-before关系。第三类是配合锁使用的“改进可见性”。比如在synchronized块内更新普通变量相比于每次锁进出额外用一个volatile变量来发布结果可以降低锁的范围。但这种情况要小心设计不会用之前别乱用。3. synchronized完整的互斥与内存语义3.1 synchronized用什么锁住临界区对象头与管程模型synchronized能同时解决原子性、可见性和有序性靠的是“锁”的概念。Java里每一个对象都可以成为一把锁这个锁信息就存在对象头的Mark Word里。synchronized修饰实例方法时锁的是当前实例对象this修饰静态方法时锁的是该类的Class对象修饰代码块时锁的是括号里指定的对象。一旦线程获得锁进入临界区其他想进入同一个锁的线程就必须阻塞等待。锁的互斥保证了临界区代码不会并发执行所以count哪怕拆成三步只要整个方法都用同一个锁串起来就不会出现互相覆盖。synchronized还提供wait/notify这套配合机制底层是建立在管程模型上的。可以把管程理解成一个带门禁的房间线程必须先拿到钥匙进房间房间里面一次只能有一个人其他人排队在门外等。wait会让线程主动让出房间并等待通知notify会叫醒一个等待者重新抢钥匙。这套模型非常成熟很多并发工具本质上都围绕它做优化。3.2 锁升级路径从偏向锁到重量级锁早期大家觉得synchronized性能差是因为它底层直接调用操作系统的互斥量线程阻塞和唤醒需要用户态和内核态切换代价很高。后来JVM做了大量优化引入了“无锁→偏向锁→轻量级锁→重量级锁”的升级路径。一开始一个锁对象没有被竞争只有一个线程反复进入临界区JVM会启用偏向锁在对象头里记录线程ID后续这个线程再来就不用真正抢锁。一旦出现第二个线程竞争偏向锁撤销并升级为轻量级锁。轻量级锁靠CAS自旋尝试获取锁适合短小临界区线程不用立刻阻塞。如果自旋超过一定次数或竞争加剧就升级为重量级锁线程真正进入阻塞排队。需要提醒的是偏向锁在某些JDK版本已经默认关闭比如较新的LTS版本中默认不再启用偏向锁。但这条升级路径依旧值得了解因为很多线上日志里的“BLOCKED”状态、锁竞争优化都和“到底哪个锁阶段”有直接关系。它也能解释为什么现在synchronized在很多场景下并不比显式锁差太多。3.3 synchronized自带可见性内存屏障视角很多人不知道synchronized也有“刷新主内存”的能力。线程进入synchronized块时会从主内存重新读取锁保护的共享变量线程退出synchronized块时会把工作内存中修改过的变量强制刷新到主内存。这背后的机制同样是内存屏障效果上等价于在synchronized块的入口做了一个全量读屏障在出口做了一个全量写屏障。因为同一把锁的加锁和释放存在happens-before关系所以如果线程A先释放锁线程B后获取同一把锁线程B必然能看到线程A在临界区内做的所有修改。注意这是“同一把锁”换了一把锁等于两个互不相干的房间彼此之间没有可见性保证。理解了这一点就能明白为什么很多老项目即便完全没有volatile共享状态只要都在synchronized里读写也不会出可见性问题。反而是那种“只给写方法加锁、读方法不加锁”的代码才会因为读不到锁内更新的状态出现旧数据。4. 选型实战什么场景该用volatile什么场景必须synchronized4.1 双重检查锁单例volatile是刚需最经典的选型问题就是双重检查锁DCL单例。先看代码public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }这里为什么必须加volatile关键在于new Singleton()不是一个原子操作。它大致分三步第一分配一块内存第二在这块内存上执行构造函数第三把内存地址赋值给instance引用。编译器为了性能可能把第二步和第三步重排序变成先赋值引用、再执行构造函数。这样就会出现一个极端情况线程A正在创建对象刚把引用写到instance字段构造函数还没跑完线程B判断instance不为null直接返回并将这个半成品对象拿去用结果读取到的是默认值或者直接抛异常。volatile的作用就是通过内存屏障禁止这个重排序保证只有对象完整构造完成后引用才会被外部看到。这个例子特别适合理解“有序性”在实际工程里的价值。它不是一个理论概念是真的会造成可用性事故。类似的问题还会出现在发布不可变对象、初始化缓存等场景中。4.2 状态开关、累加器和复合操作的选型对照我把常见场景按特征列了个表方便直接对照场景特征典型例子推荐方案原因写操作不依赖旧值读多写少线程停止标志、配置切换开关volatile轻量可见性足够单一数值累加/更新页面访问计数、请求计数器AtomicInteger / LongAdder / synchronized需要原子复合操作读-判断-写这种check-then-act先检查余额再扣款、库存扣减synchronized / ReentrantLock需要整体原子且防重入多个变量需要保持一致转账时同时改账户A和账户Bsynchronized 或事务化操作volatile无法保证组合原子性不可变对象发布配置对象构建后整个发布final volatile 引用既加速又保证安全发布缓存共享数据HashMap并发读写ConcurrentHashMap等并发容器要靠锁或CAS保护内部结构这里的核心是不要因为volatile更轻量就强行把它往所有并发场景上套。它只解决“看见”的问题不解决“别挤我”的问题。一旦出现了“先读一个值再基于它做判断最后写入新值”这种路径就应该默认上锁。4.3 选型心法先画线程读写关系再决定我在实际项目里被并发问题折磨过几次后养成了一个习惯不急着写代码先在纸上画清楚这个共享变量被哪些线程读、哪些线程写改写的时候依赖不依赖当前值。如果是“单线程写、多线程读”volatile基本是最优解。如果是“多线程写但写的是彼此独立的不同字段或槽位”volatile也常常够用。如果是“多线程同时改写同一个值而且新值依赖旧值”这时候一定要锁或原子类。如果是一组变量需要一起更新才合理那必须把它们放进同一个临界区。画完这份关系图选哪个关键字往往就水到渠成了。很多人纠结volatile和synchronized其实不是因为不懂写法而是没理清自己的并发模型。模型一乱工具再好也会用错。5. 实际项目里的常见误区与排查思路5.1 误区一把volatile当成万能可见性开关我见过一个线上故障程序里有个布尔标记主线程控制工作线程退出代码写的是volatile boolean running。结果有时候工作线程要好几秒才响应退出。排查后发现工作线程的判断逻辑远不止这个布尔值而是“running为true且队列为空且状态为READY”三者的组合。三个普通字段没有一致的可见性保证即使running是volatile另外两个字段刷新时机不可控导致线程没有及时退出。这就是典型的“只给一个字段上volatile但共享状态不是一个字段”的误区。volatile能保证的是某个volatile写之前的其他写都可见但那要求读也发生在这个volatile写之后的临界点上。如果你的逻辑依赖几个变量组合的“同一时刻快照”应该把它们放进同一个锁或使用原子化的状态对象而不是指望一个flag把整片状态都带上。5.2 误区二同步对象选错导致锁形同虚设synchronized锁的对象一旦选错所有临界区都会失效。最常见的反例是锁一个会被重新赋值的引用private Object lock new Object(); public void methodA() { synchronized (lock) { doSomething(); } } public void resetLock() { lock new Object(); // 这行很可能让后面的锁全部失效 }如果你在另一个线程里调用了resetLock那么后续methodA锁的是新对象而原来等待旧锁的线程还在旧对象上排队。两批人各拿各的钥匙临界区就形同虚设。修复方式很简单锁对象要么用final要么直接用Class对象不要让锁引用指向变化。还有一种坑是用字符串字面量作为锁。JVM会复用相同的字符串实例如果别的模块也用了同一个字符串做锁就会出现无意义的相互阻塞。排查这类问题最直接的手段是jstack导出线程栈看线程到底阻塞在哪个对象的monitor上。看到“waiting for monitor lock”后面的对象描述基本就能定位出锁对象是不是选错了。5.3 一点关于并发改造的真实体会我个人现在做并发改造时遵循一个很保守的顺序先把业务逻辑里所有涉及共享状态的路径找出来标注“读”“写”“复合操作”。能真正用volatile的场景我才会用但凡有一点“读完要判断再写”的味道我第一选择就是synchronized。这样可能不如某些精巧的无锁方案性能惊艳但胜在正确性容易验证出问题时也更好排查。另外如果你维护的是老代码想从“只给方法加synchronized”改成“用volatile减少锁粒度”一定记得先做并发压测并且要加上线程状态监控。因为锁范围缩小后共享状态的一致性边界变了这个改动风险比表面看起来大得多。我在某次重构里因为少考虑一个嵌套状态变量导致上线后偶发脏读回滚后才缓过来。所以先保证语义正确再谈性能优化。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑