1. 项目背景

业务场景:某广告平台的"实时竞价"模块需要在高并发下维护一个"广告位-出价"的映射表。开发选用 HashMap 存储,读写都用 synchronized 保护。压测时发现 100 并发线程竞争下,单次读写延迟超过 10ms——远高于广告交易所的 5ms 超时窗口。团队紧急切换到 ConcurrentHashMap,读写延迟降至 0.3ms,但 QPS 却掉了一半——因为 ConcurrentHashMap.size() 在遍历时执行了全量分段计数。

痛点:

  1. "线程安全"的误区:Vector / Collections.synchronizedMap() / ConcurrentHashMap / 外部加锁——开发者经常把四者当成"一样的线程安全"而随意切换。实际上前三者只保证单个方法(get/put)的原子性,对"先 check 后 act"的复合操作(如 if (map.containsKey(k)) map.put(k, v+1))无能为力——只有 ConcurrentHashMap.compute() 能原子地完成。
  2. ConcurrentModificationException 的意外触发:用 for-each 遍历 HashMap 时中途修改——这是在测试环境最容易踩的坑。而生产上更隐蔽的是:代码 fork-join 模式下多线程共享同一个 ArrayList 的迭代器——fast-fail 直接抛异常。
  3. 扩容的"隐形地雷":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 设计,改用:

  1. 无锁 CAS:如果目标桶为空,用 CAS 尝试插入头节点——失败说明有竞争,退化为 synchronized。
  2. synchronized 锁桶头节点:如果桶非空,锁住该桶的头节点(粒度极细,每个桶一把锁)。
  3. 链表→红黑树:当链表长度 ≥ 8 且数组长度 ≥ 64,链表转为红黑树——查找从 O(n) 降为 O(log n)。

技术映射:JDK 7 的 Segment ↔ 食堂 16 个窗口,每个窗口独立排队(并发度最高 16)。JDK 8 的 CAS+锁头节点 ↔ 食堂改成了 1024 个迷你自助餐台,每个餐台独立管理——粒度更细、并发度更高。

小白:那 HashMap 的扩容为什么这么慢?有没有办法提前规避?

大师:HashMap 扩容需要三个条件同时触发:

  1. size >= threshold(threshold = capacity × loadFactor,默认 16×0.75=12)
  2. 插入的桶位置已有元素(发生碰撞)
  3. 不是替换已有 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);
    }
}

可能遇到的坑:

  1. ConcurrentHashMap.keySet() 的弱一致性:keySet().iterator() 返回的是遍历开始时的快照——遍历期间新插入的 key 可能不会被迭代到。这是一个文档声明了的"弱一致性"(Weakly Consistent),不是 bug。
  2. HashMap 的 loadFactor 设过大:loadFactor=0.95f 虽然减少了扩容,但每个桶链表更长——查找从 O(1) 退化为 O(n/桶数)。默认的 0.75 是时间-空间折中最优。
  3. ArrayList.contains() 的 O(n) 陷阱:频繁调用 contains() 检测"是否存在"——如果有 10 万个元素,每个检查 10 万次,用 HashSet 替代 ArrayList 可把复杂度从 O(n²) 降到 O(n)。
  4. 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 适用场景

  1. 高并发读写 Map:ConcurrentHashMap——唯一选择,synchronizedMap 在高并发下完全不可比。
  2. LRU/访问序缓存:LinkedHashMap(initialCapacity, 0.75f, true) + removeEldestEntry 重写方法。
  3. 高并发计数:LongAdder > AtomicLong > synchronized + long——前者吞吐是后者的 10-100 倍。
  4. 监听器/观察者列表:CopyOnWriteArrayList——注册频繁但遍历更频繁。
  5. 去重集合: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 思考题

  1. 进阶题:HashMap 的 hash() 方法为什么要对 key 的 hashCode 做高 16 位与低 16 位的异或运算((h = key.hashCode()) ^ (h >>> 16))?如果不做这个扰动函数,对于某些 hashCode 分布不均的 key(如连续的 Integer),HashMap 的桶分布会怎样?

  2. 实战题:你需要实现一个"读多写多"的计数器映射表(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的网络实战圣经

posted on 2026-09-25 13:59  一天不进步,就是退步  阅读(6)  评论(0)    收藏  举报