← 返回 Java 后端知识路线
阶段 02集合与并发

JUC、CAS、volatile 与并发集合:按问题选择工具

通过点击计数和共享缓存,比较原子类、volatile、ConcurrentHashMap 与锁的适用边界。

第 14 / 35 篇
JUCCASvolatileConcurrentHashMap

先看这一课值不值得学

学完后,你手里多了哪些代码积木

用 JUC 组件表达原子性、可见性和任务调度。

本课正式新增

语法 / API / 命令你必须会到什么程度
volatile保证共享变量的可见性和有序性
AtomicInteger用 CAS 完成单变量原子更新
ConcurrentHashMap并发访问的映射结构
ExecutorService / Future提交任务并获得异步结果

本课只借用,先别硬背

  • 底层内存屏障用于解释,不要求手写 JVM 实现

学完必须能独立写

  • 实现并发计数和任务批处理
  • 按问题选择锁、原子类或并发集合
本课目录
  1. 1. 现实问题:加一个计数器为什么仍会丢数据
  2. 2. 最小可运行示例:AtomicInteger 与 ConcurrentHashMap
  3. 3. 调用链与对象变化
  4. 4. 为什么这样设计
  5. 5. 项目落点:缓存和统计分别建模
  6. 6. 易错排查
  7. 7. 一页复习

1. 现实问题:加一个计数器为什么仍会丢数据

访问量统计看似只是 count++,但它包含读取、加一、写回三步。两个线程可以读到相同旧值,最后都写回同一个新值。把字段改成 volatile 后,线程能更快看到变化,却仍然可能丢更新。并发工具要先对应问题:可见性、原子性、复合操作和集合协作并不相同。

JUC(java.util.concurrent)提供原子类、锁、队列、同步器和并发集合,CAS 是“比较并在未变化时交换”的硬件支持抽象。它们不是比 synchronized 更高级的装饰,而是针对不同共享模式的工具。

2. 最小可运行示例:AtomicInteger 与 ConcurrentHashMap

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

public class ConcurrentCounterDemo {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger total = new AtomicInteger();
        ConcurrentHashMap<String, AtomicInteger> byPath = new ConcurrentHashMap<>();
        Thread[] workers = new Thread[4];
        for (int i = 0; i < workers.length; i++) {
            workers[i] = Thread.startVirtualThread(() -> {
                for (int j = 0; j < 1_000; j++) {
                    total.incrementAndGet();
                    byPath.computeIfAbsent("/home", key -> new AtomicInteger()).incrementAndGet();
                }
            });
        }
        for (Thread worker : workers) worker.join();
        System.out.println(total.get() + " / " + byPath.get("/home"));
    }
}

JDK 21 的 Thread.startVirtualThread 让示例不用手动管理平台线程,但核心仍是原子递增和并发 Map。computeIfAbsent 的映射函数应短小、无外部副作用;若值本身还要递增,必须使用 AtomicInteger 或其他原子操作,而不是先 get 再 put。

3. 调用链与对象变化

每次 incrementAndGet 读取 AtomicInteger 当前值,通过 CAS 尝试把旧值替换为旧值加一;如果期间被其他线程改动,就重试。成功后返回新值。volatile 字段的读写也有可见性与顺序语义,但没有这段复合更新的 CAS 循环。

ConcurrentHashMap 将键定位、桶级协作和内存可见性封装起来,computeIfAbsent 在缺失时建立一个值并返回引用;后续 AtomicInteger 的变化由它自己负责。Map 安全不代表 value 对象的所有操作自动安全,容器和元素各有自己的线程边界。

4. 为什么这样设计

原子类适合一个独立数值的无锁更新,volatile 适合一个线程写、多个线程读且写入本身原子的状态标志,例如停止信号。多个字段之间有不变量时,AtomicInteger 不能替代锁或不可变快照。CAS 可能在高竞争下反复重试,性能要用数据验证。

并发集合解决容器结构的并发访问,不会替你解决业务级“检查后更新”协议。ConcurrentHashMap 里缓存未命中时的加载、过期和失败回退,仍需要明确策略。工具越底层,越要把不变量写进方法和测试。

5. 项目落点:缓存和统计分别建模

接口访问量可以先用 LongAdder 做高并发统计,最终批量落库;缓存可以用 ConcurrentHashMap 保存有限的本地数据,但要配合过期、容量和失效机制。不要拿并发 Map 代替 Redis,也不要把统计原子性误当成数据库事务。

练习:实现一个停止标志为 volatile 的任务,并用 CountDownLatch 测试线程最终退出;再写一个 computeIfAbsent 缓存,模拟加载失败,确认异常不会把错误值永久缓存。比较 AtomicInteger、LongAdder 和 synchronized 在低竞争与高竞争下的结果。

6. 易错排查

  • volatile 计数仍不准确:++ 是复合操作,需要原子类或锁。
  • ConcurrentHashMap 里放普通 ArrayList:Map 安全不等于 value 内部操作安全。
  • CAS 自旋占满 CPU:竞争太高或失败路径没有退让,重新评估锁、分段和数据分区。
  • 原子值跨多个字段:两个字段必须同时一致时,用不可变对象替换或锁住复合更新。

7. 一页复习

可见性问 volatile,单值原子更新问 AtomicInteger/LongAdder,容器并发访问问 ConcurrentHashMap,多个状态一起变化问锁或不可变快照。JUC 工具要绑定一个明确的不变量和生命周期,不能按“无锁更快”盲选。任何统计和缓存都要说明最终一致性边界。

把三个概念放到同一个停止任务里:volatile running 让工作线程能看到主线程写入 false,AtomicInteger 让每次独立计数不丢失,锁或不可变对象保证“计数和最后时间”一起更新。只给最后时间加 volatile 不能保护计数,给计数加原子类也不能保证两字段同步。工具的选择来自不变量数量,而不是来自类名新旧。

CAS 的输入是期望旧值和新值,成功输出 true 并完成一次更新,失败输出 false 后重读再试;高竞争下失败次数可能很多,CPU 反而被自旋消耗。LongAdder 适合最终汇总,不适合要求每次读取都得到严格实时值的业务。ConcurrentHashMap 的 key/value 访问安全,也不代表“先判断再删除”这个复合动作具备业务原子性。

项目可以把 VisitCounter 放在 metrics 模块,把 LocalCache 放在 adapter,Service 只依赖计数和缓存接口。缓存 value 若是可变 List,要么在创建时固化,要么由 value 自己提供并发方法。对外暴露的统计要说明是近实时、最终一致还是事务内精确,日志和指标名称也要保持稳定。

排错时总数少,先确认是否使用了普通 ++;停止不及时,检查线程是否真的读取 volatile 而不是缓存到局部变量;缓存 value 数据损坏,检查 Map 安全和元素安全是否被混淆;CPU 飙高,采样 CAS 失败和线程 dump。练习是用 CountDownLatch 让 4 个任务各加 1000 次,再故意加入一个复合字段验证原子类的边界。

可以把一次 AtomicInteger 更新写成输入旧值、CAS 比较、成功新值三列:若旧值仍为 10,则改成 11;若另一个线程先改成 12,当前 CAS 失败,重读 12 后再尝试 13。这个循环没有锁,但不是没有等待。若业务要求同时更新计数和时间,应该用不可变记录整体替换或用锁,而不是拼两个独立原子类。

项目中的本地缓存可以用 ConcurrentHashMap 保护键空间,用 immutable value 保护值内容,用 TTL/版本字段保护生命周期。统计模块把 LongAdder 的结果定期快照并批量写库,写库失败要保留未上报量或告警。缓存和指标都属于辅助能力,不能让并发工具把主业务状态从数据库悄悄移走。

排错时先区分读不到新值、更新丢失、容器异常和资源耗尽:前者查 volatile/内存可见性,后两者查 Atomic/复合操作和 value 类型,CPU 占满查 CAS 失败与自旋,Map 过大查容量和淘汰。不要用 println 证明内存模型,使用有同步起点的测试和线程状态采样。练习是为同一 counter 写 synchronized、AtomicInteger、LongAdder 三版并记录适用条件。

JUC 工具的选择要写成一句不变量说明:此字段只要求可见、此数字要求单值原子、此 Map 要求容器安全、此复合状态要求一起更新。没有这句话时,换工具往往只是换一种竞态。

验证清单:让 4 个线程各累加 1000 次,比较普通 ++AtomicIntegerLongAdder 的最终值及读取时机;另设一个 running 标志,确认停止请求能被工作线程看到。对 ConcurrentHashMap 先判断再删除的复合操作制造竞争,观察为什么容器安全不等于业务动作原子;再把计数和最后时间放入不可变快照整体替换。复盘每个失败时只回答一个问题:是看不到、丢更新、状态未成组,还是资源过量?答案决定使用 volatile、CAS、锁或限流,而不是凭性能印象选类。

练习复盘:给本地缓存加入版本号和过期时间,命中、过期、并发刷新、刷新失败各写一条状态表;刷新失败时旧值是否继续服务必须明确。测试要记录 CAS 重试次数、Map 大小和线程池队列,才能判断“结果正确”是否伴随自旋过高或缓存无界增长。

进阶附录:VarHandle 与内存序

VarHandle 提供更底层的变量访问和内存语义,适合实现并发库,不适合普通业务直接替代原子类。学习时先区分 acquire/release、volatile 和 opaque 语义,再用 jcstress 之类的工具验证可见性;不要靠一台机器上的偶然输出证明内存模型。

本课按「Java 21 java.util.concurrent、CAS 与并发集合」的学习范围组织,正文与示例均为本站原创整理。