Skip to content

并发与内存模型 ​

两个线程,一个写 x = 1,一个读 x。墙上时钟里写在读前面,读到的仍可能是 0。

这不是 JVM 抽风。JLS §17.4.5 第一句:If one action happens-before another, then the first is visible to and ordered before the second。没有这条边,规范允许读到旧值,也允许读到新值。面试里说「加了 volatile 保证可见性」——可见性从哪来的,就是这条边。

happens-before:可见性来自同步边,不是墙上时钟

C++ 有自己的 memory model(memory_order_*),Go 的 happen-before 写在内存模型文档里,Python 因为 GIL,字节码之间多数时候看起来像串行。Java 的模型钉在 JLS 17,跟 HotSpot 怎么实现无关——实现必须遵守,你可以按规范推理。


一、冲突、data race、正确同步 ​

1、什么算冲突,什么算 data race ​

同一变量,至少一个是写,两次访问就 冲突(§17.4.1)。两次冲突访问之间没有 happens-before,程序就有 data race(§17.4.5)。

注意是「同一变量」,不是「同一 cache line」。两个线程各写自己的 int,即使挤在同一行缓存,仍是定义良好(只是 false sharing,慢)。两个线程写同一个 int,没有锁、没有 volatile、没有其它同步,就是 data race。

Java 的 data race 不是 C++ 那种 UB。规范仍然限制:数组 length 不会读错,虚方法分派不会跳到随机地址——这些不是 inter-thread action,规范单独保护。data race 能让你看见过期字段、半截 long、对象「构造完了但字段还是默认值」。不要说「Java 有 data race 也没事」——没事的是类型安全,不是你的业务字段。

2、正确同步这条总闸 ​

程序 correctly synchronized,当且仅当所有顺序一致(sequentially consistent)的执行里都没有 data race。一旦正确同步,所有执行看起来都是顺序一致的(§17.4.3)。这句话的用处:你不用去想 CPU store buffer、不用去想 JIT 怎么重排——锁配对了、volatile 配对了,按源码顺序推理就合法。

没正确同步,happens-before 一致的执行可以不是顺序一致的。规范自己的例子(JLS 21 Table 17.4.5-A),初始 A == B == 0:

text
线程1          线程2
B = 1;         A = 2;
r2 = A;        r1 = B;

程序序只给了「写 B hb 读 A」「写 A hb 读 B」。两条线程之间 没有 synchronizes-with。规范允许的一种执行(读看见初始值):

全局顺序(允许)ABr1r2
开始00
线程1 读 A → r2=0000
线程2 读 B → r1=00000
线程1 写 B=10100
线程2 写 A=22100

顺序一致要求每条动作按程序序发生,那样至少有一个读能看见对方的写。上面这张表 happens-before 一致(没有读到「未来的写」),但 不是 顺序一致。r1==r2==0 是规范允许的可见性反例,不是 JVM 抽风。

加 volatile 给 A 或 B,写 hb 后续读,这张表非法,至少一边能看见 1 或 2。

3、++ 为什么不是原子 ​

java
int x = 0;                              // 两个线程各自 x++

x++ 是读、加、写三步。两个线程交错,两次读到同一个值,两次写回同一个 值+1,一次更新丢了。volatile int x; x++; 同样丢——volatile 管的是这一次读或这一次写的边,不管复合操作。

要原子加减:AtomicInteger。get 是 volatile 读,set 是 volatile 写,compareAndSet 是 volatile 读加写(Javadoc 对 VarHandle 的对应写得很清楚)。incrementAndGet 内部 CAS 循环。

高频统计用 LongAdder:分段单元格,sum() 不是某一瞬间的精确快照。分布式账本别用它。


二、happens-before 边从哪来 ​

§17.4.5 加上 §17.4.4 的 synchronizes-with,后端日常用得到的边就这些:

程序序。 同一线程里,源码前面的动作 hb 后面的。x = 1; v = true; 在这个线程里,写 x hb 写 v。

锁。 对同一 monitor m 的 unlock synchronizes-with 后续(按 synchronization order)对 m 的 lock。因此 unlock hb 后续 lock。synchronized (m) 退出时做 unlock,下一次进入做 lock。传递之后:线程 A 在临界区里写的字段,线程 B 进同一把锁之后读,必须看见。

volatile。 对变量 v 的写 synchronizes-with 后续任意线程对 v 的读。因此写 hb 后续读。注意是 同一个字段。写 volatile ready 并不能单独让另一个字段变可见——要靠程序序 + 传递:线程 A 先写普通字段 x,再写 ready;线程 B 读到 ready == true 之后再读 x,才有 写 x hb 写 ready hb 读 ready hb 读 x。

start。 t.start() hb 被启动线程里的任何动作。

join。 被 join 的线程里所有动作 hb join() 的成功返回。

默认值。 每个变量被写成默认值(0 / false / null)synchronizes-with 每个线程的第一个动作。所以你不会读到「比默认值更脏」的位模式,除非碰上下面说的 64 位撕裂。

构造器结束 hb finalize 开始。 别依赖 finalize。Java 18 起 finalize 已 deprecated for removal。清理用 Cleaner 或 try-with-resources。

传递。 hb 是传递的。图里 1 hb 2 hb 3 hb 4,所以 1 hb 4。

java.util.concurrent.locks.Lock 的文档把内存语义钉死了:成功的 lock 等同 monitor 的 Lock 动作,成功的 unlock 等同 Unlock。失败的加锁、重入的加锁/解锁,不要求 额外的内存同步效果。ReentrantLock 和 synchronized 在可见性上是同一类边,不是「更强」或「更弱」。

CountDownLatch 文档:count 到 0 之前,countDown() 之前的动作 hb 另线程成功返回 await() 之后的动作。CyclicBarrier、Semaphore、Exchanger、并发容器的单元素操作,各自文档都写了 Memory consistency effects。用 j.u.c 的时候,边在文档里,不要靠猜。


三、volatile 不是锁,long 可能撕成两半 ​

volatile 禁止这个字段上的 data race,给读/写配对一条 hb 边。它 不是 互斥。

JLS §17.7:非 volatile 的 long / double,一次写在模型里可以当成 两次 32 位写。一个线程可能看见「高 32 位来自这次写、低 32 位来自另一次写」。volatile 的 long/double 读写始终原子。引用类型的读写始终原子,不管指针是 32 位还是 64 位。

共享的 64 位计数器,要么 volatile long(只保证原子读写,不保证 ++),要么 AtomicLong / LongAdder。64 位机器上普通 long 常常一条 mov,规范仍然允许撕。不要拿「我看了反汇编」当同步。

volatile 不能让 i++ 变原子,也不能代替锁做复合不变式(「先改 a 再改 b,中间状态别人看不见」——那得互斥)。

VarHandle(9)把 volatile / acquire / release / opaque 暴露成显式 API。AtomicInteger.getAcquire / setRelease 就是这层。新代码要细粒度序用 VarHandle;日常字段仍是 volatile 或锁。


四、synchronized、wait/notify、ReentrantLock ​

1、锁的是对象,不是名字 ​

synchronized 锁的是对象。synchronized (x) 和 x 指向的那块对象头上的 monitor 绑定,不是和变量名绑定。synchronized (new Object()) 每次新对象,等于没锁。synchronized (SomeClass.class) 锁的是 Class 对象,全 JVM 同一份(同一加载器下)。

synchronized 方法等价于整个方法体 synchronized (this)(实例方法)或 synchronized (Class)(静态方法)。

HotSpot 实现上 mark word 会从无锁 → 偏向 → 轻量(CAS)→ 重量(OS mutex)升级,21 默认偏向锁已经关。这是实现,规范只认 monitor 的 lock/unlock 边。面试答「偏向锁」要补一句 21 默认关。

2、wait / notify 必须持有 monitor ​

否则 IllegalMonitorStateException(§17.2)。典型写法:

java
synchronized (lock) {
    while (!ready) {                // 必须 while,不能 if
        lock.wait();                // 释放 monitor,被唤醒后重新竞争
    }
    // 条件成立,仍持有锁
}

synchronized (lock) {
    ready = true;
    lock.notifyAll();
}

用 if 会在虚假唤醒(规范允许)或多条件共用一把锁时把不成立的条件当成成立。notify 只叫醒一个,叫醒谁不保证;条件复杂就 notifyAll。wait(timeout) 超时返回后仍要检查条件——不知道是超时还是被叫醒。

wait 会释放 monitor,sleep 不会。sleep 在 synchronized 里睡,锁一直握着。虚拟线程上这会钉 carrier,下一节说。

3、ReentrantLock ​

可重入,同一线程反复 lock 会加持有计数,unlock 要配对。文档给的写法是立刻 try/finally:

java
lock.lock();
try {
    // ...
} finally {
    lock.unlock();
}

漏了 unlock,别的线程永远拿不到。synchronized 靠字节码的 monitorenter/exit(包括异常路径)保证释放;Lock 没有这个语言级保证。

多出来的能力:

  • tryLock():立刻返回,不尊重公平。要尊重公平用 tryLock(0, SECONDS)。
  • lockInterruptibly:等锁时响应中断。
  • 超时。
  • newCondition() 多路条件,一把锁几个等待集。synchronized 只有对象上那一个 wait set。

公平锁(构造器 true)倾向把锁给等得最久的线程,吞吐通常更差。文档写了:公平不保证调度公平,一个线程仍可能连续拿到锁。最大重入 Integer.MAX_VALUE,超过抛 Error。

不要 synchronized (lock) 同时又 lock.lock()。Lock 实例只是普通对象,锁它的 monitor 和调 lock() 没有 规范上的关系。

虚拟线程那节会说:Java 21 里 synchronized 会钉住 carrier。热路径上长时间阻塞,用 ReentrantLock,不是为了「更高级」,是为了能卸载。

4、AQS 一眼 ​

ReentrantLock、CountDownLatch、Semaphore、ReentrantReadWriteLock 内部都是 AbstractQueuedSynchronizer。AQS 管两件事:一个 int state(CAS 改),一个 CLH 变体的等待队列。子类实现 tryAcquire / tryRelease(独占)或 tryAcquireShared / tryReleaseShared(共享)。

ReentrantLock 的 state 是持有计数,0 表示没人持有。CountDownLatch 的 state 是剩余 count,共享获取在 state==0 时成功。自己写同步器,优先组合现成的 Latch / Semaphore,真要写就扩 AQS,不要用 wait/notify 从零搭。


五、final 字段:构造结束才冻结 ​

§17.5.1:构造器 c 里写了对象 o 的 final 字段 f,c 退出时(正常或突然)对 f 发生一次 freeze。另一个构造器被 this(...) 调到并写了 final,freeze 发生在 被调的那个 构造器结束时。

freeze 之后,通过「正常发布」看见这个对象的线程,读 final 字段必须看见构造器里写入的值。这是 String、不可变对象能在无锁下安全发布的规范依据。

构造器里把 this 塞进别的线程能看见的地方(静态字段、监听器、启动新线程),叫 this-escape。freeze 还没发生,别的线程可能读到 final 的默认值。内部类隐式持有外部 this,构造器里启动线程跑这个内部类,是同一类事故。

反序列化、反射改 final:规范允许,freeze 发生在这次修改之后。对象在改完之前不该被别的线程看见。

final 数组:数组引用 freeze 了,元素没有。final int[] a 保证别人看见的 a 指向这个数组,不保证 a[0] 的写入可见——元素还是普通字段语义。要可见,元素也得同步,或发布前填完、发布后不再改(安全发布整块)。


六、双重检查:volatile 不是装饰 ​

java
class Holder {
    private static volatile Holder instance;
    static Holder get() {
        Holder h = instance;
        if (h == null) {
            synchronized (Holder.class) {
                h = instance;
                if (h == null) {
                    instance = h = new Holder();
                }
            }
        }
        return h;
    }
}

new Holder() 在字节码里拆成三步(字段假设一个 int x,构造器里 x=1):

text
new           Holder
dup
invokespecial Holder.<init>   // 里面 putfield x=1
putstatic     Holder.instance

有 volatile 的 putstatic 是 volatile 写。程序序:putfield x hb putstatic instance。读 instance 看见非 null 之后读 x,传递后必须看见 1。

线程 A(构造)线程 B(无 volatile 时规范允许)
new + 写 x=1 + 赋 instance
读 instance ≠ null
读 x,仍可以是 0(赋引用排到写字段前面)
当 x 是 1 才安全——那是碰巧,不是 hb

去掉 volatile,putstatic 不再是 volatile 写,第三、四行之间没有边。这是教科书级 data race。改一行加回 volatile,B 读到非 null 之后读 x 必须是 1。

局部变量 h 是为了少读一次 volatile。内层再读一次,避免两个线程都过了外层 if。枚举单例、类初始化(§12.4.2 那把锁)更干净。能用类初始化就别手写双重检查。

java
enum Holder {
    INSTANCE;
    // 枚举实例在枚举类 <clinit> 里创建,LC 保证发布
}

七、BlockingQueue 的边,和 execute 对着文档 ​

BlockingQueue.put 之前的动作 hb 另线程 take 成功返回之后的动作(类文档 Memory consistency effects)。LinkedBlockingQueue、ArrayBlockingQueue、SynchronousQueue 都给这条边。所以「生产者线程里填好的对象,消费者 take 到之后读字段」是安全的,不必再给字段加 volatile——前提是对象发布之后别再 unsync 地改。

SynchronousQueue 不存元素,put 和 take 握手。newCachedThreadPool 用它:任务不排队,交不出去就开新线程。ArrayBlockingQueue 有界,满了 put 阻塞,这是背压。LinkedBlockingQueue() 无界,put 几乎不阻塞,背压消失,OOM 在堆上——newFixedThreadPool 踩的就是这个。


八、线程池:execute 的三步,不是「核心线程」四个字 ​

ThreadPoolExecutor 构造器:corePoolSize、maximumPoolSize、keepAliveTime、workQueue、threadFactory、handler。

execute 的顺序(文档):

  1. 当前线程数 < core:创建新线程跑这个任务(即使其它线程闲着)。
  2. 否则入队。入队成功就结束。
  3. 入队失败(队列满)且线程数 < max:再开线程。
  4. 否则交给 RejectedExecutionHandler。

默认拒绝策略 AbortPolicy 抛 RejectedExecutionException。还有 CallerRunsPolicy(调用者线程自己跑,形成背压)、DiscardPolicy(默默扔)、DiscardOldestPolicy(扔队列头再试)。生产上 CallerRuns 或自己的拒绝(打指标、降级)比默默扔有用。

Executors.newFixedThreadPool(n):core = max = n,队列是 无界 LinkedBlockingQueue。任务堆积只涨队列,不涨线程,OOM 在堆上。newCachedThreadPool:core = 0,max = Integer.MAX_VALUE,队列是 SynchronousQueue(不存任务),来一个任务开一个线程,闲 60s 收。突发流量能把线程开爆。

这两份工厂都有坑。自己 new ThreadPoolExecutor,队列有界,拒绝策略写明。线程工厂给名字,不然 dump 里全是 pool-1-thread-3。

shutdown 不再接新任务,等已提交的跑完。shutdownNow 试图中断在跑的、返回队列里还没跑的。任务要响应中断,否则 shutdownNow 停不下来。awaitTermination 才是「等到或超时」。

虚拟线程出来之后,I/O 密集不要再开一个 200 的固定池硬扛。下一节。CPU 密集仍然是核数附近的平台线程池,虚拟线程帮不上。


九、虚拟线程:还是 Thread,不绑死 OS 线程 ​

平台线程 1:1;虚拟线程 M:N

JEP 444 原文:platform thread 是 java.lang.Thread 的传统实现,一生占用 一条 OS 线程;virtual thread 也是 Thread 的实例,跑在 OS 线程上,但 不 把这条 OS 线程占到代码结束。很多虚拟线程可以先后跑在同一条 OS 线程上。调度器当前指派的那条平台线程叫 carrier。虚拟线程一生可以换 carrier,对 Java 代码来说,Thread.currentThread() 永远是虚拟线程自己,看不见 carrier;两边的栈、ThreadLocal 互不可见。

创建:

java
Thread t = Thread.ofVirtual().name("v-1").start(task);
Thread.startVirtualThread(task);          // 等价 ofVirtual().start
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
    exec.submit(task);                    // 每个任务一条虚拟线程
}

new Thread(task).start() 仍是平台线程。Thread 的公开构造器只造平台线程。

JEP 444 写得很硬:Do not pool virtual threads。池是用来分享贵的东西的,虚拟线程不贵,没有理由池化。从旧的线程池迁过来,迁到 virtual-thread-per-task 的 ExecutorService,不是把池子里的平台线程换成虚拟线程继续池。

阻塞 I/O(Socket、多数 File API)会把虚拟线程从 carrier 上卸下来,carrier 去跑别人。Java 21 里 钉住 carrier、卸不下来的两种情况(JEP 444):

  1. 正在执行 synchronized 方法或块;
  2. 正在执行 native 方法或 foreign function。

钉住不会让程序算错,会让可扩展性变差:虚拟线程在 synchronized 里做 BlockingQueue.take() 或 I/O,carrier 和底下的 OS 线程一起停。调度器 不会 为了钉住去扩并行度。热路径、长时间阻塞,把 synchronized 换成 ReentrantLock。偶发的、只护内存操作的 synchronized 不用换。

Object.wait() 和部分文件系统调用,Java 21 也不会卸载。这些实现会临时把调度器 ForkJoinPool 的平台线程数抬到超过处理器数来补偿。这是实现细节,不要当成「wait 和 I/O 一样廉价」。

虚拟线程不是把 CPU 算得更快。CPU 密集仍是一条虚拟线程占一个 carrier,和平台线程打满核没本质区别。它解决的是「线程数被 OS 线程上限卡住」的 I/O 并发。Go 的 goroutine 是同类问题的另一种实现;Java 21 把这层露成仍然叫 Thread 的 API,旧代码不用换类型。

ThreadLocal 在虚拟线程上语义还在,但百万条虚拟线程各自一份 ThreadLocal,内存会炸。JEP 444 之后的方向是约束 ThreadLocal 的使用;21 里你自己别在虚拟线程上当缓存用。结构化并发(21 是预览,StructuredTaskScope)把子任务寿命绑到块上,比自己收集 Future 干净,21 正式特性之外当预览看,别当默认 API。

虚拟线程默认不保持 JVM 活着(不像非 daemon 平台线程)。main 结束、只剩虚拟线程,JVM 可以退。要等它们,自己 join 或用 Executor 的 close/awaitTermination。


十、完整走一遍:可见性从写到读 ​

java
class Box {
    int x;
    volatile boolean ready;
}

static final Box BOX = new Box();

// 线程 A
BOX.x = 1;
BOX.ready = true;

// 线程 B
if (BOX.ready) {
    System.out.println(BOX.x);      // 必须是 1
}
  1. 线程 A 写 x=1,再写 ready=true。程序序:写 x hb 写 ready。
  2. 写 volatile ready synchronizes-with 线程 B 后续读 ready。
  3. 线程 B 读到 true 之后读 x。程序序:读 ready hb 读 x。
  4. 传递:写 x hb 写 ready hb 读 ready hb 读 x。所以读 x 必须看见 1。

去掉 volatile,步骤 2 没了。线程 B 可以一直看见 ready==false,也可以看见 ready==true 但 x==0(写 ready 和写 x 重排,或 x 的写还在 store buffer)。规范都允许。测试一万次都看见 1,证明不了没 data race。

把 ready 的写放进 synchronized (BOX),读也 synchronized (BOX),边换成 unlock hb lock,同样合法。ready 这时不必 volatile。锁已经给了更强的互斥,复合不变式也能护。


十一、ThreadLocal:弱引用 key,值挂在线程上 ​

ThreadLocal.get() 不是去一张全局 map 查当前线程,是反过来:每条 Thread 自己带一张 ThreadLocalMap threadLocals,key 是 ThreadLocal 对象,value 是你存的东西。

ThreadLocal 弱引用 key 与泄漏形状

java
public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);          // t.threadLocals
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) return (T) e.value;
    }
    return setInitialValue();
}

ThreadLocalMap 不是 HashMap。它是开放寻址(线性探测)的 Entry[],INITIAL_CAPACITY=16,负载 2/3 扩容,下标 key.threadLocalHashCode & (len-1),撞了就 nextIndex 往后挪一格。关键在 Entry:

java
static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;                            // 强引用
    Entry(ThreadLocal<?> k, Object v) { super(k); value = v; }
}

key 是弱引用,value 是强引用。 这条决定了内存泄漏的形状:

引用谁持有GC 后
ThreadLocal 对象(key)你的字段 / 栈 + Entry 的弱引用你不再引用它,弱引用不挡 GC,key 被回收,Entry.get() 变 null
valueEntry.value 强引用 ← table ← Thread只要线程活着,value 就活着

于是「key 没了、value 还在」的 stale entry(陈旧条目)出现。线程池里的线程活很久,value 堆积不回收,就是泄漏。JDK 的缓解:get/set 撞到 entry.get()==null 的槽会顺手 expungeStaleEntry 清一格(探测式清理),set 还会 cleanSomeSlots。但这是「碰上才清」,不保证清干净。

正解只有一条:用完 remove()。线程池场景尤其:任务结束前 finally { tl.remove(); },否则复用的线程带着上一个任务的 value 进下一个任务,既泄漏又串数据。

java
static final ThreadLocal<SimpleDateFormat> FMT =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
try {
    return FMT.get().format(date);           // 每线程一个 SDF,绕开 SDF 线程不安全
} finally {
    // 长期存活线程用 withInitial 常驻可以;一次性任务线程记得 remove
}

InheritableThreadLocal:子线程创建时 拷贝 父线程的值(Thread 构造器里 createInheritedMap)。注意是创建那一刻拷贝,父线程之后改了子线程看不到;线程池的线程是复用的,InheritableThreadLocal 在池上几乎必错——线程不是每个任务新建的。跨线程传上下文用显式传参或 TransmittableThreadLocal 这类库,不要赌继承。

虚拟线程:百万条虚拟线程各带一张 ThreadLocalMap,value 全在堆上,内存会炸。JEP 444 明说别把 ThreadLocal 当每线程缓存用;21 里跨作用域传值优先 ScopedValue(预览)而不是给虚拟线程塞 ThreadLocal。


十二、和三门语言对照 ​

C++GoPythonJava
可见性依据memory model / atomichappen-before 文档多靠 GILJLS 17 happens-before
data raceUBrace,不是 UBGIL 下多数字节码串行不是 UB,字段可见性仍没了
锁std::mutexsync.Mutexthreading.Lockmonitor / ReentrantLock
轻量线程无标准goroutine M:P:N无(有解释器线程)21 起虚拟线程 M:N
线程本地thread_localgoroutine 无内建,靠 contextthreading.localThreadLocal,弱引用 key
原子 64 位std::atomic对齐后一般原子int 任意精度非 volatile long 可撕裂

下一篇把堆、对象头、G1 的 Region 和 Full GC 钉完。并发写出来的对象,最终都要落到那块堆上。