【Java面试】HashMap死循环问题

简介: 【Java面试】HashMap死循环问题

问题

最近几道面试题被问了是否了解并发情况下JDK7中HashMap发生死循环,导致CPU占用100%的问题。

由于HashMap并非是线程安全的,所以在高并发的情况下必然会出现问题,这是一个普遍的问题。

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

这是为什么?

原因分析

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

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

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

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

JDK7中HashMap代码

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;
    }
  }
}

第一个for循环是遍历原来的table,第二个while循环用于遍历table中每个位置的链表。也就是如果这次的Entry节点下面有链表,就会执行while循环。

由于JDK7的时候HashMap使用的是头插法,因此先来的数据会在链表尾部,后来的数据在链表头部。其实插入数据的时候是先让要插入节点的next指针指向原有的数据,然后再覆盖掉原有的数据。

例如插入数据的顺序是1,2,3,那么插入完毕之后链表如下所示。

单线程情况:

此时假设我们开始扩容,那么就会执行transfer函数。其中newTable就是扩容后的数组,其大小为原数组的两倍。

我们现在开始外层for循环,现在我们遍历到了3这个Entry了。

由于这个Entry不等于null,所以会执行while语句中的内容。

首先我们的e一开始是3,那么e.next就是2

之后开始进行rehash操作,然后得到hash值之后,我们根据这个hash值在扩容后的HashMap中获取新的插入位置的索引 i。

并且让e(3)的next指针指向这个新数组的索引为i的位置,如下:

之后执行newTable[i]=e这个语句,那么根据头插法,变成如下:

之后执行e=next,也就是让e指针指向2。

然后继续执行while中的逻辑进行头插法操作,就会发现最后的结果是这样子的。

可以发现单线程的情况下执行头插法进行扩容是没有问题的。

移动的逻辑也很清晰,遍历原来table中每个位置的链表,并对每个元素进行重新hash,在新的newTable找到归宿,并插入。(JDK8之后由于只需要两次扰动,因此性能更高,并且元素在newTable的索引要么为原本的索引位置,要么为原索引位置+此次扩容的大小)

多线程情况:

这里假设有两个线程进行扩容方法,那么就是有两个线程进入了resize方法,假设两个线程都执行到了transfer方法,并且两个线程都执行到了下面这一步,在这个时候,如果有一个线程阻塞住了。

if (rehash) {
  e.hash = null == e.key ? 0 : hash(e.key);
}

那么此时的table情况如下:

这两个e其实在线程之间是不互相影响的,他们在各自的线程的私有栈中。

这里假设第一个线程他在运行,另一个线程阻塞,那么第一个线程就会先进行扩容。

然后此时就和单线程扩容情况一样,扩容后如下:

此时线程2醒了,由于线程2的e指针和e.next指向的是3和2,那么由于线程1扩容之后,值没有改变,所以线程2醒来之后的情况如下:

可以发现虽然他们指向的值没有变,但是顺序已经变了,e.next.next=e

而本来应该是e.next=2,e=3这种情况的,所以他们的顺序已经相反了。

然后我们再来一下线程2的扩容:

我们刚才设定从if代码出开始阻塞,那么阻塞结束了继续进行,得到hash值之后获取在newTable中的索引位置。

然后开始第一次插入:

也就是e.next先指向扩容后数组的某个位置

到此第一次循环结束,可以发现好像没有问题,然后我们开始第二次循环:

此时next为3,以为e此时为2,e.next由于一开始倒置的问题变为了3

之后我们第三次进入while循环

此时的e.next就是NULL了,然后再让e.next指向newTable[i],如下:

此时可以发现已经生成了一个环形了,那么3的next是2 , 2的next又是3,那么就是无论如何这个e和e.next都在2和3之间跳动了,那么就会导致卡死再这里。

这就是再JDK7下多线程情况下HashMap的并发问题。

他会在扩容的时候从一个链表变成一个环状结构。


相关文章
|
27天前
|
Java 程序员
Java社招面试中的高频考点:Callable、Future与FutureTask详解
大家好,我是小米。本文主要讲解Java多线程编程中的三个重要概念:Callable、Future和FutureTask。它们在实际开发中帮助我们更灵活、高效地处理多线程任务,尤其适合社招面试场景。通过 Callable 可以定义有返回值且可能抛出异常的任务;Future 用于获取任务结果并提供取消和检查状态的功能;FutureTask 则结合了两者的优势,既可执行任务又可获取结果。掌握这些知识不仅能提升你的编程能力,还能让你在面试中脱颖而出。文中结合实例详细介绍了这三个概念的使用方法及其区别与联系。希望对大家有所帮助!
164 60
|
3天前
|
Java 程序员 开发者
Java社招面试题:一个线程运行时发生异常会怎样?
大家好,我是小米。今天分享一个经典的 Java 面试题:线程运行时发生异常,程序会怎样处理?此问题考察 Java 线程和异常处理机制的理解。线程发生异常,默认会导致线程终止,但可以通过 try-catch 捕获并处理,避免影响其他线程。未捕获的异常可通过 Thread.UncaughtExceptionHandler 处理。线程池中的异常会被自动处理,不影响任务执行。希望这篇文章能帮助你深入理解 Java 线程异常处理机制,为面试做好准备。如果你觉得有帮助,欢迎收藏、转发!
36 14
|
6天前
|
安全 Java 程序员
Java 面试必问!线程构造方法和静态块的执行线程到底是谁?
大家好,我是小米。今天聊聊Java多线程面试题:线程类的构造方法和静态块是由哪个线程调用的?构造方法由创建线程实例的主线程调用,静态块在类加载时由主线程调用。理解这些细节有助于掌握Java多线程机制。下期再见! 简介: 本文通过一个常见的Java多线程面试题,详细讲解了线程类的构造方法和静态块是由哪个线程调用的。构造方法由创建线程实例的主线程调用,静态块在类加载时由主线程调用。理解这些细节对掌握Java多线程编程至关重要。
34 13
|
26天前
|
算法 安全 Java
Java线程调度揭秘:从算法到策略,让你面试稳赢!
在社招面试中,关于线程调度和同步的相关问题常常让人感到棘手。今天,我们将深入解析Java中的线程调度算法、调度策略,探讨线程调度器、时间分片的工作原理,并带你了解常见的线程同步方法。让我们一起破解这些面试难题,提升你的Java并发编程技能!
65 16
|
22天前
|
Java 程序员 调度
Java 高级面试技巧:yield() 与 sleep() 方法的使用场景和区别
本文详细解析了 Java 中 `Thread` 类的 `yield()` 和 `sleep()` 方法,解释了它们的作用、区别及为什么是静态方法。`yield()` 让当前线程释放 CPU 时间片,给其他同等优先级线程运行机会,但不保证暂停;`sleep()` 则让线程进入休眠状态,指定时间后继续执行。两者都是静态方法,因为它们影响线程调度机制而非单一线程行为。这些知识点在面试中常被提及,掌握它们有助于更好地应对多线程编程问题。
55 9
|
28天前
|
安全 Java 程序员
Java面试必问!run() 和 start() 方法到底有啥区别?
在多线程编程中,run和 start方法常常让开发者感到困惑。为什么调用 start 才能启动线程,而直接调用 run只是普通方法调用?这篇文章将通过一个简单的例子,详细解析这两者的区别,帮助你在面试中脱颖而出,理解多线程背后的机制和原理。
60 12
|
1月前
|
监控 Dubbo Java
Java Dubbo 面试题
Java Dubbo相关基础面试题
|
1月前
|
SQL Java 数据库连接
Java MyBatis 面试题
Java MyBatis相关基础面试题
|
1月前
|
存储 监控 算法
Java JVM 面试题
Java JVM(虚拟机)相关基础面试题
|
1月前
|
SQL 监控 druid
Java Druid 面试题
Java Druid 连接池相关基础面试题