Java中HashMap在高并发下的头插法死循环问题及尾插法的改进
文章目录
- Java中HashMap在高并发下的头插法死循环问题及尾插法的改进
- 一、引言
- 二、HashMap的基本结构与头插法原理(JDK 1.7)
- (一)HashMap的基本结构
- (二)头插法的代码实现与操作过程
- 三、高并发下头插法导致死循环的原因
- (一)并发扩容场景
- (二)死循环产生过程
- 四、尾插法(JDK 1.8)的原理与优势
- (一)尾插法的代码实现与操作过程
- (二)尾插法避免死循环的原因
一、引言
HashMap是Java中常用的集合类,用于存储键值对数据。在JDK 1.7及之前版本中,HashMap在高并发环境下使用头插法进行元素插入时可能会出现死循环问题,这一问题严重影响了程序的正确性和性能。而JDK 1.8引入了尾插法来解决这一潜在风险。本文将深入探讨头插法导致死循环的原因以及尾插法如何避免这种情况。
二、HashMap的基本结构与头插法原理(JDK 1.7)
(一)HashMap的基本结构
HashMap内部维护了一个数组(table),数组的每个元素是一个单向链表,用于存储键值对。当向HashMap中添加元素时,首先根据键的哈希值计算出在数组中的索引位置,如果该位置为空,则直接创建新节点插入;如果该位置已有元素,则通过遍历链表来判断键是否已存在,若不存在则将新节点插入到链表头部(头插法)。
(二)头插法的代码实现与操作过程
以下是JDK 1.7中HashMap的部分代码片段,用于展示头插法的实现:
void addEntry(int hash, K key, V value, int bucketIndex) {// 获取当前索引位置的头节点Entry<K,V> e = table[bucketIndex];// 创建新节点并将其插入到链表头部table[bucketIndex] = new Entry<>(hash, key, value, e);if (size++ >= threshold)resize();
}
在头插法中,新节点总是插入到链表的头部。例如,假设有一个HashMap,初始时数组为空,插入键值对(“A”, 1),此时数组的某个索引位置创建了一个包含(“A”, 1)的链表节点。当再次插入键值对(“B”, 2)且哈希到相同索引位置时,新节点(“B”, 2)会被插入到链表头部,形成(“B”, 2) -> (“A”, 1)的链表结构。
三、高并发下头插法导致死循环的原因
(一)并发扩容场景
在高并发环境下,当HashMap中的元素数量达到阈值(threshold)时,会触发扩容操作(resize)。扩容操作会创建一个新的更大容量的数组,并将原数组中的元素重新哈希到新数组中。
(二)死循环产生过程
假设在扩容过程中有两个线程同时对HashMap进行操作。线程A开始执行扩容操作,它已经将原数组中的部分节点迁移到了新数组中,例如,对于某个链表,它已经迁移了第一个节点到新数组。此时,线程B开始执行添加元素操作,并且该元素哈希到了与线程A正在操作的相同链表位置。由于头插法,线程B会将新节点插入到原链表头部。
然后线程A继续执行剩余节点的迁移,当它迁移原链表中的第二个节点时,由于线程B的插入操作改变了链表结构,线程A在迁移过程中会按照新的链表结构进行操作,可能会导致链表形成环。例如,原链表为A -> B,线程B插入节点C后变为C -> A -> B,线程A在迁移时可能会错误地将B的next指针指向A,形成A -> B -> A的死循环。
当其他线程在遍历这个链表时,就会陷入死循环,导致程序无法继续正常执行,CPU资源被大量占用,最终可能导致系统崩溃或性能严重下降。
四、尾插法(JDK 1.8)的原理与优势
(一)尾插法的代码实现与操作过程
JDK 1.8中,HashMap在插入元素时采用了尾插法。以下是相关代码片段:
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {return new Node<>(hash, key, value, next);
}void putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {//...其他代码if ((p = tab[i = (n - 1) & hash]) == null)tab[i] = newNode(hash, key, value, null);else {Node<K,V> e; K k;if (p.hash == hash &&((k = p.key) == key || (key!= null && key.equals(k))))e = p;else {// 遍历链表找到合适位置插入到尾部for (int binCount = 0; ; binCount++) {if ((e = p.next) == null) {p.next = newNode(hash, key, value, null);if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1sttreeifyBin(tab, i);break;}if (e.hash == hash &&((k = e.key) == key || (key!= null && key.equals(k))))break;p = e;}}//...其他代码}
}
在尾插法中,新节点会被插入到链表的尾部。例如,对于上述同样的情况,当插入键值对(“B”, 2)时,它会被添加到链表(“A”, 1)的尾部,形成(“A”, 1) -> (“B”, 2)的结构。
(二)尾插法避免死循环的原因
在高并发场景下,即使发生扩容操作,由于新节点总是插入到链表尾部,不会改变已存在节点的顺序和引用关系。所以在扩容过程中,各个线程对链表的操作不会相互干扰,不会出现类似头插法中由于节点顺序改变而导致的死循环问题。虽然尾插法不能完全解决HashMap在高并发下的所有问题(如数据覆盖等),但相比头插法,大大提高了在高并发环境下的安全性和稳定性。
综上所述,JDK 1.7中HashMap的头插法在高并发时可能引发死循环问题,而JDK 1.8的尾插法通过改变节点插入位置的策略有效地避免了这一情况,使得HashMap在高并发场景下的表现更加可靠。但在实际高并发应用中,仍然建议使用更适合并发场景的集合类,如ConcurrentHashMap,以确保程序的高效性和正确性。