简单聊聊 ConcurrentHashMap 源码

简单聊聊 ConcurrentHashMap 源码

简单聊聊线程池源码 那篇一个套路:与其背"ConcurrentHashMap 是线程安全的",不如按 JDK 1.8 的核心执行流程

手写一个迷你版 MiniConcurrentHashMap,

一行一行对照源码,聊明白 put 到底是怎么并发安全的。

开头先抛个坑,迷你版第一行就验证:

java 复制代码
ConcurrentHashMap<Object, Object> map = new ConcurrentHashMap<>();
map.put(null, null);   // 直接抛 NullPointerException

同样是 Map,HashMap 允许 null,它连 null 都不让进------这是并发设计的第一道防线。


一、它到底在解决什么问题

一句话:既要并发安全,又不想像 Hashtable 那样全表加锁。

并发安全性 并发粒度
HashMap ❌ 不安全(1.7 链表头插会死循环;1.8 并发 put 覆盖丢数据) 无锁
Hashtable / synchronizedMap ✅ 安全 整张表一把锁,任何操作串行
ConcurrentHashMap ✅ 安全 锁桶头 + CAS,不同桶互不干扰

1.8 的设计核心八个字:"读不加锁,写锁桶头" 。迷你版 MiniConcurrentHashMap 完整复刻了这条主路径。

二、前置:为什么 key/value 都不能为 null

putVal 第一行就判空:

java 复制代码
if (key == null || value == null) throw new NullPointerException();

两个原因:

  1. 歧义 :get(key) == null 既可能表示"没有这个 key",也可能表示"值是 null";单线程 HashMap 能容忍,但 CHM 是给 putIfAbsent 这类复合操作用的,null 会摧毁"是否存在"的语义;
  2. 并发传递歧义 :并发下很难说清 put(k, null) 这次写入到底有没有发生,索性设计层面直接禁止,从源头消灭不确定性。

面试点:这是 CHM 和 HashMap 最常被问到的表面区别,要能说出"为什么"。

三、三个支撑结构(先认识零件)

java 复制代码
// ① 桶头节点:hash/key 不可变,value/next 是 volatile → 读不需要锁
static class Node<K, V> {
    final int hash;
    final K key;
    volatile V value;
    volatile Node<K, V> next;
}

// ② 桶数组:元素级 volatile + CAS
private volatile AtomicReferenceArray<Node<K, V>> table;

// ③ 扩容阈值 = 容量 * 0.75;元素个数
private volatile int sizeCtl;
private final AtomicInteger baseCount = new AtomicInteger();

三个设计点:

  • Node 的 value/next 是 volatile ,这是"读不加锁"的物理基础------读到的值一定是某个线程完整写入后的值,不会读"写了一半"的脏状态;
  • table 是 volatile + 元素级 CAS :JDK 用 Unsafe.getObjectVolatile / compareAndSwapObject 操作原生数组;迷你版用 AtomicReferenceArray 提供同样能力,不依赖 jdk.unsupported;
  • sizeCtl 一个变量身兼两职:平时是扩容阈值(容量 × 0.75),初始化/扩容时是竞争标志。

四、put 执行流程:死循环 + CAS 的四大防线

迷你版 putVal 的核心骨架(对应 JDK 的 putVal):

java 复制代码
private V putVal(K key, V value) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());          // 扰动:高位参与定位,减少碰撞

    for (;;) {                                  // 死循环 + CAS
        AtomicReferenceArray<Node<K, V>> tab = table;
        int n = tab.length();
        int i = (n - 1) & hash;                 // 定位桶
        Node<K, V> f = tab.get(i);              // volatile 读桶头

        if (f == null) {
            // 分支B: 桶是空的 → CAS 直接写,无锁,最快路径
            Node<K, V> node = new Node<>(hash, key, value);
            if (tab.compareAndSet(i, null, node)) {
                addCount(1);
                return null;                    // 写入成功,收工
            }
            continue;                           // CAS 失败:被别人抢先,重试
        }

        // (JDK 分支C: f.hash == MOVED → 扩容中,helpTransfer 帮忙搬,迷你版省略)

        synchronized (f) {                      // 分支D: 桶不空 → 锁桶头
            if (tab.get(i) == f) {              // 双重检查:桶头还是不是我锁的那个
                V old = null;
                for (Node<K, V> e = f; ; e = e.next) {
                    if (e.hash == hash && Objects.equals(e.key, key)) {
                        old = e.value;          // key 已存在 → 覆盖
                        e.value = value;
                        break;
                    }
                    if (e.next == null) {
                        e.next = new Node<>(hash, key, value);   // 尾插
                        break;
                    }
                }
                if (old == null) addCount(1);   // 只有"新增"才计数
                return old;
            }
        }
        // 桶头被换(只有扩容会换) → 重试
    }
}

画成流程图:

复制代码
        put(key, value)
            │
        key/value 判空 ──null──▶ 抛 NPE
            │
     hash = spread(key.hashCode())
            │
    ┌───────▼────────死循环 + CAS────────┐
    │  B. 桶是空的?    ──是──▶ CAS 直接写入 ══▶ 收工(最快路径,无锁)
    │                      │
    │               CAS 失败(别人抢先)→ 重试
    │  D. 桶不空       ──▶ synchronized 锁桶头
    │                       └─ 双重检查 tab.get(i)==f
    │                            ├─ key 已存在 → 覆盖
    │                            └─ 不存在 → 尾插新节点
    └─────────────────────────────────────┘
            │
       addCount(1) → 超过 sizeCtl 触发扩容

为什么"空桶用 CAS,非空桶才加锁"

  • 空桶是 put 的最快路径 :两个线程同时看见空桶,compareAndSet(i, null, node) 保证只有一个成功 ,另一个 CAS 失败回到循环头重试(可能走到分支 D)。没有锁,一次 CAS 结束;
  • 非空桶锁"桶头节点"而不是整张表 :只有落在同一个桶的线程才竞争这把锁,不同桶的 put 完全并行------这就是 1.8 比 Hashtable 并发度高的本质;
  • 双重检查 tab.get(i) == f:锁定时桶头可能被其他线程换掉(扩容迁移),必须确认还是原来那个节点,防止锁错对象、写错桶。

一个容易漏的细节:为什么是"尾插"

JDK 1.7 的 HashMap/CHM 扩容用头插 ,并发下会形成环形链表死循环;1.8 全部改尾插,新节点挂在链尾,链表顺序稳定,死循环问题从根上消失。

五、扩容 resize(为什么演示里要开关)

java 复制代码
private void addCount(int delta) {
    int c = baseCount.addAndGet(delta);
    if (resizeEnabled && c >= sizeCtl) resize();   // 超过阈值 → 扩容
}

private synchronized void resize() {
    // 容量翻倍,重哈希,尾插
    ...
    table = newTab;
    sizeCtl = (int) (newCap * LOAD_FACTOR);
}

三个要点:

  1. 触发条件 :元素个数 ≥ 容量 × 0.75,容量翻倍(迷你版场景 4 实测:8 -> 1616 -> 32,阈值 6、12 各触发一次);
  2. 双重检查防重复扩容 :resize()synchronized,进入后先看 baseCount <= sizeCtl------可能别的线程已经扩过了,直接返回;
  3. resizeEnabled 开关 :迷你版的扩容是单线程 synchronized,和 JDK 的多线程搬桶不一样,所以并发插入演示(场景 3)把扩容关掉,避免简化版扩容的竞态污染"不丢数据"的结论。

六、get 无锁,size 近似

java 复制代码
public V get(Object key) {
    int hash = spread(key.hashCode());
    Node<K, V> e = table.get((table.length() - 1) & hash);
    for (; e != null; e = e.next) {
        if (e.hash == hash && Objects.equals(e.key, key)) return e.value;
    }
    return null;
}
  • get 完全不加锁 ,靠的就是 Node 的 volatile value + volatile next:读到的要么是完整旧值、要么是完整新值;
  • size() 返回 baseCount.get()。JDK 1.8 更进一步:争用高时把计数分散到一组 CounterCell[] (LongAdder 思路),size() 求和------所以 CHM 的 size() 是个近似值,并发语义下完全可接受。迷你版简化为一个 AtomicInteger,但"读时求和、不是遍历全表"的思路一致。

七、迷你版和 JDK 的差距(面试要能诚实说出来)

迷你版 JDK 1.8 影响
构造器直接建好 table 首次 put 才 initTable(),CAS 抢初始化权 懒加载优化,行为等价
没有分支 C ForwardingNode(hash=MOVED) + helpTransfer 多线程搬桶 put 遇到扩容会主动帮忙搬
resize() 单线程 synchronized 多线程分区间并发迁移 + 高低位拆分 并发扩容性能差,但逻辑正确
没有红黑树 链表 ≥ 8 且容量 ≥ 64 → treeifyBin 转红黑树 极端碰撞下查询 O(n)→O(log n)
AtomicReferenceArray Unsafe.getObjectVolatile + CAS 操作原生数组 能力一致,JDK 更底层
单个 AtomicInteger 计数 baseCount + CounterCell[] 分散计数 高并发计数争用更小

核心执行路径------判空、spread 扰动、死循环 + CAS、空桶 CAS、锁桶头 + 双重检查、尾插、addCount 阈值扩容------与 JDK 完全一致。 把这几个点讲清楚再去啃 JDK 源码,剩下的都是增量。

八、演示怎么跑:四个场景对应四个面试点

MiniConcurrentHashMap 演示代码

java 复制代码
package com.interview.thread;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReferenceArray;

/**
 * 迷你版 ConcurrentHashMap ------ 复刻 1.8 的 put 执行流程,去掉细节,保留骨架。
 *
 * 与 JDK 的对应关系(详解见 md/简单聊聊ConcurrentHashMap源码.md):
 *  - 分支B:空桶 CAS 直接写(最快路径,无锁)
 *  - 分支D:非空桶 synchronized 锁桶头,链表尾插,双重检查桶头没变
 *  - addCount:baseCount 计数,超过 sizeCtl 触发扩容
 *
 * 有意识的简化(面试要能诚实说出来):
 *  - 分支A initTable:JDK 并发 CAS 抢初始化,这里构造器里直接建好;
 *  - 分支C 扩容中:JDK 用 ForwardingNode(hash=MOVED)+ helpTransfer 多线程搬桶,
 *    这里扩容是 synchronized 单线程,也没有 ForwardingNode;
 *  - 红黑树:JDK 链表≥8 且容量≥64 才转树,这里省略(TreeBin 太复杂);
 *  - JDK 用 Unsafe 操作原生 volatile 数组,这里用 AtomicReferenceArray 提供同样的
 *    volatile 读 + CAS 写,不依赖 jdk.unsupported。
 */
public class MiniConcurrentHashMap<K, V> {

    private static final int DEFAULT_CAPACITY = 16;
    private static final float LOAD_FACTOR = 0.75f;
    private static final int MAXIMUM_CAPACITY = 1 << 30;

    /** 桶头节点:hash/key 不可变,value/next 是 volatile → 读不需要锁 */
    static class Node<K, V> {
        final int hash;
        final K key;
        volatile V value;
        volatile Node<K, V> next;

        Node(int hash, K key, V value) {
            this.hash = hash;
            this.key = key;
            this.value = value;
        }
    }

    /** 桶数组:元素级 volatile + CAS(演示版替代 JDK 的 Unsafe) */
    private volatile AtomicReferenceArray<Node<K, V>> table;
    /** 扩容阈值 = 容量 * 0.75 */
    private volatile int sizeCtl;
    /** 元素个数 */
    private final AtomicInteger baseCount = new AtomicInteger();

    /** 演示开关:并发场景关掉扩容,避免简化版单线程扩容的竞态 */
    volatile boolean resizeEnabled = true;

    public MiniConcurrentHashMap() {
        this(DEFAULT_CAPACITY);
    }

    public MiniConcurrentHashMap(int initialCapacity) {
        int cap = 1;
        while (cap < initialCapacity) cap <<= 1;
        this.table = new AtomicReferenceArray<>(cap);
        this.sizeCtl = (int) (cap * LOAD_FACTOR);
    }

    /** 扰动:高位异或低位参与定位,减少碰撞(JDK 的 spread) */
    private static int spread(int h) {
        return (h ^ (h >>> 16)) & 0x7fffffff;
    }

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

    private V putVal(K key, V value) {
        // 第一道防线:key/value 都不允许 null。HashMap 允许,这里直接抛 NPE。
        if (key == null || value == null) throw new NullPointerException();

        int hash = spread(key.hashCode());

        for (;;) {                                  // 死循环 + CAS
            AtomicReferenceArray<Node<K, V>> tab = table;
            int n = tab.length();
            int i = (n - 1) & hash;                 // 定位桶
            Node<K, V> f = tab.get(i);              // volatile 读桶头

            if (f == null) {
                // 分支B: 桶是空的 → CAS 直接写,无锁,最快路径
                Node<K, V> node = new Node<>(hash, key, value);
                if (tab.compareAndSet(i, null, node)) {
                    addCount(1);
                    return null;                    // 写入成功,收工
                }
                continue;                           // CAS 失败:被别人抢先,重试
            }

            // (JDK 分支C: f.hash == MOVED → helpTransfer 帮忙扩容。这里没有并发扩容,省略)

            // 分支D: 桶不空 → 锁桶头,只锁这一个桶,不同桶互不干扰
            synchronized (f) {
                if (tab.get(i) == f) {              // 双重检查:桶头还是不是我锁的那个
                    V old = null;
                    for (Node<K, V> e = f; ; e = e.next) {
                        if (e.hash == hash && Objects.equals(e.key, key)) {
                            old = e.value;          // key 已存在 → 覆盖
                            e.value = value;
                            break;
                        }
                        if (e.next == null) {
                            e.next = new Node<>(hash, key, value);   // 尾插
                            break;
                        }
                    }
                    // (JDK: 这里会判断链表长度 ≥8 → treeifyBin 转红黑树,省略)
                    if (old == null) addCount(1);   // 只有"新增"才计数
                    return old;
                }
            }
            // 桶头被换(本演示只有扩容会换) → 重试
        }
    }

    /** 无锁读:靠 Node.value/next 的 volatile 保证读到的是某个线程完整写入的值 */
    public V get(Object key) {
        int hash = spread(key.hashCode());
        AtomicReferenceArray<Node<K, V>> tab = table;
        Node<K, V> e = tab.get((tab.length() - 1) & hash);
        for (; e != null; e = e.next) {
            if (e.hash == hash && Objects.equals(e.key, key)) return e.value;
        }
        return null;
    }

    public int size() {
        return baseCount.get();
    }

    /** 计数 + 扩容检查(JDK 是 baseCount + CounterCell 分散计数,这里简化为一个 AtomicInteger) */
    private void addCount(int delta) {
        int c = baseCount.addAndGet(delta);
        if (resizeEnabled && c >= sizeCtl) resize();
    }

    /** 扩容:容量翻倍,重哈希尾插(JDK 是多线程搬桶 + 高低位拆分 + ForwardingNode) */
    private synchronized void resize() {
        AtomicReferenceArray<Node<K, V>> oldTab = table;
        int oldCap = oldTab.length();
        if (oldCap >= MAXIMUM_CAPACITY) { sizeCtl = Integer.MAX_VALUE; return; }
        // 双重检查:可能别的线程已经扩过了(配合 synchronized 防重复扩容)
        if (baseCount.get() <= sizeCtl) return;

        int newCap = oldCap << 1;
        AtomicReferenceArray<Node<K, V>> newTab = new AtomicReferenceArray<>(newCap);

        for (int i = 0; i < oldCap; i++) {
            Node<K, V> e = oldTab.get(i);
            while (e != null) {
                Node<K, V> next = e.next;
                e.next = null;                      // 断开旧链,防止把整条链带过去
                insertTail(newTab, (newCap - 1) & e.hash, e);
                e = next;
            }
        }
        table = newTab;
        sizeCtl = (int) (newCap * LOAD_FACTOR);
        System.out.println("[MiniCHM] 触发扩容:" + oldCap + " -> " + newCap + ", 新阈值=" + sizeCtl);
    }

    private void insertTail(AtomicReferenceArray<Node<K, V>> tab, int idx, Node<K, V> node) {
        Node<K, V> head = tab.get(idx);
        if (head == null) {
            tab.set(idx, node);
        } else {
            Node<K, V> cur = head;
            while (cur.next != null) cur = cur.next;
            cur.next = node;
        }
    }

    // ==================== 演示 ====================
    public static void main(String[] args) throws Exception {
        // 场景1: 正常 put/get
        MiniConcurrentHashMap<String, Integer> map = new MiniConcurrentHashMap<>();
        map.put("A", 1);
        map.put("B", 2);
        System.out.println("场景1  get(A)=" + map.get("A") + ", get(B)=" + map.get("B") + ", size=" + map.size());

        // 场景2: 不允许 null → NPE(和 HashMap 最大的表面区别)
        try {
            map.put(null, 1);
        } catch (NullPointerException e) {
            System.out.println("场景2  put(null, 1) 抛 NPE ✓");
        }
        try {
            map.put("A", null);
        } catch (NullPointerException e) {
            System.out.println("场景2  put(\"A\", null) 抛 NPE ✓");
        }

        // 场景3: 8 线程并发 put,关闭扩容(避免简化版单线程扩容的竞态),验证不丢数据
        MiniConcurrentHashMap<Integer, Integer> cmap = new MiniConcurrentHashMap<>();
        cmap.resizeEnabled = false;
        int threads = 8, perThread = 1000;
        ExecutorService pool = Executors.newFixedThreadPool(threads);
        CountDownLatch start = new CountDownLatch(1);
        List<Future<?>> futures = new ArrayList<>();
        for (int t = 0; t < threads; t++) {
            final int base = t * perThread;
            futures.add(pool.submit(() -> {
                try {
                    start.await();                  // 等所有线程就绪,一起开闸
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                for (int j = 0; j < perThread; j++) cmap.put(base + j, j);
            }));
        }
        start.countDown();
        for (Future<?> f : futures) f.get();
        pool.shutdown();
        int expected = threads * perThread;
        System.out.println("场景3  8 线程并发 put " + expected + " 个, size=" + cmap.size()
                + (cmap.size() == expected ? "  ✓ 不丢数据" : "  ✗ 丢数据了!"));
        System.out.println("场景3  get(123)=" + cmap.get(123));

        // 场景4: 单线程触发扩容(阈值=8*0.75=6,插满触发 8->16,再到 16->32)
        MiniConcurrentHashMap<Integer, Integer> rmap = new MiniConcurrentHashMap<>(8);
        for (int i = 0; i < 20; i++) rmap.put(i, i);
        System.out.println("场景4  插 20 个后 size=" + rmap.size() + ", get(15)=" + rmap.get(15));
    }
}
复制代码
场景1  get(A)=1, get(B)=2, size=2
场景2  put(null, 1) 抛 NPE ✓
场景2  put("A", null) 抛 NPE ✓
场景3  8 线程并发 put 8000 个, size=8000  ✓ 不丢数据
场景3  get(123)=123
[MiniCHM] 触发扩容:8 -> 16, 新阈值=12
[MiniCHM] 触发扩容:16 -> 32, 新阈值=24
场景4  插 20 个后 size=20, get(15)=15
场景 演示内容 对应面试问题
1 正常 put/get 基本使用
2 put(null, ...) 抛 NPE CHM 为什么不允许 null?
3 8 线程并发 8000 个不丢数据 并发 put 会丢数据吗?
4 阈值触发两次扩容 什么时候扩容?怎么避免重复扩容?

九、结语

ConcurrentHashMap 1.8 的 put,本质是一条"尽可能无锁、不得已才加锁"的降级路线:

复制代码
空桶 → CAS 一把梭(无锁)
非空桶 → synchronized 只锁桶头(细粒度)
扩容中 → 帮忙搬(协作,JDK)

再加三件配套:spread 扰动 降低碰撞、尾插 根除死循环、baseCount + CounterCell 分散计数。把这五块讲清楚,CHM 的源码面试基本就过关了------完整代码见 MiniConcurrentHashMap.java,也可以对照 JDK 的 ConcurrentHashMap 逐行读,体会 Doug Lea 的 CAS 与 volatile 功力。

相关推荐
lhldsg38 分钟前
智慧场馆解决方案小程序开发实战:从架构设计到部署指南
java·小程序·uni-app
Freak嵌入式1 小时前
Pico UART 数据收发实战:硬件配置、MicroPython 编程
java·开发语言·单片机·嵌入式硬件·生活
devilnumber2 小时前
Oracle 与 MySQL substr 函数差异总结
java·数据库·mysql·oracle
cfm_29142 小时前
线程池阻塞队列
java·开发语言
坐吃山猪2 小时前
【多线程】ThreadPool线程池参数说明
java·网络
evans在进步3 小时前
Java 常用设计模式(二):装饰器、适配器、责任链、模板方法、策略与观察者
java·开发语言·设计模式
jvmind_dev3 小时前
频繁 new JedisCluster 之后,对象池为什么回不去了?
java·后端
AKA__Zas3 小时前
文件 I/O(速通版
java·intellij-idea·学习方法
学长毕业设计3 小时前
基于SpringBoot的咖啡馆管理系统的设计与实现(源码+文档+讲解视频)
java·spring boot·后端
骇客野人3 小时前
SpringBoot业财一体化系统设计和落地实施方案
java·spring boot·后端