并发三大问题与 JMM 的使命
多线程共享变量时,Bug 通常来自三个方向:可见性、原子性、有序性。可见性指一个线程的修改另一个线程看不到;原子性指「读改写」被拆成多步,中间被插队;有序性指编译器与 CPU 为优化重排指令,执行顺序与源码不一致。
Java 内存模型(Java Memory Model,JMM)就是一份「并发契约」:它规定哪些读写组合是合法的、哪些重排被禁止、什么时候一个线程的写入对另一个线程可见。遵守这份契约写代码,结果才可预测;违反它,Bug 只在特定硬件与负载下偶现,极难复现。
public class Counter {
private int value = 0;
public void increment() { value++; }
public int get() { return value; }
}
上面的 `value++` 不是原子操作:它要「读旧值、加一、写回」三步,两个线程并发调用时可能互相覆盖,结果比预期小。这不是锁没加对的问题,而是对内存模型理解不到位——并发安全不是「碰运气」,而是严格按契约组合同步原语。
JMM 是并发代码的「交通规则」:不看规则开车,事故只在路口偶发。
- happens-before规则体系。
- volatile可见性与禁重排。
- synchronized锁升级过程。
- CAS与原子类原理。
主内存与工作内存模型
JMM 把内存抽象成两层:所有线程共享的「主内存」,以及每个线程私有的「工作内存」。线程读变量,先拷到自己的工作内存;写变量,也是先写工作内存再同步回主内存。同步时机不由程序员控制,这就是可见性问题的根源。
JMM 给出的核心保障是一组 happens-before 规则:如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见,且 A 的指令不会被重排到 B 之后。常用规则包括:程序顺序规则、锁规则(解锁 happens-before 后续加锁)、volatile 规则、线程启动与终止规则、传递性规则。
// 锁规则:线程 A 解锁后,线程 B 获取同一把锁,
// 则 A 在临界区内的所有写入对 B 可见
synchronized (lock) {
sharedValue = 100;
}
这些规则是推导并发正确性的地基:只要代码路径上存在 happens-before 链,数据就是安全的;找不到这样的链,就必须补同步原语,而不是指望「多跑几次没问题」。
volatile 语义与使用边界
`volatile` 保证两件事:写 volatile 变量立即刷回主内存,读 volatile 变量直接从主内存拿;同时对 volatile 变量的读写禁止重排。它解决可见性与有序性,但**不解决原子性**——`volatile int` 的自增依然不安全。
// 正确用法:状态开关,一写多读
volatile boolean running = true;
while (running) {
doWork();
}
// 另一个线程 running = false 后,循环一定能看到
典型误用是把 volatile 用在计数器、余额这类「读改写」场景。正确边界是:该变量要么只被单线程写、多线程读;要么写操作本身就是原子的(如布尔开关、引用替换)。超过这个边界,就该上原子类或锁。
synchronized 与锁升级
`synchronized` 是互斥同步的基石:进入临界区前获取锁,退出时释放锁,同时建立 happens-before 关系。现代 JVM 对它做了大量优化:无竞争时用偏向锁,轻度竞争升级为轻量级锁(自旋),竞争激烈才膨胀为重量级锁(阻塞挂起)。
public synchronized void transfer(Account to, int amount) {
this.balance -= amount;
to.balance += amount;
}
锁的是对象不是方法:实例方法锁 this,静态方法锁 Class 对象。锁粒度决定并发度,能用「更小的锁对象」或「更短的临界区」就不要锁整类。锁升级让 synchronized 在低竞争下性能不输显式锁,写起来还更简单,是现代 Java 并发代码的首选互斥工具。
CAS 与原子类
原子类不走锁,走 CAS(Compare-And-Swap):比较当前值与期望值,相等才更新,否则重试。它无锁、无阻塞、轻量,适合计数、状态位这类高频小操作。`AtomicInteger`、`AtomicLong`、`AtomicReference` 都是典型代表。
AtomicLong total = new AtomicLong(0);
long prev = total.getAndAdd(100);
// 高并发下替代 synchronized 计数,吞吐更高
CAS 有个经典陷阱叫 ABA 问题:值从 A 变成 B 又变回 A,CAS 会误判「没被改过」。业务不在意中间过程时无所谓,在意版本变化就用 `AtomicStampedReference` 带版本号。超高竞争的计数场景,`LongAdder` 把竞争分散到多个单元,读时才汇总,比 AtomicLong 更激进。
内存屏障与有序性
指令重排让「看似无关」的代码也可能出现诡异顺序。经典例子是双重检查锁:第一次判断非空直接返回,省去每次加锁;但对象的发布可能「半初始化」——引用先被赋值,构造还没跑完。`volatile` 修饰实例字段后,借助内存屏障禁止重排,才保证发布完整。
private volatile static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
`final` 字段也有特殊的可见性保证:构造方法里对 final 字段的写入,对拿到该对象引用的线程保证可见。理解这些屏障语义,比背结论更重要——知道「为什么必须 volatile」,才不会在换个场景时又踩坑。
JMM 实践清单与陷阱
- 共享可变数据必须有 happens-before 链:锁、volatile、原子类三者选其一。
- 布尔开关、状态标记用 volatile;计数器、余额用原子类或锁。
- 临界区尽量短,锁粒度尽量小,但不要为省锁引入可见性隐患。
- 双重检查锁的字段必须 volatile,否则发布可能不完整。
- 字符串、Integer 这类不可变对象做锁要注意驻留池导致的意外共享。
- 写并发代码前先画清楚读写关系,再决定同步策略,不要事后补丁。
并发安全的本质是把「共享可变」变成「受控共享」:要么不可变,要么有同步链。