Skip to content

03.3 — ConcurrentHashMap

定位: 线程安全的 HashMap — 从分段锁到 CAS + synchronized 的并发演进 面试高频度: ⭐⭐⭐⭐⭐ 考查方式: JDK7 vs JDK8 实现差异、为什么线程安全、size() 实现、并发度

一、这是什么?为什么需要它?

为什么 HashMap 线程不安全?

java
// JDK 7 HashMap 死循环问题(扩容时头插法导致链表环)
// JDK 8 虽修复了死循环,但仍有数据丢失问题

Map<String, String> map = new HashMap<>();
// 两个线程同时 put → 可能数据覆盖、size 计数错误

所以:需要线程安全的哈希表。

为什么不直接用 Hashtable 或 Collections.synchronizedMap()?

java
// Hashtable:所有方法加 synchronized(锁整个表)
public synchronized V put(K key, V value) { ... }
public synchronized V get(Object key) { ... }

// 问题:并发度 = 1
// 线程 A 正在读取,线程 B 必须等待 → 完全串行化
// 1 个 CPU 干活,其他 CPU 空转

ConcurrentHashMap 的目标:高并发下保持线程安全,同时最大化并发度。

二、原理拆解

2.1 JDK 7 实现:分段锁

┌─────────────────────────────────────────────────────────────┐
│              JDK 7 ConcurrentHashMap                          │
│                                                              │
│  ┌──────────────────────────────────────────────────┐       │
│  │            Segment 数组(默认 16 段)               │       │
│  ├──────┬──────┬──────┬──────┬──────┬──────┬────────┤       │
│  │  S0  │  S1  │  S2  │ ...  │      │      │  S15   │       │
│  ├───┬──┴──┬───┴──┬───┴──┬───┴──┬───┴──┬───┴──┬─────┤       │
│  │   │     │      │      │      │      │      │      │       │
│  │  HashEntry[] 数组(每个 Segment 里的小 HashMap)          │
│  │  每个 Segment 继承 ReentrantLock                          │
│  └────────────────────────────────────────────────────┘       │
│                                                              │
│  put(key, value):                                            │
│    1. 通过 key 定位 Segment                                  │
│    2. 锁 Segment(tryLock 自旋一定次数)                      │
│    3. 在 Segment 内的 HashEntry 数组操作                      │
│    4. 释放锁                                                 │
│                                                              │
│  并发度 = Segment 数量(默认 16)                              │
│  不同 Segment 的 put 可以完全并行!                            │
└─────────────────────────────────────────────────────────────┘

优点:多读多写时可以并行(不同 Segment)。 缺点:Segment 数量固定,扩容复杂,Segment 继承 ReentrantLock 浪费内存。

2.2 JDK 8 实现:CAS + synchronized

┌─────────────────────────────────────────────────────────────┐
│              JDK 8 ConcurrentHashMap                          │
│                                                              │
│  ┌──────────────────────────────────────────────────┐       │
│  │  Node<K,V>[] table(同 HashMap 一样的桶数组)        │       │
│  ├────┬────┬────┬────┬────┬────┬────┬────┬────┬─────┤       │
│  │ 0  │ 1  │ 2  │ 3  │ 4  │ 5  │ 6  │ 7  │ 8  │ ...  │       │
│  └─┬──┴─┬──┴─┬──┴─┬──┴─┬──┴─┬──┴─┬──┴─┬──┴─┬──┴─────┘       │
│    │    │    │    │    │    │    │    │    │                  │
│      链表或红黑树                                              │
│                                                              │
│  同步策略:                                                   │
│  - 数组初始化:CAS(无锁)                                     │
│  - 放入空桶:CAS(无锁)                                       │
│  - 放入非空桶:synchronized(桶的头节点)(细粒度锁)              │
│                                                              │
│  相比 JDK 7 的优势:                                          │
│  - 锁粒度更细:只锁单个桶,不是整个 Segment                     │
│  - 内存占用更少:没有 Segment 和 ReentrantLock 开销             │
│  - 读操作几乎无锁(volatile 保证可见性)                        │
└─────────────────────────────────────────────────────────────┘

put 流程(JDK 8):

java
final V putVal(K key, V value, boolean onlyIfAbsent) {
    // 1. 计算 hash
    int hash = spread(key.hashCode());

    for (Node<K,V>[] tab = table;;) {
        // 2. 延迟初始化(CAS 无锁)
        if (tab == null || tab.length == 0)
            tab = initTable();

        // 3. 桶为空 → CAS 放入(无锁!)
        else if ((f = tabAt(tab, i)) == null) {
            if (casTabAt(tab, i, null, new Node(hash, key, value)))
                break;
        }

        // 4. 正在扩容 → 帮忙扩容
        else if (f.hash == MOVED)
            tab = helpTransfer(tab, f);

        // 5. 桶不为空 → synchronized 锁桶
        else {
            synchronized (f) {  // 锁桶的头节点
                // ... 遍历链表/红黑树,插入或替换
            }
        }
    }
}

2.3 并发读:几乎无锁

java
public V get(Object key) {
    int hash = spread(key.hashCode());
    Node<K,V>[] tab; Node<K,V> e;
    // 直接读,不使用锁
    e = tabAt(tab, (n - 1) & hash);
    // volatile 读保证可见性
}

为什么不加锁? Node 的 val 和 next 都是 volatile 的,保证可见性和有序性。

java
static class Node<K,V> {
    final int hash;
    final K key;
    volatile V val;      // 保证读线程看到最新写入
    volatile Node<K,V> next;  // 保证看到最新链表结构
}

2.4 size() 实现

java
public int size() {
    long n = sumCount();
    return (n < 0L) ? 0 : (n > Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int) n;
}

// sumCount 使用 CounterCell 数组进行分段计数
// 多个线程同时 put 时,各自更新不同的 CounterCell
// 避免 CAS 竞争
JDK 7:累加每个 Segment 的 modCount
JDK 8:CounterCell 数组 + CAS 更新
  先尝试无锁 CAS 更新 baseCount
  竞争激烈时,使用 CounterCell 分散竞争
  size() 时累加 baseCount + CounterCell[]

三、图解全景

┌─────────────────────────────────────────────────────────────┐
│         JDK 7 vs JDK 8 ConcurrentHashMap 对比               │
│                                                              │
│  ┌─────────── JDK 7 ───────────────────────┐                │
│  │                                          │                │
│  │  Segment[0]                              │                │
│  │  ┌───┐ ┌───┐ ┌───┐                     │                │
│  │  │ H │→│ H │→│ H │→...  (HashEntry[])  │                │
│  │  └───┘ └───┘ └───┘                     │                │
│  │  ReentrantLock                          │                │
│  │                                          │                │
│  │  并发度 = Segment 数 (默认 16)           │                │
│  │  内存 = 16 ReentrantLock + 16 HashEntry[]│               │
│  └──────────────────────────────────────────┘                │
│                                                              │
│  ┌─────────── JDK 8 ───────────────────────┐                │
│  │                                          │                │
│  │  table[0] → Node → Node → ...           │                │
│  │  table[1] → null (CAS 放入)              │                │
│  │  table[2] → Node (synchronized 锁此节点) │                │
│  │                                          │                │
│  │  并发度 = table 长度 (理论 16, 默认 16)   │                │
│  │  内存 ≈ 普通 HashMap + 一点 volatile     │                │
│  └──────────────────────────────────────────┘                │
└─────────────────────────────────────────────────────────────┘

四、实战验证

4.1 线程安全对比

java
Map<String, Integer> hashMap = new HashMap<>();
Map<String, Integer> chm = new ConcurrentHashMap<>();
int THREADS = 10;
int COUNT = 1000;

// 10 个线程同时 put
ExecutorService pool = Executors.newFixedThreadPool(THREADS);

// HashMap 测试(可能抛异常或数据丢失)
for (int t = 0; t < THREADS; t++) {
    pool.submit(() -> {
        for (int i = 0; i < COUNT; i++) {
            hashMap.put(Thread.currentThread().getName() + i, i);
        }
    });
}

// ConcurrentHashMap 测试(安全)
for (int t = 0; t < THREADS; t++) {
    pool.submit(() -> {
        for (int i = 0; i < COUNT; i++) {
            chm.put(Thread.currentThread().getName() + i, i);
        }
    });
}

pool.shutdown();
// HashMap 最终 size 经常 < 10000(数据丢失)
// ConcurrentHashMap 永远 = 10000

五、面试视角

追问答案要点
JDK 7 和 JDK 8 的 ConcurrentHashMap 区别锁:Segment(ReentrantLock) vs CAS+synchronized;并发度:固定16 vs 动态table长度
为什么 JDK 8 用 synchronized 而不是 ReentrantLock?JDK 6+ 对 synchronized 深度优化,锁升级机制下性能趋近于 ReentrantLock,且 JVM 可自由优化
ConcurrentHashMap 的 get 需要加锁吗?不需要,Node 的 val 和 next 是 volatile,保证可见性
ConcurrentHashMap 的 size() 怎么保证准确?CounterCell 分段计数 + CAS 更新,不保证 100% 实时一致(最终一致性)
ConcurrentHashMap 的迭代器是 fail-safe 的吗?是。迭代时不会抛 ConcurrentModificationException,但不保证看到最新写入

📚 相关链接

  • **HashMap核心原理** — HashMap 的基础原理
  • **并发工具类(AQS)** — AQS 与并发工具
  • ← 返回 **集合索引**

Knowledge4J — Java 知识库