本节摘要:volatile 是"可见性与序"的原语:写刷主存、读绕副本,机器层对应内存屏障;synchronized 在其上叠加互斥:字节码层是 monitorenter 与 monitorexit,运行层落在对象监视器上。本节从字节码到硬件把两个原语各拆一层,并顺带讲清 final 的安全发布语义——三个关键字各管一段,边界划清后选型不再纠结。
上一节立了判例法,本节看判决如何执行。两个原语的分工一句话能说清:volatile 保证"看得见"(可见性与序),synchronized 在此之上还保证"同时只有一个人能做"(互斥)。价差也由此而来:volatile 无阻塞、成本低;synchronized 涉及锁,代价与竞争程度挂钩(7.3 展开三级升级)。本节的目的是让你在下一次评审里,能用机制语言而不是口令语言讨论这两个关键字。
volatile 在字节码层几乎"隐形"——字段访问还是 getfield 与 putfield,没有专用指令。它的全部效力写在字段标志位上(javap 输出里的 ACC_VOLATILE),编译器与运行时据此插屏障、禁优化:
public class VolatilePeek { private volatile int flag; private int plain; void writer() { plain = 42; // 普通写 flag = 1; // volatile 写:带写屏障,且禁止与前面的写重排 } int reader() { int f = flag; // volatile 读:带读屏障,且禁止与后面的读重排 return plain; // 按 happens-before,这里保证看到 42 } }
机器层的执行图景:volatile 写时,把本线程的待写数据连同之前的写全部推到共享存储(主存语义);volatile 读时,让本线程的缓存副本失效、从共享存储取。于是 7.1 的判例有了物理实现:写者置位,读者必见。
选型上 volatile 的边界要背准:它适合"一个线程写、其他线程读"的状态标志与发布引用;不适合"读改写"复合操作(自增、累加),因为 volatile 不提供原子性。计数场景的正解是原子类(AtomicInteger 的 CAS 循环)或锁,而不是给字段加 volatile 了事。
synchronized 的字节码层有实体的两条指令。看一段同步块的反汇编:
public void add(String item) { synchronized (this) { items.add(item); } }
$ javap -c Counter public void add(java.lang.String); Code: 0: aload_0 1: dup 2: astore_2 3: monitorenter // 拿 this 的监视器锁,失败则阻塞 4: aload_0 5: getfield #7 // items 8: aload_1 9: invokeinterface #13 // List.add 14: pop 15: aload_2 16: monitorexit // 正常路径释放 17: goto 25 20: astore_3 21: aload_2 22: monitorexit // 异常路径也必须释放! 23: athrow 25: return Exception table: from to target type 4 17 20 any
两个细节值得停一停。其一,异常表保证异常路径同样执行 monitorexit——这就是"同步块抛异常不会漏放锁"的字节码证据。其二,编译器为每个出口都生成了释放指令,临界区越短,这段"锁税"越少。
运行层,每把锁落在对象头上(对象头里的标记字记录锁状态,7.3 的三级升级就在这个字段上演变),对象关联的监视器(Monitor)维护持有者与等待队列。获取失败不是空转等待:线程进入阻塞队列,被操作系统挂起,等持有者 monitorexit 时唤醒。这就是"synchronized 悲观"的由来——它默认按最坏情况处理,把调度权交给操作系统。4.2 提过优化搭档:JIT 的锁消除会删掉"对象不逃逸"的同步(局部 StringBuffer 的 synchronized 方法整个无锁化),锁粗化会把相邻的加锁区间合并——所以书上的"同步慢"结论,务必先确认你写的那段没被编译器救掉。
方法级 synchronized 没有这两条指令:静态方法与实例方法分别在方法访问标志里带 ACC_STATIC 与 ACC_SYNCHRONIZED,调用方据此走监视器逻辑;实例方法锁 this,静态方法锁类对象——两者不相干,混用时各自为政,这是并发 bug 的惯用藏身处。
第三个关键字常被漏讲,却与发布语义直接相关:final 字段只要在构造函数里赋值,且 this 没有在构造期间逸出,其他线程即便通过 data race 拿到引用,也保证看到 final 字段的正确值。机制是 final 写之后编译器自动插入的屏障与禁重排约束(读方也禁止把 final 读提到构造之外)。这意味着不可变对象(全 final、无逸出)是并发编程里最省心的发布方式——这也是为什么标准库大量使用不可变结构。
与 volatile/synchronized 的边界划法:final 管"构造后不被改"的可见性,volatile 管"运行中可变"的可见性,synchronized 管"一段逻辑的互斥"。三者常常组合:不可变配置对象用 final 发布(零成本),可变标志用 volatile,复合操作进 synchronized 块。
⚠️ 常见坑:锁选错对象或选错层级。锁 Integer 缓存对象、字符串字面量这类全局共享对象,等于和整个 JVM 里所有用同样对象加锁的代码抢一把锁;实例方法与静态方法各自加 synchronized 却操作同一份数据,是"看着同步了其实没有"的典型。锁对象应当是业务语义上的私有、稳定、不可替换单元。
💡 关键直觉:同步原语的成本排序来自它们的职责排序——final 只承诺构造后不变,volatile 承诺随时可见,synchronized 承诺独占执行。承诺越多、代价越高。评审并发代码时先问"这里需要哪一级承诺",需要一级却用了三级,就是白付性能税;需要三级却只给一级,就是在埋雷。
两个原语的解剖完成,但 synchronized 的代价还没算完:同一把锁在不同竞争程度下竟然有完全不同的形态——偏向锁几乎免费,重量级锁动用操作系统。这条升级路径就是下一节的主题。