深入剖析 ConcurrentHashMap:Java 并发编程的性能利器

一、概述

ConcurrentHashMap 是 Java 并发包 (java.util.concurrent) 中提供的一个线程安全的哈希表实现,它是对 HashMap 的线程安全版本,但在实现上与 Hashtable 有很大不同,提供了更高的并发性能。

二、架构设计对比

(一)底层结构

  • JDK 7:使用 Segment 数组 + HashEntry 链表。
  • JDK 8:使用 Node 数组 + 链表/红黑树。

(二)并发单位

  • JDK 7:并发度为 Segment 个数(默认 16 个)。
  • JDK 8:并发度为桶(Bucket)的个数。

(三)锁粒度

  • JDK 7:采用段锁(锁住整个 Segment)。
  • JDK 8:使用桶锁(synchronized 锁单个桶首节点)。

(四)锁实现

  • JDK 7:使用 ReentrantLock 作为锁。
  • JDK 8:使用 CAS + synchronized(先 CAS 尝试修改,修改失败则对桶使用 synchronized 锁)。

三、主要特点

  1. 线程安全:支持多线程并发访问而不需要外部同步。
  2. 高并发性:通过分段锁(Java 7)或 CAS + synchronized(Java 8+)实现高并发。
  3. 弱一致性:迭代器反映的是创建迭代器时或之后的某个时刻的哈希表状态。
  4. 不允许 null 键或 null 值:与 HashMap 不同,ConcurrentHashMap 不允许 null 键或值。

四、底层实现原理详解

(一)JDK 7 底层原理实现

在 Java 7 中,ConcurrentHashMap 使用分段锁(Segment)技术:

final Segment<K,V>[] segments;  // 段数组

static final class Segment<K,V> extends ReentrantLock {
   transient volatile HashEntry<K,V>[] table;  // 每个段内部的哈希表
   // ...
}
  • 将整个哈希表分成多个段(Segment),每个段相当于一个小的 Hashtable
  • 每个段有自己的锁,不同段可以并发操作。
  • 默认并发级别(concurrencyLevel)为 16,即默认有 16 个段(Segment),初始化时支持设置其他值,一旦初始化后,不支持修改。
  • 整表的初始化容量为 16(非单个 Segment 容量)。单个 Segment 容量的计算公式:
    int segmentCapacity = initialCapacity / concurrencyLevel;
    segmentCapacity = roundUpToPowerOf2(segmentCapacity); // 取不小于结果的2的幂
    
    计算得到每个 Segment = 1,然后向上取 2 的幂为 2,因此 Segment[i] 的默认大小为 2。
  • 扩容是按 Segment 独立扩容的,负载因子是 0.75,初始容量为 2,计算得出初始阈值为 1:
    threshold = (int)(segmentCapacity * loadFactor); // segmentCapacity=2, loadFactor=0.75 → threshold=1
    
    当插入第一个元素时,size=1≥threshold=1,触发扩容 2 倍,变为 4。源码中会检查 ++count>threshold 判断是否需要扩容。

(二)JDK 8 底层实现原理

Java 8 对 ConcurrentHashMap 进行了重大改进:

  • 移除了分段锁设计,改用 CAS + synchronized 实现更细粒度的锁。
  • 当哈希冲突时,使用链表 + 红黑树结构(类似 HashMap,当链表长度超过阈值(默认为 8)时,如果哈希表的容量达到 64 时,转换为红黑树,否则进行扩容)。
  • 使用 volatile 关键字和 Unsafe 类提供的 CAS 操作保证原子性。
  • 使用 Node 数组代替了原来的 Segment 数组。

五、核心方法解析

(一)put() 方法执行过程

1. JDK 8 中的 put 过程
public V put(K key, V value) {
    return putVal(key, value, false);
}

final V putVal(K key, V value, boolean onlyIfAbsent) {
    // 1. 检查 key/value 是否为 null(ConcurrentHashMap 不允许 null 键值)
    if (key == null || value == null) throw new NullPointerException();
    
    // 2. 计算哈希值(扰动函数 + 强制正数)
    int hash = spread(key.hashCode()); // (h ^ (h >>> 16)) & 0x7fffffff
    int binCount = 0; // 记录链表长度(用于树化判断)
    // ...
}
  • 表初始化:如果表为空,则 initTable() 初始化表,通过 CAS 保证线程安全。
    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(); // 内部使用 sizeCtl 控制并发初始化
        // ...
    }
    
  • 定位桶并尝试无锁插入:先通过 Unsafe.getObjectVolatile 原子读取桶头节点,通过 CAS(也是 Unsafe 类方法)尝试插入,失败时继续循环。
    else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
        // 1. 桶为空时,直接 CAS 插入新节点
        if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
            break; // 插入成功则退出循环
    }
    
  • 处理哈希冲突(锁细化控制):仅锁定冲突桶的头节点(synchronized(f)),其他桶仍可访问,并二次检查桶的头节点,防止加锁期间发生扩容修改。当链表长度≥8时触发转化红黑树,若表容量小于 64 则优先扩容,避免过早树化。
  • 协助扩容与计数更新:遇到 MOVED 节点时,当前线程协助迁移数据(即多线程并行加速扩容);通过使用 countercell[] 分段计数方式,避免单一 baseCountCAS 竞争问题。
    // 1. 检测到扩容中(ForwardingNode 标记)
    else if ((fh = f.hash) == MOVED) // MOVED = -1
        tab = helpTransfer(tab, f); // 协助数据迁移
    
    // 2. 更新元素计数(CAS + 分段计数)
    addCount(1L, binCount);
    
2. JDK 7 中的 put 过程

JDK 7 的 ConcurrentHashMap 采用分段锁(Segment)设计,put 过程如下:

  1. 计算段位置
    • 首先计算 key 的 hash 值。
    • 根据 hash 值确定应该放在哪个 Segment 中。
      // 当前 segmentShift 的值为 32 - 4 = 28,segmentMask 为 16 - 1 = 15
      Segment<K,V> segment = segments[(hash >>> segmentShift) & segmentMask]
      
  2. 获取段锁:尝试获取该 Segment 的锁(可重入锁),如果获取失败,线程会阻塞等待。
  3. 段内操作
    • 获取锁后,在 Segment 内部的 HashEntry 数组上进行操作。
    • 计算桶位置:
      int index = (tab.length - 1) & hash
      
    • 遍历链表查找是否已存在相同 key,如果存在,更新 value,如果不存在,采用头插法插入新节点。
  4. 检查扩容
    • 检查是否需要扩容(超过阈值)。
    • 扩容时只扩容当前 Segment 的 HashEntry 数组。
  5. 释放锁:操作完成后释放 Segment 锁。

伪代码实现

public V put(K key, V value) {
   Segment<K,V> s;
   // 1. 计算 hash
   int hash = hash(key);
   // 2. 找到对应的 Segment
   int j = (hash >>> segmentShift) & segmentMask;
   s = ensureSegment(j);
   // 3. 调用 Segment 的 put 方法
   return s.put(key, hash, value, false);
}

// Segment 内部的 put 方法
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
   // 加锁
   HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
   try {
       // 在锁保护下操作
       HashEntry<K,V>[] tab = table;
       int index = (tab.length - 1) & hash;
       HashEntry<K,V> first = entryAt(tab, index);
       // 遍历链表...
       // 插入或更新...
       // 检查扩容...
   } finally {
       unlock(); // 释放锁
   }
}

(二)get 方法的执行过程

get() 操作是无锁的,依赖 volatilefinal 语义来保证线程安全。

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    // 1. 计算哈希
    int h = spread(key.hashCode());
    
    // 2. 定位桶位置(volatile 读取)
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        
        // 3. 检查桶头节点
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val; // 直接匹配头节点
        }
        
        // 4. 特殊节点处理
        else if (eh < 0) 
            return (p = e.find(h, key)) != null ? p.val : null;
        
        // 5. 遍历链表
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null; // 未找到
}

关键设计

  • 通过 Unsafe.getObjectVolatile() 实现原子读(tabAt())。
  • Node.valNode.nextvolatile 修饰保证可见性。
  • 遇到 ForwardingNodehash=MOVED)时调用其 find() 方法自动跳转到新表。

(三)size 方法执行过程

Java 8 中使用一个 volatile 变量 baseCountCounterCell 数组来统计元素数量,通过 CAS 更新,避免了锁的使用。size() 不保证强一致性,采用分治统计优化并发性能。

public int size() {
    long n = sumCount(); // 实际统计方法
    return (n < 0L) ? 0 : (n > Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n;
}

final long sumCount() {
    CounterCell[] as = counterCells;
    long sum = baseCount;
    if (as != null) {
        for (CounterCell a : as) {
            if (a != null)
                sum += a.value; // 累加所有分片
        }
    }
    return sum;
}

(四)技术更新机制(在 addCount() 中)

  1. 优先尝试 CAS 更新 baseCount
    if (U.compareAndSwapLong(this, BASECOUNT, s = baseCount, s + x))
        return;
    
  2. 竞争时初始化/更新 CounterCell
    • 线程通过哈希(ThreadLocalRandom.getProbe())定位自己的 CounterCell 槽位,然后通过 CAS 更新所属槽位的值:
      if (cellsBusy == 0 && counterCells == as &&
           U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
           try {
               // 扩容或初始化 CounterCell 数组
           } finally {
               cellsBusy = 0;
           }
      }
      
  3. 动态扩容 CounterCell 数组
    • 当检测到冲突频繁时,通过 CELLSBUSY 锁双倍扩容数组。

六、扩容机制对比

(一)JDK 7 扩容机制

1. 核心特点
  • 分段扩容:每个 Segment 独立扩容,不影响其他 Segment。
  • 单线程扩容:每个 Segment 的扩容由持有该 Segment 锁的线程完成。
  • 扩容时机:当单个 Segment 中的元素数量超过 容量 × 负载因子
2. 扩容流程
  1. 创建一个新的 HashEntry 数组,大小为原来的 2 倍。
  2. 重新计算所有元素在新数组中的位置。
  3. 将旧数组中的元素迁移到新数组。
  4. 用新数组替换旧数组。
3. 关键代码片段
// Segment 内部的 rehash 方法
void rehash() {
   HashEntry<K,V>[] oldTable = table;
   int oldCapacity = oldTable.length;
   if (oldCapacity >= MAXIMUM_CAPACITY)
       return;
   
   // 新数组是原数组大小的 2 倍
   HashEntry<K,V>[] newTable = HashEntry.newArray(oldCapacity<<1);
   threshold = (int)(newTable.length * loadFactor);
   // ...迁移数据...
   table = newTable;
}

(二)JDK 8 扩容机制

1. 核心特点
  • 整体扩容:整个哈希表一起扩容。
  • 多线程协同:多个线程可以共同参与扩容。
  • 渐进式迁移:不需要一次性完成所有数据迁移。
  • 扩容时机:当元素总数超过 容量 × 负载因子 或链表长度≥8但表容量<64。
2. 扩容流程
  1. 创建新数组(大小为原数组 2 倍)。
  2. 分配迁移任务给多个线程(每个线程负责一个桶区间)。
  3. 迁移时对每个桶加锁(synchronized)。
  4. 使用 ForwardingNode 标记已迁移的桶。
  5. 迁移完成后替换旧数组。
3. 关键优化
  • 并发迁移:通过 transferIndexsizeCtl 协调多线程扩容。
  • 无锁化任务分配:使用 CAS 操作分配迁移任务。
  • 扩容期间读写不阻塞:读操作可以访问新旧表,写操作协助迁移。

七、Hash 值计算

(一)JDK 7 的 Hash 计算

private int hash(Object k) {
   int h = k.hashCode();
   h += (h << 15) ^ 0xffffcd7d;
   h ^= (h >>> 10);
   h += (h << 3);
   h ^= (h >>> 6);
   h += (h << 2) + (h << 14);
   return h ^ (h >>> 16);
}
  1. 多次位运算:通过多次位移和异或操作,使哈希值更分散,减少冲突。
  2. 无扰动优化:虽然计算复杂,但未采用类似 HashMap扰动函数(如 hash ^ (hash >>> 16))。
  3. 分段锁依赖:由于 JDK 7 采用 Segment 分段锁,哈希计算主要用于确定 Segment 索引和 HashEntry 数组索引。

(二)JDK 8 的 Hash 计算

static final int spread(int h) {
   return (h ^ (h >>> 16)) & 0x7fffffff;
}
  1. 扰动优化
    • 采用 h ^ (h >>> 16)(类似 HashMap),使高位参与运算,减少哈希冲突。
    • & 0x7fffffff 确保结果为正数(因为 ConcurrentHashMap 的桶索引不能为负)。
  2. 更简单高效
    • 相比 JDK 7 的复杂位运算,JDK 8 的计算更简洁,性能更高。
  3. 适应新结构
    • JDK 8 改用数组 + 链表 + 红黑树结构,不再依赖 Segment 分段锁,而是使用 CAS + synchronized 优化并发性能。

(三)为什么 JDK 8 优化 Hash 计算?

  1. 减少计算开销:JDK 7 的多次位运算在并发场景下可能成为性能瓶颈。
  2. 适应新结构:JDK 8 改用红黑树处理冲突,即使哈希冲突稍多,也能保证 O(log n) 的查询效率。
  3. CAS 友好:更简单的哈希计算能提高 CAS(Compare-And-Swap)操作的效率。

八、使用示例

ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

// 线程安全的 put
map.put("key1", 1);

// 线程安全的复合操作
map.compute("key1", (k, v) -> v == null ? 1 : v + 1);

// 线程安全的遍历
map.forEach((k, v) -> System.out.println(k + ": " + v));

九、适用场景

  • 高并发环境下的键值存储。
  • 需要线程安全的哈希表且对性能要求较高。
  • 替代传统的 HashtableCollections.synchronizedMap

十、注意事项

  1. 虽然线程安全,但复合操作(如 check-then-act)仍需额外同步。
  2. 迭代器是弱一致性的,不保证反映最新的修改。
  3. 批量操作(如 putAll)不保证原子性。
  4. Java 8 后的版本性能优于早期版本。

总结

ConcurrentHashMap 是 Java 并发编程中的重要工具类,合理使用可以显著提高多线程环境下的程序性能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值