我秋招面试字节的时候,被问到这么一道题:

“HashMap 线程不安全,那你在多线程场景下用什么?”

我脱口而出:“ConcurrentHashMap。”

面试官追问:“那你说说它是怎么保证线程安全的?”

我卡壳了。

说出来不怕你笑话,我当时只会背"分段锁"三个字,但具体怎么实现的、为什么 JDK 1.8 要改成 CAS + synchronized、一致性是怎么保证的——全是一团浆糊。

这篇文章,就是我踩过坑之后写给自己的复盘笔记。


一、为什么 HashMap 不安全?

在说 ConcurrentHashMap 之前,先搞清楚 HashMap 为什么不安全。

三个字:并发修改。

两个线程同时往 HashMap 里 put 数据,可能发生:

  1. 数据覆盖:线程 A 和 B 同时算出了相同的 index,A 写入后 B 直接覆盖
  2. 死循环:JDK 1.7 的扩容 rehash,链表可能形成环,导致死循环(这个坑我在线上踩过)
  3. 数组越界:扩容时数组引用不一致,导致空指针

所以多线程下用 HashMap,约等于给自己埋雷。


二、JDK 1.7:分段锁 SegmentedMap

怎么实现的?

// JDK 1.7 的结构
Segment[] segments;  // 每个 Segment 是一把锁

// 一次 put
public V put(K key, V value) {
    int hash = hash(key);
    int segIndex = hash % segments.length;
    synchronized (segments[segIndex]) {  // 只锁一个 Segment
        // 在 Segment 内部操作 HashMap
    }
}

核心思想:把整个 map 分成 N 个段(默认 16),每个段独立加锁。

优点

  • 多个线程可以并发操作不同的段,吞吐量提升

缺点

  • 锁的粒度还是太粗(锁的是整个 Segment)
  • 并发度受 Segment 数量限制,16 就是天花板

三、JDK 1.8:CAS + Synchronized

为什么改?

JDK 1.7 的分段锁听起来不错,但问题来了:

  • Segment 数量固定,并发度上不去
  • 实现复杂,代码维护成本高

于是 JDK 1.8 直接抄了 HashMap 的数据结构,改用桶级锁 + CAS。

核心机制

// JDK 1.8 的结构
Node[] table;  // 和 HashMap 一样,数组 + 链表/红黑树

// 一次 put 的大致逻辑
1. 计算 hash,找到数组位置
2. if (该位置为空) {
       // 用 CAS 写入,可能失败重试
       CAS + 自旋写入
   } else {
       // 该位置有数据,加锁
       synchronized (该位置的 Node) {
           // 链表/红黑树插入
       }
   }

两个核心武器:

  1. CAS:乐观锁,适合低并发场景,线程不阻塞
  2. synchronized:悲观锁,适合高并发写入,锁单个桶

什么时候用哪个?

场景用什么原因
数组位置为空CAS无竞争,直接写入
链表/红黑树操作synchronized需要独占修改

四、面试高频问题

Q1:ConcurrentHashMap 怎么保证线程安全的?

三板斧:

  • CAS 保证原子性:空位置写入
  • synchronized 保证可见性:链表/红黑树操作
  • volatile 保证可见性:Node 的 value 和 next 指针

Q2:和 Hashtable 有什么区别?

对比项HashtableConcurrentHashMap
锁粒度全局一把锁桶级锁(JDK 1.8)
并发度低(等于1)高(N 个桶)
实现复杂度简单复杂
推荐不推荐推荐

我的踩坑:以前觉得 Hashtable 更"安全",结果生产环境用了之后 QPS 腰斩。换成 ConcurrentHashMap 之后,吞吐量直接翻倍。

Q3:JDK 1.8 为什么用 synchronized 而不是 ReentrantLock?

官方的解释:

  • JVM 层面优化:synchronized 在 JVM 层面做了很多优化(偏向锁、轻量级锁、自旋锁)
  • API 简化:不需要手动释放锁,不容易出错
  • 性能足够:在 JVM 的优化下,synchronized 性能已经不输 ReentrantLock

Q4:size() 怎么保证准确的?

不能保证准确。

ConcurrentHashMap 的 size() 是一个历史遗留问题:

  • JDK 1.7:每个 Segment 独立计数,累加时需要加锁
  • JDK 1.8:用一个 volatile 的 baseCount + CounterCell 数组,不保证完全准确

为什么不用锁保护? 因为要保证性能,牺牲了一点准确性。


五、我踩过的坑

坑1:以为 size() 是准确的

// 错误用法
if (map.size() > 0) {
    // 以为一定不为空,结果翻车
}

// 正确用法
if (!map.isEmpty()) {
    // 用 isEmpty() 判断,更可靠
}

坑2:putIfAbsent 的误用

// 场景:实现分布式锁
map.putIfAbsent(key, value);

// 以为这样就能保证只有一个线程成功
// 实际上:需要检查返回值判断是否成功

String oldValue = map.putIfAbsent(key, value);
if (oldValue != null) {
    // 说明已经有值了,获取锁失败
}

坑3:遍历时修改

// 错误:ConcurrentHashMap 的迭代器是弱一致性的
for (String key : map.keySet()) {
    if (需要删除) {
        map.remove(key);  // 可能抛异常
    }
}

// 正确:用迭代器的 remove
Iterator<String> iter = map.keySet().iterator();
while (iter.hasNext()) {
    String key = iter.next();
    if (需要删除) {
        iter.remove();  // 安全删除
    }
}

六、记忆口诀

ConcurrentHashMap 三层保障:
1. CAS 写入(空位置,原子操作)
2. synchronized 修改(链表/红黑树,独占锁)
3. volatile 可见(Node.value 和 next)

对比 Hashtable:
- Hashtable = 全局锁 = 串行
- CHM = 桶锁 = 并行

写在最后

面试问到 ConcurrentHashMap,其实是在考你对并发的理解。

不要只背"分段锁"、"CAS + synchronized"这几个词。要能说出来:

  • 什么场景用 CAS,什么场景用 synchronized
  • 为什么这样设计
  • 有什么取舍

我被问住那次,就是因为只背了概念,没理解为什么。

后来我花了一整晚看源码,把 JDK 1.7 和 1.8 的区别、put/get/remove 的流程都手画了一遍。

技术这东西,真的不能只背,要理解本质。

更多推荐