HashMap 与 ConcurrentHashMap 详解 — 面试再也不怕问并发Map了

🧑‍💻 作者:没有逆称
💡 前言:HashMap 和 ConcurrentHashMap 是 Java 面试的高频考点,尤其是 ConcurrentHashMap 的并发原理,JDK 7 和 JDK 8 的区别,扩容机制等。本文从数据结构开始讲起,逐步深入到并发原理,力求把每个细节都讲清楚。


📌 文章目录


一、HashMap 基础 — 底层数据结构

1.1 数组 + 链表 = HashMap?

HashMap 的底层是**数组(桶)+ 链表(解决哈希冲突)**的结构:

HashMap 内部结构
┌─────────────────────────────────────────────────────┐
│  Node<K,V>[] table(数组,每个元素叫桶)               │
├─────────────────────────────────────────────────────┤
│  index 0 │ index 1 │ index 2 │ index 3 │ ...      │
│    ▼        ▼         ▼         ▼                  │
│  [链表头] [链表头] [链表头] [链表头]                  │
│    │        │        │        │                   │
│    ▼        ▼        ▼        ▼                   │
│  下一个节点  null     下一个节点  下一个节点          │
└─────────────────────────────────────────────────────┘

基本原理

  1. put(key, value) 时,先用 hash(key) 计算哈希值
  2. 根据 hash % table.length(或 hash & (length - 1))算出数组下标
  3. key-value 封装成 Node 放到对应位置的链表头

为什么用 & 运算而不是 %?

// 取模运算
index = hash % table.length;  // 较慢

// 位运算(JDK 用这个)
index = hash & (table.length - 1);  // 快得多,要求 length 是 2 的幂次

1.2 hash() 方法 — 扰动函数

JDK 7 和 JDK 8 的 hash 实现略有不同,JDK 8 做了优化:

// JDK 8 的 hash 实现
static final int hash(Object key) {
    int h;
    // key.hashCode() 的高 16 位和低 16 位异或,保留更多哈希特征
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

// 计算数组下标
index = (table.length - 1) & hash(key);

为什么要扰动?

key.hashCode() 返回的 32 位 int 值,如果只用低几位做下标,高位信息就浪费了。异或高 16 位可以让哈希值分布更均匀,减少哈希冲突。

原始 hashCode:  11111111 11111111 11111111 11001010
                                    ▼
扰动后 hash:    11111111 11111111 11001010 10111100
                                    ↑
                               高16位与低16位异或

1.3 关键参数

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

    // 默认初始容量(必须是 2 的幂次)
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;  // 16

    // 最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30;  // 2^30

    // 默认负载因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    // 链表转红黑树的阈值(JDK 8 新增)
    static final int TREEIFY_THRESHOLD = 8;

    // 红黑树转链表的阈值
    static final int UNTREEIFY_THRESHOLD = 6;

    // 最小树形化容量阈值
    static final int MIN_TREEIFY_CAPACITY = 64;
}
参数 默认值 说明
initialCapacity 16 初始桶数组大小
loadFactor 0.75 负载因子,决定何时扩容
threshold capacity × loadFactor 触发扩容的阈值(默认 12)

💡 负载因子 0.75 的选择:空间利用率和哈希冲突概率的平衡。太高 → 冲突多、链表长;太低 → 空间浪费、扩容频繁。


二、HashMap 的哈希冲突解决 — 拉链法与红黑树

2.1 什么是哈希冲突?

两个不同的 key,算出来的数组下标相同,就是哈希冲突

// 假设 table.length = 16
hash("张三") & 15 = 5
hash("李四") & 15 = 5  // 冲突!

2.2 拉链法(Separate Chaining)

把冲突的元素用链表串起来:

// Node 节点结构
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;  // 指向下一个节点的指针
}
index = 5 的桶:
  Node("张三", 1) → Node("李四", 2) → null

JDK 7 vs JDK 8 链表插入顺序:

  • JDK 7:头插法(新元素插到链表头)→ new Node.next = oldHead
  • JDK 8:尾插法(新元素插到链表尾)→ 遍历到尾部追加

⚠️ JDK 7 头插法有个严重问题:并发下扩容时,可能形成环形链表,导致死循环。JDK 8 改成尾插法解决了这个问题。

2.3 红黑树优化(JDK 8 新增)

当链表长度超过 8 时,链表会转换成红黑树

链表长度 ≤ 8      →  链表(O(n) 查找)
链表长度 > 8       →  红黑树(O(log n) 查找)
红黑树节点数 ≤ 6   →  退化为链表

为什么选 8 而不是其他数字?

源码注释里有说明:哈希碰撞服从泊松分布,链表长度达到 8 的概率只有 0.00000006(千万分之六)。换句话说,8 是一个非常极端的情况,正常业务几乎不会触发红黑树。但如果真的触发了,说明哈希函数设计有问题。


三、HashMap 扩容机制 — 为什么要翻倍?

3.1 扩容时机

size(元素个数) > threshold(capacity × loadFactor) 时,触发扩容:

// put() 里的扩容判断
if (++size > threshold) {
    resize(2 * table.length);  // 容量翻倍
}

举个例子

  • 初始 capacity = 16loadFactor = 0.75
  • threshold = 12
  • 放入第 13 个元素时,触发扩容 → capacity = 32threshold = 24

3.2 扩容过程

扩容前:capacity = 16
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│  │  │  │  │  │  │  │  │  │  │  │  │  │  │  │  │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘

扩容后:capacity = 32(翻倍)
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│  │  │  │  │  │  │  │  │  │  │  │  │  │  │  │  │  │  │  │  │  │  │  │  │  │  │  │  │  │  │  │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘

3.3 重新计算下标 — 为什么容量必须 2 的幂次?

这是面试常考的点。扩容后重新计算下标,不需要重新 hash,可以直接用新增的那位判断:

// 原容量 oldCap = 16,二进制 10000
// 新容量 newCap = 32,二进制 100000

// 原下标 = hash & (oldCap - 1) = hash & 01111
// 新下标有两种可能:
//   - hash 第 5 位为 0 → 新下标 = 旧下标
//   - hash 第 5 位为 1 → 新下标 = 旧下标 + oldCap

// 举例:
// hash = 27 (二进制 11011)
// oldCap - 1 = 15 (二进制 01111)
// 27 & 15 = 11  (旧下标)

// newCap - 1 = 31 (二进制 11111)
// 27 & 31 = 27  (新下标 = 旧下标 + 16)

结论:容量是 2 的幂次时,扩容后只需要检查哈希值新增的那一位是 0 还是 1,就能确定新下标,不用重新算 hash


四、HashMap 线程不安全的表现

HashMap 不是线程安全的,并发下会出现三个经典问题:

4.1 数据覆盖

// 两个线程同时 put(key, value),同一个桶的位置
// 线程A:计算下标为 3
// 线程B:计算下标也为 3
// 结果:后写的覆盖先写的,数据丢失

4.2 死循环(JDK 7 头插法)

JDK 7 在并发扩容时,链表头插法可能导致环形链表get() 时死循环:

JDK 7 扩容时的死循环形成过程:

原链表:A → B → C

线程A扩容:A → B → C(还没复制完)
线程B也扩容:同时操作同一个桶
        ↓
      A.next = B
      B.next = A  ← 形成环形链表!
        ↓
      遍历 get() 时:A → B → A → B → ... 死循环,CPU 100%

JDK 8 改用尾插法解决了这个问题,但仍然不是线程安全的

4.3 并发场景下不要用 HashMap

场景 推荐方案
并发度低,不需要锁 Collections.synchronizedMap(new HashMap<>())
并发读为主 ConcurrentHashMap
需要原子操作(increment) ConcurrentHashMap + compute() 等方法
读远多于写,写需要原子操作 ConcurrentSkipListMap(跳表,有序)

五、ConcurrentHashMap 登场 — 解决并发问题

5.1 为什么不用 synchronized 包装 HashMap?

Collections.synchronizedMap() 给整个 Map 加了一把全局锁,任何操作都要竞争同一把锁:

// synchronizedMap 底层就是 synchronized 包裹每个方法
public synchronized V get(Object key) { ... }
public synchronized V put(K key, V value) { ... }

问题:同一时刻只能有一个线程操作 Map,其他线程全部阻塞,并发性能极差

5.2 核心思想:分段锁

ConcurrentHashMap 的核心思路是把锁粒度拆细,让不同线程操作不同桶时互不影响。


六、JDK 7 vs JDK 8 — ConcurrentHashMap 的演进

6.1 JDK 7:Segment 分段锁

ConcurrentHashMap
       │
       ├── Segment[0](锁1)→ 桶0-15
       ├── Segment[1](锁2)→ 桶16-31
       ├── Segment[2](锁3)→ 桶32-47
       └── ...
  • 默认 16 个 Segment(可配置)
  • 每个 Segment 持有一把锁
  • 不同 Segment 之间完全并发
// JDK 7 底层结构
public class ConcurrentHashMap<K,V> {
    // Segment 数组,继承了 ReentrantLock
    final Segment<K,V>[] segments;

    static final class Segment<K,V> extends ReentrantLock {
        transient volatile HashEntry<K,V>[] table;
    }
}

优点:分段锁,并发度高
缺点:Segment 数量固定,扩容只发生在 Segment 内部,且不能全局扩容

6.2 JDK 8:去掉 Segment,改为 synchronized + CAS

JDK 8 做了翻天覆地的改变:

JDK 8 ConcurrentHashMap
       │
       ├── 去掉 Segment
       ├── 桶数组:Node<K,V>[]
       ├── 每个桶单独加锁(synchronized)
       ├── 用 CAS 保证并发安全
       └── 链表 > 8 → 红黑树(JDK 8 新增)

为什么 JDK 8 要改?

  1. Segment 分段锁数量固定(默认 16),当数据集中在某一个段时,该段压力依然很大
  2. JDK 6 引入了偏向锁、轻量级锁优化,synchronized 性能大幅提升,不再是"重量级锁"的代名词
  3. 红黑树的引入让查询复杂度从 O(n) 降到 O(log n)

七、JDK 8 ConcurrentHashMap 核心原理

7.1 数据结构

public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
    implements ConcurrentMap<K,V>, Serializable {

    // 底层数组(volatile,保证可见性)
    transient volatile Node<K,V>[] table;

    // 控制并发的基础变量
    private transient volatile int sizeCtl;
}

Node 结构:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V value;        // value 用 volatile,保证可见性
    volatile Node<K,V> next; // 链表指针
}

7.2 put() 流程

public V put(K key, V value) {
    return putVal(key, value, false);
}

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();

    int hash = spread(key.hashCode());  // 和 HashMap 一样的扰动函数
    int binCount = 0;                    // 当前桶的链表长度

    // 自旋 + CAS
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f;
        int n, i, fh;

        // 1. 数组未初始化 → CAS 初始化数组
        if (tab == null || (n = tab.length) == 0) {
            tab = initTable();
        }
        // 2. 桶为空 → CAS 放入新节点(无锁操作)
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) {
                break;
            }
        }
        // 3. 桶有值 → synchronized 锁住桶,处理链表/红黑树
        else {
            synchronized (f) {  // 只锁当前桶,不影响其他桶
                // 检查 f 是否还是原节点(可能其他线程已修改)
                if (tabAt(tab, i) != f) continue;

                // 链表插入
                if (fh >= 0) {
                    binCount = 1;
                    for (Node<K,V> e = f;; ++binCount) {
                        if (e.hash == hash && (ek = e.key) == key || (ek != null && ek.equals(key)))) {
                            V oldVal = e.value;
                            if (!onlyIfAbsent) e.value = value;
                            break;
                        }
                        Node<K,V> pred = e;
                        if ((e = e.next) == null) {
                            pred.next = new Node<K,V>(hash, key, value, null);
                            break;
                        }
                    }
                }
                // 红黑树插入
                else if (f instanceof TreeBin) {
                    TreeBin<K,V> t = (TreeBin<K,V>) f;
                    // 红黑树插入(putTreeVal)
                }
            }

            // 链表长度 > 8 → 转红黑树
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD) {
                    treeifyBin(tab, i);
                }
                if (alreadyLocked) return oldValue;
                return null;
            }
        }
    }
    // 更新计数器
    addCount(1L, binCount);
    return null;
}

put() 流程总结:

put(key, value)
    │
    ├── 数组未初始化?  → CAS 初始化(多线程竞争,只有一个人成功)
    │
    ├── 桶为空?         → CAS 直接放(无锁,true)
    │
    └── 桶不为空?       → synchronized 锁住这个桶
            │
            ├── 链表模式 → 遍历找到则覆盖,没找到则尾插
            ├── 红黑树模式 → 红黑树插入
            │
            └── 链表长度 > 8 → 转红黑树

7.3 并发读 — 不加锁怎么保证线程安全?

ConcurrentHashMap 的读操作完全不加锁

public V get(Object key) {
    Node<K,V>[] tab;
    Node<K,V> e, p;
    int n, eh;
    K ek;

    // 1. 计算 hash
    int h = spread(key.hashCode());

    // 2. 数组已初始化 && 桶不为空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {

        // 3. 第一个节点就是 → 直接返回
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && ek.equals(key))) {
                return e.value;
            }
        }
        // 4. eh < 0 说明是红黑树,用 treeFind 查找
        else if (eh < 0) {
            return (p = e.find(h, key)) != null ? p.value : null;
        }
        // 5. 链表模式 → 遍历查找
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && ek.equals(key)))) {
                return e.value;
            }
        }
    }
    return null;
}

读不加锁的原因:

  • valuevolatile 的 → 每次读取都是主内存最新值
  • Node.next 也是 volatile 的 → 链表结构变化能感知到

7.4 扩容机制 — 多线程并发扩容

ConcurrentHashMap 的扩容是多线程并发的,这是它性能强大的关键:

// 核心扩容方法:transfer()
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;

    // 每个线程负责一段桶的迁移
    // MIN_TRANSFER_STRIDE = 16,最小迁移单位
    stride = (n >>> 3) / NCPU;  // 按 CPU 核数分配
    if (stride < MIN_TRANSFER_STRIDE) stride = MIN_TRANSFER_STRIDE;

    // 第一个线程会初始化新数组 nextTab
    if (nextTab == null) {
        nextTab = (Node<K,V>[]) new Node<?,?>[n << 1];
        nextTable = nextTab;
        transferIndex = n;
    }

    // 线程池并发迁移
    for (int i = 0; i < n; i += stride) {
        // 每个线程 CAS 抢任务(领自己负责的桶区间)
        // 然后把旧桶节点拆分到新数组两个位置
    }
}

扩容原理:

旧数组 capacity = 16          新数组 capacity = 32
┌──┬──┬──┬──┬──┬──┬──┬──┐     ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │B │C │D │E │F │G │H │     │A │  │B │  │C │  │D │  │E │  │F │  │G │  │H │  │
└──┴──┴──┴──┴──┴──┴──┴──┘     └──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
                              重新计算下标:hash & (newCap - 1)
                              hash 第 5 位为 0 → 原位置
                              hash 第 5 位为 1 → 原位置 + oldCap

多线程扩容的关键:

  • 多个线程各自领一段桶迁移,互相不影响
  • transferIndex 从后往前分配(避免重复)
  • sizeCtl 记录参与扩容的线程数

八、面试高频问题整理

Q1:HashMap 和 ConcurrentHashMap 的区别?

维度 HashMap ConcurrentHashMap
线程安全 ❌ 不安全 ✅ 安全
锁粒度 JDK 7:Segment 分段锁;JDK 8:桶级锁
并发读 ✅ 读操作不加锁(volatile)
数据结构 数组+链表+红黑树 同 HashMap
null 支持 key/value 都可以为 null key/value 都不能为 null
性能 单线程场景高 多线程场景高
底层实现 数组+链表(单向链表) CAS + synchronized + volatile

Q2:ConcurrentHashMap 在 JDK 7 和 JDK 8 的区别?

维度 JDK 7 JDK 8
数据结构 Segment + HashEntry(数组+链表) Node[] + 链表/红黑树
并发方式 Segment 分段锁(默认16个) synchronized 锁桶 + CAS
锁粒度 Segment 级别(较粗) 桶级别(更细)
并发扩容 不支持 支持多线程并发扩容
红黑树 ❌ 不支持 ✅ 支持(链表长度 > 8)
查询复杂度 最坏 O(n) 链表 O(n),红黑树 O(log n)

Q3:ConcurrentHashMap 的 size() 是怎么计算的?

JDK 7 用 Segment 分别维护各自的计数,求和得到总数。JDK 8 改成了 baseCount + CounterCell[]

// JDK 8 的计数机制:
// 每次 put 成功,CAS 尝试给 baseCount + 1
// 如果 CAS 失败(并发竞争),则放到 CounterCell 数组中各自分别计数
// 查询 size 时:baseCount + sum(CounterCell[])

private transient volatile long baseCount;
private transient volatile CounterCell[] counterCells;

// put() 时调用
final long sumCount() {
    long sum = baseCount;
    for (CounterCell cell : counterCells) {
        sum += cell.value;
    }
    return sum;
}

为什么不用一个 AtomicLong? 因为高并发下 CAS 竞争激烈,容易失败重试。CounterCell 数组分散计数,减少竞争。

Q4:put() 时,如果发现正在扩容怎么办?

// JDK 8 的处理:帮助扩容(Helper Thread)
else if ((fh = f.hash) == MOVED) {
    // MOVED = -1,表示正在扩容
    // 当前线程去帮助扩容,扩容完了再继续插入
    tab = helpTransfer(tab, f);
}

helpTransfer() 会让当前线程参与扩容过程,领一段桶来迁移,而不是干等着。

Q5:ConcurrentHashMap 的 key 和 value 为什么不能为 null?

HashMap 允许 key == null,因为单线程下可以区分"找不到 key"和"key 对应的 value 是 null"。

ConcurrentHashMap 是多线程的,无法区分

  • 线程 A 读取 key,发现 value == null → 到底是 value 本来就是 null,还是 key 不存在?
  • 如果 key 不存在,另一个线程可能正在 put

所以直接禁止 null,避免歧义。这也是它和 Hashtable 少有的共同点。

Q6:HashMap 的负载因子为什么是 0.75?

源码注释的解释:时间和空间成本的平衡

假设 capacity = 16,负载因子:

  • 0.5:扩容阈值 = 8,空间利用率低,但哈希冲突少
  • 0.75:扩容阈值 = 12,平衡点,泊松分布验证过
  • 1.0:扩容阈值 = 16,空间利用率高,但哈希冲突严重,链表变长,查询变慢

0.75 附近时,哈希碰撞次数的期望值最优,查找复杂度接近 O(1)。


九、使用场景总结与选型建议

9.1 什么时候用哪个?

单线程?
    │
    ├── ✅ HashMap(简单高效)
    │
    └── 多线程?
            │
            ├── 并发读为主,偶尔写?
            │       ├── ✅ ConcurrentHashMap(读不加锁)
            │       └── 推荐:`new ConcurrentHashMap<>()`
            │
            ├── 需要有序遍历?
            │       └── ✅ ConcurrentSkipListMap(跳表,有序)
            │
            └── 需要锁住整个 Map 操作?
                    └── ❌ 考虑 Collections.synchronizedMap()
                            (性能差,一般不推荐)

9.2 总结对比表

集合 线程安全 并发度 数据结构 null 支持 适用场景
HashMap 单线程 数组+链表/红黑树 key/value 都可 null 单线程快速查找
Hashtable 低(全表锁) 数组+链表 都不行 不推荐
ConcurrentHashMap 高(桶锁) 数组+链表/红黑树 都不行 高并发写场景
ConcurrentSkipListMap 跳表 都不行 需要有序的高并发场景
Collections.synchronizedMap() 低(全表锁) 同 HashMap 都可 null 不推荐

📌 回到目录


参考资料


更多推荐