吊打 HashTable!ConcurrentHashMap 凭什么成为并发神器

0 阅读8分钟

前言

日常开发中,HashMap 是我们最常用的键值对存储容器,上手简单、性能优秀。但一旦放到多线程并发场景,HashMap 就会暴露出致命问题:数据覆盖、链表成环、CPU 飙高、程序死循环。 很多人会退而求其次用 HashTable,可它锁粒度太大、并发性能拉胯,根本撑不起高并发业务。 而 ConcurrentHashMap 就是为解决这个痛点而生:既保证线程安全,又拥有接近 HashMap 的高性能,成为并发编程里的标配容器。今天从使用痛点、对比差异、源码底层、核心优势、适用场景一次性讲透。

一:HashMap 为什么不能用于多线程?

  • HashMap底层是数组+链表+红黑树,它的任何方法都没有加任何同步锁
  • 这样就导致多线程同时执行put,扩容操作时
    • 多线程同时插入元素,会发生数据覆盖,丢失数据;

    • JDK7 扩容时容易形成循环链表,调用 get() 时无限遍历,导致 CPU 100%;

    • 无法保证复合操作(先判断再赋值)的原子性,业务逻辑错乱。

所以HashMap适合在单线程下使用,并发场景下会有风险

二:HashTable为什么被淘汰

HashTable 是早期线程安全 Map,原理极其粗暴:所有方法 putgetremove 都加了 synchronized,锁住整个哈希表对象。但这样做的弊端也很明显:

  1. 并发下读写互斥、写写互斥、读读也互斥
  2. 同一时刻只能有一个线程操作,并发量大时大量线程阻塞;
  3. 性能极差,高并发场景完全扛不住。 相当于只给卫生间一把钥匙,所有人都要排队使用,效率极差

三:ConcurrentHashMap 凭什么又安全又快?

先看整体架构

image.png

由数组,单项链表,红黑树组成

核心思路: 缩小锁的粒度,不锁整张表,只锁局部节点,配合 CAS 无锁操作,做到读几乎无锁、写只锁当前桶

在 JDK 1.7 中,ConcurrentHashMap 采用 分段锁(Segment)  设计,将整个哈希表分成多个 Segment,每个 Segment 内部持有一个 ReentrantLock,并管理一个 HashEntry<K,V>[] 数组。

// JDK 1.7 简化结构
static final class Segment<K,V> extends ReentrantLock {
    transient volatile HashEntry<K,V>[] table;
    // ...
}
final Segment<K,V>[] segments;
  • 并发度:默认 16,即最多允许 16 个线程同时写入不同的分段。
  • put 流程:对 key 的 hash 值取模定位到 Segment,加锁后执行链表插入。
  • get 流程:无锁读取,因为 HashEntry 的 value 和 next 都是 volatile 修饰。
  • size 计算:先尝试不加锁遍历所有 Segment 计算两次,若结果一致则返回;否则对所有 Segment 加锁重算。

缺点:当并发线程数超过分段数时,锁竞争依然存在;内存占用高(每个 Segment 单独 lock);扩容只影响单个 Segment,整体容量受限。

在JDK8中解决了这个问题

JDK 8 彻底废弃分段锁,采用 Node 数组 + CAS + synchronized 的组合策略,将锁粒度细化到每个桶(bin)的头节点。同时引入红黑树(当链表长度 > 6 且数组长度 ≥ 64 时)优化极端冲突下的查找性能。 通过- 通过 volatile 修饰数组节点,保证可见性,get 全程无锁,效率极高。 往空桶放数据时,先用 CAS 无锁自旋 尝试写入,成功就不用加锁。 CAS 竞争失败后,只对当前哈希桶的头节点synchronized 锁,只锁一个桶,不锁整个表。 扩容、树化、迁移都采用分段迁移、并发协助扩容多个线程可以一起帮忙扩容,大幅减少扩容阻塞时间。

// 存储桶的数组,首次使用时初始化,容量总是 2 的幂
transient volatile Node<K,V>[] table;

// 扩容时使用的新数组(仅在扩容期间有效)
private transient volatile Node<K,V>[] nextTable;

// 控制标识符,含义复杂:
// -1:表示正在初始化
// -N:表示有 N-1 个线程正在进行扩容
// 正数:表示下次触发扩容的阈值(容量 * 负载因子)
private transient volatile int sizeCtl;

// 基础计数(不加锁时使用)
private transient volatile long baseCount;

// 辅助计数数组,用于高并发下减少对 baseCount 的竞争
private transient volatile CounterCell[] counterCells;
构造方法:延迟初始化
public ConcurrentHashMap(int initialCapacity) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException();
    int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
               MAXIMUM_CAPACITY :
               tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
    // sizeCtl 暂存初始容量,真正的 table 在首次 put 时创建
    this.sizeCtl = cap;
}

tableSizeFor 保证容量为 2 的幂,与 HashMap 类似

put操作
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());  // 二次扰动,高位参与寻址
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();                // 1. 初始化数组
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 2. 桶为空,CAS 插入新节点
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
                break;
        }
        else if ((fh = f.hash) == MOVED)
            // 3. 当前桶正在扩容,协助迁移
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            // 4. 对桶头节点加锁(synchronized 细粒度锁)
            synchronized (f) {
                if (tabAt(tab, i) == f) {   // 双重检查
                    if (fh >= 0) {          // 普通链表节点
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = 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) {  // 红黑树节点
                        Node<K,V> p;
                        binCount = 2;
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            if (binCount != 0) {
                // 链表长度超过 TREEIFY_THRESHOLD(8),尝试转红黑树
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);   // 5. 计数,并触发扩容检查
    return null;
}
get操作
public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        if ((eh = e.hash) == h) {          // 头节点即要找的
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        else if (eh < 0)                   // 树节点或正在扩容的转发节点
            return (p = e.find(h, key)) != null ? p.val : null;
        while ((e = e.next) != null) {     // 遍历链表
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}
  • 完全无锁table 和 Node.val 都被 volatile 修饰,保证读取的可见性。
  • 弱一致性:遍历过程中若有其他线程修改了链表,不会抛出 ConcurrentModificationException,可能读取到旧值。
扩容机制 transfer() 与多线程协助

当 put 后元素个数超过阈值 sizeCtl 时,触发扩容。扩容流程核心由 transfer 方法执行,支持多线程并发迁移

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    // 每个线程负责迁移的桶数(最少 16)
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE;
    if (nextTab == null) {            // 初始化新数组(2倍容量)
        // ... 创建 nextTab
    }
    int nextn = nextTab.length;
    ForwardingNode<K,V> fwd = new ForwardingNode<>(nextTab); // 转发节点标记已迁移
    boolean advance = true;
    boolean finishing = false;
    for (int i = 0, bound = 0;;) {
        // 分配任务区间 [bound, i]
        while (advance) {
            // ...
        }
        // 迁移当前桶
        Node<K,V> f;
        if ((f = tabAt(tab, i)) == null)
            advance = casTabAt(tab, i, null, fwd);    // 空桶直接标记转发
        else if ((fh = f.hash) == MOVED)
            advance = true;
        else {
            synchronized (f) {                        // 锁住头节点,迁移链表/树
                // 将原桶中的节点拆分为低位链和高位链(类似 HashMap)
                // 放入 nextTab 的 i 和 i+n 位置
            }
        }
    }
    if (finishing) {
        table = nextTab;            // 完成,更新 table 引用
        sizeCtl = (n << 1) - (n >>> 1); // 重新计算阈值
    }
}
  • sizeCtl 高位记录参与扩容的线程数(例如 sizeCtl = - (参与线程数 + 1))。
  • 每个线程通过 transferIndex 字段分配一段连续的桶区间,独立迁移。
  • 迁移完一个桶后设置 ForwardingNode(hash 值 MOVED=-1),其他线程遇到它时会跳过或协助。
  • 最后完成时,最后一个退出的线程负责重置 table 和 sizeCtl
5. 计数:size() 与 mappingCount()

高并发下全局计数器的维护很有技巧:

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

sumCount() 将 baseCount 与 counterCells 数组内的所有值累加。addCount() 逻辑:

private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    // 优先使用 counterCells 累加(分散热点)
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        // CAS 失败则使用 counterCells
        fullAddCount(x, unbranded);
    }
    // 检查是否需要扩容
    if (check >= 0) {
        // ...
    }
}
  • 每个 CounterCell 对象独占一个缓存行(@sun.misc.Contended 避免伪共享),减少竞争。
  • size() 并不是实时精确值,但能保证最终一致性。
6. 树化与反树化
  • treeifyBin():当链表长度 ≥ TREEIFY_THRESHOLD(8) 时,尝试将链表转为红黑树。但如果数组长度 < MIN_TREEIFY_CAPACITY(64),会先扩容。
  • 红黑树查找时间复杂度 O(log n),显著提升冲突链表的性能。
  • 当红黑树节点数 ≤ UNTREEIFY_THRESHOLD(6) 时,在 resize 过程中会退化为链表。

四、并发设计核心技巧

  1. Unsafe + volatile
    大量使用 U.getObjectVolatileU.compareAndSwapObject 等底层操作,保证无锁读写的可见性和原子性。
  2. 锁粒度最小化
    只在修改链表头节点或红黑树根节点时使用 synchronized,不同桶之间无竞争。
  3. 伪共享优化
    CounterCell 使用 @Contended 注解填充缓存行,避免多线程频繁修改相邻内存地址导致的缓存失效。
  4. 弱一致性迭代器
    KeySetValues 等视图迭代器不抛出 ConcurrentModificationException,遍历过程中修改集合不会影响已遍历元素,且不保证一定看到最新修改。
  5. 扩容友好
    多线程共同迁移数据,大幅缩短扩容停顿时间;ForwardingNode 实现读写过程中扩缩容的无缝衔接。