🌺The Begin🌺点点关注,收藏不迷路🌺

1. 引言:并发容器的困局与破局

在Java并发编程的浩瀚星河中,ConcurrentHashMap无疑是一颗璀璨的明星。它的出现,完美解决了HashMap线程不安全与Hashtable性能低下这一两难困境。

但是,你是否真正思考过:JDK 1.7和1.8中的ConcurrentHashMap究竟有何本质区别?为什么1.8要抛弃曾经引以为傲的“分段锁”设计?从SegmentCAS+synchronized,这背后隐藏着怎样的设计哲学转变?

本文将带你深入源码,用流程图和对比分析,彻底搞懂这两个版本的核心差异。

2. 一图看懂:整体架构对比

JDK 1.8 架构

ConcurrentHashMap

Node数组
volatile修饰

Node0
链表/红黑树

Node1
链表/红黑树

...

NodeN
链表/红黑树

JDK 1.7 架构

ConcurrentHashMap

Segment数组
继承ReentrantLock

Segment0

Segment1

...

Segment15

HashEntry数组

链表节点

对比维度 JDK 1.7 JDK 1.8
数据结构 Segment数组 + HashEntry数组 + 链表 Node数组 + 链表 + 红黑树
锁机制 ReentrantLock(分段锁) CAS + synchronized(桶锁)
锁粒度 Segment级别(默认16个) 单个桶(bucket)级别
并发度 固定为Segment数组长度 理论上等于数组长度
查询复杂度 O(n) O(log n)(红黑树优化)

3. JDK 1.7:分段锁的辉煌时代

3.1 核心设计思想:“分而治之”

JDK 1.7的ConcurrentHashMap采用了经典的分段锁设计,将整个Map分割成多个独立的Segment,每个Segment都是一个“微型HashMap”,拥有自己的锁。

Segment内部结构

Segment继承ReentrantLock

HashEntry数组

HashEntry0

HashEntry1

...

key,value,next

key,value,next

3.2 源码解析:Segment的定义

static final class Segment<K,V> extends ReentrantLock implements Serializable {
    // 真正存放数据的桶数组
    transient volatile HashEntry<K,V>[] table;
    
    // 元素个数
    transient int count;
    
    // 扩容阈值
    transient int threshold;
    
    // 负载因子
    final float loadFactor;
}

static final class HashEntry<K,V> {
    final int hash;
    final K key;
    volatile V value;      // volatile保证可见性
    volatile HashEntry<K,V> next;  // volatile保证可见性
}

关键点:

  • Segment继承ReentrantLock:每个Segment都是一把独立的锁
  • HashEntry的value和next用volatile修饰:保证读操作无需加锁即可看到最新值

3.3 put操作流程图解

成功

失败

开始put

计算key的hash值

定位到对应Segment

尝试获取Segment锁

定位桶索引

自旋/阻塞获取锁

遍历链表

key已存在?

覆盖value

头插法插入新节点

需要扩容?

rehash扩容

设置节点

释放锁

结束

put操作核心流程

  1. 二次哈希定位Segment
  2. 调用tryLock()尝试获取锁
  3. 失败则scanAndLockForPut()自旋(最多64次),超时则阻塞
  4. 定位桶,遍历链表
  5. 头插法插入新节点
  6. 判断是否需要扩容(Segment独立扩容)
  7. 释放锁

3.4 get操作:无锁读的秘密

public V get(Object key) {
    int hash = hash(key);
    // 定位Segment
    Segment<K,V> s = segmentForHash(hash);
    HashEntry<K,V>[] tab = s.table;
    // 定位桶,遍历链表
    for (HashEntry<K,V> e = tab[index]; e != null; e = e.next) {
        if (e.hash == hash && key.equals(e.key))
            return e.value;  // volatile保证可见性
    }
    return null;
}

为什么get不需要加锁?

  • HashEntry的value和next都是volatile修饰
  • 通过Unsafe的getObjectVolatile保证读取最新值
  • 弱一致性的设计:可能读到旧值,但不会读到无效值

4. JDK 1.8:从分段锁到CAS+synchronized的蜕变

4.1 核心变革:为什么放弃Segment?

Segment设计虽然优秀,但存在三大痛点:

  1. 并发度受限:默认只有16个Segment,无法充分利用多核CPU
  2. 结构臃肿:两层数组导致内存开销大
  3. 扩容效率低:每个Segment独立扩容,无法并行

JDK 1.8彻底重构,采用CAS + synchronized组合拳:

锁策略对比

Segment锁

锁住整个Segment
包含多个桶

桶锁

只锁住单个桶
其他桶完全并发

4.2 新的数据结构:Node + 链表 + 红黑树

// Node节点:取代HashEntry
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;
}

// 红黑树节点(当链表长度≥8时转换)
static final class TreeNode<K,V> extends Node<K,V> {
    TreeNode<K,V> parent;
    TreeNode<K,V> left;
    TreeNode<K,V> right;
}

为什么引入红黑树?

  • 链表过长时查询效率O(n)
  • 红黑树将最坏复杂度降至O(log n)

4.3 put操作:CAS + synchronized协同作战

开始put

计算hash值

table未初始化?

initTable
CAS初始化

定位桶位置

桶为空?

CAS插入新节点

CAS成功?

成功

节点hash==MOVED?

协助扩容
helpTransfer

synchronized锁住头节点

遍历链表/红黑树

key已存在?

覆盖value

尾插法插入新节点

链表长度≥8?

treeifyBin转红黑树

完成

addCount计数

结束

核心代码解析

final V putVal(K key, V value, boolean onlyIfAbsent) {
    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();
        // 情况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:桶不为空,synchronized锁住头节点
        else {
            V oldVal = null;
            synchronized (f) {
                // 再次确认头节点未变化
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {  // 链表
                        // 遍历链表,尾插法
                    } else if (f instanceof TreeBin) {  // 红黑树
                        // 树节点插入
                    }
                }
            }
            // 检查是否需要转红黑树
            if (binCount >= TREEIFY_THRESHOLD)
                treeifyBin(tab, i);
            break;
        }
    }
    addCount(1L, binCount);
    return null;
}

4.4 为什么选择synchronized而不是ReentrantLock?

对比项 ReentrantLock synchronized (1.6+)
性能 较高 已优化,接近ReentrantLock
锁升级 偏向锁→轻量级锁→重量级锁
自旋 需手动实现 JVM自动优化
代码简洁 较复杂 简洁
资源开销 较高(AQS Node) 较低

结论:锁细化到桶级别后,竞争概率极低,synchronized的轻量级锁和自旋优化足以应对,且避免了ReentrantLock的AQS节点开销。

5. 扩容机制的对决

5.1 JDK 1.7:单线程分段扩容

扩容后

扩容前

Segment0

旧table0

Segment1

旧table1

新table0
2倍大小

新table1
2倍大小

特点

  • 每个Segment独立扩容,不影响其他Segment
  • 单线程执行,无法并行
  • 扩容时该Segment的读写被阻塞

5.2 JDK 1.8:多线程协作扩容

这是1.8最精妙的设计:让读写线程都参与扩容

触发扩容

创建新table
2倍大小

设置transferIndex=新数组长度

还有桶未迁移?

线程领取任务
CAS修改transferIndex

迁移当前桶的节点

放置ForwardingNode
hash=MOVED=-1

扩容完成

其他线程put/get

发现ForwardingNode?

helpTransfer
协助迁移

ForwardingNode的关键作用

static final class ForwardingNode<K,V> extends Node<K,V> {
    final Node<K,V>[] nextTable;
    ForwardingNode(Node<K,V>[] tab) {
        super(MOVED, null, null, null);  // hash = -1
        this.nextTable = tab;
    }
}

当线程访问到ForwardingNode时,就知道该桶已迁移,会主动帮助完成剩余迁移任务。

6. 核心差异对比总结

对比维度 JDK 1.7 ConcurrentHashMap JDK 1.8 ConcurrentHashMap
数据结构 Segment + HashEntry + 链表 Node + 链表 + 红黑树
锁机制 ReentrantLock(分段锁) CAS + synchronized(桶锁)
锁粒度 整个Segment(含多个桶) 单个桶(一个链表/树的头节点)
并发度 固定为Segment数(默认16) 理论等于数组长度,动态
插入方式 头插法 尾插法
扩容方式 单线程,Segment独立扩容 多线程协作,全局扩容
扩容时机 先判断扩容,再插入 先插入,再判断扩容
查询复杂度 O(n) O(log n)(红黑树优化)
size()实现 加锁累加Segment.count baseCount + CounterCells
死锁风险 无(尾插法避免扩容死循环)
性能特点 并发度有上限 近乎无锁,性能随CPU核心数扩展

7. 日常用法对比

// 1.7和1.8的API完全一致,底层实现不同
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();

// 基本操作
map.put("key", "value");
String value = map.get("key");

// 原子操作(两个版本都支持)
map.putIfAbsent("key", "default");
map.computeIfAbsent("key", k -> fetchFromDB(k));

// 遍历(弱一致性)
map.forEach((k, v) -> System.out.println(k + "=" + v));

8. 面试高频追问

8.1 ConcurrentHashMap能保证强一致性吗?

不能。 它是最终一致性。get操作不加锁,可能读到其他线程正在修改但尚未提交的数据。

8.2 为什么不允许null键和null值?

因为HashMap中containsKey(key)返回false时,无法区分是key不存在还是value为null。在多线程环境下,这种二义性会带来严重问题。

8.3 红黑树的转换阈值为什么是8?

统计学上,哈希分布符合泊松分布,链表长度达到8的概率极小(约0.00000006)。此时转换红黑树是性价比最高的选择。

9. 总结:演进的智慧

从JDK 1.7到1.8,ConcurrentHashMap的演进体现了Java并发编程的两大趋势:

  1. 锁粒度越来越细:从锁Segment到锁单个桶
  2. 无锁化倾向增强:CAS操作的大量使用

选择建议

  • 生产环境优先使用JDK 1.8及以上版本
  • 如果需要高并发读写,1.8的多线程协作扩容优势明显
  • 代码无需改动,JVM自动优化

📌 本文基于JDK 1.7.0_80和JDK 1.8.0_202源码分析,不同小版本可能存在细微差异。

如果觉得本文对你有帮助,欢迎点赞、收藏、转发~

在这里插入图片描述


🌺The End🌺点点关注,收藏不迷路🌺
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐