Java并发容器:ConcurrentHashMap实现原理深度剖析

Java并发容器:ConcurrentHashMap实现原理深度剖析

引言

在Java并发编程的世界里,HashMap是绝大多数开发者的首选数据结构,但它却是非线程安全的。在多线程环境下,HashMap的扩容操作可能会导致死循环(JDK 1.7)或数据丢失(JDK 1.8),这对生产环境来说是不可接受的。

为了解决线程安全问题,早期的开发者可能会使用Hashtable或者Collections.synchronizedMap。然而,这两者都是通过简单的synchronized修饰方法来实现同步,相当于一把全局大锁,导致并发性能极其低下,在高并发场景下会成为系统的瓶颈。

为了在保证线程安全的同时提供极高的并发性能,Doug Lea大师为我们带来了ConcurrentHashMap。本文将深入剖析ConcurrentHashMap在JDK 1.7和JDK 1.8中的实现差异,重点讲解其核心原理、CAS机制以及在实际项目中的应用。

核心概念

在深入源码之前,我们需要掌握几个核心概念,这是理解ConcurrentHashMap设计的基石。

1. 分段锁

在JDK 1.7中,ConcurrentHashMap采用了分段锁的设计。它将数据分成一段一段地存储,然后给每一段数据配一把锁。当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问,从而实现了真正的并发访问。

2. CAS (Compare And Swap)

JDK 1.8中,ConcurrentHashMap大量使用了CAS操作。CAS是一种乐观锁机制,它包含三个操作数:内存值(V)、预期原值(A)和新值(B)。当且仅当V的值与A相等时,才将V的值更新为B,否则不做任何操作。这种机制避免了重量级锁带来的性能开销。

3. volatile 可见性

无论是JDK 1.7还是1.8,ConcurrentHashMap中的节点(Node/HashEntry)的valuenext属性都被volatile修饰。这保证了多线程环境下,一个线程对数据的修改对其他线程是立即可见的。

技术原理深度剖析

ConcurrentHashMap的实现发生了革命性的变化,我们需要分别进行剖析。

JDK 1.7:分段锁时代的经典设计

在JDK 1.7中,ConcurrentHashMap的核心数据结构是Segment数组。

内部结构:
- ConcurrentHashMap内部维护了一个Segment数组。
- 每个Segment本质上是一个小的Hashtable(内部维护了一个HashEntry数组)。
- Segment继承自ReentrantLock,所以它本身就是一把锁。

并发处理机制:
当执行put操作时,线程会根据Hash算法定位到具体的Segment,然后对该Segment加锁。只有拿到锁的线程才能操作该Segment下的数据。

缺点:
虽然比分段锁之前的Hashtable性能好很多,但Segment数组的大小默认为16,这就意味着理论上最大并发度只有16。如果某个Segment非常拥挤,仍然会存在竞争。并且,这种结构在内存占用上相对较大。

JDK 1.8:CAS + synchronized 的极致优化

JDK 1.8摒弃了Segment的概念,转而采用了与HashMap 1.8类似的数组 + 链表 + 红黑树结构。这一改变极大地提升了并发性能。

内部结构:
- Node数组:类似于HashMap的桶数组。
- 链表与红黑树:当链表长度超过8且数组长度超过64时,链表会转化为红黑树,查询效率从O(n)提升到O(log n)。

核心并发控制:

JDK 1.8摒弃了分段锁,采用了CAS + synchronized来实现更细粒度的锁控制。

1. put操作的核心流程

  1. 计算Hash:计算key的hash值,并定位到数组下标。
  2. 初始化:如果数组为空,初始化数组。
  3. CAS插入
    • 如果定位到的位置为空(没有Hash冲突),直接使用CAS操作将新节点插入。
    • 由于没有显式加锁,这是性能最优的路径。
  4. 协助扩容:如果检测到当前位置的节点是MOVED状态(值为-1),说明数组正在扩容,当前线程会协助扩容。
  5. synchronized加锁
    • 如果定位到的位置不为空(发生Hash冲突),则使用synchronized锁住当前桶的头节点。
    • 遍历链表或红黑树,更新Value或插入新节点。

2. 关键源码逻辑解析

让我们看看JDK 1.8中putVal方法的核心逻辑(伪代码形式):

final V putVal(K key, V value, boolean onlyIfAbsent) {
    // 省略空值校验...
    int hash = spread(key.hashCode());
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;

        // 情况1:数组为空,初始化
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();

        // 情况2:目标桶为空,CAS无锁插入
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
                break; 
        }

        // 情况3:正在扩容,协助迁移
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);

        // 情况4:发生Hash冲突,加锁处理
        else {
            V oldVal = null;
            synchronized (f) { // 锁住当前桶的头节点
                // 再次检查头节点是否变化(防止其他线程修改了链表头)
                if (tabAt(tab, i) == f) {
                    // 链表处理逻辑...
                    // 红黑树处理逻辑...
                }
            }
            // 省略计数逻辑...
        }
    }
    return null;
}

为什么JDK 1.8要使用synchronized而不是ReentrantLock?
1. 性能优化:在JDK 1.6之后,JVM对synchronized做了大量优化(偏向锁、轻量级锁、重量级锁的锁升级机制),在低竞争下性能非常好。
2. 内存节省ReentrantLock需要显式创建对象,而synchronized直接锁

posted @ 2026-02-28 19:03  寒人病酒  阅读(11)  评论(0)    收藏  举报