qinghuan
← JUC + JVM / 全部笔记

单元 92 / JUC + JVM

P0 · 必会主线L2 · 进阶

并发集合:容器安全不代表跨调用业务自动原子

用计数更新看懂并发集合能保护哪一次操作,再检查多次调用、多个键和可变 value 是否仍会相互影响。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能用容器原子 API 修复读改写竞态,并判断写时复制是否适合业务负载。

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

为什么学它

线程安全容器保护单次 API 调用,跨调用和跨键的业务规则仍需要额外协调。

先读这些内容

知识点清单

  • ConcurrentHashMap
  • putIfAbsent
  • compute 与 merge
  • 复合操作
  • 弱一致遍历
  • CopyOnWriteArrayList
  • 快照迭代
  • 跨键不变量

运行前先确认JDK17 ConcurrentHashMap、CopyOnWriteArrayList与LongAdder,源码固定17.0.12+7。算法在全部写入任务结束后才计算排名,避免把并发遍历误当全局一致快照。

01先弄懂它是什么

集合标注线程安全,不表示你连续调用几次方法就自动成为一个整体。containsKey 后再 put,中间仍可能有别的线程插入;get 旧计数、加一再 put,也可能丢更新。单个键的“读、算、写”可以用 putIfAbsent、compute 或 merge 等操作表达;如果业务同时关联多个键,还要另行保证它们一起正确变化。

ConcurrentHashMap 适合多个线程按键访问,CopyOnWriteArrayList 适合很少修改、经常读取遍历的列表,阻塞队列适合交接任务。它们对遍历、写入成本、null 和等待各有不同规则。先看实际读写方式与一致性要求,再选类,不能把普通集合统一换成并发集合就认为问题全部解决。

并发 Map 保护的是“这个键对应哪个值”,不会顺便保护值对象里的所有操作。例如 Map 里存着普通 ArrayList,两个线程拿到同一个列表后同时 add,列表仍可能被改坏。可以让值不可变,每次按键整体替换;也可以让值对象自己做好同步。把已初始化的对象正确交给其他线程,和交出去后怎样安全修改它,是两件都要处理的事。

文中这几个词,先认一认

单键原子更新
对一个键的读取、计算和写回作为 API 承诺的一次完整更新,不被并发操作拆开影响。
弱一致遍历
遍历时可能看到并发修改的一部分,不保证所有内容都来自同一个瞬间的快照。
写时复制
修改时复制出新数组;已经开始的遍历继续读取旧数组,因此写入成本较高。
热点键
大量请求集中修改同一个键,大家争用这一处,限制整体并行能力。

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

02看看它怎么工作

ConcurrentHashMap在固定JDK实现中使用数组、桶内节点和必要的树形结构,空桶安装、桶内更新与扩容有不同协调路径。并发度不再等同于旧教程中的固定Segment数量,构造参数concurrencyLevel也不应被解释为精确锁段数。源码研读必须带版本,尤其不能将JDK7结构套到JDK17。

computeIfAbsent适合构造短小的缺失值,merge适合已有计数或聚合值更新。回调需要保持短小,不应在其中做远程IO或递归更新同一映射,因为相关更新可能被阻塞,递归结构还可能触发错误。返回null通常表示不建立或移除映射,必须按所用方法契约理解,不能把它当普通可存储值。

其他线程还在写入时,遍历结果和 size 可以帮助观察情况,却不能用来精确决定“是否还有一个容量名额”。若要得到一致的排名,可以先停止或隔离写入再读取,也可以用带版本的状态明确统计哪一段数据。CopyOnWriteArrayList 的迭代器会继续看创建时的那份数组,但数组里的对象仍可能被修改。频繁修改列表会反复复制数组,增加内存和复制开销。

正在加载流程图…

单键更新保护计数;等待所有写入结束才为最终排名建立稳定输入。
查看流程图源码(Mermaid)
flowchart TD
 I["多个工作者处理词元"] --> M["ConcurrentHashMap.merge 单键原子累加"]
 M --> H["全部写入Future成功结束"]
 H --> S["在静止计数上遍历"]
 S --> Q["容量k的最小堆保留最佳候选"]
 Q --> R["按次数降序与词名升序输出"]
 V["Map中的可变value"] -.-> X["其内部状态仍需独立同步"]

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

  1. 识别先检查后修改等复合操作,改成合适原子API。
  2. 检查value是否可变,明确其同步或不可变约束。
  3. 保持计算回调短小,避免阻塞与递归更新。
  4. 明确遍历是监控视图还是必须一致的业务快照。
  5. 用热点、空值、并发更新与快照边界测试实际契约。

03对照实现代码

ConcurrentHashMap计算映射的原子边界 (新标签页)↗

OpenJDK17.0.12+7 · ConcurrentHashMap#computeIfAbsent / merge

原创公开API示例;固定源码通过桶级协调、CAS及计算占位等路径保证方法契约,此代码不冒充内部存储实现。

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

final class AtomicFrequencyModel {
    private final ConcurrentHashMap<String,Long> counts=new ConcurrentHashMap<>();
    void increment(String key) {
        counts.merge(key,1L,(before,delta) -> Math.addExact(before,delta));
        // 读取旧值和写入新值属于同一键上的原子合并。
    }
    long count(String key) { return counts.getOrDefault(key,0L); }
    // 单键读取不提供整张表的原子快照。
    boolean register(String key,long initial) {
        return counts.putIfAbsent(key,initial)==null; // 缺失判断与安装合为一项操作。
    }
}

computeIfAbsent首先检查参数和键散列,再根据桶状态处理已有映射、空桶、扩容或冲突链。映射已存在时通常直接返回值,缺失时才计算。一次原子计算不等于这个键在整个进程生命周期永远只会执行一次初始化,删除后、返回null后或失败后都可能需要再次计算。

空桶计算可能用保留节点协调,非空桶也要在相关桶上协调检查与写入。因此回调太慢,会拖住其他相关更新,不能把昂贵网络请求放进去还期待完全互不影响。通常应把慢加载与短暂安装映射分开,并明确允许重复加载,还是同一键只能有一次加载在进行。

merge 遇到没有的键,会放入调用方给出的非 null 值;键已存在时,才调用合并函数计算新值。合并函数返回 null,则删除这个键。本文计数用 Math.addExact,数值超出范围就明确报错,避免大正数悄悄变成负数。但这些保证只针对一个键:从 a 扣 3,再给 b 加 3,调用两次 merge 仍不是一笔共同成功或失败的转账。

扩容可能由多个线程协作完成,读取也要识别转发到新表的状态。并发读取通常不要求锁住整表,但汇总由多个元素观察组成,不天然属于同一瞬间。只有已知写入停止等额外条件成立时,才能把遍历结果作为稳定全量输入进行严格排序。

代码先调用谁,再调用谁

  1. 计算键散列并定位桶
  2. 按已有值、空桶、冲突或扩容状态选择路径
  3. 在相应原子协调范围计算或合并映射
  4. 更新桶与计数等内部状态
  5. 返回本键结果,调用方仍需处理跨键一致性

04动手写一个例子

并发词频统计与确定性Top K

输入词元列表,使用多个工作者统计频率;全部统计结束后返回前k个词,次数多优先,同次数按词名字典序升序。词元按原字符串区分,空列表和k=0返回空,null词元拒绝。

输入 1
[b,a,b,a,c],k=2
输出 1
[(a,2),(b,2)]
输入 2
[java,ai,java],k=9
输出 2
[(java,2),(ai,1)]

将输入复制成稳定列表,按工作者编号分配步进索引,每个索引只由一个工作者处理。每个词使用merge原子加一,避免get再put丢失更新。协调者等待全部Future成功结束后才遍历计数,此后没有工作者继续修改,所以排名基于稳定数据。

维护一个容量k的堆,堆顶是当前保留结果中最差的一项:次数更少更差,同次数词名更大更差。新候选比堆顶更好时替换,否则丢弃。这样扫描过任何前缀后,堆都保留该前缀最好的至多k项,不必把所有唯一词都排序。

最后把堆内容按次数降序、词名升序排序用于展示。堆的内部顺序不能直接作为输出排名。字符串比较与散列包含字符处理成本,通常把词长看作有界时才写成简单的n与U复杂度;真实长文本还需考虑分词、规范化和字符编码,本题刻意让输入已经是词元。

为什么这样写是对的每个输入索引恰好贡献一次计数;扫描任意前缀后,堆中保存该前缀排名最好的至多k个唯一词。

时间 通常O(n+U log(k+1)+k log(k+1))额外空间 计数O(U)、候选O(k)、输入快照O(n)

n是词元数,U是唯一词数;并发散列更新按均摊操作数估计,热点竞争不保证线性加速,字符串操作另计字符成本。

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

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

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

Main.java · 完整可运行程序 · Java
import java.util.*;
import java.util.concurrent.*;

public class Main {
    record Hit(String word,long count) {}
    static List<Hit> top(List<String> input,int k,int workers) throws Exception {
        if(k<0 || workers<=0) throw new IllegalArgumentException();
        List<String> tokens=List.copyOf(input); // 固定输入并拒绝null词元。
        if(k==0 || tokens.isEmpty()) return List.of();
        ConcurrentHashMap<String,Long> counts=new ConcurrentHashMap<>();
        int parallel=Math.min(workers,tokens.size());
        ExecutorService executor=Executors.newFixedThreadPool(parallel);
        List<Future<?>> jobs=new ArrayList<>();
        try {
            for(int t=0;t<parallel;t++) {
                int first=t;
                jobs.add(executor.submit(() -> {
                    for(long i=first;i<tokens.size();i+=parallel)
                        counts.merge(tokens.get((int)i),1L,Math::addExact); // 按键原子累加。
                }));
            }
            for(Future<?> job:jobs) job.get(5,TimeUnit.SECONDS); // 写入全部结束后再排名。
        } finally {
            for(Future<?> job:jobs) if(!job.isDone()) job.cancel(true);
            executor.shutdownNow();
        }
        Comparator<Hit> worst=Comparator.comparingLong(Hit::count)
                .thenComparing(Hit::word,Comparator.reverseOrder());
        PriorityQueue<Hit> heap=new PriorityQueue<>(worst);
        for(var entry:counts.entrySet()) {
            Hit hit=new Hit(entry.getKey(),entry.getValue());
            if(heap.size()<k) heap.add(hit);
            else if(worst.compare(hit,heap.peek())>0) { heap.poll(); heap.add(hit); }
            // 堆顶始终是保留集合中最差者,较好候选才能替换。
        }
        List<Hit> result=new ArrayList<>(heap);
        result.sort(Comparator.comparingLong(Hit::count).reversed().thenComparing(Hit::word));
        return List.copyOf(result);
    }
    static void check(boolean ok) { if(!ok) throw new AssertionError(); }
    public static void main(String[] args) throws Exception {
        check(top(List.of("b","a","b","a","c"),2,3).equals(List.of(new Hit("a",2),new Hit("b",2))));
        check(top(List.of("java","ai","java"),9,2).equals(List.of(new Hit("java",2),new Hit("ai",1))));
        check(top(List.of(),2,1).isEmpty());
        check(top(List.of("a"),0,1).isEmpty());
        check(top(Collections.nCopies(1000,"hot"),1,4).equals(List.of(new Hit("hot",1000))));
        check(top(List.of("z","b","a"),1,2).equals(List.of(new Hit("a",1))));
        try { top(Arrays.asList("a",null),1,1); throw new AssertionError(); }
        catch(NullPointerException expected) { }
        System.out.println("PASS 7");
    }
}

这些特殊情况也要试一试

  • k超过唯一词数返回全部唯一词。
  • 同次数排序由词名明确打破平局。
  • 热点词多线程累加不能丢失次数。
  • 空输入与k为0不创建工作线程。
进阶选读 · 从精确热点计数到分片与近似Top K

场景单个热门词占大部分流量,所有线程争用同一键;海量唯一词又使精确Map无法常驻内存。

做法先按工作者局部统计再合并,或对统计量使用LongAdder减少热点争用;唯一词过多时研究Count-Min Sketch配合候选结构,并明确误差与概率参数。严格业务额度仍需精确原子状态。

复杂度局部Map总空间最坏O(pU),合并与条目总数成正比;Count-Min Sketch更新O(d)、空间O(d·w),精度由宽度与深度决定。

好处与代价分片提升更新扩展性却增加汇总成本,近似结构节省内存却可能高估频率;LongAdder.sum在并发更新期间也不是严格原子快照。

05项目里怎么用

先检查是否真的需要多个线程写同一Map。批处理常可以每个工作者维护局部HashMap,完成后合并,减少热点共享;在线持续计数则更适合并发映射或分片累加。避免为很小的数据集付出线程创建与同步成本,性能结论应来自相同输入和相同正确性要求下的测量。

computeIfAbsent 只帮助按键计算和安装值,不是完整缓存:没有自动有效期、容量淘汰、失败缓存和热点重建规则。回调返回 null 或抛异常后,还可能再次加载。长期缓存应采用行为明确的组件,并验证数据允许多旧、失败后怎样重新加载。

接口要说明遍历时能看见什么。监控允许大致反映当前数量,结算账单可能要求同一时刻的严格一致结果,不能共用一个“线程安全所以都正确”的结论。保存快照时还要固定 value;只复制 Map,里面的可变对象仍可能继续变化。

容易踩的坑

  • containsKey加put或get加put造成复合竞态。
  • 并发Map里放普通ArrayList后无同步修改。
  • compute回调做慢IO或递归修改同一映射。
  • 用并发size作精确容量与授权裁决。
  • 把弱一致迭代和CopyOnWrite快照语义混淆。

几种做法,怎么选

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

方案优势代价与限制适用条件
ConcurrentHashMap原子合并单键操作契约清楚热点键争用,非整表事务并发索引与精确单键更新
局部Map后合并减少共享竞争需要额外局部内存与汇合有限批次统计
CopyOnWriteArrayList遍历快照稳定,读路径简单每次写复制数组且元素仍可能可变小型且很少修改的监听器列表
LongAdder统计分散热点累加压力并发sum不提供严格单值快照监控计数与统计聚合

06检验是否读懂了

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

先用自己的话解释

01线程安全Map为什么仍会丢失get加put的计数?

展开参考答案与推导

两次调用之间可交错,多个线程都读取同一个旧值再覆盖,应使用merge或compute表达原子更新。

02CopyOnWrite迭代快照是否复制了每个元素对象?

展开参考答案与推导

没有,它通常固定数组结构引用;元素本身仍可能可变,需要另行保护或不可变化。

03为什么正文等所有Future结束后再排名?

展开参考答案与推导

并发遍历不是整表同一时刻快照,结束写入后才能把计数当稳定全量数据。

换个情况,还会处理吗

01ConcurrentHashMap换上后value列表仍损坏,怎么查?

展开参考答案与推导

检查取得同一可变value后是否并发修改,Map只保护映射关系;改为不可变替换或value内部同步。

02computeIfAbsent里查数据库导致其他更新变慢,怎么办?

展开参考答案与推导

分析桶与热点键争用,把慢加载与短临界安装分离,或使用具备加载去重与失败策略的缓存组件。

03监控计数允许近似,额度扣减不允许,选型如何不同?

展开参考答案与推导

监控可用LongAdder或分片汇总,额度需要精确原子条件更新与持久业务约束,不能用sum后再减的松散协议。

写代码试一试

01实现并发词频Top K。

输入 / 条件
b,a,b,a,c;k2
输出 / 预期
a2,b2

提示 单键merge加最差候选堆。

展开参考答案与推导

按正文先完成统计再排名,次数相同按字典序。

02实现跨键转移计数且保持总和。

输入 / 条件
a10,b5,转移3
输出 / 预期
a7,b8

提示 两次merge不是一笔事务。

展开参考答案与推导

使用统一锁或不可变整图快照保护两个键,并检查余额与溢出。

03对比局部统计合并与共享merge。

输入 / 条件
同一百万词元,分别均匀和热点分布
输出 / 预期
结果完全一致,性能由实测报告

提示 先校验正确性再测吞吐与分配。

展开参考答案与推导

固定输入、线程数和运行条件,采用规范基准方法,分别说明内存与汇合成本。

读完之后,独立完成

课后作业:自己做一遍

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

这次练什么

能用容器原子 API 修复读改写竞态,并判断写时复制是否适合业务负载。

本篇重点加练

单键原子回调和跨键业务约束并不等价,观察及异常路径同样要遵守状态协议。

  • compute 与 merge
  • 跨键不变量

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

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

基础必做

  1. 作业 01代码推演

    缓存初始化使用先 containsKey 再 put,两线程为同一键创建不同对象。构造交错并比较 putIfAbsent 返回值与本地新对象的关系。

    提交内容
    逐调用状态表
    怎样算完成
    • 区分存入对象和返回对象
    • 包含创建对象的资源清理

    练到的知识点ConcurrentHashMap · putIfAbsent

  2. 作业 02原理问答

    购物车每个用户映射到可变条目集合,外层使用 compute 更新映射。分析映射操作与条目对象修改分别受什么约束。

    提交内容
    对象共享图与操作边界
    怎样算完成
    • 不把 value 自动视为线程安全
    • 覆盖回调异常

    练到的知识点compute 与 merge · 复合操作

  3. 作业 03代码推演

    compute 回调准备修改 value 对象后抛异常,value 同时被其他线程持有。逐项区分映射结构变化与对象内部变化的证据。

    提交内容
    引用图与异常状态记录
    怎样算完成
    • 不能只检查 Map 中是否仍有键
    • 包含回调前已有引用

    练到的知识点compute 与 merge

  4. 作业 04原理问答

    系统用两个并发 Set 记录空闲资源和忙碌资源,转换时分别 remove、add。为观察者定义分区不变量并审查读取两个集合的方式。

    提交内容
    状态集合及观察契约
    怎样算完成
    • 包含资源暂时不属于任一集合
    • 区分业务允许与实现偶然

    练到的知识点跨键不变量

综合应用

  1. 作业 05排错实验

    本地一线程遍历联系人,另一线程增删条目;分别使用 ConcurrentHashMap 和 CopyOnWriteArrayList,记录迭代器创建时点及观察结果。

    提交内容
    最小程序与运行记录
    怎样算完成
    • 区分契约允许范围与单次结果
    • 另检查元素自身可变的情况

    练到的知识点弱一致遍历 · CopyOnWriteArrayList · 快照迭代

  2. 作业 06设计评审

    两个并发映射分别维护“用户当前队伍”和“队伍成员集合”,转队要求两者一致。给出跨键不变量并评审单次容器原子操作能覆盖的范围。

    提交内容
    不变量与转队状态图
    怎样算完成
    • 包含失败和重复请求
    • 检查并发读取口径

    练到的知识点跨键不变量

  3. 作业 07编程实践

    实现本地请求注册表,允许并发登记、完成、超时及重复完成;保存状态变更历史并在批次结束后核对每个请求生命周期。

    提交内容
    Java 源码与性质测试
    怎样算完成
    • 状态终结有明确规则
    • 包含空批次和大量相同键

    练到的知识点ConcurrentHashMap · putIfAbsent · compute 与 merge · 复合操作 · 弱一致遍历 · 跨键不变量

  4. 作业 08设计评审

    单键 compute 回调中又更新其他键,多个线程以不同键进入回调。评审终止性、异常与跨键一致性,不假定内部实现细节固定。

    提交内容
    依赖图与接口风险表
    怎样算完成
    • 注明使用 API 的保证范围
    • 不依赖未公开桶布局论证

    练到的知识点compute 与 merge · 跨键不变量

  5. 作业 09编程实践

    实现本地资源登记模型,使用不可变或可变 value 两种候选表示,注入回调异常并用独立历史检查器核对跨集合约束。

    提交内容
    代码与对照证据
    怎样算完成
    • 预先声明顺序规格
    • 区分容器安全和业务性质

    练到的知识点compute 与 merge · 跨键不变量

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

  1. 作业 10设计评审

    配置白名单每秒读十万次、每分钟改十次,每项含可变权限集合。比较两种集合方案的复制、遍历一致性与元素更新风险。

    提交内容
    成本估算与测试方案
    怎样算完成
    • 读写比假设明确
    • 区分容器快照和深层对象快照

    练到的知识点compute 与 merge · 弱一致遍历 · CopyOnWriteArrayList · 快照迭代 · 跨键不变量

思维拓展

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

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

    构造所有容器调用都使用受支持的线程安全 API,但一次业务观察仍不满足“资源恰属一个集合”的历史。

    提交内容
    最小交错反例
    怎样算完成
    • 每个单次调用合法
    • 明确复合观察的时间跨度

    练到的知识点compute 与 merge · 跨键不变量

  2. 作业 12思维拓展 · 条件变化

    把资源登记迁移到多人编辑的内存索引,允许最终一致但要求界面标出未收敛状态。哪些强不变量可改为可观测的收敛条件?

    提交内容
    约束变化与验收指标
    怎样算完成
    • 说明放宽规则的用户影响
    • 包含永久不收敛的识别方法

    练到的知识点compute 与 merge · 跨键不变量

完成后,再检验一次

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

继续阅读

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

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