ConcurrentHashMap 1.7 源码深度解析:分段锁的设计与实现

ConcurrentHashMap 1.7 源码深度解析:分段锁的设计与实现

前言

在Java并发编程中,HashMap因线程不安全 在多线程环境下会出现链表环、数据丢失等问题,而Hashtable虽通过全局synchronized锁 保证线程安全,但锁粒度太大,所有操作都竞争同一把锁,并发效率极低。为了解决这一矛盾,JDK1.5引入了ConcurrentHashMapJDK1.7版本的ConcurrentHashMap采用经典的「分段锁(Segment)」机制,通过减小锁粒度实现高并发,成为多线程环境下哈希表的首选。

本文将从底层结构、核心设计原理、核心方法源码、扩容机制等维度,结合流程图和源码片段,深度解析ConcurrentHashMap 1.7的实现细节,同时讲解其并发安全的核心保障,让大家理解「分段锁」设计的精髓。本文适合Java后端开发工程师、并发编程学习者,也是Java面试的高频考点解析。

一、ConcurrentHashMap 1.7 核心底层结构

ConcurrentHashMap 1.7的底层结构采用三层嵌套 的设计:Segment数组 + HashEntry数组 + 单向链表 ,核心是通过Segment实现分段锁,这也是其与HashMap 1.7(数组+链表)、Hashtable(全局锁的数组+链表)最核心的区别。

1.1 核心结构组成

  1. Segment :继承自ReentrantLock(可重入锁),是ConcurrentHashMap的分段锁核心 ,一个Segment就是一个独立的锁区域;多个Segment组成一个Segment数组,默认长度为16(对应默认并发级别16)。
  2. HashEntry :是ConcurrentHashMap的数据存储节点 ,采用final修饰保证不可变,通过volatile修饰next节点保证可见性,是并发安全的基础。
  3. HashEntry数组:每个Segment内部维护一个HashEntry数组,数组的每个元素是一个HashEntry单向链表的头节点,用于解决哈希冲突。

1.2 结构可视化流程图

ConcurrentHashMap
Segment数组

默认长度16

每个Segment是独立锁
Segment[0]

ReentrantLock+HashEntry数组
Segment[1]

ReentrantLock+HashEntry数组
...

共16个Segment
Segment[15]

ReentrantLock+HashEntry数组
HashEntry[0]链表头
HashEntry[1]链表头
HashEntry[len-1]链表头
HashEntry节点

final key

final hash

volatile value

volatile next
下一个HashEntry节点
...链表尾

1.3 核心成员变量(源码节选)

java 复制代码
public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
        implements ConcurrentMap<K, V>, Serializable {
    // 1. Segment数组,存储所有分段锁
    final Segment<K,V>[] segments;
    // 2. 默认初始容量:16(整个Map的初始容量,平均分配给16个Segment,每个Segment初始容量1)
    static final int DEFAULT_INITIAL_CAPACITY = 16;
    // 3. 默认负载因子:0.75(单个Segment的负载因子,与HashMap一致)
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    // 4. 默认并发级别:16(决定Segment数组的默认长度,理论上支持16个线程同时操作)
    static final int DEFAULT_CONCURRENCY_LEVEL = 16;
    // 5. 单个Segment的最大容量:2^30
    static final int MAX_SEGMENT_TABLE_CAPACITY = 1 << 30;
}

// Segment类:继承ReentrantLock,作为分段锁
static final class Segment<K,V> extends ReentrantLock implements Serializable {
    // 每个Segment内部的HashEntry数组
    transient volatile HashEntry<K,V>[] table;
    // Segment的元素数量
    transient int count;
    // Segment的扩容阈值(负载因子 * HashEntry数组长度)
    transient int threshold;
    // Segment的负载因子
    final float loadFactor;
}

// HashEntry节点:不可变设计+volatile保证并发安全
static final class HashEntry<K,V> {
    final int hash; // 哈希值,final不可变
    final K key;    // 键,final不可变
    volatile V value; // 值,volatile保证可见性
    volatile HashEntry<K,V> next; // 下一个节点,volatile保证可见性

    HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
}

1.4 关键设计细节

  1. Segment数组长度不可变 :Segment数组在初始化时确定长度(由并发级别决定,取大于等于并发级别的2的幂次),初始化后无法修改,保证分段锁的结构稳定。
  2. HashEntry不可变设计keyhashfinal修饰,一旦创建无法修改,避免了多线程下的节点篡改;valuenextvolatile修饰,保证多线程下的可见性(一个线程修改后,其他线程能立即看到)。
  3. 并发级别 :ConcurrentHashMap 1.7的并发能力由Segment数组长度决定,默认16,理论上支持16个线程同时对不同Segment执行写操作(互不阻塞),读操作全程无锁。

二、ConcurrentHashMap 1.7 核心设计原理

2.1 并发安全的核心:分段锁(Segment)

分段锁是ConcurrentHashMap 1.7解决并发问题的核心思想 ,本质是将全局锁拆分为多个细粒度的锁,每个锁保护一个独立的数据区域(Segment)

  • Hashtable的全局锁:所有读写操作都竞争同一把synchronized锁,同一时间只有一个线程能操作,并发效率为1。
  • ConcurrentHashMap的分段锁 :每个Segment是一个独立的ReentrantLock,操作不同Segment的线程不会互相阻塞,只有操作同一个Segment的线程才会竞争同一把锁。默认16个Segment,理论上并发效率提升16倍。

分段锁的优势 :减小锁粒度,提升并发度;ReentrantLock的可重入性,保证同一线程多次获取同一Segment的锁不会死锁。

2.2 哈希计算:两次哈希分散冲突

为了将key均匀分配到不同的Segment中,ConcurrentHashMap 1.7采用两次哈希计算的方式,避免所有key都哈希到同一个Segment,导致分段锁失效(所有操作竞争同一把锁)。

两次哈希的执行步骤
  1. 第一次哈希 :对key调用hashCode()计算原始哈希值,再通过ConcurrentHashMap的内部哈希算法 二次哈希,得到全局哈希值 ;将该值对Segment数组长度取模 ,得到key对应的Segment数组索引
  2. 第二次哈希 :将上述全局哈希值对当前Segment内部的HashEntry数组长度取模 ,得到key对应的HashEntry数组桶索引
哈希计算源码(核心方法)
java 复制代码
// 第一次哈希:计算key对应的Segment索引
private int segmentFor(int hash) {
    // 利用位运算替代取模,效率更高(数组长度为2的幂次时,hash % len = hash & (len-1))
    return hash & (segments.length - 1);
}
// 内部二次哈希算法:减少哈希冲突,让key更均匀分布
private static int hash(int h) {
    h += (h << 15) ^ 0xffffcd7d;
    h ^= (h >>> 10);
    h += (h << 3);
    h ^= (h >>> 6);
    h += (h << 2) + (h << 14);
    return h ^ (h >>> 16);
}

设计目的:通过两次哈希,让key既均匀分布在不同Segment,又均匀分布在Segment内部的HashEntry数组,最大程度分散哈希冲突,保证分段锁的并发效率。

2.3 读操作无锁的核心保障

ConcurrentHashMap 1.7的get方法全程无锁 ,是其高性能的重要原因,无锁的核心保障来自HashEntry的不可变设计+volatile关键字的可见性

  1. keyhash是final的,保证节点的核心标识不会被篡改;
  2. valuenext是volatile的,保证多线程下的可见性和有序性:一个线程修改了节点的value或next,其他线程能立即看到最新值,避免脏读;
  3. 写操作时对Segment加锁,保证同一Segment内的写操作是串行的,不会出现多线程同时修改链表的情况,读操作无需加锁即可保证数据一致性。

三、ConcurrentHashMap 1.7 核心方法源码解析

3.1 核心写操作:put方法(加锁+头插法)

put方法是ConcurrentHashMap 1.7的核心写操作,全程对目标Segment加锁 ,保证同一Segment内的写操作串行执行,不同Segment的写操作并行执行。同时采用头插法添加新节点(与HashMap 1.7一致),并在添加后检查是否需要扩容。

put方法整体执行流程







调用put(K key,V value)
检查key/value是否为null?
抛出NullPointerException

ConcurrentHashMap不允许空键空值
计算key的二次哈希值,得到全局hash
调用segmentFor(hash),得到目标Segment索引
获取目标Segment对象
对目标Segment加锁(lock())

ReentrantLock可重入锁
在Segment内计算hash,得到HashEntry数组桶索引
遍历桶对应的单向链表
是否找到key相同的节点?
替换该节点的volatile value值
头插法添加新HashEntry节点

新节点作为链表头
Segment内元素数是否超过扩容阈值?
对当前Segment执行扩容(仅扩容该Segment)
解锁(unlock())
返回旧值/Null

put方法核心源码(带注释)
java 复制代码
public V put(K key, V value) {
    Segment<K,V> s;
    // 禁止空键空值,与HashMap不同(HashMap允许空键空值)
    if (value == null)
        throw new NullPointerException();
    // 1. 二次哈希计算全局hash值
    int hash = hash(key.hashCode());
    // 2. 计算目标Segment的索引,通过位运算定位
    int j = (hash >>> segmentShift) & segmentMask;
    // 3. 定位目标Segment,若未初始化则先初始化
    if ((s = (Segment<K,V>)UNSAFE.getObject(segments, (j << SSHIFT) + SBASE)) == null)
        s = ensureSegment(j);
    // 4. 调用Segment的put方法,对Segment加锁操作
    return s.put(key, hash, value, false);
}

// Segment内部的put方法(核心写操作)
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    // 加锁:获取ReentrantLock锁,若获取失败则调用scanAndLockForPut自旋获取
    HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
    V oldValue;
    try {
        HashEntry<K,V>[] tab = table;
        // 计算Segment内部HashEntry数组的桶索引
        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相同的节点,替换value
                if ((k = e.key) == key || (e.hash == hash && key.equals(k))) {
                    oldValue = e.value;
                    if (!onlyIfAbsent) {
                        e.value = value; // volatile value,保证可见性
                        ++modCount;
                    }
                    break;
                }
                e = e.next;
            } else {
                // 未找到,头插法添加新节点
                if (node != null)
                    node.setNext(first);
                else
                    node = new HashEntry<K,V>(hash, key, value, first);
                int c = count + 1;
                // 检查是否需要扩容:元素数>阈值 且 HashEntry数组长度<最大容量
                if (c > threshold && tab.length < MAX_SEGMENT_TABLE_CAPACITY)
                    rehash(node); // 扩容当前Segment
                else
                    setEntryAt(tab, index, node); // 头插法设置新节点为链表头
                ++modCount;
                count = c;
                oldValue = null;
                break;
            }
        }
    } finally {
        unlock(); // 最终解锁,保证锁的释放
    }
    return oldValue;
}
put方法关键细节
  1. 禁止空键空值 :ConcurrentHashMap 1.7/1.8都不允许key或value为null,而HashMap允许,原因是多线程环境下,空值会导致无法区分是"键不存在"还是"值为null",引发并发问题。
  2. 自旋加锁 :若tryLock()获取锁失败,会调用scanAndLockForPut自旋获取锁,避免直接阻塞(ReentrantLock的非阻塞特性),提升并发效率。
  3. 仅对单个Segment扩容 :扩容是Segment级别的局部扩容,而非整个ConcurrentHashMap扩容,扩容时仅锁定当前Segment,不影响其他Segment的操作。
  4. 头插法:新节点作为链表头,插入效率高(无需遍历到链表尾),但会导致链表顺序与插入顺序相反(HashMap 1.7也采用此方式)。

3.2 核心读操作:get方法(全程无锁)

get方法是ConcurrentHashMap 1.7的核心读操作,全程无锁 ,仅通过两次哈希定位节点,遍历链表获取值,并发效率极高,其线程安全由HashEntry的finalvolatile修饰保证。

get方法执行流程





调用get(Object key)
检查key是否为null?
抛出NullPointerException
计算key的二次哈希值,得到全局hash
调用segmentFor(hash),得到目标Segment索引
获取目标Segment对象(无需加锁)
在Segment内计算hash,得到HashEntry数组桶索引
遍历桶对应的单向链表(无需加锁)
是否找到key相同的节点?
返回该节点的volatile value值
返回null

get方法核心源码(带注释)
java 复制代码
public V get(Object key) {
    Segment<K,V> s;
    HashEntry<K,V>[] tab;
    // 1. 计算二次哈希值
    int h = hash(key.hashCode());
    // 2. 定位目标Segment,通过Unsafe直接获取,无锁
    long u = (h >>> segmentShift) & segmentMask;
    if ((s = (Segment<K,V>)UNSAFE.getObject(segments, u)) != null && (tab = s.table) != null) {
        // 3. 定位HashEntry数组桶索引,遍历链表
        for (HashEntry<K,V> e = (HashEntry<K,V>)UNSAFE.getObject(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
             e != null; e = e.next) {
            K k;
            // 找到匹配的key,返回value
            if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                return e.value;
        }
    }
    return null;
}
get方法无锁的核心原因
  1. HashEntry的不可变设计keyhash是final的,不会被篡改,保证遍历链表时节点的标识不会变化。
  2. volatile的可见性valuenext是volatile的,写操作修改后,读操作能立即看到最新值,避免脏读。
  3. 写操作加锁:同一Segment内的写操作是串行的,不会出现多线程同时修改链表的情况,读操作无需加锁即可保证数据一致性。

3.3 扩容机制:rehash方法(Segment局部扩容)

ConcurrentHashMap 1.7的扩容仅针对单个Segment ,而非全局扩容,当某个Segment内的元素数量count超过其扩容阈值(threshold = 负载因子 * HashEntry数组长度),且HashEntry数组长度小于最大容量时,触发该Segment的扩容。

扩容核心步骤
  1. 创建新的HashEntry数组 :新数组长度为原数组的2倍(与HashMap一致,保证2的幂次,方便位运算取模)。
  2. 重新哈希 :将原HashEntry数组中的所有节点,通过二次哈希重新分配到新数组的桶中。
  3. 头插法转移节点 :将原链表的节点通过头插法转移到新数组的对应桶中,转移过程中当前Segment始终处于加锁状态
  4. 替换数组:将Segment的table引用指向新的HashEntry数组,完成扩容。
扩容关键细节
  1. 局部扩容:仅扩容操作的Segment,其他Segment不受影响,保证扩容时的并发效率。
  2. 加锁扩容:扩容全程对Segment加锁,避免多线程下的数组篡改,保证扩容的原子性。
  3. 无链表环问题 :虽然采用头插法,但扩容是单线程串行执行(加锁),不会像HashMap 1.7那样出现多线程下的链表环问题。

3.4 移除操作:remove方法(加锁执行)

remove方法与put方法的并发控制逻辑一致,先对目标Segment加锁,再遍历链表找到目标节点,修改链表的next引用实现节点移除,最后解锁,全程保证串行执行。

核心特点 :移除节点时,并非直接删除节点,而是通过修改前驱节点的volatile next引用,让目标节点脱离链表(GC会自动回收),利用volatile的可见性保证其他线程能立即看到链表的修改。

四、ConcurrentHashMap 1.7 扩容阈值与初始化

4.1 核心参数的计算

ConcurrentHashMap 1.7的初始化会根据初始容量、并发级别、负载因子三个参数,计算出Segment数组长度、单个Segment的初始容量、单个Segment的扩容阈值。

核心计算公式
  1. Segment数组长度 :取大于等于并发级别的最小2的幂次,默认并发级别16,故Segment数组长度为16。
  2. 单个Segment的初始HashEntry数组长度初始容量 / Segment数组长度,若无法整除则向上取整,且保证为2的幂次;默认初始容量16,故每个Segment的初始容量为1。
  3. 单个Segment的扩容阈值HashEntry数组长度 * 负载因子,默认负载因子0.75,故初始阈值为1 * 0.75 = 0(首次添加元素即触发扩容)。

4.2 懒加载初始化

ConcurrentHashMap 1.7采用懒加载机制初始化Segment和HashEntry数组:

  1. Segment数组:在ConcurrentHashMap构造方法中仅初始化数组结构,不初始化每个Segment对象。
  2. Segment对象 :当首次向某个Segment执行put操作时,才通过ensureSegment方法初始化该Segment。
  3. HashEntry数组:在Segment的初始化方法中创建,首次添加元素时若触发扩容则动态扩容。

设计目的:避免初始化时创建大量空的Segment和HashEntry数组,节省内存空间。

五、ConcurrentHashMap 1.7 与 HashMap/Hashtable 对比

为了更清晰理解ConcurrentHashMap 1.7的设计优势,将其与同版本的HashMap 1.7、Hashtable做核心维度对比:

特性 ConcurrentHashMap 1.7 HashMap 1.7 Hashtable
线程安全 是(分段锁+volatile) 是(全局synchronized)
锁机制 分段锁(ReentrantLock) 无锁 全局锁(synchronized)
并发度 高(默认16,Segment数决定) 低(同一时间仅1个线程)
读操作是否加锁 无锁 无锁 加锁(synchronized)
允许空键空值 是(空键1个,空值多个)
哈希计算 两次哈希 一次哈希 一次哈希
扩容方式 Segment局部扩容 全局扩容 全局扩容
底层结构 Segment数组+HashEntry数组+链表 数组+链表 数组+链表
扩容阈值 单个Segment独立计算 全局计算 全局计算

六、ConcurrentHashMap 1.7 优缺点分析

6.1 优点

  1. 高并发:分段锁机制减小了锁粒度,不同Segment的操作可并行执行,默认支持16个线程同时写操作。
  2. 高性能读操作:get方法全程无锁,依赖volatile和final保证并发安全,读效率与HashMap持平。
  3. 可重入锁:Segment继承ReentrantLock,支持可重入,避免同一线程多次获取锁导致的死锁。
  4. 局部扩容:仅对操作的Segment扩容,不影响其他Segment,扩容时的并发影响最小。
  5. 懒加载:Segment和HashEntry数组懒加载,节省内存空间。

6.2 缺点

  1. 分段锁粒度仍不够细 :若大量key哈希到同一个Segment,会导致该Segment成为性能瓶颈(所有操作竞争同一把锁),并发度下降。
  2. 并发级别固定:Segment数组长度初始化后无法修改,若业务并发量超过初始并发级别,无法动态提升。
  3. 哈希冲突效率低:采用单向链表解决哈希冲突,当链表过长时,查询效率降至O(n)。
  4. 不支持红黑树:与HashMap 1.8相比,无红黑树优化,链表过长时性能下降明显。
  5. 头插法的局限性:头插法虽插入效率高,但会导致链表顺序反转,且无法利用红黑树优化。

七、Java面试高频考点(ConcurrentHashMap 1.7)

  1. ConcurrentHashMap 1.7的底层结构是什么?
    答:Segment数组+HashEntry数组+单向链表;Segment继承ReentrantLock实现分段锁,HashEntry通过final+volatile保证并发安全。
  2. ConcurrentHashMap 1.7为什么采用分段锁?
    答:为了解决Hashtable全局锁的低并发问题,通过拆分锁粒度,让不同Segment的操作并行执行,提升并发效率。
  3. ConcurrentHashMap 1.7的get方法为什么无锁?
    答:HashEntry的key/hash是final的,value/next是volatile的,保证了节点的不可变和可见性;同时写操作对Segment加锁,保证同一Segment内的写操作串行,读操作无需加锁即可保证数据一致性。
  4. ConcurrentHashMap 1.7是否允许空键空值?为什么?
    答:不允许;多线程环境下,空值无法区分是"键不存在"还是"值为null",会引发并发判断问题,而HashMap是单线程的,无此问题。
  5. ConcurrentHashMap 1.7的扩容是全局的吗?
    答:不是;是Segment级别的局部扩容,仅对操作的Segment扩容,扩容时加锁,不影响其他Segment。
  6. ConcurrentHashMap 1.7和Hashtable的锁机制有什么区别?
    答:Hashtable采用全局synchronized锁,所有操作竞争同一把锁;ConcurrentHashMap 1.7采用分段锁(ReentrantLock),每个Segment是独立的锁,不同Segment的操作并行执行。

八、总结与设计演进

8.1 核心总结

ConcurrentHashMap 1.7的核心设计是分段锁(Segment) ,通过将哈希表拆分为多个独立的锁区域,解决了Hashtable全局锁的低并发问题,同时通过final+volatile保证了HashEntry的并发安全,实现了无锁读、加锁写的高性能并发模型。

其设计的精髓在于锁的粒度拆分并发安全的细粒度保障,既保证了多线程下的数据一致性,又最大化提升了并发效率,成为JDK1.7及之前多线程环境下哈希表的最优选择。

8.2 设计演进:为什么JDK1.8放弃了分段锁?

JDK1.8的ConcurrentHashMap彻底放弃了分段锁,采用CAS + synchronized + 红黑树的实现方式,原因在于1.7的分段锁存在以下局限性:

  1. 分段锁的性能瓶颈:若大量key哈希到同一个Segment,会导致锁竞争加剧,并发度下降。
  2. 并发级别固定:Segment数组长度初始化后无法修改,无法适配动态变化的业务并发量。
  3. 链表的性能问题:无红黑树优化,链表过长时查询效率低。
  4. synchronized的优化:JDK1.6对synchronized做了大量优化(偏向锁、轻量级锁、重量级锁),性能与ReentrantLock持平,甚至在某些场景下更优。

JDK1.8的实现既保留了1.7的高并发特性,又通过红黑树优化了哈希冲突的查询效率,成为更优的并发哈希表实现。

下一章我们来介绍一下JDK1.8的实现。

相关推荐
哈库纳玛塔塔2 小时前
dbVisitor 统一数据库访问库,更新 v6.7.0,面向 AI 支持向量操作
数据库·spring boot·orm
Ivanqhz2 小时前
半格与数据流分析的五个要素(D、V、F、I、Λ)
开发语言·c++·后端·算法·rust
liann1192 小时前
4.3.2_WEB——WEB后端语言——PHP
开发语言·前端·网络·安全·web安全·网络安全·php
元让_vincent2 小时前
DailyCoding C++ | SLAM里的“幽灵数据”:从一个未初始化的四元数谈C++类设计
开发语言·c++·slam·构造函数·类设计·激光里程计
zheshiyangyang2 小时前
前端面试基础知识整理【Day-4】
前端·面试·职场和发展
SmartBrain2 小时前
FastAPI 与 Langchain、Coze、Dify 技术深度对比分析
java·架构·fastapi
A9better2 小时前
C++——指针与内存
c语言·开发语言·c++·学习
FunW1n2 小时前
tmf.js Hook Shark框架相关疑问归纳总结报告
java·前端·javascript
琢磨先生David3 小时前
Java算法每日一题
java·开发语言·算法