1. 从一次诡异的并发Bug说起
去年在重构一个交易系统时,我遇到了一个令人抓狂的问题:在一个使用synchronized修饰的方法中,明明已经正确同步了共享变量的读写操作,却依然出现了数据不一致的情况。代码逻辑看起来毫无破绽,但就是会在高并发场景下偶尔出现异常结果。经过长达三天的埋点调试,最终发现这竟是指令重排序(Instruction Reordering)导致的内存可见性问题。
这个经历让我深刻意识到:synchronized虽然能保证原子性和可见性,但并不能完全禁止编译器和处理器对指令的优化重排。下面我们就来彻底剖析这个看似反直觉的现象。
2. synchronized的内存语义本质
2.1 官方规范怎么说
根据JLS(Java语言规范)第17章关于synchronized的说明,它主要保证以下两点:
- 互斥执行:同一时刻只有一个线程能进入同步块
- 内存可见性:同步块退出时会将本地内存变化刷新到主内存,进入时从主内存读取最新值
但规范中从未提及synchronized具有禁止指令重排序的语义。这是很多开发者的认知盲区——我们常常想当然地认为同步就等于完全有序。
2.2 实际生成的字节码
通过javap反编译下面这个简单示例:
public class ReorderDemo { private int count; public synchronized void increment() { count++; } }会发现synchronized在字节码层面是通过monitorenter和monitorexit指令实现的。这两个指令确实会插入内存屏障(Memory Barrier),但屏障的类型决定了其限制重排序的能力:
- LoadLoad屏障:禁止读操作重排序
- StoreStore屏障:禁止写操作重排序
- LoadStore屏障:禁止读后写重排序
- StoreLoad屏障:全能型屏障(开销最大)
关键点在于:synchronized只会在退出时插入StoreLoad屏障,而其他位置的重排序仍然可能发生。这与volatile的完整屏障语义形成鲜明对比。
3. 指令重排序的三大来源
3.1 编译器优化重排
Java编译器(如javac)在不改变单线程语义的前提下,会对指令进行优化重排。比如:
int a = 1; int b = 2;可能被重排为:
int b = 2; int a = 1;synchronized块内部的代码同样可能被编译器重排,只要不破坏同步语义。这就解释了为什么即使使用synchronized,仍然可能观察到"乱序"执行的现象。
3.2 处理器指令级并行
现代CPU采用流水线、多发射等技术实现指令级并行(ILP)。例如:
x = 1; y = x + 2; z = y * 3;处理器可能会并行执行这些没有数据依赖的指令。synchronized的屏障只能限制特定位置的重排,无法约束处理器内部的所有优化。
3.3 内存系统重排序
由于存在多级缓存体系,写操作可能被合并或延迟。比如:
// 线程1 synchronized(lock) { a = 1; b = 2; } // 线程2 synchronized(lock) { if(b == 2) { assert a == 1; // 可能失败! } }尽管使用了synchronized,由于StoreStore屏障的缺失,a=1和b=2的写入顺序对其他线程可能是颠倒的。
4. 经典案例:双重检查锁定(DCL)问题
最著名的案例莫过于单例模式的双重检查锁定实现:
public class Singleton { private static Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); // 问题出在这里! } } } return instance; } }问题在于new Singleton()这个操作并非原子性的,它包含:
- 分配内存空间
- 初始化对象
- 将引用指向内存地址
在没有禁止重排序的情况下,步骤2和3可能被颠倒。导致其他线程拿到未初始化完全的对象。这就是为什么DCL模式必须给instance字段加上volatile修饰:
private static volatile Singleton instance;volatile的写操作会插入StoreStore屏障,确保初始化完成前引用不会被设置。
5. 解决方案对比
5.1 volatile的完整屏障
与synchronized不同,volatile变量的读写会插入完整的屏障:
- 写操作前:StoreStore
- 写操作后:StoreLoad
- 读操作前:LoadLoad
- 读操作后:LoadStore
这完全禁止了重排序,但代价是更高的性能开销。
5.2 final字段的特殊语义
JVM保证final字段的初始化操作不会被重排序到构造函数之外。这是通过特殊的屏障实现的:
class FinalDemo { final int x; int y; FinalDemo() { x = 1; // 带StoreStore屏障 y = 2; } }其他线程看到y=2时,必定能看到x=1。
5.3 并发容器的安全发布
像ConcurrentHashMap这样的并发容器,内部采用更精细的屏障控制。例如put操作会确保:
- 桶数组引用的可见性
- 节点插入的有序性
- 大小计数的原子更新
6. 实战中的经验法则
经过多年踩坑,我总结出以下最佳实践:
单一变量保护用volatile:当只需要保证单个变量的可见性时,优先使用volatile而非synchronized
组合操作必须同步:涉及多个变量的原子操作,即使有volatile也需用synchronized或Lock
安全发布原则:
- 要么所有字段都是final
- 要么通过volatile引用发布
- 要么在完全构造后通过安全方式发布(如放入ConcurrentHashMap)
避免过度优化:不要为了性能而牺牲正确性,JVM的优化比你想象的更聪明
工具验证:使用JCStress等工具验证并发假设,不要依赖直觉
7. 从JVM角度看内存屏障
不同架构的处理器对内存屏障的实现差异很大:
| 屏障类型 | x86实现 | ARM实现 |
|---|---|---|
| LoadLoad | 空操作 | DMB ISHLD |
| StoreStore | 空操作 | DMB ISHST |
| LoadStore | 空操作 | DMB ISH |
| StoreLoad | MFENCE | DMB ISH |
x86作为强内存模型,很多屏障其实是空操作。而ARM等弱内存模型需要显式屏障。这也是为什么相同代码在不同平台可能表现出不同的并发行为。
HotSpot虚拟机会根据目标平台生成合适的屏障指令。通过-XX:+PrintAssembly可以观察实际生成的机器码。
8. 现代JVM的优化趋势
随着Java内存模型(JMM)的成熟,现代JVM(如HotSpot)会对同步操作做更多优化:
- 锁消除:当检测到不可能存在竞争时,直接去掉同步
public void method() { Object lock = new Object(); synchronized(lock) { // 会被消除 // ... } }- 锁粗化:将相邻的同步块合并
synchronized(lock) { /* 操作1 */ } synchronized(lock) { /* 操作2 */ } // 可能被合并为一个大同步块- 偏向锁/轻量级锁:减少无竞争时的开销
这些优化进一步说明:synchronized的设计初衷是互斥而非完全有序。