HashMap的死循环会让CPU飙升至100%?

知了一笑 2019-03-08 17:09:57 ⋅ 683 阅读

问题

由于HashMap并非是线程安全的,所以在高并发的情况下必然会出现问题,这是一个普遍的问题,虽然网上分析的文章很多,还是觉得有必须写一篇文章,让同学们能够意识到这个问题,并了解这个死循环是如何产生的。

如果是在单线程下使用HashMap,自然是没有问题的,如果后期由于代码优化,这段逻辑引入了多线程并发执行,在一个未知的时间点,会发现CPU占用100%,居高不下,通过查看堆栈,你会惊讶的发现,线程都Hang在hashMap的get()方法上,服务重启之后,问题消失,过段时间可能又复现了。

这是为什么?

原因分析

在了解来龙去脉之前,我们先看看HashMap的数据结构。

在内部,HashMap使用一个Entry数组保存key、value数据,当一对key、value被加入时,会通过一个hash算法得到数组的下标index,算法很简单,根据key的hash值,对数组的大小取模 hash & (length-1),并把结果插入数组该位置,如果该位置上已经有元素了,就说明存在hash冲突,这样会在index位置生成链表。

如果存在hash冲突,最惨的情况,就是所有元素都定位到同一个位置,形成一个长长的链表,这样get一个值时,最坏情况需要遍历所有节点,性能变成了O(n),所以元素的hash值算法和HashMap的初始化大小很重要。

当插入一个新的节点时,如果不存在相同的key,则会判断当前内部元素是否已经达到阈值(默认是数组大小的0.75),如果已经达到阈值,会对数组进行扩容,也会对链表中的元素进行rehash。

实现

HashMap的put方法实现:

1、判断key是否已经存在

public V put(K key, V value) { if (key == null) return putForNullKey(value); int hash = hash(key); int i = indexFor(hash, table.length); // 如果key已经存在,则替换value,并返回旧值 for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; // key不存在,则插入新的元素 addEntry(hash, key, value, i); return null;}

2、检查容量是否达到阈值threshold

void addEntry(int hash, K key, V value, int bucketIndex) { if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length); hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); } createEntry(hash, key, value, bucketIndex);}

如果元素个数已经达到阈值,则扩容,并把原来的元素移动过去。

3、扩容实现

void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; ... Entry[] newTable = new Entry[newCapacity]; ... transfer(newTable, rehash); table = newTable; threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);}

这里会新建一个更大的数组,并通过transfer方法,移动元素。

void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { while(null != e) { Entry<K,V> next = e.next; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } }}

移动的逻辑也很清晰,遍历原来table中每个位置的链表,并对每个元素进行重新hash,在新的newTable找到归宿,并插入。

案例分析

假设HashMap初始化大小为4,插入个3节点,不巧的是,这3个节点都hash到同一个位置,如果按照默认的负载因子的话,插入第3个节点就会扩容,为了验证效果,假设负载因子是1.

void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { while(null != e) { Entry<K,V> next = e.next; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } }}

以上是节点移动的相关逻辑。


插入第4个节点时,发生rehash,假设现在有两个线程同时进行,线程1和线程2,两个线程都会新建新的数组。


假设 线程2 在执行到Entry<K,V> next = e.next;之后,cpu时间片用完了,这时变量e指向节点a,变量next指向节点b。

线程1继续执行,很不巧,a、b、c节点rehash之后又是在同一个位置7,开始移动节点

第一步,移动节点a


第二步,移动节点b


注意,这里的顺序是反过来的,继续移动节点c


这个时候 线程1 的时间片用完,内部的table还没有设置成新的newTable, 线程2 开始执行,这时内部的引用关系如下:


这时,在 线程2 中,变量e指向节点a,变量next指向节点b,开始执行循环体的剩余逻辑。

Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;

执行之后的引用关系如下图


执行后,变量e指向节点b,因为e不是null,则继续执行循环体,执行后的引用关系


变量e又重新指回节点a,只能继续执行循环体,这里仔细分析下:

1、执行完Entry<K,V> next = e.next;,目前节点a没有next,所以变量next指向null;

2、e.next = newTable[i]; 其中 newTable[i] 指向节点b,那就是把a的next指向了节点b,这样a和b就相互引用了,形成了一个环;

3、newTable[i] = e 把节点a放到了数组i位置;

4、e = next; 把变量e赋值为null,因为第一步中变量next就是指向null;

所以最终的引用关系是这样的:


节点a和b互相引用,形成了一个环,当在数组该位置get寻找对应的key时,就发生了死循环。

另外,如果线程2把newTable设置成到内部的table,节点c的数据就丢了,看来还有数据遗失的问题。

总结

所以在并发的情况,发生扩容时,可能会产生循环链表,在执行get的时候,会触发死循环,引起CPU的100%问题,所以一定要避免在并发环境下使用HashMap。

曾经有人把这个问题报给了Sun,不过Sun不认为这是一个bug,因为在HashMap本来就不支持多线程使用,要并发就用ConcurrentHashmap。

原文作者:程序猿的内心独白

原文链接:https://www.toutiao.com/i6665069096909931019


---------------END----------------

后续的内容同样精彩

长按关注“IT实战联盟”哦




全部评论: 0

    我有话说:

    面试官:HashMap为什么是线程不安全

    一直以来只是知道HashMap是线程不安全,但是到底HashMap为什么线程不安全?

    线性表 - 循环链表

    1.引子 单链表解决了从A 查找到E过程,假设现在要求从E 查找到A,用时最短, 因为单链表是单向,只能从前往后,无法解决这个问题。因此引出了循环链表。   思路图

    2021 年最火开发语言是谁?

    哪种语言比较火🔥? 开发语言走势一直都在牵动程序员心。 2020 年已经过半,是时候分析下明年趋势了。 下面咱们就看下权威行业数据,看看 2021 年可能排在前 3 开发语言都有谁

    阿里技术:聊一聊从单机亿级流量大型网站系统架构演进过程

    网站初期,我们经常在单机上跑我们所有程序和软件。此时我们使用一个容器,如tomcat、jetty、jboos,然后直接使用JSP/servlet技术......

    挖一挖那些公司网站瘫痪SQL“终结者”

    一条慢查询造成什么后果?之前我一直觉得不就是返回数据慢一些么,用户体验变差? 其实远远不止,我经历过几次线上事故,有一次就是由一条 SQL 慢查询导致。 那次是一条 SQL 查询耗时达到 2

    DBeaver 切换日历版本控制,版本号更新 21.0

    近日 DBeaver 将版本号切换日历版本控制。切换之后,DBeaver 版本号将以发布年份为基础,其首个版本为 DBeaver 21.0(切换后 2021 年第一版),该版本是 7.3.5

    Linux 5.10.9 发布,小幅更新持续优化

    : 修复了 CPU 占用率过高问题 修复了 NUL...

    用了近10 CentOS 6 生命周期结束

    CentOS 开发者邮件列表显示,CentOS 6 已于11月30日 EOL。   邮件还提到,CentOS 6.10 目录将在12月第一周被迁移 vault.centos.org

    刚刚入职新公司,为什么老大不用Lombok?

    刚刚入职一家新公司,在写业务时候引入了Lombok 结果在老大在代码审查时候在微信群里截图说不在项目里面用Lombok,说是带来各种问题,大家都还在用吗?

    消息队列常见问题(一):生产上消息队列产生大量消息堆积有什么后果?

    大多数消息堆积原因是Consumer出现了问题,并且没有被运维/开发监控到即使修复问题,导致大量消息都积压在 MQ 中,那么造成哪些后果呢?1、消息被丢弃例如 RabbitMQ 中一条消息设置

    Java Web实战篇:增强for循环实现原理及for循环实战性能优化

    Iterator是工作在一个独立线程中,并且拥有一个 mutex 锁。 Iterator被创建之后建立一个指向原来对象单链索引表......

    架构实战篇(二)-Spring Boot整合Swagger,API可视化

    你还在跟前端对接上花费很多时间而没有效果吗?你还在为写接口文档而烦恼吗?今天就教大家一个接口对接神器...

    京东技术:多级缓存设计详解 | 给数据库减负

    传统cpu通过fsb直连内存方式显然就因为内存访问等待,导致cpu吞吐量下降,内存成为性能瓶颈。

    精品推荐:大神总结十大 JavaScript 错误及如何规避

    通过统计数据库中1000多个项目,我们发现在 JavaScript 中最常出现错误有10个。下面向大家介绍这些错误发生原因以及如何防止。

    单元测试增强工具TestableMock Mock返璞归真

    简介 阿里巴巴研发效能团队开源Java单元测试增强工具,换种思路写Mock,单元测试更简单。 无需初始化,不挑测试框架,甭管要换方法是被测类私有方法、静态方法还是其他任何类成员方法,也甭管

    NodeJS 10.5.0 中线程:实用介绍

    几天前,Node.js版本10.5.0发布,其中包含主要功能之一是添加了线程支持。

    手把手教你用Spring Cloud + Redis 是实现点赞功能,包教包

    原文:https://www.toutiao.com/i6791466166662464007 经常刷微博或者刷某条人都看见在页面各个地方都有点赞功能,like this:@  

    Linux 5.10 第二批更新,ARM64 上功能优化

    10 月 22 日,Linux 5.10 发送了该内核第二批更新,此次对 ARM64 一些功能做了性能优化。当天拉取请求为 ARM64 上 Linux 5.10 启用了 HAVE_MOVE