JDK 1.7 ConcurrentHashMap——分段锁

在Java并发编程中,ConcurrentHashMap 是不可或缺的利器。它的诞生源于对 Hashtable 性能瓶颈的突破。Hashtable 通过全局锁保证线程安全,所有读写操作都竞争同一把锁,在高并发场景下性能急剧下降。JDK 1.5 中首次引入的 ConcurrentHashMap(在 JDK 1.7 中进一步完善)采用分段锁 机制,将锁粒度从整个表细化到多个独立的段,极大提升了并发吞吐量。本文将深入剖析 JDK 1.7 中 ConcurrentHashMap 的设计思想、核心实现以及优缺点,带你领略分段锁的艺术。

一、背景:HashTable的全局锁之痛

Hashtable 是 Java 早期提供的线程安全哈希表,其所有公共方法(如 putgetsize)都通过 synchronized 修饰,相当于整个对象被一把大锁保护。当多个线程同时访问时,即使执行的是不同哈希桶的操作,也只能串行执行,CPU 资源利用率低下。随着多核处理器的普及,这种粗粒度锁显然无法满足高并发需求。因此,ConcurrentHashMap 应运而生。

二、分段锁架构设计

JDK 1.7 中的 ConcurrentHashMap 采用分段锁思想,将数据分片管理,每个分片拥有一把独立的锁。其核心结构如下:

java 复制代码
public class ConcurrentHashMap<K, V> {
    // 分段数组,默认大小16,不可扩容
    final Segment<K,V>[] segments;
    
    // Segment 内部类,继承 ReentrantLock
    static final class Segment<K,V> extends ReentrantLock {
        transient volatile HashEntry<K,V>[] table;
        transient int count;  // 该段中元素个数
        transient int modCount; // 结构变更次数,用于检测并发
        final float loadFactor;
        // ...
    }
    
    // 真正的数据节点
    static final class HashEntry<K,V> {
        final K key;
        volatile V value;
        final int hash;
        final HashEntry<K,V> next;
        // ...
    }
}
  • Segment 数组ConcurrentHashMap 持有一个 Segment 数组,默认容量为 16。每个 Segment 都继承自 ReentrantLock,因此可以独立加锁。

  • HashEntry 数组 :每个 Segment 内部维护一个 HashEntry 数组,用于存储实际的数据节点。HashEntry 中的 valuenext 字段都使用 volatile 修饰,保证多线程下的可见性。

  • 锁粒度 :多个线程可以同时访问不同 Segment 中的数据,只有访问同一 Segment 时才会发生锁竞争。因此,理论上并发度可以达到 Segment 的数量(默认 16)。

这种双层数组结构使得定位一个元素需要两次哈希计算:第一次定位到 Segment,第二次定位到 HashEntry 数组。

三、核心方法实现原理

1. put/remove 操作:分段加锁

put 方法为例,流程如下:

  • 根据 key 的哈希值定位到所属 Segment

  • 调用 Segment.put 方法,首先尝试获取该 Segment 的锁(ReentrantLock.lock())。

  • 获取锁后,在对应 HashEntry 数组中进行插入或更新操作。如果哈希桶为空或需要扩容,则同步处理。

  • 最后释放锁。

由于锁的粒度是 Segment,不同 Segment 之间的操作可以完全并发,互不干扰。remove 方法同理。

java 复制代码
// 简化后的伪代码
public V put(K key, V value) {
    int hash = hash(key);
    int segmentIndex = (hash >>> segmentShift) & segmentMask;
    Segment<K,V> seg = segments[segmentIndex];
    return seg.put(key, hash, value, false);
}

// Segment.put 内部
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    lock();  // 加锁
    try {
        HashEntry<K,V>[] tab = table;
        int index = hash & (tab.length - 1);
        HashEntry<K,V> first = tab[index];
        // 遍历链表,若key存在则替换
        for (HashEntry<K,V> e = first; e != null; e = e.next) {
            if (e.hash == hash && key.equals(e.key)) {
                V oldValue = e.value;
                if (!onlyIfAbsent) e.value = value;
                return oldValue;
            }
        }
        // 不存在则插入新节点
        tab[index] = new HashEntry<K,V>(key, hash, value, first);
        count++;
        return null;
    } finally {
        unlock(); // 释放锁
    }
}

2. get 操作:无锁读,利用 volatile

get 方法是完全无锁的,它不需要获取任何锁,只需通过 volatile 保证可见性:

  • 定位到 Segment 后,再定位到 HashEntry 数组。

  • 由于 valuenext 都是 volatile 修饰,读取到的数据一定是主内存中最新的值(或至少是某个一致性状态)。

  • 即使在遍历链表时发生了并发修改,volatile 也能保证读取到的节点引用是最新的。

java 复制代码
public V get(Object key) {
    int hash = hash(key);
    int segmentIndex = (hash >>> segmentShift) & segmentMask;
    Segment<K,V> seg = segments[segmentIndex];
    HashEntry<K,V>[] tab = seg.table;
    int index = hash & (tab.length - 1);
    for (HashEntry<K,V> e = tab[index]; e != null; e = e.next) {
        if (e.hash == hash && key.equals(e.key)) {
            return e.value;  // value 是 volatile 的
        }
    }
    return null;
}

这种设计使得读操作几乎不会阻塞写操作,从而实现了高效的读写并发。

3. size 操作:两次遍历+加锁兜底

size 方法需要统计所有 Segment 中元素的总数。如果简单地对每个 Segment 加锁再统计,在高并发下会严重影响性能。JDK 1.7 的 ConcurrentHashMap 采用了一种乐观策略

  1. 先不加锁,遍历所有 Segment,累加 countmodCount

  2. 如果两次遍历得到的 modCount 总和相同,说明遍历期间没有发生结构变更,直接返回统计结果。

  3. 如果两次 modCount 不一致,说明有线程进行了 putremove 等操作,此时会尝试对所有 Segment 加锁,再进行一次准确统计。

这种方法兼顾了效率和准确性,在多数场景下无需加锁即可快速返回。

java 复制代码
public int size() {
    final Segment<K,V>[] segments = this.segments;
    long sum = 0;
    long check = 0;
    int[] mc = new int[segments.length];
    // 重试次数
    for (int k = 0; k < 2; k++) {
        check = 0;
        sum = 0;
        for (int i = 0; i < segments.length; i++) {
            if (k == 0) {
                mc[i] = segments[i].modCount;
            } else {
                if (segments[i].modCount != mc[i]) {
                    // 不一致,进入加锁模式
                    return trySizeWithLock();
                }
            }
            sum += segments[i].count;
        }
    }
    return (int) sum;
}

四、扩容机制

JDK 1.7 的 ConcurrentHashMap 扩容发生在 Segment 内部,即 HashEntry 数组的大小调整。每个 Segment 独立扩容,互不影响。当某个 Segment 中的元素数量超过阈值(loadFactor * 当前容量)时,会创建一个新的 HashEntry 数组,容量为原来的 2 倍,然后将原数组中的元素重新哈希到新数组中。整个扩容过程在 Segment 锁的保护下完成,因此同一时刻只有一个线程对该 Segment 执行扩容,其他访问该 Segment 的线程会被阻塞。

这种设计的优点是扩容不影响其他 Segment,保证了并发度;缺点是扩容不支持多线程并行,且扩容期间该 Segment 的访问性能会下降。此外,Segment 的数量一旦初始化就无法再改变,导致整体容量受限于 Segment 数组大小和每个 Segment 内部数组大小的乘积,灵活性不足。

五、优缺点总结

优点

  • 高并发 :相比 Hashtable,锁粒度细化,默认并发度可达 16,在多线程环境下吞吐量显著提升。

  • 读操作无锁get 方法利用 volatile 实现无锁读,读写操作几乎不互斥,适合读多写少的场景。

  • 分段独立性 :每个 Segment 独立管理自己的数据,扩容、计数等操作互不干扰,降低了复杂度。

缺点

  • 两次哈希计算 :定位元素需要先定位 Segment 再定位 HashEntry,相比 HashMap 多了一次哈希运算,有一定性能开销。

  • 锁粒度依然较粗 :当多个线程竞争同一 Segment 时,仍会出现锁等待,无法进一步细化。

  • 扩容效率低 :每个 Segment 独立扩容,且不支持并发扩容,扩容期间该段会被阻塞。

  • 内存占用较高:维护了两层数组结构,内存开销比单一数组大。

六、结语

JDK 1.7 的 ConcurrentHashMap 通过分段锁机制,在保证线程安全的前提下大幅提升了并发性能,是 Java 并发容器中里程碑式的设计。它巧妙地平衡了锁的粒度与实现复杂度,为后来的 JDK 1.8 版本(采用 CAS + synchronized 的细粒度锁)奠定了基础。

理解 JDK 1.7 的 ConcurrentHashMap 不仅有助于我们掌握分段锁的思想,更能帮助我们对比不同版本的演进,从而更深刻地理解并发编程的设计权衡。如果你正维护着基于 JDK 1.7 的遗留系统,或者只是对并发容器的历史感兴趣,希望本文能为你提供清晰的脉络。

相关推荐
武超杰2 小时前
Spring Boot入门教程
java·spring boot·后端
是小蟹呀^2 小时前
Java抽象类详解:从入门到精通
java·抽象类
xcLeigh2 小时前
Python入门:Python3基础练习题详解,从入门到熟练的 25 个实例(六)
开发语言·python·教程·python3·练习题
IT 行者2 小时前
Spring Boot 集成 JavaMail 163邮箱配置详解
java·spring boot·后端
lzhdim2 小时前
SQL 入门 7:SQL 聚合与分组:函数、GROUP BY 与 ROLLUP
java·服务器·数据库·sql·mysql
弹简特2 小时前
【JavaEE】Mybatis实现分页查询功能
java·java-ee·mybatis
烤麻辣烫2 小时前
I/O流 基础流
java·开发语言·学习·intellij-idea
Jasonakeke2 小时前
我的编程来时路
java·c++·python
我命由我123452 小时前
React - BrowserRouter 与 HashRouter、push 模式与 replace 模式、编程式导航、withRouter
开发语言·前端·javascript·react.js·前端框架·html·ecmascript