Java并发编程--16-ConcurrentHashMap演进:从分段锁到CAS+synchronized

ConcurrentHashMap演进:从分段锁到CAS+synchronized

作者 :Weisian
发布时间:2026年3月

直击痛点

"HashMap在并发环境下轻轻松松带走你的CPU------死循环、数据丢失、扩容脏读,哪个不是生产事故的常客?而ConcurrentHashMap作为线程安全的'救火队员',面试必问,源码必考。但如果你还停留在JDK1.7的分段锁认知,面试官会轻轻一笑:'回去等通知吧'。"

在Java并发容器中,ConcurrentHashMap 是当之无愧的"明星选手"。它既要保证线程安全,又要追求极致性能,其设计思想贯穿了Java并发编程的演进史:

  • JDK1.5~1.7 :采用 分段锁(Segment) 设计,将数据分片,每把锁管理一段数据,实现并发读写;
  • JDK1.8及以后 :放弃分段锁,改用 CAS + synchronized 实现更细粒度的锁控制,引入红黑树优化哈希冲突,并发性能再上新台阶。

面试高频问:

  • "ConcurrentHashMap如何保证线程安全?" ------ 答不上来=基础不牢;
  • "JDK1.7和1.8的ConcurrentHashMap有什么区别?" ------ 说不清楚=技术落伍;
  • "size()方法怎么统计总数?扩容时怎么保证线程安全?" ------ 答非所问=源码没看。

本文将从线程安全痛点 切入,结合源码级解析版本对比面试考点 ,彻底讲透ConcurrentHashMap的设计演进和核心原理:

✅ 拆解HashMap线程不安全的底层原因(死循环、数据丢失);

✅ 剖析JDK1.7分段锁的设计逻辑(Segment+HashEntry);

✅ 揭秘JDK1.8的重大变革(CAS+synchronized+红黑树);

✅ 详解核心方法(put/get/size/扩容)的并发控制逻辑;

✅ 对比JDK7/JDK8的性能差异与适用场景;

✅ 高频面试题标准答案(直接背);

✅ 避坑指南:ConcurrentHashMap的使用禁忌。

📌 核心一句话
ConcurrentHashMap的演进核心是锁粒度的持续降低------JDK1.7通过分段锁(Segment)实现"段内独占、段间共享",JDK1.8进一步将锁粒度降至"节点级",结合CAS无锁操作,在高并发下性能提升3倍以上;红黑树的引入既优化了查询性能,也解决了链表过长导致的锁竞争加剧问题。
📌 面试金句先记牢

  • JDK1.7 ConcurrentHashMap:基于Segment分段锁实现,Segment继承ReentrantLock,每个Segment对应一个Hash桶数组,段内独占、段间共享;
  • JDK1.8 ConcurrentHashMap:摒弃分段锁,采用数组+链表+红黑树结构,通过CAS初始化节点、synchronized锁定链表头/红黑树根节点实现并发控制;
  • 1.8放弃分段锁原因:内存占用大、锁粒度仍不够细、扩容需全局锁、代码复杂;
  • 红黑树转化阈值:链表长度≥8且数组长度≥64(避免扩容时频繁转化),退化为链表阈值:6;
  • put方法核心逻辑:CAS初始化桶节点→synchronized锁定节点→判断链表/红黑树→插入元素→检查是否需要扩容;
  • size()方法:JDK7通过多次尝试+全局锁保证准确,JDK8通过LongAdder思想(累加baseCount+CounterCell)实现无锁统计;
  • ConcurrentHashMap不支持null键/值(避免与get返回null混淆),HashMap支持;
  • 扩容时采用多线程协助扩容(transfer),每个线程负责迁移一部分桶,避免单线程扩容的性能瓶颈。

一、HashMap的线程安全问题:为什么要用ConcurrentHashMap?

在深入ConcurrentHashMap之前,我们必须先直面HashMap在多线程环境下的"脆弱"。这不仅仅是"数据不对"那么简单,在特定版本和场景下,它甚至能搞挂你的服务器

1.1 核心概念前置:什么是"头插法"与"尾插法"?

要理解死循环,必须先懂链表插入方式。HashMap底层是"数组+链表",当发生哈希冲突时,新元素会挂在链表上。

🅰️ 头插法(Head Insertion)------ JDK 1.7 的噩梦

逻辑 :新来的节点直接插在链表头部 ,原来的头节点变成第二个。
特点 :插入速度快(不需要遍历到尾),但会反转链表顺序

java 复制代码
// 头插法伪代码 (JDK 1.7)
void addAtHead(Node newNode) {
    newNode.next = head; // 1. 新节点指向旧头
    head = newNode;      // 2. 头指针指向新节点
    // 
}

结果:链表顺序翻转!A 变成了 B->A ,在变成了 C->B->A 。

🅱️ 尾插法(Tail Insertion)------ JDK 1.8 的改进

逻辑 :新来的节点挂在链表尾部
特点 :需要遍历找到尾巴(稍慢),但保持链表原有顺序

java 复制代码
// 尾插法伪代码 (JDK 1.8)
void addAtTail(Node newNode) {
    tail.next = newNode; // 1. 旧尾巴指向新节点
    tail = newNode;      // 2. 尾巴指针更新为新节点
}

结果:链表顺序不变!A->B 变成了 A->B->C

💡 通俗类比

  • 头插法 :就像大家排队买票,新来的人直接插队到最前面。原本排第一的老张被挤到了第二,老李被挤到了第三......队伍顺序全乱了。
  • 尾插法 :新来的人乖乖排到队伍最后面。队伍顺序保持不变,公平且有序。

1.2 致命缺陷一:JDK 1.7 的死循环(CPU 100% 元凶)

这是Java面试中最经典的"送命题"。在JDK 1.7中,由于头插法 + 多线程扩容 ,会导致链表形成环形结构

🔍 灾难现场还原

假设有一个链表:Thread AThread B 同时触发扩容(resize),它们都要把这个链表搬运到新数组中。

  1. 初始状态 :链表 A -> B(A是头,B是尾)。
  2. Thread A 执行
    • 拿到 A,准备把头插到新位置。
    • 此时 A.next 还是 B
    • ⚡️ 线程切换:A 还没改指针,CPU 切给了 B。
  3. Thread B 执行
    • 拿到 A,执行头插:A 指向 null(新表头),新表头=A
    • 拿到 B,执行头插:B 指向 A,新表头=B
    • 此时新表链表变成了:B -> A(顺序反转了!)。
    • ⚡️ 线程切换:B 跑完了,切回 A。
  4. Thread A 继续执行(灾难发生)
    • A 手里还拿着旧的引用关系:它认为 A.nextB
    • A 执行头插逻辑:A.next = 新表头
    • 关键点 :此时新表头已经是 B 了(被B改过了)。
    • 于是:A.next = B
    • 闭环形成 :之前 B 已经指向了 A (B->A),现在 A 又指向了 B (A->B)。
    • 结果A <-> B 双向死循环!

多线程扩容混乱
A.next = B
B.next = A (B先插入导致)
A
B
正常链表
A
B
null

💥 后果

当你调用 map.get(key) 时,程序会沿着链表遍历:A -> B -> A -> B ... 永无止境。

  • 现象:CPU 占用率瞬间飙升至 100%,服务假死,无法响应任何请求。
  • 修复 :JDK 1.8 改为尾插法,因为尾插法不会反转链表,所以即使多线程操作,顶多数据覆盖,绝不会成环。

1.3 致命缺陷二:数据丢失(JDK 1.7 & 1.8 共有)

即使JDK 1.8解决了死循环,数据丢失 依然是无解的痛点。这是因为 put 操作不是原子的(包含"计算哈希 -> 定位桶 -> 判断空 -> 插入"多个步骤)。

🔍 灾难现场还原

假设桶位置 index=5 当前为空。

  1. Thread A :计算发现 index=5 为空,准备插入节点 NodeA
  2. Thread B :几乎同时计算发现 index=5 也为空,准备插入节点 NodeB
  3. Thread A 执行 :成功将 NodeA 放入 table[5]
  4. Thread B 执行 :因为它之前检查过是空的,它直接把 NodeB 覆盖写入 table[5]
  5. 结果NodeA 凭空消失了!
💻 可复现代码(突出数据丢失)

这段代码在 JDK 1.7 和 1.8 都会出现数据丢失,只是 1.7 还可能顺便死循环。

java 复制代码
// 数据丢失演示
Map<Integer, String> map = new HashMap<>();
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
    int finalI = i;
    executor.submit(() -> {
        map.put(finalI, "value" + finalI);
    });
}
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
System.out.println("期望size=1000,实际size=" + map.size()); 
// 实际size经常小于1000(数据丢失)

注意:每次运行丢失的数量可能不同,这就是并发的"不确定性"恐怖之处。


1.4 妥协方案:Hashtable 为什么不行?

既然HashMap不安全,那用古老的 Hashtable 行不行?

java 复制代码
Map<String, Object> map = new Hashtable<>();

原理Hashtable 的所有方法(put, get, remove)都加了 synchronized 关键字。
问题 :这是全表锁(粗粒度锁)

  • 场景:哪怕你有100个线程,其中99个在读不同的Key,只要第100个线程在写,其他99个读线程全部要排队等待!
  • 类比:就像高速公路只有一个收费站,无论多少车道,所有车必须排成一队通过。
  • 性能 :在高并发下,性能极差,吞吐量远低于 ConcurrentHashMap

1.5 终极诉求:我们需要什么样的Map?

为了解决上述问题,我们需要一种新的数据结构,满足以下苛刻条件:

需求 描述 HashMap Hashtable 理想方案 (CHM)
线程安全 不死循环、不丢数据
读性能 读操作是否阻塞 ✅ (快) ❌ (慢,需锁) ✅ (无锁)
写性能 写操作并发度 ✅ (快) ❌ (串行) ✅ (分段/细粒度锁)
扩容效率 扩容是否阻塞所有线程 ❌ (单线程) ❌ (全表锁) ✅ (多线程协助)

核心设计哲学

"能并行的尽量并行,该加锁的只锁最小范围。"

  • 读操作:尽量不加锁(利用 volatile/CAS)。
  • 写操作:只锁住当前操作的那个"桶"(Bucket),而不是整张表。

这就引出了我们今天的主角 ------ ConcurrentHashMap。它在 JDK 1.7 和 JDK 1.8 中采用了完全不同的策略来实现这一目标。


二、JDK1.7 ConcurrentHashMap:分段锁的经典设计

JDK1.7的ConcurrentHashMap采用分段锁(Segment) 思想:将数据分成一段一段的,每段分配一把锁,多个线程访问不同段的数据可并发执行,解决了Hashtable全表锁的性能瓶颈。

2.1 核心结构:Segment + HashEntry

java 复制代码
// JDK1.7 ConcurrentHashMap核心结构
public class ConcurrentHashMap<K, V> {
    // Segment数组,默认长度16(并发度16)
    final Segment<K,V>[] segments;
    
    // Segment内部类,继承ReentrantLock
    static final class Segment<K,V> extends ReentrantLock implements Serializable {
        // 每个Segment包含一个HashEntry数组
        transient volatile HashEntry<K,V>[] table;
        transient int count;        // 元素个数
        transient int modCount;      // 修改次数
        transient int threshold;     // 扩容阈值
        final float loadFactor;      // 负载因子
    }
    
    // HashEntry节点(链表)
    static final class HashEntry<K,V> {
        final int hash;
        final K key;
        volatile V value;           // value用volatile保证可见性
        volatile HashEntry<K,V> next;
    }
}

2.2 核心设计思想

  1. 分段锁 :每个Segment继承ReentrantLock,独立管理一段HashEntry数组;
  2. 默认并发度:16(segments数组长度),最多允许16个线程同时写(不同段);
  3. 定位元素两次Hash
    • 第一次Hash:计算Key的hashCode,再经过扰动函数得到hash值,定位到Segment;
    • 第二次Hash:利用hash值的高位信息,再次哈希,定位到Segment内的HashEntry桶的索引;
  4. 写操作:线程需先获取Segment的锁(ReentrantLock),再操作HashEntry链表;
  5. 读操作 :HashEntry的value和next用volatile修饰,保证可见性,读操作无锁。

二次Hash定位机制目的:减少哈希冲突,让不同Key尽量落入不同Segment,从而减少锁竞争。

2.3 put()方法源码分析(JDK1.7)

java 复制代码
// JDK1.7 ConcurrentHashMap.put()
public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null) throw new NullPointerException();
    int hash = hash(key);
    // 第一次Hash:定位Segment
    int j = (hash >>> segmentShift) & segmentMask;
    if ((s = (Segment<K,V>)UNSAFE.getObject(segments, (j << SSHIFT) + SBASE)) == null)
        s = ensureSegment(j); // 未初始化则创建
    return s.put(key, hash, value, false);
}

// Segment内部的put方法
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    // 尝试获取锁(ReentrantLock)
    HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
    V oldValue;
    try {
        HashEntry<K,V>[] tab = table;
        // 第二次Hash:定位桶位置
        int index = (tab.length - 1) & hash;
        HashEntry<K,V> first = entryAt(tab, index);
        
        for (HashEntry<K,V> e = first;;) {
            if (e != null) {
                K k;
                // 遍历链表,找到相同key则替换
                if ((k = e.key) == key || (e.hash == hash && key.equals(k))) {
                    oldValue = e.value;
                    if (!onlyIfAbsent) {
                        e.value = value;
                        ++modCount;
                    }
                    break;
                }
                e = e.next;
            } else {
                // 没找到key,插入新节点
                if (node != null)
                    node.setNext(first);
                else
                    node = new HashEntry<K,V>(hash, key, value, first);
                int c = count + 1;
                if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                    rehash(node); // 扩容
                else
                    setEntryAt(tab, index, node);
                ++modCount;
                count = c;
                oldValue = null;
                break;
            }
        }
    } finally {
        unlock(); // 释放锁
    }
    return oldValue;
}

scanAndLockForPut():自旋获取锁,避免线程立即挂起,优化锁竞争。

2.4 get()方法源码分析(JDK1.7)

java 复制代码
// JDK1.7 ConcurrentHashMap.get()
public V get(Object key) {
    Segment<K,V> s;
    HashEntry<K,V>[] tab;
    int h = hash(key);
    // 定位Segment
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
        (tab = s.table) != null) {
        // 定位HashEntry并遍历链表
        HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
            (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
        while (e != null) {
            K k;
            if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                return e.value;
            e = e.next;
        }
    }
    return null;
}

关键点

  • 读操作完全无锁 ,全靠volatile保证可见性;
  • HashEntry的value和next都是volatile,写操作对读操作立即可见。

2.5 size()方法:先乐观后悲观(多次尝试+全局锁)

JDK1.7统计元素总数时,需要遍历所有Segment的count值:

  1. 先尝试2次无锁统计(遍历所有Segment的count);
  2. 如果两次结果一致,直接返回;
  3. 如果不一致,获取所有Segment的锁(全局锁),再统计。

缺点:高并发下,全局锁会导致所有Segment阻塞,性能低下。

2.6 JDK1.7的核心缺陷

  1. 锁粒度仍不够细:Segment是粗粒度锁,同一Segment内的不同桶操作仍需串行;
  2. 内存占用高:默认创建16个Segment,每个Segment都有独立的HashEntry数组;
  3. 扩容成本高:仅扩容单个Segment,无法全局扩容;
  4. 查询性能差:链表过长导致查询时间复杂度O(n),且加剧锁竞争。

三、JDK1.8 ConcurrentHashMap:CAS+synchronized的极致优化

JDK1.8对ConcurrentHashMap进行了革命性重构

  • 放弃分段锁,改用CAS + synchronized锁住桶头节点;
  • 引入红黑树,解决链表过长导致的查询效率问题;
  • 锁粒度从Segment级别细化到桶级别
  • 支持并发扩容,多个线程协同迁移数据。

3.1 为什么放弃分段锁?

  1. 内存占用:每个Segment继承ReentrantLock,包含同步队列等额外对象,内存开销大;
  2. 锁粒度仍不够细:一个Segment管理多个桶,多个线程同时访问同一个Segment的不同桶仍需竞争锁;
  3. 扩容复杂:Segment扩容是针对Segment内部的HashEntry数组,不支持多线程并发扩容;
  4. 并发度固定:默认16个Segment,无法根据实际桶数动态调整并发度;
  5. JDK1.8的锁优化:synchronized经过优化(偏向锁→轻量级锁→重量级锁),性能已不输ReentrantLock。

3.2 核心结构:Node + 红黑树

java 复制代码
// JDK1.8 ConcurrentHashMap核心结构
public class ConcurrentHashMap<K,V> {
    // Node数组(桶数组)
    transient volatile Node<K,V>[] table;
    
    // 扩容时的临时数组
    private transient volatile Node<K,V>[] nextTable;
    
    // 基础计数器
    private transient volatile long baseCount;
    
    // 计数单元数组(LongAdder思想)
    private transient volatile CounterCell[] counterCells;
    
    // 控制标识符:负数表示正在初始化或扩容,正数表示阈值
    private transient volatile int sizeCtl;
    
    // Node节点(链表)
    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;
    }
    
    // TreeNode节点(红黑树节点)
    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;
    }
    
    // TreeBin:红黑树的根节点,包装TreeNode
    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;
    }
}

结构示意图

复制代码
ConcurrentHashMap (JDK1.8)
├── Node[] table (桶数组)
│   ├── Node (hash=0) -> Node -> Node (链表)
│   ├── Node (hash=1) -> TreeNode (红黑树)
│   ├── Node (hash=2) -> Node (链表)
│   └── ...
├── CounterCell[] counterCells (分段计数)
└── sizeCtl (扩容控制)
核心设计:
  1. Node(基础节点)

    • 存储KV数据,valnext字段用volatile修饰;
    • 不可变(仅能通过替换节点修改)。
  2. TreeNode(红黑树节点)

    • 继承Node,用于红黑树存储;
    • 当链表长度≥8且数组长度≥64时,链表转化为红黑树;
    • 红黑树查询时间复杂度O(logn),远优于链表O(n)。
    • 红黑树节点数 < 6 时,红黑树退化为链表
  3. ForwardingNode(扩容节点)

    • 扩容时用于标记已迁移的桶;多个线程协同迁移数据,每个线程负责一部分桶;
    • 读操作遇到该节点,会协助扩容;
    • 写操作遇到该节点,会等待扩容完成。
并发控制:CAS + synchronized

JDK1.8放弃分段锁,采用更细粒度的并发控制:

  1. CAS(无锁操作):用于初始化桶节点、插入第一个节点,避免加锁开销;
  2. synchronized(节点锁):仅锁定操作的槽位节点(链表头/红黑树根),锁粒度降至节点级;
  3. volatile:保证节点数据的可见性,避免指令重排序。

3.3 核心方法:put(JDK1.8源码级解析)

java 复制代码
// JDK1.8 ConcurrentHashMap 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(); // 不支持null
    int hash = spread(key.hashCode()); // 二次Hash,减少冲突
    int binCount = 0; // 链表长度计数器

    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(); // CAS初始化数组
        }
        // 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; // CAS成功,无需加锁
            }
        }
        // 3. 遇到扩容节点,协助扩容
        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) { // 链表节点(hash≥0)
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            // 找到相同key,替换值
                            if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent) {
                                    e.val = value; // volatile保证可见性
                                }
                                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;
                            }
                        }
                    }
                }
            }
            // 5. 检查是否需要转化为红黑树
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD) { // TREEIFY_THRESHOLD=8
                    treeifyBin(tab, i); // 转化为红黑树(需数组长度≥64)
                }
                if (oldVal != null) {
                    return oldVal;
                }
                break;
            }
        }
    }
    // 6. 检查是否需要扩容
    addCount(1L, binCount);
    return null;
}
put方法核心步骤(必记):
  1. 参数校验:禁止null键/值;
  2. 二次Hash:减少哈希冲突;
  3. 自旋+CAS初始化:数组未初始化则CAS初始化,桶为空则CAS插入第一个节点;
  4. 协助扩容:遇到ForwardingNode,协助完成扩容;
  5. synchronized加锁:锁定桶节点,保证操作原子性;
  6. 链表/红黑树处理:遍历链表(替换/新增)或红黑树插入;
  7. 红黑树转化:链表长度≥8且数组长度≥64时,转化为红黑树;
  8. 扩容检查:新增元素后,检查是否需要扩容。

3.4 核心方法:get(JDK1.8,无锁)

java 复制代码
// JDK1.8 ConcurrentHashMap 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()); // 二次Hash
    // 1. 数组非空且桶有节点
    if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) {
        // 2. 桶头节点匹配
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek))) {
                return e.val;
            }
        }
        // 3. 红黑树节点(hash=-2)或扩容节点(hash=-1)
        else if (eh < 0) {
            return (p = e.find(h, key)) != null ? p.val : null;
        }
        // 4. 遍历链表
        while ((e = e.next) != null) {
            if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) {
                return e.val;
            }
        }
    }
    return null;
}
get方法核心特点:
  • 完全无锁:仅通过volatile保证节点数据的可见性;
  • 高效查询:红黑树查询时间复杂度O(logn),远优于JDK1.7的链表O(n);
  • 扩容兼容:遇到ForwardingNode,会直接从新数组查询数据。

3.5 核心方法:size()(JDK1.8,无锁统计)

JDK1.8采用LongAdder思想(baseCount + CounterCell)实现无锁统计:

  1. baseCount:基础计数器,低并发下直接累加;
  2. CounterCell数组:高并发下,线程分散累加至不同的CounterCell,避免竞争;
  3. 最终统计:baseCount + 所有CounterCell的值。
java 复制代码
// JDK1.8 size()核心逻辑
public int size() {
    long n = sumCount();
    return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}

final long sumCount() {
    CounterCell[] as = counterCells; CounterCell a;
    long sum = baseCount;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null) {
                sum += a.value; // 累加所有CounterCell的值
            }
        }
    }
    return sum;
}

优势 :无全局锁,高并发下统计性能提升10倍以上(代价是统计结果可能不是绝对精确,可通过mappingCount()获取精确值)。

扩展:LongAdder思想基本原理(空间换时间)

LongAdder 借鉴了数据库分库分表或并行求和的思路:既然大家都抢一个变量会堵死,那我把变量拆成多个,让大家分散去改,最后再汇总。

LongAdder 内部维护了两部分:

  1. base :基础值。低竞争时直接更新它(类似 AtomicLong)。
  2. cells[] :一个数组(类似哈希桶),里面存放多个 Cell 对象,每个 Cell 也是一个长期变量。

工作流程:

当多个线程同时执行 add() 时:

  1. 先试 base :如果竞争不激烈,直接 CAS 更新 base
  2. 冲突则分流 :如果 CAS 失败(说明竞争激烈),线程会根据自身特征(如线程 ID 哈希)随机选择一个 cells 数组的下标
  3. 更新 Cell :线程只更新自己选中的那个 Cell 的值。因为 cells 数组有多个元素,不同线程大概率落到不同的 Cell 上,互不干扰,无需重试
  4. 求和 :当需要获取最终结果(sum())时,将 base + 所有 Cell 的值加起来。

💡 通俗类比

  • AtomicLong :全公司 1000 人只有一个捐款箱,大家排队投钱,前面的人没投完,后面的人只能干等着或反复挤。
  • LongAdder :公司放了10 个捐款箱(Cells)。大家随机找一个箱子投钱。因为箱子多,大部分人能同时投,不用排队。最后统计总额时,把 10 个箱子的钱加一起即可。

一句话总结
LongAdder 思想就是:用"多个变量分散写入"来避免"单个变量热点竞争",最后通过"求和"得到总数,以空间换时间,实现高并发下的高性能计数。

3.6 核心机制:多线程协助扩容(transfer)

JDK1.8的扩容是多线程协作的,避免了JDK1.7单线程扩容的性能瓶颈:

  1. 扩容触发条件:元素总数≥数组容量×负载因子(默认0.75);
  2. 扩容流程
    • 初始化新数组(容量翻倍);
    • 遍历旧数组,将每个桶的节点迁移至新数组;
    • 其他线程操作时遇到ForwardingNode,会协助迁移当前桶;
    • 迁移完成后,替换旧数组为新数组。

优势:扩容速度随线程数增加而提升,高并发下扩容耗时大幅降低。


四、JDK7 vs JDK8 全方位对比

对比维度 JDK1.7 ConcurrentHashMap JDK1.8 ConcurrentHashMap
数据结构 Segment数组 + HashEntry数组 + 链表 Node数组 + 链表 + 红黑树
锁机制 分段锁(Segment继承ReentrantLock) CAS + synchronized(节点锁)
锁粒度 段级(默认16段) 节点级(桶级)
并发度 等于Segment数量(默认16) 理论上等于数组长度(无上限)
查询性能 O(n)(链表) O(1)(数组)/O(logn)(红黑树)
扩容方式 单Segment扩容(单线程) 全局扩容(多线程协助)
size()实现 多次尝试 + 全局锁 baseCount + CounterCell(无锁)
null键/值 不支持 不支持
内存占用 高(多Segment数组) 低(单个数组)
高并发性能 高(提升3倍以上)
适用场景 JDK7及以下环境,低并发 JDK8及以上环境,高并发

五、避坑指南:ConcurrentHashMap的致命陷阱

5.1 陷阱1:误用null键/值

ConcurrentHashMap严格禁止null键/值 (HashMap支持),否则抛出NullPointerException

java 复制代码
// 运行时异常!
ConcurrentHashMap<String, String> chm = new ConcurrentHashMap<>();
chm.put("key", null); // NullPointerException

原因:get()返回null时,无法区分"key不存在"和"value为null",避免业务逻辑错误。

5.2 陷阱2:迭代器的弱一致性

ConcurrentHashMap的迭代器是弱一致性的:

  • 迭代过程中,其他线程修改数据,迭代器不会抛出ConcurrentModificationException
  • 迭代器只能遍历到迭代创建时已存在的元素,新增元素可能无法遍历到。
java 复制代码
ConcurrentHashMap<String, String> chm = new ConcurrentHashMap<>();
chm.put("1", "A");
chm.put("2", "B");

// 创建迭代器
Iterator<String> iterator = chm.keySet().iterator();
// 新增元素
chm.put("3", "C");

// 迭代结果:仅输出1、2,不会输出3
while (iterator.hasNext()) {
    System.out.println(iterator.next());
}
  • 建议:适用于日志统计等对实时性要求不苛刻的场景;若需强一致快照,需手动加锁。

5.3 陷阱3:size()结果不精确

JDK1.8的size()采用无锁统计,结果可能不是绝对精确:

java 复制代码
ConcurrentHashMap<String, String> chm = new ConcurrentHashMap<>();
// 高并发下,size()可能小于实际元素数
System.out.println(chm.size()); // 不精确
System.out.println(chm.mappingCount()); // 精确(遍历所有元素)
  • 建议:若需要精确大小,需外部加锁(不推荐,牺牲性能);通常业务场景接受估算值。

5.4 陷阱4:红黑树转化的条件

链表转化为红黑树需满足两个条件:

  1. 链表长度≥8;
  2. 数组长度≥64。

若数组长度<64,即使链表长度≥8,也只会扩容数组,不会转化为红黑树(避免扩容时频繁转化)。

5.5 陷阱5:foreach遍历的线程安全

使用foreach遍历ConcurrentHashMap时,底层仍使用迭代器,同样是弱一致性:

java 复制代码
// foreach本质是迭代器,弱一致性
for (String key : chm.keySet()) {
    // 其他线程新增的key可能无法遍历到
}

5.6 避坑6:初始化容量设置

  • 问题:默认容量16,负载因子0.75。若预估数据量大,频繁扩容影响性能;

  • 建议 :构造时指定初始容量,避免自动扩容。

    java 复制代码
    // 预估需要1000个元素,设置初始容量为 1000 / 0.75 + 1 = 1334 -> 自动扩到2048
    Map<String, Object> map = new ConcurrentHashMap<>(1334);

六、面试高频真题(标准答案直接背)

6.1 基础必答

Q1:ConcurrentHashMap为什么不支持null键/值?

答案

  1. 避免歧义:get()返回null时,无法区分"key不存在"和"value为null";
  2. 并发安全:null值无法通过volatile保证可见性,可能导致线程读取到旧值;
  3. 设计理念:ConcurrentHashMap定位为高并发场景下的可靠容器,禁止null可避免业务逻辑错误。
Q2:JDK1.8 ConcurrentHashMap的put方法核心流程是什么?

答案

  1. 参数校验:禁止null键/值;
  2. 二次Hash:计算key的hash值,减少冲突;
  3. 自旋+CAS:数组未初始化则CAS初始化,桶为空则CAS插入第一个节点;
  4. 协助扩容:遇到ForwardingNode,协助完成扩容;
  5. synchronized加锁:锁定桶节点(链表头/红黑树根);
  6. 节点处理:遍历链表(替换/新增)或红黑树插入;
  7. 红黑树转化:链表长度≥8且数组长度≥64时,转化为红黑树;
  8. 扩容检查:新增元素后,检查是否需要扩容。
Q3:JDK1.8为什么放弃分段锁,改用CAS+synchronized?

答案

  1. 锁粒度更细:分段锁是段级锁,同一Segment内的不同桶仍需串行;节点锁仅锁定操作的桶,并发度更高;
  2. 性能更优:synchronized在JDK1.8中已优化(偏向锁/轻量级锁),性能接近ReentrantLock;CAS无锁操作进一步降低开销;
  3. 内存占用更低:分段锁需要创建多个Segment数组,内存占用高;节点锁仅需单个数组;
  4. 扩容更高效:分段锁仅能单Segment扩容,节点锁支持多线程协助全局扩容。
Q4:ConcurrentHashMap如何保证线程安全?

JDK1.7版本

  • 采用分段锁设计,默认16个Segment,每个Segment继承ReentrantLock;
  • 写操作需获取Segment锁,锁住一段数据;
  • 读操作无锁,HashEntry的value和next用volatile保证可见性;
  • size()先无锁统计两次,失败则锁所有Segment。

JDK1.8版本

  • 采用CAS + synchronized锁住桶头节点;
  • 插入空桶时,用CAS无锁操作;
  • 插入非空桶时,用synchronized锁住头节点,再操作链表/红黑树;
  • 读操作无锁,Node的val和next用volatile保证可见性;
  • 计数器采用CounterCell数组分段计数,减少竞争;
  • 扩容时多线程协同迁移,提高效率。
Q5:ConcurrentHashMap的size()是如何统计的?

JDK1.7

  1. 先尝试不加锁统计两次,比较两次的modCount总和;
  2. 如果两次一致,直接返回;
  3. 如果不一致,说明有并发修改,锁住所有Segment再统计。

JDK1.8

  1. 采用baseCount + CounterCell[]数组(LongAdder思想);
  2. 更新计数时,优先CAS更新baseCount,失败则随机选一个CounterCell CAS更新;
  3. 统计总数时,累加baseCount和所有CounterCell的值;
  4. 避免多线程竞争同一个计数器,性能更高。

6.2 深度追问

Q6:ConcurrentHashMap的get方法为什么不需要加锁?

答案

  1. 节点的valnext字段用volatile修饰,保证可见性:
    • 写操作修改val时,volatile保证其他线程能立即看到最新值;
    • 写操作新增节点时,volatile保证链表/红黑树的结构变化对其他线程可见;
  2. get操作是只读的,不修改数据,无需加锁;
  3. 弱一致性:允许读取到旧数据,符合高并发场景的性能优先原则。
Q7:JDK1.8 ConcurrentHashMap的扩容机制是怎样的?

答案

  1. 触发条件:元素总数≥数组容量×负载因子(默认0.75);
  2. 扩容流程:
    • 初始化新数组(容量翻倍);
    • 遍历旧数组,将每个桶的节点迁移至新数组(链表节点按hash值拆分,红黑树节点重新映射);
    • 其他线程操作时遇到ForwardingNode,会协助迁移当前桶;
    • 迁移完成后,替换旧数组为新数组;
  3. 核心优势:多线程协作扩容,扩容速度随线程数增加而提升,避免单线程扩容的性能瓶颈。
Q8:ConcurrentHashMap的链表什么时候转红黑树?什么时候退化为链表?

树化条件

  • 链表长度 ≥ 8;
  • 且桶数组长度 ≥ 64(如果数组长度<64,优先扩容)。

退化条件

  • 红黑树节点数 ≤ 6;
  • 扩容时,如果节点被拆分到两个桶,导致红黑树变小,也会退化为链表。

原因

  • 树化阈值8:基于泊松分布,链表长度达到8的概率极低,兼顾时间和空间;
  • 退化阈值6:避免频繁的树化和退化(8和6之间留缓冲)。
Q9:ConcurrentHashMap和Hashtable的区别?

答案

  1. 锁机制:
    • Hashtable:全表独占锁(synchronized修饰方法);
    • ConcurrentHashMap:JDK7分段锁,JDK8节点锁+CAS;
  2. 并发性能:
    • Hashtable:所有操作串行,性能极低;
    • ConcurrentHashMap:高并发下性能优异(JDK8尤甚);
  3. null支持:
    • Hashtable:不支持null键/值;
    • ConcurrentHashMap:不支持null键/值;
  4. 迭代器:
    • Hashtable:快速失败(抛出ConcurrentModificationException);
    • ConcurrentHashMap:弱一致性(不抛异常)。
Q10:ConcurrentHashMap的迭代器是强一致性还是弱一致性?

弱一致性

  • 迭代器创建后,可能看不到其他线程后续的修改;
  • 迭代器遍历过程中,不会抛出ConcurrentModificationException
  • 遍历的是迭代器创建时刻的快照,或者遍历过程中遇到修改也能继续(不保证一致性);
  • 这是为了性能考虑,避免迭代时加锁。

6.3 实战场景题

Q11:高并发场景下,如何选择HashMap、Hashtable、ConcurrentHashMap?

答案

  1. 单线程场景:优先选HashMap(性能最优);
  2. 低并发场景(读多写少):可选Hashtable(简单),但推荐ConcurrentHashMap(性能更好);
  3. 高并发场景:必须选ConcurrentHashMap:
    • JDK8环境:直接用ConcurrentHashMap(CAS+synchronized,性能最优);
    • JDK7环境:用ConcurrentHashMap(分段锁,避免HashMap线程不安全);
  4. 禁止使用:
    • 并发场景下禁止用HashMap(死循环、数据丢失);
    • 高并发场景下禁止用Hashtable(全表锁,性能差)。
Q12:如何实现ConcurrentHashMap的有序遍历?

答案

  1. 方案1:使用ConcurrentSkipListMap(有序的并发Map,基于跳表实现);

  2. 方案2:遍历ConcurrentHashMap的entrySet,手动排序:

    java 复制代码
    ConcurrentHashMap<String, String> chm = new ConcurrentHashMap<>();
    chm.put("3", "C");
    chm.put("1", "A");
    chm.put("2", "B");
    
    // 转换为List并排序
    List<Map.Entry<String, String>> list = new ArrayList<>(chm.entrySet());
    list.sort(Map.Entry.comparingByKey());
    
    // 有序遍历
    for (Map.Entry<String, String> entry : list) {
        System.out.println(entry.getKey() + ":" + entry.getValue());
    }
  3. 方案3:使用Collectors.toMap收集到有序Map中(需保证线程安全)。


总结

1. 核心知识点速记口诀

复制代码
HashMap并发乱,数据丢失CPU满;
Hashtable全表锁,性能差到没法躲;
JDK7分段锁,16段并发够,
读操作不用锁,volatile值不错;
JDK8大变革,桶锁真出色,
CAS加sync,树化防冲突,
计数器分段算,扩容并发干,
弱一致迭代器,面试问不虚。

2. 核心要点回顾

  1. JDK1.7:分段锁(Segment数组 + HashEntry链表),并发度16,读无锁写加锁;
  2. JDK1.8:CAS + synchronized(Node数组 + 链表/红黑树),锁粒度桶级别;
  3. JDK1.8优势:锁粒度更细、支持红黑树、支持并发扩容、内存占用更少;
  4. size():JDK1.7先无锁后全锁,JDK1.8用CounterCell分段计数;
  5. 扩容:JDK1.8多线程协同迁移,避免STW;
  6. 弱一致性:迭代器、size()、get()都只保证最终一致性。

3. 实战建议

  • JDK版本选择:优先升级至JDK8+,充分利用CAS+synchronized的性能优势;
  • 并发度调优:初始化ConcurrentHashMap时指定合适的容量(避免频繁扩容);
  • 有序需求:若需有序遍历,优先用ConcurrentSkipListMap;
  • 精确统计:需精确元素数时,用mappingCount()而非size();
  • 避免误区:ConcurrentHashMap不是万能的,极端高并发下需结合本地缓存(如Caffeine)使用。

写在最后

ConcurrentHashMap的演进,是Java并发编程中"锁粒度持续降低"的典型案例------从Hashtable的全表锁,到JDK7的分段锁,再到JDK8的节点锁,每一次升级都围绕"在保证线程安全的前提下,最大化并发性能"。

理解ConcurrentHashMap的核心,不仅能应对面试,更能掌握高并发编程的核心思想:锁粒度越小,并发度越高;无锁操作(CAS)越多,性能越好。在实际开发中,选择合适的并发容器,结合业务场景调优,才能真正发挥高并发的性能优势。

如果觉得有帮助,欢迎点赞、收藏、转发!

相关推荐
福运常在2 小时前
股票数据API(14)股票近年增发数据
java·python·maven
Java小王子呀2 小时前
JAVA 导出Excel中添加下拉框用POI
java·excel
短剑重铸之日10 小时前
《ShardingSphere解读》07 读写分离:如何集成分库分表+数据库主从架构?
java·数据库·后端·架构·shardingsphere·分库分表
知我Deja_Vu10 小时前
【避坑指南】ConcurrentHashMap 并发计数优化实战
java·开发语言·python
daidaidaiyu11 小时前
Spring IOC 源码学习 事务相关的 BeanDefinition 解析过程 (XML)
java·spring
鬼蛟12 小时前
Spring————事务
android·java·spring
西门吹-禅13 小时前
【sap fiori cds up error】
java·服务器·sap cap cds
敲代码的嘎仔13 小时前
Java后端面试——SSM框架面试题
java·面试·职场和发展·mybatis·ssm·springboot·八股
大傻^13 小时前
Spring AI Alibaba RAG实战:基于向量存储的检索增强生成
java·人工智能·spring