并发探针:JMM、CAS 与 ConcurrentHashMap
这是一个单文件、无第三方依赖的 Java 探针,用可复现的交错和反射读取,回答六个容易混淆的问题:
volatile能否修复count++的丢失更新?volatile发布一个对象时,读线程能否看到写线程之前的普通字段写入?- ABA 会让
AtomicInteger的 CAS 误成功吗?带版本戳的 CAS 如何阻止? ConcurrentHashMap的懒初始化、扩容和构造器容量提示在内部字段上如何体现?- 多线程并发
merge会不会丢失计数? ConcurrentHashMap是否接受null键和null值?
环境基线
| 项 | 值 |
|---|---|
| 运行时 | OpenJDK 17.0.19(Liberica JDK 17) |
| 源码基线 | OpenJDK 17u jdk-17.0.12+7 |
| 操作系统 | macOS arm64 |
| 依赖 | 仅 JDK,无第三方库 |
ConcurrentHashMap、AtomicInteger、LongAdder 三个源文件已与本机 17.0.19 src.zip 逐字节比对一致。内部字段名、sizeCtl 的编码方式属于实现细节,不是 Java API 契约;换 JDK 版本或厂商后必须重新运行,不能把本文输出直接外推。
运行
cd labs/concurrency
JAVA_HOME=/Library/Java/JavaVirtualMachines/liberica-jdk-17.jdk/Contents/Home ./run.shrun.sh 编译 ConcurrencyProbe.java,把标准输出同时写入 out/concurrency-probe.txt。--add-opens java.base/java.util.concurrent=ALL-UNNAMED 只用于反射读取 JDK 内部字段,不是业务代码的常规配置。
实测输出
完整原始输出见 out/concurrency-probe.txt。
java.version=17.0.19 vm=OpenJDK 64-Bit Server VM
== 1. volatile is visibility, not atomicity ==
plain int, two threads final=1
volatile int, two threads final=1
AtomicInteger CAS loop final=2
== 2. volatile safe publication ==
reader sees value=42
== 3. ABA ==
AtomicInteger: expected=0 cas=true final=2
AtomicStampedReference: expected=0/0 cas=false final=0/2
== 4. ConcurrentHashMap lazy init and resize ==
new CHM<>() table=0 sizeCtl=0 size=0
after put 1 table=16 sizeCtl=12 size=1
after put 13 table=32 sizeCtl=24 size=13
size=13 mappingCount=13
new CHM<>(1000) table=0 sizeCtl=2048 size=0
after first put table=2048 sizeCtl=1536 size=1
== 5. ConcurrentHashMap atomic merge ==
merge total=80000 expected=80000
0=20000 1=20000 2=20000 3=20000
== 6. ConcurrentHashMap rejects null ==
null key -> NullPointerException
null value -> NullPointerException
size=0读法
1. volatile 只解决可见性,不解决读改写原子性
探针用 CyclicBarrier 把两个线程的交错固定为“都先读、都后写”:
int observed = counter.read();
await(barrier);
counter.write(observed + 1);两个线程都读到 0,屏障放行后都写 1,所以普通 int 和 volatile int 的最终值都是 1。volatile 让写入对后续读取可见,但 read(); write() 仍是两个动作,中间没有排他。
AtomicInteger 版本把“读、计算、CAS”放进重试循环:一个线程 CAS 成功后,另一个线程的旧期望值失效,重新读取再试,最终得到 2。这里证明的是 CAS 循环能保证这一次递增不丢,不是“原子类让所有复合操作都原子”。
2. volatile 写读建立发布关系
写线程先写普通字段 value = 42,再写 volatile ready = true;读线程自旋等待 ready 为 true,然后读取 value。按 JLS 17 §17.4.5,volatile 写 happens-before 后续对同一字段的读,配合程序顺序和传递性,读线程看到 42。
这是一次成功发布的观测,不是“去掉 volatile 必然读到 0”的证明:没有同步时,读到 42、0 或陷入循环都被规范允许,失败是否出现取决于 JIT、CPU 和时序。join(2000) 只是防止探针无限等待。
3. ABA:值相同不等于状态未变
AtomicInteger 从 0 被另一个线程改成 1 再改回 0,主线程仍以期望值 0 执行 compareAndSet(0, 2),CAS 成功。CAS 只比较当前值,看不到中间历史。
AtomicStampedReference 把引用和版本戳一起比较:另一线程把版本从 0 推到 2,主线程仍持有版本 0,CAS 失败,最终引用/版本为 0/2。版本戳是“检测变化”,不是“阻止变化”;版本回绕、状态模型设计不当仍会重新引入问题。
4. ConcurrentHashMap 的懒初始化与容量提示
| 阶段 | table | sizeCtl | 含义 |
|---|---|---|---|
new ConcurrentHashMap<>() | 0 | 0 | 未分配,首次插入才初始化 |
首次 put 后 | 16 | 12 | 表长 16,阈值为 0.75 × 16 |
第 13 次 put 后 | 32 | 24 | 跨过阈值,扩容到 32 |
new ConcurrentHashMap<>(1000) | 0 | 2048 | 构造器把容量提示折算成 2 的幂,尚未分配 |
其首次 put 后 | 2048 | 1536 | 表长 2048,阈值为 0.75 × 2048 |
两个容易背错的点:
new ConcurrentHashMap<>(1000)的sizeCtl是 2048,不是 1024。JDK 17 构造器按1 + initialCapacity / loadFactor估算,再取不小于它的 2 的幂;它和HashMap<>(1000)首次分配 1024 的行为不同,不能把 HashMap 的容量直觉直接搬过来。sizeCtl的含义是重载的:初始化前表示初始容量,初始化后表示扩容阈值,-1表示正在初始化,扩容期间为负数并编码扩容戳与协助线程数。它属于内部实现,不是可依赖的 API。
5. 并发 merge 的原子边界
8 个线程各执行 10,000 次 merge(i & 3, 1, Integer::sum),总计 80,000,四个键各 20,000。merge 对同一个键的“读取旧值、计算、写回或删除”是一次原子调用,因此不会丢计数;不同键仍可并行。
这不证明跨键不变量原子,也不证明映射函数可以随意做副作用。JDK 文档明确要求计算应短小,且不得在计算过程中修改同一映射;探针只覆盖每个键独立累加这一种模式。
6. null 被拒绝
put(null, "value") 和 put("key", null) 都抛 NullPointerException,映射保持为空。禁止 null 让 get 返回 null 只表示“没有映射”,不会与“存在但值为 null”混淆;compute、merge 的“返回 null 即删除”语义也依赖这一点。
这个实验不证明什么
- 第 1 组用屏障强制了特定交错,只证明“这种交错下 volatile 计数会丢”,不是随机压测,也不是“volatile 永远丢更新”的概率结论。
- 第 2 组只观测到一次成功发布;没有同步时的失败是规范允许而非必然发生,不能用单次运行证明或否定可见性问题。
- 第 4 组通过反射读取内部字段,字段名和
sizeCtl编码可能随实现变化;输出只对上述 JDK 基线负责。 - 第 5 组是语义冒烟测试,不是吞吐/延迟基准;线程数、迭代数和
System.out都会影响测量,不能据此比较merge与synchronized的性能。 - 本实验不覆盖
transfer的多线程协作细节、树化/退化、弱一致迭代器、compute递归更新异常等路径;这些需要按源码和文档单独推导。