ConcurrentHashMap 源码解析(JDK8)高并发哈希表的终极实现

2026-04-25阅读 335热度 335
线程

一、先一句话抓住核心(JDK7 vs JDK8)

要透彻理解JDK8 ConcurrentHashMap的设计精髓,必须从其演进脉络入手。

JDK7版本采用“分段锁”架构:将哈希表划分为多个Segment,每个Segment独立加锁。这种设计虽优于Hashtable的全局锁,但锁粒度仍停留在Segment级别,并发度受限于预设的分段数量。

JDK8的设计哲学发生了根本性变革:数据结构回归经典的数组+链表/红黑树组合;同步机制则升级为CAS无锁操作配合synchronized锁定单个哈希桶(头节点)。锁粒度被压缩至单个桶,实现了并发性能的指数级提升。

核心结论:JDK8摒弃了分段锁,采用极细粒度的桶级别锁与无锁CAS协同,这是为现代高并发场景量身定制的架构。

二、JDK8 ConcurrentHashMap 核心结构

剖析源码,其核心数据结构清晰而高效:

public class ConcurrentHashMap {
    // 哈希表数组
    transient volatile Node[] table;
    // 链表转红黑树阈值
    static final int TREEIFY_THRESHOLD = 8;
    // 红黑树退化为链表阈值
    static final int UNTREEIFY_THRESHOLD = 6;
    // 树化的最小容量
    static final int MIN_TREEIFY_CAPACITY = 64;
    // 控制标识:初始化、扩容、计数
    transient volatile int sizeCtl;
}

三、核心 Node 节点(JDK8 真实结构)

底层存储单元Node节点的定义,奠定了高并发读写的基础:

static class Node implements Map.Entry {
    final int hash;
    final K key;
    volatile V val;
    volatile Node next;
    // ...
}

这里有三个关键设计值得深入分析:

首先,table数组本身由volatile修饰,确保了数组引用本身的可见性,任何线程都能瞬时感知到数组的更新。

其次,Node节点的valnext字段同样被声明为volatile,这为无锁化的读操作提供了内存语义保障。

最后,sizeCtl变量是整个容器的控制中枢。它身兼多职:既是初始化的控制信号,也是扩容的阈值与协调器。深刻理解其状态变迁,就掌握了ConcurrentHashMap并发逻辑的核心。

四、核心流程:put 方法源码(最精简准确版)

理论结合实践,以下精简版putVal流程涵盖了所有核心并发场景:

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[] tab = table;;) {
        Node f; int n, i, fh;
        // 1. 数组为空 → 初始化
        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<>(hash, key, value, null)))
                break;
        }
        // 3. 头节点 hash == MOVED → 正在扩容,帮助扩容
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        // 4. 正常哈希冲突 → 锁头节点
        else {
            V oldVal = null;
            synchronized (f) { // 只锁当前桶的头节点!
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        // 链表插入
                    } else {
                        // 红黑树插入
                    }
                }
            }
        }
    }
    addCount(1L, binCount);
    return oldVal;
}

掌握此流程,足以应对关于put操作的深度提问。其执行逻辑可归纳为四步:

第一步,参数校验。key和value均不允许为null,这是保证并发语义清晰性的铁律。

第二步,哈希计算。通过spread方法对键的哈希码进行二次扰动,优化分布,减少碰撞。

第三步,核心循环。包含四个关键分支:若数组未初始化,则触发初始化;若目标桶为空,则通过CAS无锁插入新节点,这是最高效的路径;若检测到头节点为特殊标记(MOVED),表明正处于扩容阶段,当前线程将协助进行数据迁移;若发生常规哈希冲突,则使用synchronized锁定该桶的头节点,随后在链表或红黑树中完成插入。

第四步,后续处理。插入成功后更新元素计数,并判断是否触发扩容。若链表长度达到阈值8且数组容量不小于64,则将链表转换为红黑树,以保障极端情况下的性能。

五、为什么 JDK8 这么强?

分析核心流程后,其高性能根源可归结为四大设计优势:

锁粒度极致细化。 仅锁定发生冲突的单个哈希桶,其余桶的读写操作完全并行,极大提升了并发吞吐量。

无冲突场景零锁竞争。 多数插入操作目标桶为空,直接通过CAS完成,性能开销接近无锁的HashMap。

多线程协同扩容。 这是JDK8设计的精髓。扩容任务被拆分为多个子区间,所有参与的线程可并行迁移数据,而非被动等待,大幅缩短了扩容导致的停顿时间。

自适应数据结构。 链表与红黑树根据实际情况动态转换。在遭遇严重哈希冲突时,能将操作时间复杂度从O(n)优化至O(log n),有效抵御哈希碰撞攻击,保障了性能下限。

六、必须搞懂的关键细节(面试高频)

宏观设计之外,以下细节是区分“了解”与“精通”的关键。

1. 为什么 key 和 value 不能为 null?

HashMap允许null键值,但ConcurrentHashMap明确禁止。根本原因在于并发语义的清晰性:若允许null值,当get(key)返回null时,调用者无法区分是该key不存在,还是其关联的value本就是null。这种二义性会破坏线程安全契约,因此采用最严格的设计,杜绝null。

2. get 方法真・完全无锁

这是ConcurrentHashMap读性能卓越的基石。get操作全程无需任何锁。其安全性依赖于volatile的内存可见性保证:table数组引用、Node节点的valnext字段均为volatile。只要写操作遵循规则更新这些字段,读线程就能安全地获取最新值。读取桶头节点时,使用tabAt提供的原子读语义。

3. sizeCtl 的 4 种含义(超级高频)

此变量是理解容器并发控制的总开关,其数值代表四种不同状态:

等于-1:表示数组正在初始化中。

小于-1:表示正在进行扩容,其低位数记录了参与扩容的线程数量。

等于0:表示创建时未指定初始容量,采用默认容量。

大于0:此状态最为关键,其值代表下一次触发扩容的阈值(容量 * 负载因子,默认为0.75)。

4. 为什么链表长度 ≥8 才树化?

阈值8的设定基于严谨的概率统计。在理想的哈希函数下,根据泊松分布,单个哈希桶内链表长度达到8的概率极低(约千万分之六)。若实际达到此长度,通常意味着遇到了哈希攻击或hashCode实现质量不佳。此时将链表转为红黑树,旨在将最坏情况下的操作复杂度从O(n)降至O(log n),确保性能底线。

5. 树化必须满足两个条件

链表长度≥8仅是前提之一。另一个必要条件是数组长度必须达到MIN_TREEIFY_CAPACITY(默认64)。若数组容量尚小,优先选择扩容来分散节点,而非直接树化。因为扩容能从根源上缓解冲突,而树化会引入额外的内存与性能开销。

6. 扩容机制(JDK8 灵魂)

扩容机制是ConcurrentHashMap并发设计的集中体现。触发条件是元素数量达到阈值(sizeCtl)。新容量为旧容量的两倍。迁移过程采用“分治”策略:将旧数组划分为多个区间,由多个线程并行迁移各自区间内的桶。迁移过程中,已处理的桶头节点会被替换为ForwardingNode(其hash值为MOVED),其他线程在读写时遇到此标记,会主动加入协助迁移。待所有数据迁移完毕,才会将引用指向新table。整个过程高效、协同,最大化利用了多核资源。

7. size () 是弱一致性

size()方法返回的是近似值,而非实时精确计数。因为CHM并未为了获取绝对准确的数量而全局加锁,那会严重牺牲性能。它是通过汇总各个计数单元的值来估算总量。因此,在高并发更新下,它提供的是一个弱一致性的视图。若业务仅需大致数量,size()完全适用;若强依赖精确计数,则需考虑其他方案。官方也推荐使用mappingCount()方法,其返回long类型,更不易溢出。

七、JDK7 vs JDK8 一张表总结

(此处保留原文结构,内容已整合至第一部分“先一句话抓住核心”)

八、总结(可直接背)

JDK8的ConcurrentHashMap通过一系列精妙的设计,实现了业界领先的并发性能:它摒弃了分段锁,采用数组+链表/红黑树的基础结构;读操作完全无锁,写操作在无冲突时使用CAS,有冲突时仅锁定单个桶;扩容过程支持多线程协同,极大提升了效率。同时,通过禁止null键值保证了语义清晰,通过弱一致性的size()优先保障了性能。

因此,在高并发场景下的选型非常明确:首选ConcurrentHashMap。传统的HashMap与Hashtable因线程安全问题,已不在考虑之列。其设计堪称并发编程领域的典范之作。

免责声明

本网站新闻资讯均来自公开渠道,力求准确但不保证绝对无误,内容观点仅代表作者本人,与本站无关。若涉及侵权,请联系我们处理。本站保留对声明的修改权,最终解释权归本站所有。

相关阅读

更多
欢迎回来 登录或注册后,可保存提示词和历史记录
登录后可同步收藏、历史记录和常用模板
注册即表示同意服务条款与隐私政策