1. 项目背景
业务场景:某广告平台的"实时竞价"模块需要在高并发下维护一个"广告位-出价"的映射表。开发选用 HashMap 存储,读写都用 synchronized 保护。压测时发现 100 并发线程竞争下,单次读写延迟超过 10ms——远高于广告交易所的 5ms 超时窗口。团队紧急切换到 ConcurrentHashMap,读写延迟降至 0.3ms,但 QPS 却掉了一半——因为 ConcurrentHashMap.size() 在遍历时执行了全量分段计数。
痛点:
- "线程安全"的误区:
Vector/Collections.synchronizedMap()/ConcurrentHashMap/ 外部加锁——开发者经常把四者当成"一样的线程安全"而随意切换。实际上前三者只保证单个方法(get/put)的原子性,对"先 check 后 act"的复合操作(如if (map.containsKey(k)) map.put(k, v+1))无能为力——只有ConcurrentHashMap.compute()能原子地完成。 ConcurrentModificationException的意外触发:用for-each遍历HashMap时中途修改——这是在测试环境最容易踩的坑。而生产上更隐蔽的是:代码 fork-join 模式下多线程共享同一个ArrayList的迭代器——fast-fail直接抛异常。- 扩容的"隐形地雷":
HashMap扩容(rehash)需要复制所有元素到新桶数组——千万级数据的扩容在单线程下耗时 1-3 秒。如果没有预分配容量——每次自动扩容都伴随着卡顿。
本章从 ArrayList/HashMap 的内部结构和复杂度出发,深入 ConcurrentHashMap 的并发设计(分段锁→CAS+红黑树的演进),最后针对"读多写少/写多/有序/高并发计数"四类场景给出精确的选型表。
2. 项目设计
(小胖的代码被 Review 打回来——"别用 HashMap + synchronized,这里该用 ConcurrentHashMap。" 小胖不服。)
小胖:大师,HashMap + synchronized 和 ConcurrentHashMap 到底有什么区别?不都是"线程安全的 Map"吗?为什么 Review 非让我改?
大师(在白板上画出四个象限):区别大了。我先教你一个"线程安全四象限"检查法:
| 方案 | get 并发 | put 并发 | 复合操作原子性 | 迭代线程安全 |
|---|---|---|---|---|
HashMap (无同步) |
不安全 | 不安全 | 不安全 | 可能无限循环 |
synchronizedMap |
安全(串行) | 安全(串行) | 不安全(需外部锁) | 不安全(需外部锁) |
ConcurrentHashMap |
高并发 | 高并发 | 安全(compute等) | 安全(弱一致性) |
Hashtable |
安全(串行) | 安全(串行) | 不安全(需外部锁) | 不安全(需外部锁) |
看第三列——"复合操作原子性"——这是最致命的区别。
// synchronizedMap 的"原子性假象"
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());
// 线程 A 和 B 同时执行:
Integer val = syncMap.get("count"); // ← 两个线程都读到 10
syncMap.put("count", val + 1); // ← 两个线程都写回 11(丢了 1 个更新)
Collections.synchronizedMap 保证 get 是原子的,put 是原子的——但 get + put 这个"复合操作"不是原子的。中间的空隙会被其他线程插入。
而 ConcurrentHashMap 提供了原子复合操作:
ConcurrentHashMap<String, Integer> chm = new ConcurrentHashMap<>();
chm.compute("count", (k, v) -> v == null ? 1 : v + 1); // 原子性的读-改-写
技术映射:synchronizedMap ↔ 银行大堂的单窗口服务(一个人办理时排他锁门,但办完开门→下一个人进来之间有空隙),ConcurrentHashMap.compute ↔ ATM 机(存取一体,一次操作完成,中间无法被其他人打断)。
小胖:那 ConcurrentHashMap 在 JDK 7 和 JDK 8 里实现差别好大——我见过两种不同的面试题答案!到底怎么回事?
大师:好,这是理解 CHM 演进的关键:
JDK 7 时代——分段锁 (Segment):
ConcurrentHashMap
├── Segment[0] → HashEntry[] → (链表)
├── Segment[1] → HashEntry[] → (链表)
└── Segment[N] → HashEntry[] → (链表)
16 个 Segment,每个 Segment 拥有一把 ReentrantLock。写操作只锁自己所在的 Segment,不同 Segment 之间可以并发——所以并发度最高 16。
JDK 8 时代——CAS + synchronized + 红黑树:
Node[] table
├── index[0] → Node → Node → Node (链表)
├── index[1] → Node → TreeNode (红黑树)
└── index[N] → Node → Node (链表)
抛弃了 Segment 设计,改用:
- 无锁 CAS:如果目标桶为空,用
CAS尝试插入头节点——失败说明有竞争,退化为synchronized。 - synchronized 锁桶头节点:如果桶非空,锁住该桶的头节点(粒度极细,每个桶一把锁)。
- 链表→红黑树:当链表长度 ≥ 8 且数组长度 ≥ 64,链表转为红黑树——查找从 O(n) 降为 O(log n)。
技术映射:JDK 7 的 Segment ↔ 食堂 16 个窗口,每个窗口独立排队(并发度最高 16)。JDK 8 的 CAS+锁头节点 ↔ 食堂改成了 1024 个迷你自助餐台,每个餐台独立管理——粒度更细、并发度更高。
小白:那 HashMap 的扩容为什么这么慢?有没有办法提前规避?
大师:HashMap 扩容需要三个条件同时触发:
size >= threshold(threshold = capacity × loadFactor,默认 16×0.75=12)- 插入的桶位置已有元素(发生碰撞)
- 不是替换已有 key(新增元素才扩容)
扩容过程(resize()):
[0][1][2][3][4][5][6][7] (capacity=8, threshold=6)
↓ 第 7 个元素插入 → size=7 > threshold=6
↓ 触发 resize → 容量翻倍为 16
↓ 遍历所有旧桶的链表,重新计算 hash→分配到新桶
↓ 如果链表长 ≥ 8 且 capacity ≥ 64,转为红黑树
千万级数据的扩容需要重新分配所有元素——这是 O(n) 的操作,期间所有 put 操作被阻塞。规避方案:构造时预估容量并显式设置 new HashMap<>(expectedSize / 0.75f + 1)。
技术映射:HashMap 扩容 ↔ 餐厅把 4 人桌换成 8 人桌——但不是"原地变大",而是"所有顾客站起来,按新编号重新入座"。人越多,重新入座越慢。
小胖:那什么时候用 ArrayList、什么时候用 LinkedList、什么时候用 CopyOnWriteArrayList?
大师:这是根据访问模式选型的经典决策树:
读多写少 + 需要遍历?
├── 是 → ArrayList (连续内存, CPU 缓存友好)
└── 否 →
├── 频繁头插/头删 → LinkedList (或 ArrayDeque)
├── 读多写极少 + 多线程 → CopyOnWriteArrayList
└── 无特殊要求 → ArrayList (90% 场景的默认选择)
记住一个简单口诀:
ArrayList的get(i)= O(1)(随机访问无敌),add(0, e)= O(n)(头插极其痛苦)LinkedList的get(i)= O(n)(必须从头遍历),add(0, e)= O(1)(头插是主场)CopyOnWriteArrayList:每次写操作复制整个数组→写开销 O(n),读无锁→适合监听器列表等"写极少、读极多"场景。
3. 项目实战
3.1 环境准备
| 组件 | 版本 | 用途 |
|---|---|---|
| JDK | OpenJDK 21 | 运行示例 |
| JMH | 可选 | 微基准测试对比 |
3.2 分步实现
步骤一:演示 ConcurrentHashMap vs Collections.synchronizedMap 的复合操作差异
目标:证明 synchronizedMap 的 get+put 不是原子操作。
// CompoundOpDemo.java —— 复合操作原子性对比
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
public class CompoundOpDemo {
static final int THREADS = 10;
static final int ITERATIONS = 1000;
public static void main(String[] args) throws Exception {
// 方案 A: synchronizedMap + 复合操作(不安全)
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());
syncMap.put("count", 0);
long lost = countLostUpdates(syncMap);
System.out.println("synchronizedMap 丢失更新: " + lost
+ " (预期 " + THREADS * ITERATIONS + ")");
// 方案 B: ConcurrentHashMap 原生复合操作(安全)
ConcurrentHashMap<String, Integer> chm = new ConcurrentHashMap<>();
chm.put("count", 0);
lost = countLostUpdatesWithCHM(chm);
System.out.println("ConcurrentHashMap 丢失更新: " + lost
+ " (预期 " + THREADS * ITERATIONS + ")");
}
// synchronizedMap 的 get+put —— 原子性不足
static long countLostUpdates(Map<String, Integer> map)
throws Exception {
CountDownLatch latch = new CountDownLatch(THREADS);
for (int i = 0; i < THREADS; i++) {
new Thread(() -> {
for (int j = 0; j < ITERATIONS; j++) {
Integer val = map.get("count"); // ← 非原子
map.put("count", val + 1); // ← 非原子
}
latch.countDown();
}).start();
}
latch.await();
return ITERATIONS * THREADS - map.get("count");
}
// ConcurrentHashMap 的 compute —— 原子
static long countLostUpdatesWithCHM(ConcurrentHashMap<String, Integer> map)
throws Exception {
CountDownLatch latch = new CountDownLatch(THREADS);
for (int i = 0; i < THREADS; i++) {
new Thread(() -> {
for (int j = 0; j < ITERATIONS; j++) {
map.compute("count", (k, v) -> v + 1); // ← 原子操作
}
latch.countDown();
}).start();
}
latch.await();
return ITERATIONS * THREADS - map.get("count");
}
}
步骤二:观察 HashMap 扩容前后的性能变化
目标:用预先分配容量 vs 不预分配来对比插入耗时。
// HashMapResizeDemo.java —— 扩容的代价
import java.util.*;
public class HashMapResizeDemo {
static final int COUNT = 10_000_000;
public static void main(String[] args) {
// 1. 不预分配——自动扩容多次
long start = System.currentTimeMillis();
Map<Integer, String> map1 = new HashMap<>(); // 默认 16 → 扩容 24 次
for (int i = 0; i < COUNT; i++) {
map1.put(i, "value-" + i);
}
long time1 = System.currentTimeMillis() - start;
System.out.println("无预分配: " + time1 + " ms" +
", 最终容量=" + getCapacity(map1));
// 2. 预分配容量——一次都不扩容
int expectedSize = (int) (COUNT / 0.75f) + 1;
start = System.currentTimeMillis();
Map<Integer, String> map2 = new HashMap<>(expectedSize);
for (int i = 0; i < COUNT; i++) {
map2.put(i, "value-" + i);
}
long time2 = System.currentTimeMillis() - start;
System.out.println("预分配: " + time2 + " ms" +
", 最终容量=" + getCapacity(map2));
System.out.println("预分配提速: " + (time1 - time2) + " ms");
}
// 反射获取 HashMap 内部 table 数组长度
static int getCapacity(Map<?, ?> map) {
try {
java.lang.reflect.Field f = HashMap.class.getDeclaredField("table");
f.setAccessible(true);
Object[] table = (Object[]) f.get(map);
return table == null ? 0 : table.length;
} catch (Exception e) {
return -1;
}
}
}
步骤三:四种 Map 选型的多线程性能对比
目标:对比 HashMap(无锁)、synchronizedMap、ConcurrentHashMap、Hashtable 在 4 线程读写下的吞吐。
// MapBenchmark.java —— 四种 Map 性能对比
import java.util.*;
import java.util.concurrent.*;
public class MapBenchmark {
static final int THREADS = 4;
static final int OPERATIONS = 1_000_000;
public static void main(String[] args) throws Exception {
benchmark("HashMap (不安全)", new HashMap<>(), false);
benchmark("Hashtable", new Hashtable<>(), true);
benchmark("synchronizedMap", Collections.synchronizedMap(new HashMap<>()), true);
benchmark("ConcurrentHashMap", new ConcurrentHashMap<>(), true);
}
static void benchmark(String name, Map<Integer, String> map,
boolean isThreadSafe) throws Exception {
CountDownLatch latch = new CountDownLatch(THREADS);
long start = System.nanoTime();
for (int t = 0; t < THREADS; t++) {
int threadId = t;
new Thread(() -> {
int base = threadId * OPERATIONS;
for (int i = 0; i < OPERATIONS; i++) {
map.put(base + i, "value");
map.get(base + i);
}
latch.countDown();
}).start();
}
latch.await();
long elapsed = System.nanoTime() - start;
long totalOps = THREADS * OPERATIONS * 2L; // put + get
double throughput = totalOps / (elapsed / 1_000_000_000.0);
System.out.printf("%-25s %10.0f ops/s (", name, throughput);
if (!isThreadSafe) {
System.out.print("不");
}
System.out.println("线程安全, size=" + map.size() + ")");
}
}
步骤四:演示 ConcurrentModificationException 与解决方案
// ConcurrentModificationDemo.java —— 迭代时修改的陷阱
import java.util.*;
import java.util.concurrent.*;
public class ConcurrentModificationDemo {
public static void main(String[] args) {
System.out.println("=== 1. ArrayList 的 fast-fail ===");
List<String> list = new ArrayList<>(List.of("A", "B", "C", "D"));
try {
for (String s : list) {
if (s.equals("B")) {
list.remove(s); // ← ConcurrentModificationException!
}
}
} catch (ConcurrentModificationException e) {
System.out.println("捕获: ConcurrentModificationException");
System.out.println("解决: 用 Iterator.remove() 或 removeIf()");
}
System.out.println("\n=== 2. 正确删除方式 — Iterator.remove() ===");
list = new ArrayList<>(List.of("A", "B", "C", "D"));
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String s = it.next();
if (s.equals("B")) {
it.remove(); // ← 安全
}
}
System.out.println("删除后: " + list);
System.out.println("\n=== 3. 正确删除方式 — removeIf() (JDK 8+) ===");
list = new ArrayList<>(List.of("A", "B", "C", "D"));
list.removeIf(s -> s.equals("B"));
System.out.println("删除后: " + list);
System.out.println("\n=== 4. ConcurrentHashMap 迭代时允许修改 ===");
ConcurrentHashMap<String, Integer> chm = new ConcurrentHashMap<>();
chm.put("A", 1);
chm.put("B", 2);
chm.forEach((k, v) -> {
if (k.equals("A")) {
chm.put("C", 3); // ← 允许!不会抛异常
}
System.out.println(k + "->" + v);
});
System.out.println("最终: " + chm);
}
}
可能遇到的坑:
ConcurrentHashMap.keySet()的弱一致性:keySet().iterator()返回的是遍历开始时的快照——遍历期间新插入的 key 可能不会被迭代到。这是一个文档声明了的"弱一致性"(Weakly Consistent),不是 bug。HashMap的loadFactor设过大:loadFactor=0.95f虽然减少了扩容,但每个桶链表更长——查找从 O(1) 退化为 O(n/桶数)。默认的 0.75 是时间-空间折中最优。ArrayList.contains()的 O(n) 陷阱:频繁调用contains()检测"是否存在"——如果有 10 万个元素,每个检查 10 万次,用HashSet替代ArrayList可把复杂度从 O(n²) 降到 O(n)。TreeMap的 key 必须实现 Comparable:如果 key 没实现 Comparable 且没传 Comparator,put时抛ClassCastException——很多人以为"Map 不要求 key 可比较",忘了 TreeMap 是有序的。
3.3 高频场景选型速查表
| 场景 | 推荐结构 | 备选 | 不推荐 |
|---|---|---|---|
| 读多写少 — Map | ConcurrentHashMap |
ImmutableMap(Guava) |
synchronizedMap |
| 写多 — Map | ConcurrentHashMap |
分片 Map | Hashtable |
| 有序 — Map | LinkedHashMap(插入序) |
TreeMap(key 序) |
HashMap |
| 高并发计数 | LongAdder |
AtomicLong |
synchronized + long |
| 大量随机读 — List | ArrayList |
— | LinkedList |
| 大量头插/头删 — List | LinkedList |
ArrayDeque |
ArrayList |
| 写极少读极多 — List | CopyOnWriteArrayList |
ImmutableList(Guava) |
synchronizedList |
| 去重 + 快速查 — Set | HashSet |
LinkedHashSet(保序) |
ArrayList |
4. 项目总结
4.1 优点与缺点
| 集合 | 优点 | 缺点 |
|---|---|---|
| ArrayList | 随机访问 O(1),CPU 缓存友好 | 头插入 O(n),扩容需复制全数组 |
| LinkedList | 头尾插入 O(1),无扩容概念 | 随机访问 O(n),内存开销大(每个节点 24+ 字节) |
| HashMap | 查找 O(1) 均摊,最通用的 Map | 扩容代价高、线程不安全 |
| ConcurrentHashMap | 极细粒度锁、支持原子复合操作 | 内存开销比 HashMap 大(含 sizeCtl/nextTable 等字段) |
| TreeMap | 有序、支持范围查询(subMap) | 查找 O(log n),比 HashMap 慢 3-5 倍 |
| CopyOnWriteArrayList | 读无锁,适合监听器列表 | 写 O(n)(复制全数组),内存翻倍 |
| LinkedHashMap | 保持插入序或访问序(适合 LRU 缓存) | 内存开销比 HashMap 大(维护双向链表) |
4.2 适用场景
- 高并发读写 Map:
ConcurrentHashMap——唯一选择,synchronizedMap在高并发下完全不可比。 - LRU/访问序缓存:
LinkedHashMap(initialCapacity, 0.75f, true)+removeEldestEntry重写方法。 - 高并发计数:
LongAdder>AtomicLong>synchronized+long——前者吞吐是后者的 10-100 倍。 - 监听器/观察者列表:
CopyOnWriteArrayList——注册频繁但遍历更频繁。 - 去重集合:
HashSet(无序)和LinkedHashSet(插入序)分别适用不同的数据展示场景。
不适用场景:
- 实时交易系统(要求 P99 < 1ms)——HashMap 的扩容停顿不可接受,应改用预分配+无锁数组。
- 极高并发(百万 QPS)——
ConcurrentHashMap的分段 CAS 竞争可能成为瓶颈,需用LongAdder[]分片。 - 内存极度受限(< 32MB)——Java 集合对象的对象头开销太大,需用 primitive 数组替代。
4.3 注意事项
| 类型 | 详细说明 |
|---|---|
| HashMap 死循环 (JDK 7) | JDK 7 的 HashMap 在并发 resize 时可能产生环形链表→get() 死循环→CPU 100%。JDK 8 修复(改用尾插法+红黑树),但依然不鼓励多线程用 HashMap |
| ConcurrentHashMap 的 size() 代价 | CHM 的 size() 不是 O(1)——需要遍历所有分段计数。频繁调用 size() 对性能影响显著 |
| ArrayList.subList() 陷阱 | subList() 返回的是原 list 的视图,修改视图会影响原 list,反之亦然——且 subList 不支持序列化 |
| TreeMap 不允许 null key | TreeMap.put(null, val) 抛 NPE(因为需要比较 null),但 HashMap.put(null, val) 允许 |
4.4 常见踩坑经验
案例 1:HashMap 负载因子设 2.0 导致 CPU 100%
某项目想减少扩容频率,将 new HashMap(16, 2.0f) 的负载因子设得很高。结果链表长度经常超过 20,get() 从 O(1) 退化到 O(20)。大规模遍历时 CPU 飙升。根因:负载因子过高→桶太满→链表过长→查询退化。修复:恢复到默认 0.75,或改用容量精确预分配。
案例 2:CopyOnWriteArrayList 用作"全局事件总线"导致频繁 GC
某微服务用 CopyOnWriteArrayList 维护全局监听器,但服务每秒注册/注销 50 个监听器——每次写入复制 2KB 数组,每秒 50 次 × 2KB = 100KB/s。加上对象创建→ Young GC 频繁。根因:COW 不适合"高频写入"场景。修复:改用 ConcurrentHashMap.newKeySet()(写开销远小于 COW)。
案例 3:Jackson 序列化 ConcurrentHashMap 的"幽灵 null"
某后端服务把 ConcurrentHashMap 序列化为 JSON 返回给前端,前端偶尔收到 null 值——即使后端确认 put 了值。根因:Jackson 遍历 CHM 的 entrySet() 时,CHM 内部可能正在 resize——某个桶正在迁移中,iterator 在这个桶里看到的是 null。修复:用 chm.forEach() 代替 iterator,或在序列化前 snapshot:new HashMap<>(chm)。
4.5 思考题
-
进阶题:
HashMap的hash()方法为什么要对 key 的 hashCode 做高 16 位与低 16 位的异或运算((h = key.hashCode()) ^ (h >>> 16))?如果不做这个扰动函数,对于某些 hashCode 分布不均的 key(如连续的 Integer),HashMap 的桶分布会怎样? -
实战题:你需要实现一个"读多写多"的计数器映射表(key=商品 ID,value=销量),QPS 约 50 万。
ConcurrentHashMap<Long, LongAdder>和ConcurrentHashMap<Long, AtomicLong>哪个更合适?请用 JMH 写微基准测试验证你的结论。
答案提示:思考题 1 答案见
java.util.HashMap.hash()源码及注释(扰动函数让高位参与索引计算,减少低位不同但高位相同的 key 的碰撞);思考题 2 答案见LongAdder的 Cell 分段设计——比 CAS 自旋的AtomicLong在高并发下吞吐高 10 倍。
下一章预告:第 16 章是基础篇的综合实战——电商秒杀预热服务的 JVM 体检与加固,融会贯通前 15 章的全部知识,完成"开发/运维/测试"三联 checklist 验收。
延伸阅读与资源
Java 工程师进阶:从 JVM 生产排障到OpenJDK原理
Nacos 3.x 注册配置中心实战修炼:从入门到源码扩展
Elasticsearch从入门到进阶的实战之旅
MySQL Server 9从入门到进阶的实战之旅
RabbitMQ从入门到进阶的实战之旅:从单机到大促高可用架构
Celery 入门到进阶之路:从异步任务到自研调度平台
LangGraph 生产级实战进阶:从零到生产级Agent工作流开发
Dify 从入门到源码:LLM 应用平台实战修炼
从零到生产级:FastAPI 异步高并发、源码与 SRE 实战
实战SQLAlchemy 2.0: 从 CRUD 到生产级架构
从零打造企业级 AI 助手:LangChain RAG、Agent 与生产实战
后端工程师 AI 转型课:Ollama 私有化大模型从入门到生产
MongoDB 实战进阶与内核修炼
NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化/性能调优)
Milvus向量数据库实战修炼:从 0 到 1 精通向量检索与生产落地
Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号