ConcurrentHashMap源码分析

什么是ConcurrentHashMap

在并发环境下,直接使用 HashMap会导致数据不一致甚至死循环。而使用 Collections.synchronizedMap()包装的Map,虽然线程安全,但它是通过在整个对象上使用 粗粒度锁(synchronized) ​ 来实现的,性能极差(所有操作串行化)。

因此,需要一种支持高并发读写、且保证线程安全的 Map。这就是 ConcurrentHashMap的使命。

ConcurrentHashMap的演变

JDK 1.7以及之前,ConcurrentHashMap由一组Segment(段)组成,每个Segment本质上是一个独立的ReentrantLock和一个小型的HashEntry数组。

JDK 1.8以及之后,采用CASsynchronized精细化锁。

ConcurrentHashMap的底层数据结构

节点类

普通节点

java 复制代码
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;
}

ConcurrentHashMapHashMap中的Node有点不同,就在于valnext属性使用了volatile进行修饰。

红黑树节点

java 复制代码
 static final class TreeNode<K,V> extends Node<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
}

Forwading节点

java 复制代码
static final class ForwardingNode<K,V> extends Node<K,V> {
        final Node<K,V>[] nextTable;
}

参数

sizeCtl

负数:表示初始化或扩容状态

  1. -1 : 表示哈希表 table正在初始化中。这个状态由某个线程通过 CAS 操作独占。

  2. -(1 + n) : 表示哈希表 table正在扩容中。

高 16 位: 存放一个扩容标识戳 (resizeStamp),由当前 table 的长度 n计算得来,用于标记本次扩容是基于哪个长度的 table 进行的。

低 16 位: 表示正在进行扩容的线程数 + 1 。例如,-(1 + 2)表示有 2 个线程正在协助扩容。

零或正数:

  1. 0 : 创建 ConcurrentHashMap 时未指定初始容量,使用默认初始容量(16)。
  2. 正数
    • 在初始化之前 : 表示用户指定的初始容量(会调整为 2 的幂次)。
    • 在初始化或扩容完成之后 : 表示下一次触发扩容的阈值capacity * loadFactor)。

put操作流程

java 复制代码
    public V put(K key, V value) {
        return putVal(key, value, false);
    }

实际的工作委托给putVal函数,第三个参数false表示如果键已存在,则替换。

putVal的内部,整体是一个巨大的自旋循环

java 复制代码
for (Node<K,V>[] tab = table;;) {
 // 无限循环直到插入成功
}
  1. ConcurrentHashMap是懒加载的,因此如果底层的数组是空的,则先进行数组初始化
java 复制代码
if (tab == null || (n = tab.length) == 0)
 tab = initTable();
  1. 如果底层数组不为空,但是当前插入的桶为空,使用CAS插入数据,如果成功则直接插入,如果失败则循环重试。
java 复制代码
if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
    if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
        break;                   // no lock when adding to empty bin
}
  1. 如果当前插入的桶的头是一个ForwardingNode,说明正在扩容,因此线程帮助扩容
java 复制代码
if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
  1. 如果不是第2种和第3种情况,将会使用synchronized锁住该桶头,在该桶内,查找对应的键,如果找到则更新值,如果没有找到则插入尾部。
java 复制代码
else {
    V oldVal = null;
    synchronized (f) { // 锁住链表头节点
        // 双重检查
        if (tabAt(tab, i) == f) {
            // 插入逻辑
        }
    }
}
  1. 插入后,如果达到阈值,则转成红黑树
  2. 更新元素个数,采用类似LongAdder的分段计数的思想。

初始化

ConcurrentHashMap中初始化底层数组时,依旧使用自旋+CAS的方法处理

java 复制代码
while ((tab = table) == null || tab.length == 0){
 ...
}
  1. 如果当前有其他的线程正在初始化,那么将放弃CPU
java 复制代码
if ((sc = sizeCtl) < 0)
             Thread.yield(); // lost initialization race; just spin
  1. 如果没有其他线程在初始化,那么cas设置当前的状态标签为-1
java 复制代码
else if (U.compareAndSetInt(this, SIZECTL, sc, -1))
  1. 竞争成功后,进行双重检查,然后初始化数组,最后恢复状态标签
java 复制代码
try {
 if ((tab = table) == null || tab.length == 0) { // 双重检查
     int n = (sc > 0) ? sc : DEFAULT_CAPACITY;  // 确定初始容量
     Node<K,V>[] nt = new Node<?,?>[n];         // 创建数组
     table = tab = nt;                          // volatile 写入
     sc = n - (n >>> 2);                       // 计算扩容阈值:n*0.75
 }
} finally {
 sizeCtl = sc;  // 恢复为正的阈值
}

get流程

get操作是ConcurrentHashMap高性能读的关键。不需要加锁 ,因为节点的valnext都是 volatile的,保证了可见性 。 根据哈希定位到桶,然后遍历链表或搜索红黑树即可。如果遇到ForwardingNode,说明正在扩容,会调用其 find方法到新数组中去查找。

相关推荐
Barkamin3 小时前
多线程简单介绍
java·开发语言·jvm
Lucifer三思而后行3 小时前
Oracle DBA 效率提升的秘密:批量部署环境再也不头疼!
后端
Lucifer三思而后行3 小时前
一条命令装好 Oracle 数据库?这个脚本做到了!
后端
Lucifer三思而后行3 小时前
国产化适配实战:麒麟 V10 + Oracle 19c RAC 自动化部署方案
后端
Lucifer三思而后行3 小时前
2026 年还值得学 Oracle 吗?一个 DBA 的真实看法
后端
Lucifer三思而后行3 小时前
2026 年了,为什么你还在手动安装 Oracle 数据库?
后端
小比特_蓝光3 小时前
算法篇二----二分查找
java·数据结构·算法
沸点小助手3 小时前
「国产龙虾谁能打过OpenClaw & 你敢让微信龙虾碰代码吗」沸点获奖名单公示|本周互动话题上新🎊
前端·后端·面试
田梓燊3 小时前
leetcode 56
java·算法·leetcode