Java集合(3)

Java集合(3)

作者:没有四次元口袋的蓝胖

日期:2026-07-02

标签:Java, 并发集合, ConcurrentHashMap


一、ConcurrentHashMap 概述

ConcurrentHashMap 是线程安全的 HashMap,在多线程环境下替代 HashMap 和 Hashtable。

核心特点:

  • 线程安全(高并发下性能好)
  • 不允许 key 和 value 为 null
  • JDK 1.7:分段锁(Segment)
  • JDK 1.8:CAS + synchronized + 红黑树

面试地位: 和 HashMap 一样是必考题,经常和 HashMap、Hashtable 对比着问,必须能说出 1.7 和 1.8 的实现区别以及线程安全的原理。


二、底层结构

2.1 JDK 1.7 底层结构 ------ 分段锁

结构: Segment 数组 + HashEntry 数组 + 链表

复制代码
ConcurrentHashMap
└── Segment[] (分段数组,默认16个Segment)
    ├── Segment[0]
    │   └── HashEntry[](每个Segment内部自己的哈希表)
    │       ├── [0] → Entry → Entry
    │       ├── [1] → Entry
    │       └── ...
    ├── Segment[1]
    │   └── HashEntry[]
    └── ...

核心思想:分段锁

  • 把整个 Map 分成 16 个段(Segment)
  • 每个 Segment 有自己的锁(继承自 ReentrantLock)
  • 不同 Segment 之间可以并发操作
  • 同一个 Segment 内仍然是串行的

Segment 的数量叫并发度(concurrencyLevel),默认 16,可以在构造时指定。

java 复制代码
// JDK 1.7 核心结构
static class Segment<K,V> extends ReentrantLock implements Serializable {
    transient volatile HashEntry<K,V>[] table;
    transient int count;       // 元素个数
    transient int modCount;    // 修改次数
    transient int threshold;   // 扩容阈值
    final float loadFactor;
}

static final class HashEntry<K,V> {
    final int hash;
    final K key;
    volatile V value;
    volatile HashEntry<K,V> next;
}

JDK 1.7 的缺点:

  • 并发度固定(默认16),且是 Segment 级别的,同一 Segment 内还是串行
  • 分段数组 + 哈希表数组,两层数组,结构复杂
  • 链表长度大了查询效率还是 O(n)

2.2 JDK 1.8 底层结构 ------ CAS + synchronized

结构: Node 数组 + 链表 + 红黑树(和 HashMap 一样的数据结构)

复制代码
ConcurrentHashMap
└── Node[] table(哈希桶数组)
    ├── [0] → Node → Node(链表,长度<8)
    ├── [1] → null
    ├── [2] → TreeNode(红黑树,链表>=8且数组>=64)
    ├── [3] → Node
    └── ...

和 JDK 1.7 的区别:

  • 放弃了分段锁,锁粒度更细(桶级别)
  • 引入红黑树优化长链表查询
  • 用 synchronized 代替 ReentrantLock
  • 数据结构和 HashMap 对齐(Node 数组 + 链表 + 红黑树)
java 复制代码
// JDK 1.8 核心结构
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;           // volatile 保证可见性
    volatile Node<K,V> next;  // volatile 保证可见性
}

// 红黑树节点
static final class TreeNode<K,V> extends Node<K,V> {
    TreeNode<K,V> parent;
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;
    boolean red;
}

// 树的根节点(放在桶的位置)
static final class TreeBin<K,V> extends Node<K,V> {
    TreeNode<K,V> root;
    volatile TreeNode<K,V> first;
    volatile Thread waiter;
    volatile int lockState;   // 锁状态
}

为什么用 synchronized 而不是 ReentrantLock?

  • JDK 1.8 对 synchronized 做了大量优化(偏向锁、轻量级锁、自适应自旋等)
  • 锁粒度变细了(只锁一个桶),锁的持有时间很短
  • synchronized 是 JVM 层面的,优化空间更大,性能不比 ReentrantLock 差

三、线程安全原理

3.1 JDK 1.7 ------ 分段锁机制

复制代码
操作1(修改 Segment[0]) ←→ 可以并发 ←→ 操作2(修改 Segment[5])
操作1(修改 Segment[0]) ←→ 串行 ←→ 操作2(也修改 Segment[0])

put 过程:

  1. 根据 key 的 hash 找到对应的 Segment
  2. 尝试获取该 Segment 的锁(ReentrantLock)
  3. 获取锁成功 → 执行 put 操作(和 HashMap 类似)
  4. 释放锁

size() 计算:

经典的不加锁统计方式:

  1. 先不加锁,遍历所有 Segment,累加 count,记录 modCount
  2. 再统计一次,比较两次的 modCount 是否相同
  3. 如果相同,说明期间没有修改,直接返回结果
  4. 如果不同(有并发修改),重试一次
  5. 还不行,就给所有 Segment 都加锁,再统计

3.2 JDK 1.8 ------ CAS + synchronized

JDK 1.8 的线程安全更精细,不同场景用不同的方式:

场景 实现方式
空桶插入 CAS(无锁)
链表/红黑树插入 synchronized 锁桶头节点
元素计数 CAS + baseCount + CounterCell
扩容 多线程协助扩容(ForwardingNode)
3.2.1 空桶插入:CAS 无锁

当插入的位置(桶)为空时,用 CAS 直接插入,不需要加锁:

java 复制代码
// casTabAt 就是用 Unsafe 的 CAS 操作
if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
    if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
        break;  // CAS 成功 → 插入完成
    // CAS 失败 → 说明并发插入了,循环重试
}

原理: 比较当前位置是不是 null,是就设为新节点,不是就失败重试。

3.2.2 非空桶插入:synchronized 锁桶头

当桶里已经有元素时,锁住桶头节点(链表头或红黑树根),在锁内执行插入:

java 复制代码
synchronized (f) {  // f 是桶头节点
    if (tabAt(tab, i) == f) {  // 双重检查:确认桶头没变
        if (fh >= 0) {         // 链表节点(hash >= 0)
            // 链表插入逻辑...
            binCount = 1;
            for (Node<K,V> e = f;; ++binCount) {
                // 找到相同 key → 覆盖
                // 遍历到尾 → 尾插新节点
            }
        }
        else if (f instanceof TreeBin) {  // 红黑树节点
            // 红黑树插入逻辑...
        }
    }
}

// 插入后检查链表长度是否需要转红黑树
if (binCount != 0) {
    if (binCount >= TREEIFY_THRESHOLD)
        treeifyBin(tab, i);  // 转红黑树
    ...
}

锁粒度: 只锁当前桶的头节点,其他桶不受影响 → 并发度 = 桶的数量

3.2.3 元素计数:baseCount + CounterCell

HashMap 直接用 size 字段计数就行,因为单线程。

ConcurrentHashMap 不行,因为多线程同时改 size 会有并发问题。

JDK 1.8 的做法(和 LongAdder 思想一样):

java 复制代码
// 基础计数
private transient volatile long baseCount;

// 计数单元格数组(竞争激烈时用)
private transient volatile CounterCell[] counterCells;

计数逻辑:

  1. 先尝试用 CAS 修改 baseCount,成功就 +1
  2. 如果 CAS 失败(有竞争),就使用 CounterCell 数组,每个线程在自己的格子里 +1
  3. size() 的时候,baseCount + 所有 CounterCell 的和 = 总数

核心思想:分散竞争。把一个变量分散到多个单元格,减少 CAS 冲突,用空间换时间。

size() 返回的是近似值: 因为统计过程中可能有并发的增删,所以 size() 的结果不是精确值(弱一致性)。

3.2.4 扩容:多线程协助扩容

JDK 1.8 的 ConcurrentHashMap 有一个很牛的设计:多线程一起帮忙扩容

大致过程:

  1. 某个线程触发扩容,创建新数组(容量×2),设置 sizeCtl 控制状态
  2. 其他线程如果也在操作数据,发现正在扩容,就帮忙一起搬数据
  3. 每个线程搬一段(默认16个桶),搬完了再拿下一段
  4. 全部搬完后,替换旧数组

ForwardingNode(转移节点):

正在迁移的桶会被替换成一个 ForwardingNode(hash = -1),它指向新数组。

如果一个线程操作时发现桶头是 ForwardingNode,说明这个桶已经被搬走了,就去新数组里操作,或者帮忙扩容。


四、Put 流程(JDK 1.8)

4.1 总流程图

复制代码
put(key, value)
    ↓
key 或 value 为 null?
    └── 是 → 抛 NullPointerException(不允许 null!)
    ↓
计算 hash(扰动函数,和 HashMap 类似)
    ↓
table 为空?
    ├── 是 → initTable() 初始化数组(CAS 保证只初始化一次)
    └── 否 → 继续
    ↓
定位桶下标 i = (n-1) & hash
    ↓
桶为空?
    ├── 是 → CAS 插入新节点 → 成功则结束,失败则循环重试
    └── 否 → 继续
    ↓
桶头是 ForwardingNode(正在扩容)?
    └── 是 → 帮忙扩容 → 扩容完后回到循环重试
    ↓
synchronized 锁住桶头节点
    ↓
双重检查:桶头还是原来那个吗?
    └── 不是 → 释放锁,重新循环
    ↓
是链表节点(hash >= 0)?
    ├── 是 → 遍历链表
    │       ├── 找到相同 key → 覆盖 value
    │       └── 没找到 → 尾插新节点
    └── 否(红黑树 TreeBin)→ 红黑树插入
    ↓
释放 synchronized 锁
    ↓
链表长度 >= 8?
    └── 是 → 尝试转红黑树(数组<64则优先扩容)
    ↓
元素计数 +1(CAS + CounterCell)
    ↓
需要扩容吗(size > 阈值)?
    └── 是 → 触发扩容(多线程协助)
    ↓
返回旧 value(覆盖的情况)或 null

4.2 和 HashMap put 的核心区别

对比项 HashMap ConcurrentHashMap
key/value 为 null 允许 不允许,直接抛异常
空桶插入 直接赋值 CAS 插入
非空桶插入 直接操作 synchronized 锁桶头
元素计数 size 字段直接++ CAS + CounterCell 分散计数
扩容 单线程扩容 多线程协助扩容
线程安全 不安全 安全

五、Get 流程

java 复制代码
public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode()); // 计算 hash

    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {

        // 1. 桶头就是目标
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        // 2. hash < 0 → 特殊节点(红黑树/ForwardingNode)
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;

        // 3. 链表 → 遍历
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

关键点:Get 操作不需要加锁!

  • Node.valNode.next 都是 volatile 的,保证可见性
  • 数据结构的变化对读线程也是可见的
  • 扩容时通过 ForwardingNode 去新数组找

弱一致性: get 只能保证读到的是某个时刻的快照,不保证读到最新的值。如果需要强一致性,得用 HashtableCollections.synchronizedMap(但这俩性能差)。


六、与 Hashtable 的对比 ⭐⭐⭐

6.1 Hashtable 的实现

Hashtable 也是线程安全的 Map,但实现方式非常粗暴------所有方法都加 synchronized,相当于锁整个哈希表。

java 复制代码
public synchronized V put(K key, V value) { ... }
public synchronized V get(Object key) { ... }
public synchronized int size() { ... }
public synchronized V remove(Object key) { ... }

问题:

  • 所有操作共用一把锁,并发度只有 1
  • 读读也互斥(两个线程同时 get 也要排队)
  • 性能差,基本被淘汰

6.2 对比表

对比项 ConcurrentHashMap Hashtable HashMap
线程安全 ✅ 高效(细粒度锁) ✅ 低效(全表锁)
锁方式 JDK7分段锁 / JDK8 CAS+synchronized synchronized 全方法 无锁
锁粒度 桶级别 整张表 -
并发度 高(=桶的数量) 1 -
null key/value ❌ 都不允许 ❌ 都不允许 ✅ 都允许
扩容 多线程协助 单线程 单线程
size() 结果 弱一致(近似值) 强一致 强一致
出现版本 JDK 1.5 JDK 1.0 JDK 1.2
性能 最高
推荐使用 ✅ 多线程推荐 ❌ 已废弃 单线程用

6.3 为什么 ConcurrentHashMap 不允许 null?

Doug Lea 的设计决策: 在并发场景下,get 返回 null 有歧义:

  • 是 key 不存在?
  • 还是 value 本身就是 null?

单线程的 HashMap 可以用 containsKey() 确认,但并发环境下,get() 返回 null 和 containsKey() 之间数据可能已经被改了,无法判断。干脆不允许 null,避免二义性。


七、JDK 1.7 vs JDK 1.8 对比

对比项 JDK 1.7 JDK 1.8
数据结构 Segment 数组 + HashEntry 数组 + 链表 Node 数组 + 链表 + 红黑树
锁实现 ReentrantLock(分段锁) CAS + synchronized
锁粒度 Segment 级别(默认16段) 桶级别(更细)
并发度 Segment 数量(默认16) 桶的数量(动态扩容)
查询效率 O(n)(链表) O(log n)(红黑树优化)
元素计数 两次统计 + 全部加锁 CAS + CounterCell
扩容 单线程扩容 多线程协助扩容
复杂度 两层数组,复杂 一层数组,简洁

八、思维导图速览

复制代码
ConcurrentHashMap
├── 概述
│   ├── 线程安全的 HashMap
│   ├── 不允许 key/value 为 null
│   └── 高并发高性能
├── 底层结构
│   ├── JDK 1.7:分段锁
│   │   ├── Segment[](继承 ReentrantLock)
│   │   ├── 每个 Segment 内部有自己的 HashEntry[]
│   │   └── 并发度 = Segment 数量(默认16)
│   └── JDK 1.8:CAS + synchronized
│       ├── Node[] + 链表 + 红黑树
│       ├── 锁粒度细化到桶
│       └── 和 HashMap 结构对齐
├── 线程安全原理(JDK 1.8)
│   ├── 空桶插入 → CAS 无锁
│   ├── 非空桶插入 → synchronized 锁桶头
│   ├── 元素计数 → CAS + CounterCell(分散竞争)
│   └── 扩容 → 多线程协助 + ForwardingNode
├── Put 流程
│   ├── null 检查 → 抛异常
│   ├── 空数组 → initTable 初始化
│   ├── 空桶 → CAS 插入
│   ├── 正在扩容 → 帮忙扩容
│   ├── 非空桶 → synchronized 锁 + 链表/红黑树插入
│   ├── 计数 +1
│   └── 超阈值 → 扩容
├── Get 流程
│   ├── 不需要加锁(volatile 保证可见性)
│   └── 弱一致性
├── vs Hashtable
│   ├── 锁粒度:细 vs 粗
│   ├── 性能:高 vs 低
│   ├── 并发度:高 vs 1
│   └── 推荐:ConcurrentHashMap ✅
└── JDK 1.7 vs 1.8
    ├── 数据结构:分段锁 vs CAS+synchronized+红黑树
    ├── 锁粒度:Segment级 vs 桶级
    ├── 查询:O(n) vs O(log n)
    └── 扩容:单线程 vs 多线程协助

九、写在最后

学习建议

  1. JDK 1.8 为主,1.7 了解思路:面试问得更多的是 1.8 的实现,但 1.7 的分段锁思想也要知道
  2. 和 HashMap 对比着学:数据结构很像,重点记不一样的地方(CAS、synchronized、CounterCell、协助扩容)
  3. 理解"分散竞争"思想:LongAdder 和 ConcurrentHashMap 的 size 计数都是这个思路,很经典
  4. ForwardingNode 是亮点:多线程协助扩容的设计很巧妙,了解一下能给面试加分

面试高频题(附答题思路)

Q1:ConcurrentHashMap 怎么实现线程安全的?

JDK 1.8 用 CAS + synchronized。空桶用 CAS 无锁插入;非空桶用 synchronized 锁桶头节点,锁粒度很细;元素计数用 CAS + CounterCell 分散竞争;扩容是多线程协助的。

Q2:ConcurrentHashMap 和 Hashtable 的区别?

Hashtable 是全表加 synchronized,性能差;ConcurrentHashMap 锁粒度细(桶级别),性能高。Hashtable 是老古董,不推荐用。

Q3:ConcurrentHashMap 为什么不允许 null?

并发场景下 get 返回 null 有歧义,不知道是 key 不存在还是 value 为 null,而且 containsKey 判断也不可靠(并发可能被修改),所以干脆禁止。

Q4:JDK 1.7 和 1.8 的 ConcurrentHashMap 区别?

1.7 是分段锁(Segment + ReentrantLock),并发度固定;1.8 是 CAS + synchronized,锁粒度到桶级别,还引入了红黑树、多线程协助扩容等优化,性能更好。

Q5:ConcurrentHashMap 的 get 为什么不用加锁?

因为 Node 的 val 和 next 都是 volatile 的,保证了可见性。扩容时通过 ForwardingNode 去新数组找,也能正确读到数据。但 get 是弱一致性的,不保证读到最新值。