ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

HashMap 到底怎么个不安全,JDK7 和 JDK8 的坑还不一样 「Java 进阶之路」系列 Day23写在前面“HashMap 线程不安全这句话几乎每个人都会背但追问一句具体是怎么个不安全”很多人只能含糊地说会出问题。这篇结合 Day22 讲过的底层结构把 JDK7 和 JDK8 里HashMap分别会踩的坑讲清楚再回过头看看 Day08 讲过的ConcurrentHashMap到底是怎么把这些坑一一填平的。一、是什么多线程操作 HashMap 会出什么问题HashMap从头到尾没有任何同步措施put、get、扩容这些操作在多线程并发调用时完全依赖运气——如果几个线程的操作恰好在时间上错开可能看起来没问题一旦真正并发触发会出现几类具体故障。二、为什么数据覆盖是通病死循环才是 JDK7 独有的坑先纠正一个容易搞混的点数据覆盖和死循环是两个完全独立的问题。数据覆盖的根源是最基础的put操作没有同步保护这个问题JDK7和JDK8都有、从来没被解决过死循环只发生在扩容迁移这一个特定场景只存在于JDK7JDK8已经修复。下面分开讲。数据覆盖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处理到eA刚读完next A.next也就是B还没来得及修改A.next就被挂起了—还没开始迁移2挂起中一直没恢复完整跑完整个迁移依次把A、B、C头插进新桶顺序被反转成C → B → A → nullC → B → A → null线程二自己的视角下已迁移完毕没有任何问题3恢复执行用的是第1步读到的旧数据eA、nextB执行A.next 新桶当前的头此刻新桶头是C于是A.next C再把新桶头设成A—A → C → B → A环已经出现A指向CC指向B、B指向A都是线程二留下的正好绕回A4e变成next也就是B继续下一轮B.next 新桶当前的头此刻是A→ 其实没变化新桶头变成B—B → A → C → B环还在5e变成nextB.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会成功另一个会失败并重试不会出现两边都判断为空、都直接写入这种覆盖场景。元素计数也换成了baseCount加CounterCell数组的分桶计数方式类似Day07讲的LongAdder思路避免了size这种朴素累加在高并发下的竞态问题。一个常见误区用 synchronizedMap 包一层就等于 ConcurrentHashMap 了吗MapString,ObjectmapCollections.synchronizedMap(newHashMap());这样包一层确实能解决线程安全问题——但代价是给整个map加了一把粗粒度的锁任何一次get/put都要抢这唯一的一把锁并发度约等于单线程串行效果上更接近Day08讲过的Hashtable而ConcurrentHashMap的锁粒度精确到单个桶绝大多数操作走的是无锁或者只锁一个桶的路径并发度天差地别。线程安全和高效的线程安全不是一回事实际项目里需要真正的高并发场景直接选ConcurrentHashMap不要指望用synchronizedMap包一层就能达到同等效果。四、面试追问Q1HashMap 线程不安全具体体现在哪些地方主要有三类问题一是put操作判断桶是否为空和写入新节点不是原子的多线程并发写同一个空桶时会出现数据覆盖、丢失更新这个问题JDK7和JDK8都存在二是内部的size字段用简单的自增维护多线程并发下和i一样存在竞态条件导致计数不准确同样两个版本都有三是JDK7扩容时可能因为链表反转形成环形链表导致遍历时死循环、CPU被打满这个问题是JDK7独有的JDK8改成尾插法后已经修复。Q2JDK7 和 JDK8 的 HashMap 线程不安全问题是同一回事吗不完全是。数据覆盖和size计数不准这两类问题本质是put操作本身没有任何同步保护导致的JDK7、JDK8都一样存在JDK8并没有修复它们。真正只存在于JDK7、并被JDK8修复的是扩容迁移时因为头插法反转链表顺序而可能形成环形链表导致的死循环——JDK8改成尾插法后这个特定问题不再出现但JDK8的并发扩容依然可能因为多线程同时操作中间状态而丢失部分数据只是表现形式从死循环卡死变成了更隐蔽的数据悄悄少了。Q3ConcurrentHashMap 是怎么解决 put 操作的原子性问题的JDK8的ConcurrentHashMap在目标桶为空时用CAS操作无锁地尝试插入多个线程竞争同一个空桶时只有一个能成功失败的会重试桶里已经有节点时用synchronized锁住这个桶的头节点再进行链表或红黑树操作。这样判断桶状态和写入整体上被保护成了原子操作不会出现HashMap那种覆盖丢失的问题。Q4Collections.synchronizedMap 包装的 HashMap和 ConcurrentHashMap 是一回事吗不是。synchronizedMap是给整个map加一把粗粒度的全局锁任何操作都要抢这一把锁并发度接近单线程串行ConcurrentHashMap的锁粒度精确到单个桶大部分操作是无锁或者只锁一个桶并发度远高于前者。两者都能解决线程安全这个问题但并发性能差距很大高并发场景应该直接用ConcurrentHashMap。Q5多线程环境下调用 HashMap.size()返回的结果一定准确吗不一定。size字段是通过简单的自增维护的多个线程并发执行插入操作时size这个操作本身不是原子的可能出现多个线程读到同一个旧值、各自加一再写回的情况导致最终size比实际插入的元素数量要小这是i类操作在并发场景下典型的竞态问题。下一篇预告Day24 讲HashSet、TreeSet、LinkedHashSet这三个Set实现该怎么选——它们和今天讲的HashMap底层其实关系密切HashSet内部就是靠一个HashMap实现的。
返回列表