qinghuan
← JUC + JVM / 全部笔记

单元 87 / JUC + JVM

P0 · 必会主线L2 · 进阶

JMM 与 volatile:先区分可见性,再讨论原子性

从一个线程发布结果、另一个线程读取入手,理解 JMM 的可见性保证,以及 volatile 为什么保护不了 count++。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能解释停止标志与计数器的不同同步需求,并从规则而非输出猜测判断正确性。

学习理由、准备要求与知识点

为什么学它

理解可见性与原子性的区别,才能解释偶发并发错误并正确使用锁与原子类。

先读这些内容

知识点清单

  • 共享变量
  • 数据竞争
  • happens-before
  • volatile 写读
  • 安全发布
  • 复合操作
  • 线程启动
  • 线程结束与 join

运行前先确认以Java语言规范SE17第17章为语义依据,CountDownLatch源码固定OpenJDK17.0.12+7。图算法只计算给定合法先行关系的传递闭包,不是完整JMM执行合法性判定器。

01先弄懂它是什么

一个线程写了新值,另一个线程是否一定能读到?Java 内存模型(JMM)规定多线程读取允许看见哪些写入。它不是堆和栈的位置图,也不要求每次赋值立刻同步到所有 CPU 核心。编译器和处理器可以优化,但结果必须符合这些规则。学习目标是找到有保证的通信方式,而不是依赖测试中碰巧读到新值。

要分别检查操作是否完整、写入能否被看见,以及先后关系有什么保证。volatile 的读写提供特定同步保证,却不能让 count++ 的读取、加一和写回自动合成一件事;多个线程仍可能覆盖结果。synchronized 和原子更新 API 能保护不同范围的完整操作,是否够用要看整个业务动作,而不是只看变量前有没有关键字。

happens-before 说的是 Java 规则保证哪些动作先于哪些动作、前面的写入怎样对后面的读取可见,不是看时钟上谁先发生。推理时,先找同一线程里的程序顺序,再找正确配对的同步动作,最后把这些关系接起来。如果接不出所需关系,不能说程序一定出错,但也不能依赖某个特定的读取结果。即使反复测试都正常,也不会因此多出一条语言规范没有给出的保证。

文中这几个词,先认一认

程序顺序
同一线程中,按 Java 语言规则确定的执行先后关系,用来推理可观察结果。
同步关系
正确配对的锁操作、volatile 读写或并发工具提供的跨线程先后保证。
happens-before
按程序顺序、同步和传递规则推导的先行关系,用来保证可见性;不是看墙上时钟谁更早。
安全发布
通过符合并发规则的方式把对象交给其他线程,让它们能按保证看到构造和初始化结果。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

常见做法是先写普通数据,再写 volatile 完成标志;读取线程确认看到了对应的完成发布后,再读取数据。保证分三步连接起来:写数据先于写标志,标志写入与相应读取之间有同步保证,读标志又先于读数据。顺着这条关系,前面的数据写入才能传到读者。若先写完成标志、后初始化数据,就没有同样的保证。

锁也能把一个线程写好的数据交给另一个线程看见:对同一个对象解锁,先行于之后对它加锁。关键是同一把锁;两个线程各锁各的对象,不能据此保护共同字段。启动线程的 Thread.start、成功确认线程已经结束,以及相应并发工具,也各有规定的内存效果。sleep 和 yield 只是影响运行安排,等得久一点不会自动建立这些同步保证。

处理器有缓存、写缓冲和指令排序规则,JIT 会按具体机器实现 Java 要求的行为。把 volatile 简化成“强制刷回主内存”,容易漏掉编译器优化和多个变量之间的先后保证,也会误以为它让所有操作都统一排序。写应用时先按 Java 语言和 API 的明确规则推理,确有性能问题时再查看目标机器的具体实现。

正在加载流程图…

先初始化再发布,再观察发布后读取;图的边来自规范规则,不来自时间戳猜测。
查看流程图源码(Mermaid)
flowchart LR
 A["线程A 写普通数据data"] -->|程序顺序| B["线程A volatile写ready=true"]
 B -->|观察到对应发布的同步关系| C["线程B volatile读ready=true"]
 C -->|程序顺序| D["线程B 读取data"]
 A -.->|传递得到先行关系| D

按顺序看,每一步做了什么

  1. 列出共享变量及读写动作,区分复合更新与单次访问。
  2. 标出每个线程内部的程序顺序。
  3. 根据同一锁、volatile或并发工具契约添加合法跨线程边。
  4. 利用传递性寻找从数据写入到读取的完整路径。
  5. 对没有证明链的冲突重新设计同步,而非增加sleep。

03对照实现代码

CountDownLatch 用共享状态打开一次性门闩 (新标签页)↗

OpenJDK17.0.12+7 · CountDownLatch.Sync#tryReleaseShared / tryAcquireShared

原创状态递减模型,展示计数归零的判定;真实实现委托AQS完成等待与共享唤醒,内存效果以CountDownLatch API契约为准。

核心机制代码 · java
import java.util.concurrent.atomic.AtomicInteger;

final class LatchCountModel {
    private final AtomicInteger count;
    LatchCountModel(int initial) {
        if (initial < 0) throw new IllegalArgumentException();
        count = new AtomicInteger(initial);
    }
    boolean decrementToZero() {
        for (;;) {
            int before = count.get(); // 读取当前剩余计数。
            if (before == 0) return false; // 一次性门闩打开后不再减成负数。
            if (count.compareAndSet(before,before-1)) return before == 1;
            // 只有成功从1变0的本轮负责报告“刚刚打开”。
        }
    }
}

真实的 Sync 用 AQS 的 state 保存还差几个参与者。tryAcquireShared 只有看到计数为零才允许通过;tryReleaseShared 用 CAS 尝试把当前计数减一,成功减到零时,再告诉同步器可以继续放行等待者。这里的计数不是成功任务数:调用 countDown 只表示这个参与者已经走到事先约定的位置,任务是否成功还要另外记录。

countDown委托releaseShared,await委托可中断共享获取。等待线程不是靠不停读取业务结果数组来判断是否结束,而是通过同步器的准入与等待队列完成协调。理解这一层后,应继续阅读AQS的state访问和共享获取流程,而不是把示意递减循环当成完整等待实现。

API 的保证针对计数归零前参与倒数的 countDown:线程在它之前完成的动作,先行于另一个线程从对应 await 成功返回后的动作。计数已经归零后再调用 countDown,没有新的倒数作用;带超时的 await 返回 false 也不表示完成。所以工作线程应先写自己的结果格子,再倒数,汇总线程等门闩成功打开后读取。但若大家同时给一个普通 long 加一,门闩也不能补救此前丢掉的更新。

异常路径若不countDown,协调者可能永远等不完;放进finally则意味着“此任务已结束”,不代表任务成功。应另外记录结果或异常,并使用带期限的await。超时返回false不能当成所有初始化完成,更不能据此读取仍在并发修改的结构并宣称数据一致。

代码先调用谁,再调用谁

  1. 工作者写入自己的结果或失败记录
  2. finally调用countDown表示本参与者结束
  3. AQS共享释放使计数递减并在归零时传播
  4. 协调者await成功返回
  5. 协调者读取结果并单独判断业务成功与失败

04动手写一个例子

计算先行关系的传递闭包

把动作编号0到n-1,输入已经确认合法的先行边,计算任意两动作是否存在先行路径。自环或循环意味着这组严格先行约束矛盾,应拒绝;没有路径只返回“未证明”,不推断必定读到旧值。

输入 1
0写data→1写ready→2读ready→3读data
输出 1
0先行于3:true
输入 2
只有0→1与2→3
输出 2
0先行于3:false,关系未证明

先用布尔矩阵保存直接边,reach[i][j]表示当前已经知道i先行于j。逐个选择中间动作k,若i能到k且k能到j,就可以通过传递性得到i能到j。所有k处理完后,矩阵包含由输入边能推导出的全部路径。

Floyd式动态规划的不变量是:处理完前k个中间节点后,所有内部节点只来自这一集合的路径都已被发现。新加入k时,一条新路径要么不经过k而已存在,要么可拆成i到k和k到j两段。这个证明只针对图可达性,不涉及JMM的所有因果合法性条件。

严格先行关系不能让动作先行于自己,因此最终对角线为true表示输入形成循环。边的语义必须由人或更完整的分析器确认,例如不能因为两个日志时间戳一前一后就添加同步边。算法可以精确计算错误输入的闭包,却无法替错误前提提供规范保证。

为什么这样写是对的第k轮后,reach包含所有中间动作编号小于等于k的可达路径;合法严格关系的对角线必须为false。

时间 O(n³+e)额外空间 O(n²)

初始化读取e条边,三层循环计算闭包;矩阵保存n²个布尔状态。大图单次查询可改用邻接表DFS/BFS达到O(n+e)。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 35 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
public class Main {
    static boolean[][] closure(int n, int[][] edges) {
        if (n<0) throw new IllegalArgumentException();
        java.util.Objects.requireNonNull(edges);
        boolean[][] reach=new boolean[n][n];
        for (int[] edge:edges) {
            if (edge==null || edge.length!=2 || edge[0]<0 || edge[0]>=n
                    || edge[1]<0 || edge[1]>=n) throw new IllegalArgumentException("edge");
            reach[edge[0]][edge[1]]=true; // 直接边必须由可靠同步规则提供。
        }
        for (int k=0;k<n;k++)
            for (int i=0;i<n;i++)
                if (reach[i][k])
                    for (int j=0;j<n;j++) reach[i][j] |= reach[k][j];
                    // 经由k把两段已经成立的关系连接起来。
        for (int i=0;i<n;i++)
            if (reach[i][i]) throw new IllegalArgumentException("cyclic strict order");
            // 动作不能严格先行于自身,循环约束应报告为错误。
        return reach;
    }
    static void check(boolean ok) { if (!ok) throw new AssertionError(); }
    public static void main(String[] args) {
        boolean[][] ordered=closure(4,new int[][]{{0,1},{1,2},{2,3}});
        check(ordered[0][3] && !ordered[3][0]);
        check(!closure(4,new int[][]{{0,1},{2,3}})[0][3]);
        check(closure(0,new int[0][]).length==0);
        check(!closure(1,new int[0][])[0][0]);
        check(closure(2,new int[][]{{0,1},{0,1}})[0][1]);
        try { closure(2,new int[][]{{0,1},{1,0}}); throw new AssertionError(); }
        catch (IllegalArgumentException expected) { }
        try { closure(1,new int[][]{{0,1}}); throw new AssertionError(); }
        catch (IllegalArgumentException expected) { }
        System.out.println("PASS 7");
    }
}

这些特殊情况也要试一试

  • 空图与单节点无边图合法。
  • 重复边不改变闭包。
  • 自环与多节点循环拒绝。
  • 编号越界和边格式错误拒绝。
进阶选读 · 从可达性到并发执行分析

场景一个并发组件有大量访问事件,需要发现哪些读写之间缺少已知同步关系。

做法记录线程内顺序与已确认同步事件,使用向量时钟或压缩图判断部分序;对冲突变量报告未排序的访问,再结合完整语义复核。内存模型弱排序执行不能仅用一个简单图覆盖全部约束。

复杂度T个线程的朴素向量时钟每次合并或比较O(T),每事件存储O(T);大规模分析需压缩、采样或专门算法。

好处与代价更细的关系分析能发现普通压力测试遗漏的交错,但事件采集和分析成本高,并且必须明确工具覆盖的内存语义。

05项目里怎么用

优先使用不可变对象、安全构造和成熟并发容器减少共享可变状态。发布不可变配置时可整体替换volatile引用,读者一次取得本轮快照;但引用指向的集合若仍可修改,就没有得到真正不可变对象。构造期间把this泄露到其他线程也可能破坏预期初始化观察。

双重检查单例需要正确的volatile发布与完整初始化顺序,能用静态持有者或容器管理时通常更容易维护。缓存懒加载还要区分初始化一次、允许重复计算和初始化失败后是否重试,不能只解决引用可见性却遗漏业务生命周期。

并发验证分三类工作:单元测试查 API 行为,并发测试尝试找到反例,按语言规范推理则说明为何所有允许的执行都遵守规则。换 CPU、JDK 或优化阶段可能暴露旧问题,所以“本机跑一万次没错”不能替代必须具备的同步保证。

容易踩的坑

  • 把volatile自增当作原子计数。
  • 先发布ready再写业务数据,破坏所需顺序。
  • 用sleep或日志打印偶然改变调度来掩盖竞态。
  • 不同线程锁不同对象却认为存在同步关系。
  • await超时后仍当作全部结果已经安全发布。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
volatile不可变快照读路径简单,发布关系明确复合写入仍需协调配置与只读状态切换
锁保护可变状态可覆盖复杂原子操作存在阻塞与锁顺序成本多字段业务不变量
并发工具发布结果完成与可见性契约明确仍需单独处理失败、取消和超时并行任务汇总与阶段协调

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01happens-before等于实际时间先后吗?

展开参考答案与推导

不等于,它是规范定义的顺序与可见性关系;时间戳先后不能替代同步边。

02门闩await成功能修复工作者同时执行普通count++吗?

展开参考答案与推导

不能。它保证完成后的观察关系,但工作阶段丢失的复合更新已经发生,需要原子更新或独立结果槽。

03volatile引用是否让整个对象自动线程安全?

展开参考答案与推导

不会。它保证引用相关发布语义,内部可变字段与复合操作仍需独立设计。

换个情况,还会处理吗

01加一行日志后并发bug消失,应如何处理?

展开参考答案与推导

日志可能改变时序或引入同步,不代表原设计正确;列出共享访问与合法同步关系,再用最小复现和并发工具寻找交错。

02配置对象偶尔读到半初始化状态,如何定位?

展开参考答案与推导

检查构造期间this逸出、发布引用是否受同步保护,以及发布后内部集合是否仍被修改。

03初始化门闩超时后服务继续启动有什么风险?

展开参考答案与推导

部分依赖仍未完成或已失败,超时不提供成功返回的全部保证;应明确失败状态、取消或降级启动策略。

写代码试一试

01实现先行图传递闭包。

输入 / 条件
边0→1、1→2、2→3
输出 / 预期
0→3为true

提示 把路径拆成经过k的两段。

展开参考答案与推导

按正文布尔Floyd,拒绝对角线为true的循环。

02用门闩安全发布多个独立结果槽。

输入 / 条件
3工作者分别写10、20、30
输出 / 预期
await成功后和为60

提示 每个工作者仅写自己的槽位。

展开参考答案与推导

finally倒数并单独存储异常;超时不读取未完成汇总,禁止多个线程更新同一普通总和。

03设计volatile停止标志测试。

输入 / 条件
工作线程循环,控制线程设置stop
输出 / 预期
工作线程协作退出

提示 stop声明volatile,退出后join。

展开参考答案与推导

测试正常退出、启动前已停止与带期限join;不要把循环次数和调度时限当作内存模型证明。

读完之后,独立完成

课后作业:自己做一遍

本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能解释停止标志与计数器的不同同步需求,并从规则而非输出猜测判断正确性。

本篇重点加练

安全发布依赖具体先行关系,必须区分引用可见、对象初始化与发布后的可变状态。

  • happens-before
  • 安全发布

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    两个线程共享一个配置对象,一个更新字段、另一个读取并组合响应。列出共享状态、读写动作和可能的数据竞争,不以代码行执行顺序作为跨线程保证。

    提交内容
    共享变量清单与动作图
    怎样算完成
    • 标出对象内部可变字段
    • 区分读写竞争与业务不一致

    练到的知识点共享变量 · 数据竞争

  2. 作业 02代码推演

    线程 A 先写普通字段再写 volatile 标志,线程 B 在读取标志前后各读取一次普通字段。分析每次读取可使用的同步依据。

    提交内容
    先行关系图与判定记录
    怎样算完成
    • 只连接实际成立的关系
    • 区分规则证明与实验样本

    练到的知识点happens-before · volatile 写读

  3. 作业 03代码推演

    主线程在启动前后分别设置两个字段,工作线程写回结果,主线程可能 join 成功或超时。逐次标明哪些观察拥有明确同步依据。

    提交内容
    三线程事件表
    怎样算完成
    • 区分 join 成功和未结束
    • 覆盖工作线程异常结束

    练到的知识点线程启动 · 线程结束与 join

  4. 作业 04代码推演

    自行构造三个线程通过两个 volatile 字段传递状态的具体动作序列及读到值记录,其中一次读取未读到你关注的写入;再画出成立的先行关系,审查能否仅凭墙钟先后补边。

    提交内容
    动作序列、读取记录与关系图
    怎样算完成
    • 每条边注明规则
    • 构造的读取记录须说明是否符合所用规则

    练到的知识点happens-before

  5. 作业 05原理问答

    对象构造过程中把 this 注册到回调表,构造完成后才设置部分字段。评审“引用已放入线程安全容器”是否足以描述发布过程。

    提交内容
    构造与回调时间线
    怎样算完成
    • 区分容器操作和对象状态
    • 不假定回调一定延迟执行

    练到的知识点安全发布

综合应用

  1. 作业 06设计评审

    应用用 volatile 引用发布可变 Map,发布后继续原地修改两个相关键。审查发布与后续更新分别需要满足的契约。

    提交内容
    对象生命周期与更新方案评审
    怎样算完成
    • 包含复合约束
    • 明确读者能观察的版本边界

    练到的知识点安全发布 · 复合操作

  2. 作业 07编程实践

    实现本地配置快照发布实验,用有界循环与门闩控制参与线程;为普通发布和受控发布分别记录观察样本及规范层面的判断。

    提交内容
    Java 源码、记录与规则引用
    怎样算完成
    • 测试有超时和清理
    • 没有异常样本不能被写成正确性证明

    练到的知识点共享变量 · 数据竞争 · happens-before · volatile 写读 · 安全发布 · 复合操作 · 线程启动 · 线程结束与 join

  3. 作业 08设计评审

    只读配置由多个数组组成,发布后开发者为了节省内存复用原数组作为下一版构建缓冲。审查快照契约所需的所有权规则。

    提交内容
    引用所有权与版本图
    怎样算完成
    • 包含嵌套数组
    • 区分只读接口和实际不可变

    练到的知识点happens-before · 安全发布

  4. 作业 09编程实践

    编写本地小型动作图校验器,输入同步关系和读写动作,输出哪些可见性论证有路径依据;为初始化逃逸准备独立反例模型。

    提交内容
    代码、图数据及报告
    怎样算完成
    • 声明只验证输入模型而非 JVM 全行为
    • 不凭重复运行无异常认定安全

    练到的知识点happens-before · 安全发布

进阶挑战 · 掌握前置后选做

  1. 作业 10设计评审

    开发者声称加入 volatile 后“所有操作都按代码顺序执行”。准备两个不同类型的反例任务来审查此说法,并说明每个任务检验的边界。

    提交内容
    反例设计与论证
    怎样算完成
    • 分别涉及发布和复合修改
    • 不依靠日志造成的额外同步得结论

    练到的知识点happens-before · volatile 写读 · 安全发布 · 复合操作 · 线程结束与 join

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 反例构造

    构造对象顶层引用被安全发布、但内部状态仍可能被不安全修改的例子,要求发布步骤本身符合你注明的规则。

    提交内容
    反例及所有权分析
    怎样算完成
    • 区分发布缺陷与后续修改缺陷
    • 包含可变成员的真实共享路径

    练到的知识点happens-before · 安全发布

  2. 作业 12思维拓展 · 跨域迁移

    将安全发布思想迁移到热更新模型参数:推理线程只应看到完整的一版参数,后台会异步加载。重新定义发布点、参数所有权和旧版寿命。

    提交内容
    跨域生命周期设计
    怎样算完成
    • 包含部分加载失败
    • 区分单进程发布与跨机器一致性

    练到的知识点happens-before · 安全发布

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗