HashMap 线程不安全怎么办?从 Hashtable 到 ConcurrentHashMap 的完美解答

38 阅读9分钟

“在 Java 集合框架体系中,容器主要根据并发特性划分为线程安全非线程安全两大阵营。纵观 JDK 的迭代历程,容器的演进本质上是设计者在**并发安全(Safety)执行性能(Performance)**之间不断权衡与博弈的过程。这一演进脉络,不仅记录了技术的更迭,更为我们深入理解并发编程的设计哲学提供了宝贵的范本。”

那么什么是“线程安全”? 我们来看一下线程安全的定义: Brian Goetz 给出的定义是:

“当多个线程访问一个类时,如果不用考虑这些线程在运行时环境下的调度和交替执行,并且不需要额外的同步,这个类都能表现出正确的行为,那么这个类就是线程安全的。”

为了更好理解,可以将这个定义拆解为三个关键词:

  • 多线程访问:这是前提条件。如果是单线程,谈论线程安全没有意义。

  • 无须额外同步:使用者(调用方)不需要自己加锁(如 synchronizedLock),这个类内部已经处理好了。

  • 正确的行为:无论线程怎么抢占 CPU,结果永远符合预期。比如计数器不会少算,转账金额不会凭空消失。

其实其实所谓的安全 无非就是解决了这三个问题

  • 原子性 (Atomicity) :操作要么全部执行,要么全不执行,不能被中断。(例如:i++ 不是原子的)。

  • 可见性 (Visibility) :一个线程修改了数据,其他线程能立刻看到。(例如:volatile 关键字)。

  • 有序性 (Ordering) :程序代码按照预期的顺序执行,禁止指令重排。 这三个问题也是并发编程的三大核心 从内在的JMM 到表现在程序里面的volatile synchronized RLock 再到无锁操作CAS本质上都是为了在保证一定性能的同时去解决 原子性 可见性 有序性。

从最早的“全表锁”到现在的“无锁化(CAS)”和“分段思想”,JDK 的每一次迭代都在试图解决一个核心矛盾:如何在保证数据不乱的前提下,让多线程跑得更快?

我将 Java 容器的发展分为三个时代,为你梳理这条演进之路:

第一时代:上古时代的“绝对安全” (JDK 1.0)

此时的JDK处于刚刚发展阶段 为了保证安全宁愿这个实现的方式简单粗暴

观看HashTable的源码 发现它涉及到修改操作的方法都是被synchronized 关键字包围的 比如说

image.png

这种操作无疑可以保证多线程下的 可见性 原子性 有序性 因为加锁本质上就是只允许同一时间 只能有一个线程去进行操作 但是这种设计真的够优雅吗 答案是否定的 本质上加锁的操作极大的影响了性能 我们可能很多时候只有一个线程去执行 所以我们需要一种更快速的容器

第二时代:性能觉醒的“裸奔时代” (JDK 1.2)

在经历了 JDK 1.0 中 VectorHashtable 的“笨重”之后,JDK 1.2 迎来了一次彻底的思想解放。设计者意识到:在绝大多数实际应用场景中,容器仅仅是在单线程环境下作为局部变量使用的。

为了那 1% 的并发场景,让 99% 的单线程场景都背负沉重的锁竞争开销,这是极不划算的。于是, “性能至上” 成为了这一时期的绝对信条。

标题所说的HashMap正是这个阶段诞生的 观看源码可以看出 此时的容器发展完全牺牲了并发安全这个要素 它的所有操作都没有锁 这也导致了性能的极致 但也为并发安全性埋下了隐患

  • JDK 1.7:多线程扩容会导致链表成环,CPU 飙升 100%。
  • JDK 1.8:多线程 Put 会导致数据直接覆盖(丢失)。
  • 但这不重要:在设计者的眼里,这些问题是使用者应该去规避的,容器本身的职责就是

可是我们真的没有办法在并发安全和性能做一个平衡 真的做不到既要又要吗 答案是否定的

第三时代:并发大师的“黄金时代” (JDK 1.5 - J.U.C)

在这个充满设计者智慧的版本中 我们可以看到许多设计精妙的容器 就比如我们今天的主角ConcurrentHashMap

我们将 CHM 的演进分为两个时代:JDK 1.7 的“分段锁时代”JDK 1.8 的“CAS + 红黑树时代”

第一部分:JDK 1.7 —— 分段锁 (Segment Locking) 在 JDK 1.7 中,设计者 Doug Lea 的核心思想是: “大化小” 。既然锁整个大数组太慢,那我就把它切成几块(Segments),每一块自己管自己的锁。

1. 核心结构

  • Segment 数组 + HashEntry 数组
  • 一个 ConcurrentHashMap 内部维护了一个 Segment 数组。
  • 关键点Segment 这个类直接继承了 ReentrantLock。这意味着每个 Segment 本身就是一把锁。
  • 默认 concurrencyLevel = 16,也就是说整个 Map 被分成了 16 个小隔间。理论上支持 16 个线程同时并发写入(只要它们 hash 到不同的 Segment)。

2. Put 操作流程

  1. Hash 计算:先通过 Hash 算法定位到具体的 Segment

  2. 获取锁:当前线程尝试获取这个 Segment 的锁 (tryLock)。

    • 如果获取失败,会使用自旋机制(SCANNING),重试一定次数后如果还拿不到,才进入阻塞状态(挂起)。
  3. 写入:拿到锁后,就像操作一个普通的 HashMap 一样,操作内部的 HashEntry 数组(链表插入)。

  4. 释放锁

3. Get 操作 (高效的秘诀)

  • 不加锁

  • 原理HashEntry 中的 value 属性是被 volatile 修饰的。

    Java

    static final class HashEntry<K,V> {
        final int hash;
        final K key;
        volatile V value; // 重点:保证可见性
        volatile HashEntry<K,V> next;
    }
    
  • 由于 volatile 的可见性,写线程修改完,读线程能立刻看到,所以读操作完全无锁,性能极高。

4. Size 操作 (跨段统计的难题)

怎么统计总数?数据分散在 16 个桶里,且随时有人在写。

  • 机制:先尝试 “不加锁” 统计几次(最多 3 次)。
  • 如果在统计过程中,发现 modCount (修改次数) 没变,说明没人捣乱,直接返回。
  • 如果发现 modCount 变了,说明有人在写,只好强制锁住所有 Segment (全局锁),统计完再解锁。这是一次昂贵的操作。

但是这种方法在随着硬件性能提升和 JVM 对 synchronized 的大幅优化,JDK 1.7 的 Segment 显得臃肿了(为了维护 Segment,内存浪费严重)所以就有了 JDK 1.8的优化 也就是现在的 CHM

第二部分:JDK 1.8 —— CAS + Synchronized + 红黑树

1.8 的核心思想是: 原子化数据结构降维打击 。它抛弃了 Segment,直接回到了类似 HashMap 的大数组结构,但在细节上做到了极致。

这一设计转变基于一个关键的工程洞察:在大多数实际应用场景中,Hash 冲突的发生概率极低。基于此,JDK 1.8 采取了更为激进且高效的加锁策略——直接锁定 Hash 桶的头节点 (Head Node) 。这一优化将锁的粒度从原本的段级别(1/16)急剧细化至桶级别(1/N),在保证安全的前提下,最大限度地释放了并发性能。”)

2. Put 操作流程

这是面试的重灾区,也是我个人认为CCH设计最为精妙的一点:

  1. 判断初始化:如果 table 为空,调用 initTable() 初始化(利用 CAS 保证只有一个线程初始化成功)。

  2. 计算 Hash:定位到数组下标 i

  3. Case 1:桶是空的 (No Collision)

    • CAS 无锁插入:直接利用 CAS 指令尝试把新节点放入该位置。
    • 如果成功,完事。如果失败(说明瞬间有人抢了),进入下一轮循环。
  4. Case 2:发现正在扩容 (MOVED)

    • 如果发现该位置的节点的 hash 值是 MOVED (-1),说明当前 Map 正在扩容。
    • 帮忙扩容 (Help Transfer) :当前线程不干活了,加入扩容大军,帮忙把数据搬到新数组去(众筹扩容,非常有意思的设计)。
  5. Case 3:发生哈希冲突 (Collision)

    • Synchronized 锁头节点:只锁住当前这个桶的第一个节点。
    • 进入锁块后,遍历链表或红黑树进行插入。
  6. 转树:如果链表长度 > 8 且数组长度 >= 64,将链表转为红黑树。

3. 为什么 1.8 抛弃 ReentrantLock 改用 Synchronized?

  1. 节省内存ReentrantLock 需要继承 AQS,每个节点都继承这玩意儿,内存消耗太大。而 synchronized 是 JVM 原生的,锁信息存在对象头(Mark Word)里,几乎零额外开销。
  2. JVM 优化:JDK 6 以后,JVM 团队对 synchronized 做了惊人的优化(偏向锁、轻量级锁、锁消除、锁粗化)。在低竞争下,它的性能已经优于 ReentrantLock。

4. Size 操作 (LongAdder 思想)

1.8 甚至优化了计数器。它不再纠结于统计所有桶,而是引入了类似 LongAdder 的机制:

  • baseCount:基础计数器。
  • CounterCell 数组:如果并发太高,CAS 修改 baseCount 失败,就随机找一个 CounterCell 去加。
  • 最终结果 = baseCount + sum(CounterCell[])
  • 这使得 size() 在高并发下性能飞升。

JDK 1.7 的 ConcurrentHashMap 像是包工头制度(Segment),每个包工头管一片地,虽然比全厂只有一个管理员(Hashtable)强,但管理成本还是高。 而 JDK 1.8 则是自动化 + 扁平化管理。平时大家用 CAS(无锁)快速通过,真遇到冲突了,只锁当前那一个工位(Synchronized),而且引入了红黑树解决长队问题,引入了 LongAdder 解决统计问题,是目前的性能天花板。

正因其卓越的并发性能,它成为了众多主流框架底层架构的基石。 例如,我们大家都再在用的Spring 框架的核心组件——Bean 容器,其底层正是利用 ConcurrentHashMap 来管理单例对象(Singleton Objects),从而在极高的并发访问下保障了容器的线程安全。

结尾

总而言之,Java 容器的演进历程,本质上就是一部锁粒度不断细化的变革史。

这一过程始终贯穿着一条主线:在‘并发安全’的底线之上,向‘单线程效率’发起无限逼近的挑战。 这不仅是技术指标的提升,更凝聚了开发者在取舍之间的工程智慧。

深入理解这段历史,不仅能让我们掌握底层原理,更能潜移默化地提升我们的并发设计意识