面试知识

ConcurrentHashMap(并发哈希映射)与集合并发:从 Segment(分段)锁到协助扩容

09-Java核心基础与集合 面试知识整理。

ConcurrentHashMap(并发哈希映射)与集合并发:从 Segment(分段)锁到协助扩容

本章以 JDK(Java 开发工具包)8 为主线,比较 JDK(Java 开发工具包)7/17/21。目标是能讲清 JVM(Java 虚拟机)内的单键并发,而不把它误当分布式锁、数据库事务或恰好一次副作用。

版本核心实现本章结论
JDK(Java 开发工具包)7u80Segment(分段)继承 ReentrantLock(可重入锁),段内 HashEntry(哈希条目)表。以段拆开写竞争,读依赖易失可见性。
JDK(Java 开发工具包)8u402table(桶数组)、空桶 CAS(比较并交换)、冲突桶 synchronized(同步锁)。无运行时 Segment(分段);锁粒度到当前桶结构。
JDK(Java 开发工具包)17/21保留桶级协调、协助扩容和分散计数。JDK(Java 开发工具包)21 的桶访问采用获取/发布语义;私有实现可演进。

一手源码。 本机 JDK(Java 开发工具包)8:/Users/Lever/Library/Java/JavaVirtualMachines/corretto-1.8.0_402/Contents/Home/src.zipjava/util/concurrent/ConcurrentHashMap.java;本机 JDK(Java 开发工具包)21:/Users/Lever/Library/Java/JavaVirtualMachines/openjdk-21.0.2/Contents/Home/lib/src.zipjava.base/java/util/concurrent/ConcurrentHashMap.java;固定标签:JDK(Java 开发工具包)7u80-b15JDK(Java 开发工具包)17.0.2JDK(Java 开发工具包)21 API(应用程序接口)

1. 契约:单键安全,不是全局业务事务

1.1 可见性、原子边界与 null(空值)

成功更新一个 key(键)与随后读到该 key(键)非空值之间建立 happens-before(先行发生)关系;它保证该映射的安全发布,不保证两个键、一次遍历、数据库或远程调用构成同一事务。键和值均禁止 null(空值):get(读取)返回 null(空值)唯一表示缺失,避免“映射为空值”和“键不存在”必须二次检查而跨越并发窗口。

能保证不能保证工程责任
单键 put(写入)/remove(移除)和原子方法。多键原子、实时快照。状态机或数据库事务。
非空读取看到已发布节点。跨 JVM(Java 虚拟机)互斥。数据库唯一约束、Redis(远程字典服务)Lua(脚本语言)。
null(空值)表示无映射。外部调用只执行一次。幂等键、Outbox(发件箱)、对账。
flowchart LR
 A["线程甲 put(写入) k=RUNNING"] --> M["ConcurrentHashMap(并发哈希映射)单键发布"] --> B["线程乙 get(读取) k"]
 A -.不涵盖.-> D["数据库状态"]
 A -.不涵盖.-> R["另一实例与远程副作用"]

实线是单键发布;虚线必须由业务协议保证。没有整表锁,也不能把内存命中解释为持久化已提交。所有版本适用;误区是把“线程安全”说成全链路恰好一次。

热门面试题

  1. 问题(基础题):ConcurrentHashMap(并发哈希映射)的线程安全边界?
    • 考点:单键、可见性、分布式边界。
    • 回答思路:先给 JVM(Java 虚拟机)内单键保证,再排除跨键和跨实例。
    • 详细答案:它协调一个 JVM(Java 虚拟机)内映射的并发访问,单键更新安全发布,compute(计算更新方法)等方法可原子处理一个键。两个键、数据库更新、消息发送和其他实例不在该边界内;应由条件更新、唯一约束、状态机和幂等协议负责。
    • 进阶追问:读到运行中状态是否说明任务已落库?
    • 进阶回答:不说明;它只反映本进程内存。用户可见任务状态和恢复依据必须查询持久化事实。
  2. 问题(原理题):为什么禁止 null(空值)?
    • 考点:缺失判定、二次读取竞争。
    • 回答思路:解释 get(读取)返回值二义性。
    • 详细答案:允许值为 null(空值)时,get(读取)无法区分缺失和映射为空;再调 containsKey(判断是否含键)会跨越并发窗口。禁止空值使一次读取即可判定缺失,也让原子方法能把 null(空值)作为不建立/删除的控制结果。
    • 进阶追问:业务未知值如何表示?
    • 进阶回答:用枚举、状态对象或 Optional(可选值容器),不要把空引用混入业务状态。
  3. 问题(项目题):Runner(执行器)任务状态能放这里吗?
    • 考点:临时状态与最终事实。
    • 回答思路:允许本地句柄,拒绝持久化真相。
    • 详细答案:可存本实例运行句柄、短期进度和取消标志,结束必须清理并限量;领取、成功、失败和重试次数应以数据库条件更新、租约和状态机为准。
    • 进阶追问:能作为分布式锁吗?
    • 进阶回答:不能;实例各有一张表且宕机丢状态。需共享、可恢复的协调协议。

2. JDK(Java 开发工具包)7:Segment(分段)锁

2.1 segments(分段数组)、HashEntry(哈希条目)与 concurrencyLevel(并发级别)

JDK(Java 开发工具包)7 以哈希高位路由到 segments(分段数组);Segment(分段)继承 ReentrantLock(可重入锁),每段维护桶表、count(元素数)和阈值。HashEntry(哈希条目)的 value(值)/next(后继引用)和表槽位按易失语义访问,读通常不取段锁。concurrencyLevel(并发级别)被取整为段数提示,只允许不同段并行写;不是每 key(键)一把锁,也不是业务并发上限。

flowchart TB
 M["ConcurrentHashMap(并发哈希映射)"] --> S0["Segment 0\nReentrantLock(可重入锁)\nHashEntry(哈希条目)[]"]
 M --> S1["Segment 1\nReentrantLock(可重入锁)\nHashEntry(哈希条目)[]"]
 K["hash(哈希)高位"] --> S0

节点表示两级定位;一把锁保护一个段,不保护每个键。仅 JDK(Java 开发工具包)7 主路径适用;误区是将构造提示误说成固定并发度。

字段/流程作用代价
segmentShift(段位移)/掩码从哈希高位选段。热点落同段仍串行。
段内 put(写入)获取段锁后找桶、更新或扩容。同段写互斥。
易失读取读表/节点通常不取锁。不是整表快照。

热门面试题

  1. 问题(基础题):JDK(Java 开发工具包)7 如何并发写?
    • 考点:分段锁、读可见性。
    • 回答思路:先选段,再锁段内桶表。
    • 详细答案:写入先按哈希选择 Segment(分段),获取该段 ReentrantLock(可重入锁)后修改段内表;其他段可以并行。节点链接和值有易失语义,读取通常不需要段锁。
    • 进阶追问:读不锁是否代表强一致?
    • 进阶回答:不代表;它只保证发布可见,不能把多个桶读成同一时刻快照。
  2. 问题(原理题)concurrencyLevel(并发级别)越大越好吗?
    • 考点:段数、热点、内存。
    • 回答思路:指出它只影响内部划分。
    • 详细答案:更大段数可能减少不同哈希前缀的竞争,但增加对象和表开销;同一热点键仍在同段锁上排队。应修复键分布和业务分片,不能把它当性能开关。
    • 进阶追问:它在 JDK(Java 开发工具包)8 是否仍是段数?
    • 进阶回答:不是;JDK(Java 开发工具包)8 已无运行时分段数组,构造参数只是容量/并发更新提示。
  3. 问题(项目题):IoT(物联网)报警为何仍可能热点?
    • 考点:键倾斜。
    • 回答思路:同设备/租户会集中段。
    • 详细答案:报警键哈希若集中,同一段锁会竞争;应按租户/设备分片、限流并控制窗口键数。跨实例去重仍交给 Redis(远程字典服务)或流分区。
    • 进阶追问:如何证明?
    • 进阶回答:结合线程 dump(线程转储)、写入分片统计和 CPU(中央处理器)火焰图,而不是只看总吞吐。

2.2 JDK(Java 开发工具包)7 的 size(元素数)

size(元素数)多轮读取每段 count(元素数)和 modCount(结构修改计数);修改计数稳定则接受汇总,持续变化达到阈值才锁住所有段统计。因此通常避免全段锁,高写压力下会退化为锁全部 Segment(分段)。

阶段做法边界
重试比较各段 modCount(结构修改计数)总和。尽力稳定,不是无锁事务。
兜底锁所有 Segment(分段)再相加。写入被阻塞。
溢出返回 Integer.MAX_VALUE(整数最大值)。int(整数)接口限制。

热门面试题

  1. 问题(基础题):JDK(Java 开发工具包)7 的 size(元素数)为何要重试?
    • 考点:分段并发变化。
    • 回答思路:统计和写入可交错。
    • 详细答案:逐段相加时其他段仍能更新,修改计数稳定才说明采样较可信;反复变化才锁全段取得受互斥保护的总和。
    • 进阶追问:它永远无锁精确吗?
    • 进阶回答:不是;高竞争时明确会锁全部段。
  2. 问题(原理题):为何全局计数是热点?
    • 考点:同一内存地址竞争。
    • 回答思路:写线程都要更新同一值。
    • 详细答案:即使桶分段,所有增删仍写同一计数也会在锁/CAS(比较并交换)和缓存行上竞争,所以后来版本改为分散计数。
    • 进阶追问:一个 AtomicLong(原子长整型)够吗?
    • 进阶回答:功能够,高竞争吞吐却可能很差;它仍是单地址 CAS(比较并交换)热点。
  3. 问题(项目题):异步导出“处理中数”能作配额吗?
    • 考点:近似指标与全局判决。
    • 回答思路:本地数只做观测。
    • 详细答案:可用于实例监控和保护性降载,不可作多实例精确配额;全局名额须共享存储原子占用。
    • 进阶追问:重启怎么办?
    • 进阶回答:本地数丢失,任务状态以数据库为准。

3. JDK(Java 开发工具包)8+:桶级 CAS(比较并交换)与 synchronized(同步锁)

3.1 table(桶数组)、sizeCtl(容量控制字段)与 putVal(内部写入方法)

table(桶数组)首次 put(写入)才由 initTable(初始化表方法)建立。sizeCtl(容量控制字段)为 0(默认初始容量)、表未建时正初始容量、-1(初始化中)、表建成后的正扩容阈值,或扩容中的负状态(resize stamp(扩容戳)加工作者状态)。空桶用 CAS(比较并交换)安装 Node(节点);冲突桶才锁当前桶首 Node(节点)或 TreeBin(树桶),并在锁内二次确认首对象未换。

sequenceDiagram
 participant A as 线程甲
 participant T as table(桶数组)
 participant B as 线程乙
 A->>T: CAS sizeCtl(容量控制字段)为 -1,初始化
 B->>T: 空桶 CAS(比较并交换) null→Node(节点)
 A->>A: 冲突桶 synchronized(同步锁)桶首
 A->>T: 校验桶首后更新链/TreeBin(树桶)
 Note over A,T: 更新路径遇 MOVED(迁移中标记)才可 helpTransfer(协助迁移方法)

CAS(比较并交换)只抢空槽/初始化;synchronized(同步锁)不是整表锁,也不是永久桶锁,树化和扩容会替换桶首。适用于 JDK(Java 开发工具包)8+;不要把 JDK(Java 开发工具包)7 的 Segment(分段)带入此路径。

sizeCtl(容量控制字段)含义不应误解为
0(零)/表未建正数默认或初始容量提示。已有容量。
-1(负一)初始化排他。整表锁。
表建成后的正数下一次扩容阈值。当前元素数。
其他负数扩容代次与协作者状态。业务可依赖的位协议。

热门面试题

  1. 问题(基础题):JDK(Java 开发工具包)8 的 put(写入)流程?
    • 考点:懒表、空桶、冲突桶、迁移。
    • 回答思路:按四种桶状态讲。
    • 详细答案:空表先 initTable(初始化表方法);空桶 CAS(比较并交换)写节点;桶首为 MOVED(迁移中标记)先协助扩容;普通冲突桶锁当前首节点并更新链/树,新增后分散计数并检查扩容。
    • 进阶追问:锁住哪一个对象?
    • 进阶回答:当前桶首 Node(节点)或 TreeBin(树桶),不是 Map(映射接口)整体;所以锁内必须二次校验桶首。
  2. 问题(原理题):负 sizeCtl(容量控制字段)表示什么?
    • 考点:初始化和扩容状态。
    • 回答思路:分开 -1(初始化中)与其他负数。
    • 详细答案-1(初始化中)阻止重复建表;其他负数编码扩容戳和协作者状态,避免不同扩容代次混淆。位布局是私有实现,会随版本演进。
    • 进阶追问:是否意味着所有读写暂停?
    • 进阶回答:否;get(读取)仅按转发节点查找新表,更新路径才可按迁移路标协助搬迁。
  3. 问题(项目题):缓存击穿为何不能 get(读取)后 put(写入)?
    • 考点:check-then-act(先检查再执行)。
    • 回答思路:两个线程同时缺失。
    • 详细答案:两个线程都可读空后同时回源,映射即使只留一个值也已放大下游;快速纯本地构造可用 computeIfAbsent(不存在则计算),跨实例还须 Redis(远程字典服务)协调、限流和回源保护。
    • 进阶追问:它保证远程调用一次吗?
    • 进阶回答:不保证;多实例、异常、超时重试和宕机都会重复,远程接口必须幂等。

3.2 JMM(Java 内存模型)发布与读取

JDK(Java 开发工具包)8 的 tabAt(读取桶位方法)使用 Unsafe(非安全底层工具)易失读取,CAS(比较并交换)安装空桶,setTabAt(设置桶位方法)保守地易失发布;JDK(Java 开发工具包)21 用 getReferenceAcquire(获取引用)与 putReferenceRelease(发布引用),仍结合 CAS(比较并交换)和桶首监视器。结论是“先构造后发布、读取按获取语义观察”,不是某个私有方法名永不改变。

动作JDK(Java 开发工具包)8JDK(Java 开发工具包)21结论
读桶首易失读取。获取引用。看到安全发布节点。
安装空桶对象 CAS(比较并交换)。引用 CAS(比较并交换)。同一空槽只有一个赢家。
锁内发布易失写。发布引用。构造写不被重排到发布后。

热门面试题

  1. 问题(基础题):读为何通常不显式加锁?
    • 考点:安全发布。
    • 回答思路:节点先构造,桶位后发布。
    • 详细答案:读通过易失/获取语义取得桶首,写用 CAS(比较并交换)或发布语义安装已构造节点;因此单键读通常无需锁。它不是整表快照保证。
    • 进阶追问:CAS(比较并交换)失败?
    • 进阶回答:重读状态并进入冲突桶、迁移或重试路径,不能直接覆盖。
  2. 问题(原理题):JDK(Java 开发工具包)8 与 21 的差异如何表述?
    • 考点:实现演进与语义。
    • 回答思路:先报事实,再收束同步语义。
    • 详细答案:8 使用 Unsafe(非安全底层工具)易失访问,21 以获取/发布引用访问表达相同发布协议;对使用者而言应依赖公开并发契约,不反射私有字段。
    • 进阶追问:普通数组赋值可替代吗?
    • 进阶回答:不可;它既不能原子抢空槽,也没有这里所需发布/获取关系。
  3. 问题(项目题):偶尔读旧进度先查什么?
    • 考点:可变值与跨实例。
    • 回答思路:先查是否原地改值。
    • 详细答案:确认是否以不可变快照 put(写入)替换,还是绕过映射原地改可变对象;再查路由到另一实例、异步落库和缓存刷新。不能先断言是容器缺陷。
    • 进阶追问:改法?
    • 进阶回答:发布不可变值;最终进度以持久化/事件为准。

4. 扩容:协作迁移而非整表停顿

4.1 nextTable(新桶数组)、transferIndex(迁移索引)与 ForwardingNode(转发节点)

扩容创建 nextTable(新桶数组)。线程 CAS(比较并交换)递减 transferIndex(迁移索引)领取由 stride(迁移步长)界定的桶区间;搬完旧桶就在原槽放 ForwardingNode(转发节点)。JDK(Java 开发工具包)8+/21 的 get(读取)遇到它会调用 ForwardingNode.find(转发节点查找方法)沿 nextTable(新桶数组)查找,Iterator(迭代器)的 Traverser(遍历器)只切换遍历表;两者不调用 helpTransfer(协助迁移方法)、不领取迁移区间。put(写入)/replace(替换)/compute(计算更新方法)等更新路径遇 MOVED(迁移中标记)才可调用 helpTransfer(协助迁移方法)参与搬迁。resize stamp(扩容戳)与工作者计数放在 sizeCtl(容量控制字段)中防混代。

flowchart TB
 N["nextTable(新桶数组)"] --> I["transferIndex(迁移索引)=16"]
 I --> A["甲 CAS(比较并交换)领取 12..15"]
 I --> B["乙 CAS(比较并交换)领取 8..11"]
 A --> F["旧槽 ForwardingNode(转发节点)"]
 B --> F
 F --> G["get(读取):find(查找)沿 nextTable(新桶数组)"]
 F --> V["Traverser(遍历器):切换遍历表"]
 F --> H["更新路径见 MOVED(迁移中标记)→ helpTransfer(协助迁移方法)"]
 H --> T["完成后发布新 table(桶数组)"]

transferIndex(迁移索引)是工作领取指针,不是元素数;转发节点是导航路标,不是业务映射。图中的 get(读取)与 Traverser(遍历器)只导航,只有更新路径可加入协作。适用于 JDK(Java 开发工具包)8+;误区是扩容期间全表不可读,或把读路径误说成参与搬迁。

时刻指针
T016未领取。建新表。
T112[12,16)待领。
T28搬迁。[8,12)
T30见转发节点转新表。收尾。

热门面试题

  1. 问题(基础题):扩容如何协作?
    • 考点:分片、转发、帮助。
    • 回答思路:新表、领取、路标、收尾。
    • 详细答案:线程领取不同桶区间搬到新表,旧槽放 ForwardingNode(转发节点)。get(读取)通过其 find(查找)沿新表查找,Traverser(遍历器)只切换表;put(写入)/replace(替换)/compute(计算更新方法)等更新线程见 MOVED(迁移中标记)才可 helpTransfer(协助迁移方法)帮搬,全部完成后发布新表。
    • 进阶追问:转发节点价值?
    • 进阶回答:既声明旧桶已迁,又携带新表方向,避免继续写旧链或重复迁移。
  2. 问题(原理题):步长为何折中?
    • 考点:CAS(比较并交换)开销与均衡。
    • 回答思路:小块抢得频,大块尾部不均。
    • 详细答案:太小导致频繁竞争指针,太大导致少数线程长尾;源码按表和 CPU(中央处理器)计算折中,不能保证每桶同成本。
    • 进阶追问:是否无延迟尖峰?
    • 进阶回答:否;迁移、分配和 GC(垃圾回收)仍会抬高尾延迟,需预估容量和压测。
  3. 问题(项目题):报警风暴扩容如何治理?
    • 考点:键空间、堆、背压。
    • 回答思路:先止新键暴涨。
    • 详细答案:监控新键率、条目、堆、GC(垃圾回收)和 P99(99 分位响应时间);限流、限制窗口和清理可再生数据止血,长期加 TTL(存活时间)、最大条目和外部去重。
    • 进阶追问:大初始容量足够?
    • 进阶回答:只能少扩容,不能修复无界键空间。

4.2 baseCount(基础计数)与 CounterCell(计数单元)

新增/删除先 CAS(比较并交换)baseCount(基础计数);竞争时按线程探针写 CounterCell(计数单元),持续冲突才扩槽。sumCount(计数汇总方法)相加基础与所有单元;size(元素数)夹到 int(整数)范围,mappingCount(映射计数)给非负 long(长整数)。汇总期间写仍可发生,因此不是任意时刻线性一致。

flowchart LR
 W["写线程"] --> B["CAS(比较并交换)baseCount(基础计数)"]
 B -->|失败| C["按 probe(探针)更新 CounterCell(计数单元)"]
 B -->|成功| S["基础计数"]
 C --> Q["sumCount(计数汇总方法)"]
 S --> Q --> R["size(元素数)/mappingCount(映射计数)估算"]

槽是分散热点,不是一线程一槽;适用于 JDK(Java 开发工具包)8+。误区是将 mappingCount(映射计数)的更宽类型误称为更强一致。

API(应用程序接口)结果边界
sumCount(计数汇总方法)基础加所有槽。并发观察窗口。
size(元素数)int(整数)夹值。可饱和。
mappingCount(映射计数)非负 long(长整数)。仍非快照。

热门面试题

  1. 问题(基础题):为何使用 CounterCell(计数单元)?
    • 考点:CAS(比较并交换)热点。
    • 回答思路:基础快路径,冲突分槽。
    • 详细答案:低竞争直接更新基础计数;竞争时分散到槽,减少同一内存地址 CAS(比较并交换)失败,以汇总成本换写吞吐。
    • 进阶追问:每线程独占槽?
    • 进阶回答:不是,槽有限且可碰撞,只是显著降热点。
  2. 问题(原理题)mappingCount(映射计数)更准确吗?
    • 考点:范围与一致性。
    • 回答思路:区分两个维度。
    • 详细答案:它避免 int(整数)饱和,但与 size(元素数)同样在并发汇总窗口读取,不能做账务精确数。
    • 进阶追问:精确配额?
    • 进阶回答:数据库条件更新或 Redis(远程字典服务)原子脚本。
  3. 问题(项目题):计数竞争如何排查?
    • 考点:火焰图、写比率。
    • 回答思路:先确认热点在计数路径。
    • 详细答案:用火焰图/JFR(Java 飞行记录器)确认 addCount(增加计数方法)/CAS(比较并交换)重试,再看写率与热点键;专用指标用 LongAdder(长整型累加器),不要频繁轮询 size(元素数)。
    • 进阶追问:反射改槽数?
    • 进阶回答:不能依赖私有字段;优化业务模式并压测。

5. 原子复合操作与弱一致迭代

5.1 computeIfAbsent(不存在则计算)不是远程事务

if (get == null) put 存在 check-then-act(先检查再执行):两个线程可同时回源。putIfAbsent(不存在则写入)、computeIfAbsent(不存在则计算)、compute(计算更新方法)、merge(合并)只提供一个键的一次调用边界;函数返回 null(空值)表示不建/删除,异常不会形成该次映射更新。空桶计算会使用 ReservationNode(预留节点);慢函数可能占住桶协调路径,递归更新同一映射可能抛 IllegalStateException(非法状态异常)。

方法单键语义禁止推导
putIfAbsent(不存在则写入)原子安装已算好的值。值只计算一次。
computeIfAbsent(不存在则计算)协调缺失键建立。网络调用/跨实例只执行一次。
compute(计算更新方法)/merge(合并)单键重映射。多键或数据库事务。

热门面试题

  1. 问题(基础题):为何 get(读取)后 put(写入)会失败?
    • 考点:检查与动作间隙。
    • 回答思路:两线程读空。
    • 详细答案:两个线程都可看到缺失并各自做昂贵回源,最后映射虽只保留一个值,副作用已重复;快速纯计算用 computeIfAbsent(不存在则计算)协调单键。
    • 进阶追问putIfAbsent(不存在则写入)解决计算放大?
    • 进阶回答:不完全;候选值可已被两个线程计算,失败者只能释放自己的资源。
  2. 问题(原理题):为何不能在 compute(计算更新方法)做网络调用?
    • 考点:桶阻塞、异常、递归。
    • 回答思路:函数位于协调路径。
    • 详细答案:慢 I/O(输入输出)会阻塞同桶键,超时重试放大压力;异常或宕机无法让外部副作用与映射原子,递归更新还受限制。函数应短小、确定、无 I/O(输入输出)。
    • 进阶追问:能保证一次函数调用吗?
    • 进阶回答:不能扩展为外部恰好一次;重试、多实例和崩溃都可能重放。
  3. 问题(项目题):异步导出如何避免慢计算卡桶?
    • 考点:数据库判决、本地句柄。
    • 回答思路:任务创建与执行分离。
    • 详细答案:数据库按业务键唯一创建任务;本地表只存任务号到 Future(未来结果)/取消句柄,工作线程异步执行。不要把查库、生成文件或 RPC(远程过程调用)塞入计算函数。
    • 进阶追问:多实例重复提交?
    • 进阶回答:唯一约束冲突后返回已有任务,消息消费者也按任务号幂等。

5.2 weakly consistent(弱一致)遍历和 bulk(批量)操作

Iterator(迭代器)/forEach(逐个处理)不抛 CME(并发修改异常);公开契约只说明元素反映 Iterator(迭代器)创建时或之后某个时点的状态,不保证快照、完全实时或固定结果集合。当前 JDK(Java 开发工具包)8+ 的 Traverser(遍历器)遇 ForwardingNode(转发节点)只切换遍历表,不领取迁移任务;这是实现导航细节,调用方不得依赖它推导遍历去重、顺序或结果集合语义。并行 forEach(逐个处理)/reduce(归约)可用 ForkJoinPool(分治线程池)公共池,函数必须无顺序依赖、无阻塞和可安全合并。

sequenceDiagram
 participant I as Iterator(迭代器)
 participant M as Map(映射接口)
 participant W as 写线程
 I->>M: 看见 A
 W->>M: 插入 B,旧桶放 ForwardingNode(转发节点)
 I->>M: 转新表继续
 Note over I,M: 元素反映创建时或之后某时点;不是快照

图中没有迭代锁,转发节点只导航、不协助搬迁。JDK(Java 开发工具包)8+ 的 Traverser(遍历器)这样实现,但公开弱一致契约仅保证不抛 CME(并发修改异常)及元素的观察时点;调用方不得依赖图推导不重复、顺序或完整集合。误区是“弱一致=实时快照”。

操作并发语义使用边界
Iterator(迭代器)不抛 CME(并发修改异常),非快照。导出/对账不能作为权威来源。
并行 forEach(逐个处理)可拆公共池。不阻塞、不依赖顺序。
size(元素数)分散计数汇总。趋势监控,不作精确判决。

热门面试题

  1. 问题(基础题):迭代会抛 ConcurrentModificationException(并发修改异常)吗?
    • 考点:弱一致。
    • 回答思路:不会,但不全见。
    • 详细答案:不会。公开弱一致契约说明元素反映 Iterator(迭代器)创建时或之后某个时点的状态;它不保证某一时刻快照、完全实时、顺序、完整集合或调用方可依赖的去重结果。JDK(Java 开发工具包)8+ 的 Traverser(遍历器)会通过转发节点导航,但这不是额外 API(应用程序接口)保证。
    • 进阶追问:严格导出?
    • 进阶回答:数据库一致读或受控不可变快照。
  2. 问题(原理题):并行 forEach(逐个处理)总更快吗?
    • 考点:拆分成本、公共池。
    • 回答思路:计算收益要覆盖调度成本。
    • 详细答案:小表/轻函数会被分拆开销拖慢;阻塞 I/O(输入输出)会耗尽公共池,写普通累加器会丢更新。只对大规模纯计算压测后启用。
    • 进阶追问:如何累加?
    • 进阶回答LongAdder(长整型累加器)、局部归并或满足约束的 reduce(归约)。
  3. 问题(项目题):清理 IoT(物联网)过期去重项?
    • 考点:条件删除。
    • 回答思路:遍历后比对值删除。
    • 详细答案:用 remove(key, expectedValue)(按期望值移除)避免删掉刚刷新的值;弱一致漏项下轮补清,并设容量/TTL(存活时间)/指标。
    • 进阶追问:为何不定时 clear(清空)?
    • 进阶回答:会删有效窗口状态,瞬时放大重复通知。

6. 容器选型与项目边界

6.1 读写比例、迭代、null(空值)与内存

容器适用迭代/复合操作null(空值)与成本
ConcurrentHashMap(并发哈希映射)读写都多、单键状态。弱一致;原子只单键。不允许空键/值;结构额外内存。
Collections.synchronizedMap(同步映射包装器)低并发旧代码。所有访问/迭代持同一锁。取决于底层;整表锁。
Hashtable(哈希表)兼容旧 API(应用程序接口)。实例锁;复合操作仍须锁协议。禁止空值;新路径不推荐。
CopyOnWriteArrayList(写时复制列表)/CopyOnWriteArraySet(写时复制集合)读极多写极少监听器。稳定数组快照。写复制整数组,内存峰值高。
不可变快照配置发布、一致读取。建新对象后替换引用。复制成本随发布频率增长。

热门面试题

  1. 问题(基础题):ConcurrentHashMap(并发哈希映射)和 synchronizedMap(同步映射包装器)怎么选?
    • 考点:粒度与迭代。
    • 回答思路:高并发单键与低并发整锁分开。
    • 详细答案:高并发按键访问选 ConcurrentHashMap(并发哈希映射);旧 Map(映射接口)且低并发可选同步包装器,但迭代/复合操作必须外部持同一锁。二者都不是分布式方案。
    • 进阶追问:Hashtable(哈希表)?
    • 进阶回答:主要为兼容,实例锁竞争大,新代码通常不用。
  2. 问题(原理题):CopyOnWriteArrayList(写时复制列表)为何读多写少?
    • 考点:快照与复制。
    • 回答思路:读旧数组,写新数组。
    • 详细答案:读取/迭代持稳定数组快照;每写一次复制整数组并发布,频繁写和大列表产生写放大与 GC(垃圾回收)压力。
    • 进阶追问:快照迭代可删除?
    • 进阶回答:通常不支持,因为它不是当前可变容器游标。
  3. 问题(项目题):库存、缓存、导出、IoT(物联网)怎样分层?
    • 考点:本地性能层与事实层。
    • 回答思路:逐一指出最终判决。
    • 详细答案:库存本地聚合可用本表,防超卖用数据库条件扣减/流水唯一键;缓存仅削减同实例回源,跨实例用 Redis(远程字典服务);导出本表存句柄,数据库存任务;IoT(物联网)本表短窗降噪,外部系统做跨实例去重。
    • 进阶追问:共同反模式?
    • 进阶回答:把本地容器当持久化事实、分布式锁或幂等表。

7. 排障、示例与复习

7.1 证据链:热点、慢函数、扩容、OOM(内存溢出)

现象证据根因/修复
线程 BLOCKED(阻塞)多次 jstack(线程栈工具)、持锁栈。热点 key(键)/碰撞/慢 compute(计算更新方法);缩短函数、分片、限流。
CPU(中央处理器)高火焰图、JFR(Java 飞行记录器)。CAS(比较并交换)竞争、迁移、昂贵哈希;专用计数/改善键。
延迟与表增长条目、新键率、GC(垃圾回收)、P99(99 分位响应时间)。连续扩容、无界键;TTL(存活时间)、上限、背压。
OOM(内存溢出)Heap(堆)转储、支配树、垃圾回收根引用链。未清理/值过大;清理、淘汰、生命周期治理。
漏/重导出持久化对账与迭代日志。弱一致当快照;改一致读或版本快照。

项目话术。 ConcurrentHashMap(并发哈希映射)只承接 Runner(执行器)句柄、导出进度、缓存和有界告警窗口;我监控键数、TTL(存活时间)、写入率、堆和淘汰。库存/支付/多实例任务使用数据库唯一约束和状态机,Redis(远程字典服务)Lua(脚本语言)或消息系统承担共享原子性。发生问题先以线程 dump(线程转储)、火焰图、堆转储建立证据,而不盲目换容器。

热门面试题

  1. 问题(基础题):如何查热点桶?
    • 考点:线程栈、持锁函数、键分布。
    • 回答思路:先抓阻塞,再关联业务键。
    • 详细答案:多次线程 dump(线程转储)确认是否等同一 Node(节点)/TreeBin(树桶)监视器,火焰图确认持锁线程是否做慢 I/O(输入输出)或比较,再统计热点 key(键)和哈希分布。
    • 进阶追问:一定是哈希碰撞?
    • 进阶回答:不一定;单业务热点键也会竞争,慢函数还会拖同桶其他键。
  2. 问题(原理题):无界映射为何 OOM(内存溢出)?
    • 考点:节点图与扩容峰值。
    • 回答思路:条目和新旧表都占堆。
    • 详细答案:键/值引用的对象图、节点和扩容期间并存的新旧表不断增长,只要被单例持有就不能回收;调大堆只能延后,需 TTL(存活时间)、上限和堆转储证据。
    • 进阶追问:首选修复?
    • 进阶回答:限制键基数并定义完成清理/淘汰,而不是只调 Xmx(最大堆内存)。
  3. 问题(项目题):支付重复如何避免误归因?
    • 考点:持久化流水与跨实例。
    • 回答思路:先复原数据库事实。
    • 详细答案:按事件号、流水、状态迁移和提交时间确认重试/多实例/消息重复;本地弱一致统计不能作为处理证据。以唯一约束、条件更新、幂等键和 Outbox(发件箱)修复。
    • 进阶追问:本地命中高还会重复?
    • 进阶回答:会;重启、路由和扩缩容让请求落到不同实例。

7.2 可执行示例与复习清单

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.LongAdder;

public final class ConcurrentMapExample {
    public static void main(String[] args) throws Exception {
        ConcurrentHashMap<String, LongAdder> counts = new ConcurrentHashMap<>();
        CountDownLatch ready = new CountDownLatch(2), start = new CountDownLatch(1), done = new CountDownLatch(2);
        Runnable task = () -> { ready.countDown(); try { start.await(); counts.computeIfAbsent("sku-8", k -> new LongAdder()).increment(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new AssertionError(e); } finally { done.countDown(); } };
        new Thread(task).start(); new Thread(task).start(); ready.await(); start.countDown(); done.await();
        if (counts.get("sku-8").sum() != 2L) throw new AssertionError("并发验证失败");
        System.out.println("count=2");
    }
}

可用 JDK(Java 开发工具包)8/21 的 javac ConcurrentMapExample.java && java ConcurrentMapExample 运行,预期稳定输出 count=2(计数等于二)。

  • 能区分 JDK(Java 开发工具包)7 Segment(分段)与 JDK(Java 开发工具包)8+ 桶协调。
  • 能画出 ForwardingNode(转发节点)和 transferIndex(迁移索引)的协作扩容。
  • 能说明计数是估算、迭代是弱一致、原子方法只覆盖单键。
  • 能明确本地容器与数据库/Redis(远程字典服务)/消息系统的责任边界。

热门面试题

  1. 问题(基础题):一分钟概括 ConcurrentHashMap(并发哈希映射)?
    • 考点:版本、桶、边界。
    • 回答思路:7 的段、8 的桶、外部责任。
    • 详细答案:JDK(Java 开发工具包)7 用 Segment(分段)锁;8+ 空桶 CAS(比较并交换)、冲突桶锁首节点,扩容多人协作,计数分散。它解决 JVM(Java 虚拟机)内单键共享,不解决分布式事务。
    • 进阶追问:最常见错误?
    • 进阶回答:说成整表锁、实时 size(元素数)或分布式锁。
  2. 问题(原理题):它体现的设计思想?
    • 考点:分散热点、协作。
    • 回答思路:桶、计数、迁移。
    • 详细答案:桶和计数分散热点,迁移由访问线程协作;跨存储一致性外移给专用协议。
    • 进阶追问:可替代所有锁?
    • 进阶回答:不能;多对象不变量仍需事务、不可变设计或专门同步。
  3. 问题(项目题):如何证明没有滥用?
    • 考点:上限、排障、最终一致性。
    • 回答思路:本地用途与外部兜底分开。
    • 详细答案:我给本地表设置容量、TTL(存活时间)和监控,并用线程/堆证据排障;业务真相由持久化唯一性、状态机和可靠消息保证。
    • 进阶追问:代价?
    • 进阶回答:多了协议与运维复杂度,但重启和重试时仍能恢复正确性。