HashMap 到底怎么个不安全,JDK7 和 JDK8 的坑还不一样

「Java 进阶之路」系列 Day23

写在前面

“HashMap 线程不安全"这句话几乎每个人都会背,但追问一句"具体是怎么个不安全”,很多人只能含糊地说"会出问题"。这篇结合 Day22 讲过的底层结构,把 JDK7 和 JDK8 里 HashMap 分别会踩的坑讲清楚,再回过头看看 Day08 讲过的 ConcurrentHashMap 到底是怎么把这些坑一一填平的。


一、是什么:多线程操作 HashMap 会出什么问题

HashMap 从头到尾没有任何同步措施,putget、扩容这些操作在多线程并发调用时,完全依赖"运气"——如果几个线程的操作恰好在时间上错开,可能看起来没问题;一旦真正并发触发,会出现几类具体故障。


二、为什么:数据覆盖是通病,死循环才是 JDK7 独有的坑

先纠正一个容易搞混的点:数据覆盖和死循环是两个完全独立的问题。数据覆盖的根源是最基础的put操作没有同步保护,这个问题JDK7和JDK8都有、从来没被解决过;死循环只发生在"扩容迁移"这一个特定场景,只存在于JDK7,JDK8已经修复。下面分开讲。

数据覆盖:JDK7、JDK8 都有,put 操作本身就没上锁

线程一和线程二同时put不同的key

两者算出的hash定位到同一个空桶

线程一判断桶为空 准备新建节点

线程二判断桶为空 准备新建节点

线程一把新节点写入这个桶

线程二把新节点写入这个桶 覆盖了线程一刚写入的节点

线程一的数据凡是没被读取就已经丢失

put 方法里"判断桶是否为空"和"把新节点写进桶里"这两步不是原子操作,这一点JDK7和JDK8的实现逻辑是一样的:两个线程同时给不同的key做put,恰好算出同一个空桶下标,都判断出"桶是空的",都各自准备好新节点往里写——后写入的那个直接把先写入的覆盖掉,先写入的那次put操作就这样悄无声息地丢失了,程序不会有任何异常提示。这个问题从JDK7到JDK8从来没解决过,只有ConcurrentHashMap才真正修复了它。

另一个更容易被忽视的问题是元素计数不准HashMap内部维护一个size字段,每次成功插入做size++。这和 Day07 讲的 i++ 不是原子操作是同一个道理——多个线程同时执行size++,可能都读到了同一个旧值再各自加一写回,最终size比实际插入的元素数量要小,这个问题同样在两个版本里都存在。

JDK7 独有的坑:扩容时可能死循环,把 CPU 打满

JDK7 的 HashMap 扩容迁移用的是头插法,每次把节点插到新桶的头部,这会导致链表顺序被整体反转。假设旧桶下挂着 A → B → C 这条链表,用一个具体的线程交错时间线走一遍,看死循环是怎么形成的:

步骤线程一线程二新桶此刻的链表状态
1处理到e=A,刚读完next = A.next(也就是B),还没来得及修改A.next就被挂起了还没开始迁移
2(挂起中,一直没恢复)完整跑完整个迁移:依次把A、B、C头插进新桶,顺序被反转成C → B → A → nullC → B → A → null(线程二自己的视角下已迁移完毕,没有任何问题)
3恢复执行,用的是第1步读到的旧数据e=A、next=B:执行A.next = 新桶当前的头,此刻新桶头是C,于是A.next = C;再把新桶头设成AA → C → B → A(环已经出现:A指向C,C指向B、B指向A都是线程二留下的,正好绕回A)
4e变成next(也就是B),继续下一轮:B.next = 新桶当前的头(此刻是A)→ 其实没变化;新桶头变成BB → A → C → B(环还在)
5e变成next(B.next,此刻是A),继续:A.next = 新桶当前的头(此刻是B)→ A.next变成B环持续存在,e会在A、B、C之间无限打转,永远走不到null这个终止条件

能形成环的根本原因:线程一"读到next指针"和"真正修改指针"这两步之间被挂起了,挂起期间线程二已经用反转后的顺序把这几个节点重新链好;线程一恢复后,拿着挂起前读到的"过时旧指针"继续往这个已经反转的结构上操作,两边的指针方向对不上,就在A、B、C之间兜出了一个圈。之后不管是迁移代码自己的do...while(e != null)永远等不到null,还是之后有线程调用get遍历到这个环形桶,都会陷入死循环,对应的CPU核心直接被打满,而且很难自愈——这是JDK7时代真实出现过的线上故障来源。这个bug要触发有个前提:必须两个线程差不多同时触发扩容,且线程一恰好卡在这个时间点上被挂起,不是每次并发扩容都会发生,但一旦发生就是实打实的死循环。

JDK8:死循环解决了,扩容依然不安全

JDK8 把迁移逻辑改成了尾插法,不再反转链表顺序,上面这种"反转后指针对不上导致成环"的死循环在JDK8里已经不会出现了——但这不代表JDK8扩容就安全了:一个线程正在做数据迁移时,另一个线程可能同时在操作新旧数组的中间状态,导致部分节点在迁移过程中被跳过或者覆盖,最终统计出来的元素比实际插入的少。这依然是数据丢失,只是表现形式从"死循环卡死"变成了"悄悄少了几条数据",反而更难被发现和排查。


三、怎么用:ConcurrentHashMap 是怎么把这些坑填平的

Day08 讲过,JDK8的ConcurrentHashMap靠"桶为空时CAS无锁插入,桶不为空时synchronized锁住桶头节点"的组合,把put操作变成了真正的原子操作——两个线程同时往同一个空桶写数据,只有一个CAS会成功,另一个会失败并重试,不会出现"两边都判断为空、都直接写入"这种覆盖场景。元素计数也换成了baseCountCounterCell数组的分桶计数方式(类似Day07讲的LongAdder思路),避免了size++这种朴素累加在高并发下的竞态问题。

一个常见误区:用 synchronizedMap 包一层,就等于 ConcurrentHashMap 了吗

Map<String, Object> map = Collections.synchronizedMap(new HashMap<>());

这样包一层确实能解决线程安全问题——但代价是给整个map加了一把粗粒度的锁,任何一次get/put都要抢这唯一的一把锁,并发度约等于单线程串行,效果上更接近Day08讲过的Hashtable;而ConcurrentHashMap的锁粒度精确到单个桶,绝大多数操作走的是无锁或者只锁一个桶的路径,并发度天差地别。"线程安全"和"高效的线程安全"不是一回事,实际项目里需要真正的高并发场景,直接选ConcurrentHashMap,不要指望用synchronizedMap包一层就能达到同等效果。


四、面试追问

Q1:HashMap 线程不安全,具体体现在哪些地方?

主要有三类问题:一是put操作"判断桶是否为空"和"写入新节点"不是原子的,多线程并发写同一个空桶时会出现数据覆盖、丢失更新,这个问题JDK7和JDK8都存在;二是内部的size字段用简单的自增维护,多线程并发下和i++一样存在竞态条件,导致计数不准确,同样两个版本都有;三是JDK7扩容时可能因为链表反转形成环形链表,导致遍历时死循环、CPU被打满,这个问题是JDK7独有的,JDK8改成尾插法后已经修复。

Q2:JDK7 和 JDK8 的 HashMap 线程不安全问题是同一回事吗?

不完全是。数据覆盖和size计数不准这两类问题,本质是put操作本身没有任何同步保护导致的,JDK7、JDK8都一样存在,JDK8并没有修复它们。真正只存在于JDK7、并被JDK8修复的,是扩容迁移时因为头插法反转链表顺序而可能形成环形链表导致的死循环——JDK8改成尾插法后,这个特定问题不再出现,但JDK8的并发扩容依然可能因为多线程同时操作中间状态而丢失部分数据,只是表现形式从"死循环卡死"变成了更隐蔽的"数据悄悄少了"。

Q3:ConcurrentHashMap 是怎么解决 put 操作的原子性问题的?

JDK8的ConcurrentHashMap在目标桶为空时,用CAS操作无锁地尝试插入,多个线程竞争同一个空桶时只有一个能成功,失败的会重试;桶里已经有节点时,用synchronized锁住这个桶的头节点再进行链表或红黑树操作。这样"判断桶状态"和"写入"整体上被保护成了原子操作,不会出现HashMap那种覆盖丢失的问题。

Q4:Collections.synchronizedMap 包装的 HashMap,和 ConcurrentHashMap 是一回事吗?

不是。synchronizedMap是给整个map加一把粗粒度的全局锁,任何操作都要抢这一把锁,并发度接近单线程串行;ConcurrentHashMap的锁粒度精确到单个桶,大部分操作是无锁或者只锁一个桶,并发度远高于前者。两者都能解决"线程安全"这个问题,但并发性能差距很大,高并发场景应该直接用ConcurrentHashMap

Q5:多线程环境下调用 HashMap.size(),返回的结果一定准确吗?

不一定。size字段是通过简单的自增维护的,多个线程并发执行插入操作时,size++这个操作本身不是原子的,可能出现多个线程读到同一个旧值、各自加一再写回的情况,导致最终size比实际插入的元素数量要小,这是i++类操作在并发场景下典型的竞态问题。


下一篇预告

Day24 讲 HashSetTreeSetLinkedHashSet 这三个 Set 实现该怎么选——它们和今天讲的 HashMap 底层其实关系密切,HashSet 内部就是靠一个 HashMap 实现的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值