ConcurrentHashMap 深度解析:从 JDK7 到 JDK8 的演进与并发安全保障

0 阅读7分钟

在 Java 并发编程中,ConcurrentHashMap 是线程安全哈希表的核心实现,相比 Hashtable 的全表锁机制,它通过更细粒度的锁设计和数据结构优化,实现了更高的并发性能。

一、JDK7 的 ConcurrentHashMap 实现原理

1.1 核心数据结构

JDK7 中 ConcurrentHashMap 采用分段锁设计,核心数据结构由三层组成:

  • Segment 数组ConcurrentHashMap 的核心锁容器,每个 Segment 继承自 ReentrantLock,独立承担锁功能,默认并发级别为 16(即 16 个 Segment)。
  • HashEntry 数组:每个 Segment 内部维护一个 HashEntry 数组,作为哈希表的核心存储结构。
  • 链表HashEntry 是链表节点,包含 keyhashnext(均为 final 修饰,保证不变性)和 valvolatile 修饰,保证可见性)。

1.2 初始化过程

  • 默认初始容量为 16,负载因子为 0.75,并发级别为 16。
  • 初始化时,先计算 Segment 数组的长度(大于等于并发级别的最小 2 的幂),然后初始化第一个 Segment,其余 Segment 采用懒加载机制(首次使用时初始化)。

1.3 put 操作流程

  1. 计算 key 的哈希值,定位到对应的 Segment
  2. 调用 lock() 获取该 Segment 的独占锁。
  3. 定位到 SegmentHashEntry 数组的索引,遍历链表查找 key
  4. 若找到 key,直接更新 val;若未找到,创建新 HashEntry 插入链表头部。
  5. 检查 HashEntry 数量是否超过阈值(容量 × 负载因子),若超过则扩容为原容量的 2 倍。
  6. 调用 unlock() 释放锁。

1.4 get 操作流程

  1. 计算 key 的哈希值,定位到 SegmentHashEntry 数组的索引。
  2. 遍历链表查找 key,由于 HashEntryvalnext 均为 volatile 修饰,保证了可见性,因此无需加锁即可获取最新值。

1.5 扩容机制

扩容仅在单个 Segment 内部进行,不影响其他 Segment。扩容时,创建新的 HashEntry 数组(容量为原数组的 2 倍),将原数组的所有节点重新哈希后迁移到新数组。

二、JDK8 的 ConcurrentHashMap 实现原理

JDK8 对 ConcurrentHashMap 进行了彻底重构,取消了分段锁设计,改用Node 数组 + CAS + Synchronized 实现,并引入红黑树优化链表过长的问题。

2.1 核心数据结构

  • Node 数组:核心存储结构,替代了 JDK7 的 Segment 数组。Node 是链表节点,valnext 均为 volatile 修饰。
  • 链表/红黑树:当链表长度 ≥ 8 且数组长度 ≥ 64 时,链表转换为红黑树(TreeNode);当红黑树节点数 ≤ 6 时,转换回链表。TreeBin 用于封装 TreeNode,作为红黑树的根节点容器。
  • ForwardingNode:扩容时用于标记旧数组的节点,其他线程看到该节点会协助扩容。

2.2 初始化过程

  • 默认初始容量为 16,负载因子为 0.75。
  • Node 数组采用懒加载机制,首次执行 put 操作时才初始化,通过 CAS 控制初始化的并发安全。

2.3 put 操作流程

  1. 检查 Node 数组是否初始化,若未初始化则通过 CAS 初始化。
  2. 计算 key 的哈希值,定位到 Node 数组的索引。
  3. 若该索引位置为 null,通过 CAS 直接插入新 Node
  4. 若该索引位置为 ForwardingNode,说明数组正在扩容,当前线程协助扩容。
  5. 否则,用 synchronized 锁住该索引位置的头节点,遍历链表或红黑树查找 key
  6. 若找到 key,更新 val;若未找到,插入链表尾部或红黑树。
  7. 检查链表长度是否 ≥ 8,若满足则判断是否需要树化(数组长度 ≥ 64 则树化,否则扩容)。
  8. 释放锁,检查是否需要扩容(元素数量达到阈值则扩容)。

2.4 get 操作流程

  1. 计算 key 的哈希值,定位到 Node 数组的索引。
  2. 检查头节点是否为目标 key,若是则直接返回 val
  3. 若头节点是 TreeBin,则通过红黑树的 find 方法查找;否则遍历链表查找。
  4. 由于 Nodevalnextvolatile 修饰,保证了可见性,因此无需加锁。

2.5 树化与反树化

  • 树化条件:链表长度 ≥ 8 且 Node 数组长度 ≥ 64。若数组长度 < 64,则先扩容而非树化。
  • 反树化条件:红黑树节点数 ≤ 6 时,转换回链表。

2.6 扩容机制

Node 数组的元素数量达到阈值(容量 × 负载因子)时,触发扩容:

  1. 创建新的 Node 数组,容量为原数组的 2 倍。
  2. 在旧数组的每个索引位置插入 ForwardingNode,标记该位置正在迁移。
  3. 多个线程可并发参与扩容,每个线程处理旧数组的一部分区间(步长由 CPU 核心数决定),通过 CAS 控制并发安全。
  4. 迁移完成后,将 ConcurrentHashMaptable 引用指向新数组。

三、JDK7 与 JDK8 的核心差异对比

维度JDK7JDK8
锁粒度Segment 级别(默认 16 个锁)Node 级别(锁住数组索引的头节点)
数据结构Segment 数组 + HashEntry 数组 + 链表Node 数组 + 链表 + 红黑树
锁实现ReentrantLockCAS + synchronized
扩容方式单个 Segment 独立扩容整个 Node 数组并发扩容
查找效率链表遍历(O(n))链表遍历(O(n))或红黑树查找(O(logn))
并发性能受限于 Segment 数量,高并发下性能瓶颈明显锁粒度更细,红黑树优化查找,并发扩容,性能显著提升

四、并发安全的保证机制

4.1 JDK7 的并发安全保证

  • 分段锁Segment 继承 ReentrantLockputremove 等修改操作需获取锁,保证原子性。
  • volatile 可见性HashEntryvalnextvolatile 修饰,get 操作无需加锁即可读取最新值。
  • 不变性HashEntrykeyhashnextfinal 修饰,防止并发修改导致的结构破坏。

4.2 JDK8 的并发安全保证

  • volatile 可见性Nodevalnextvolatile 修饰,保证 get 操作的可见性。
  • CAS 原子性:插入头节点、初始化数组、扩容时的并发控制均通过 CAS 实现,保证原子性。
  • synchronized 锁:锁住数组索引的头节点,保证链表或红黑树修改操作的原子性。
  • 并发扩容:通过 ForwardingNode 标记和多线程协作,实现高效的并发扩容。
  • TreeBin 状态控制TreeBin 内部通过 volatile 状态变量和 CAS 控制红黑树的访问,保证并发安全。

五、代码示例

5.1 使用示例

package com.jam.demo;

import com.google.common.collect.Maps;
import lombok.extern.slf4j.Slf4j;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * ConcurrentHashMap 使用示例
 *
 * @author ken
 */
@Slf4j
public class ConcurrentHashMapDemo {

    public static void main(String[] args) {
        Map<StringString> concurrentHashMap = new ConcurrentHashMap<>();
        Map<StringString> hashMap = Maps.newHashMap();

        concurrentHashMap.put("key1""value1");
        concurrentHashMap.put("key2""value2");

        log.info("获取 key1: {}", concurrentHashMap.get("key1"));
        log.info("是否包含 key2: {}", concurrentHashMap.containsKey("key2"));

        concurrentHashMap.remove("key1");
        log.info("移除 key1 后大小: {}", concurrentHashMap.size());

        concurrentHashMap.replace("key2""newValue2");
        log.info("替换 key2 后的值: {}", concurrentHashMap.get("key2"));
    }
}

5.2 并发安全测试示例

package com.jam.demo;

import lombok.extern.slf4j.Slf4j;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * ConcurrentHashMap 并发安全测试示例
 *
 * @author ken
 */
@Slf4j
public class ConcurrentHashMapConcurrencyTest {

    private static final int THREAD_COUNT = 100;
    private static final int OPERATIONS_PER_THREAD = 1000;

    public static void main(String[] args) throws InterruptedException {
        Map<String, Integer> concurrentHashMap = new ConcurrentHashMap<>();
        ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT);
        CountDownLatch countDownLatch = new CountDownLatch(THREAD_COUNT);

        long startTime = System.currentTimeMillis();

        for (int i = 0; i < THREAD_COUNT; i++) {
            final int threadId = i;
            executorService.submit(() -> {
                try {
                    for (int j = 0; j < OPERATIONS_PER_THREAD; j++) {
                        String key = "key-" + threadId + "-" + j;
                        concurrentHashMap.put(key, j);
                        concurrentHashMap.get(key);
                    }
                } finally {
                    countDownLatch.countDown();
                }
            });
        }

        countDownLatch.await();
        executorService.shutdown();

        long endTime = System.currentTimeMillis();
        log.info("总操作数: {}", THREAD_COUNT * OPERATIONS_PER_THREAD * 2);
        log.info("最终 Map 大小: {}", concurrentHashMap.size());
        log.info("耗时: {} ms", endTime - startTime);
    }
}

六、总结

从 JDK7 到 JDK8,ConcurrentHashMap 通过数据结构和锁机制的重构,实现了性能的显著提升。JDK7 的分段锁设计是早期并发优化的经典思路,而 JDK8 则通过细粒度锁、红黑树和并发扩容等技术,进一步释放了并发编程的潜力。需要注意的是,ConcurrentHashMap 只能保证单个操作的线程安全,对于复合操作(如先检查后执行),仍需通过额外的同步机制来保证原子性。