1.写在前面
上一篇文章,我们从往HashMap中put元素开始,详细分析了创建HashMap和put元素的过程,这篇文章我想和大家一起来深入的再研究下HashMap,我先抛出几个问题:
- jdk7 和 jdk8 中 HashMap的源码和底层实现发生了什么变化? 为什么要这么改?要解决什么问题?
- jdk8 中HashMap为什么线程不安全? 有具体例子吗? 什么情况下会有线程安全问题?
2.JDK 7 中的 HashMap 实现
在 JDK 7 中,HashMap 的底层实现主要是通过数组和链表的组合来实现的。每个桶(bucket)是一个链表,所有具有相同哈希值的键值对都存储在同一个链表中。
2.1 数组和链表
HashMap 使用一个数组来存储链表的头节点。每个数组的元素(桶)是一个链表,链表中的每个节点存储一个键值对。
2.2 哈希冲突处理
当多个键的哈希值相同(即发生哈希冲突)时,这些键值对会被存储在同一个链表中。
2.3 扩容
当负载因子超过设定值时,HashMap 会进行扩容(rehash),将容量加倍,并重新分配所有键值对。
2.4 性能问题
当哈希冲突频繁发生时,链表会变得很长,导致查找、插入和删除操作的时间复杂度从平均的 O(1) 退化到最坏的 O(n)。
2.5 多线程操作时的死循环
在 JDK 7 中,HashMap 在多线程环境下使用不当确实可能导致死循环。这主要发生在 HashMap 进行扩容(rehash)的过程中。如果多个线程同时对 HashMap 进行插入操作,可能会导致链表形成环形结构,从而引发死循环。
以下是一个简单的示例,展示了在多线程环境下如何导致 HashMap 死循环:
import java.util.HashMap;
import java.util.Map;
public class HashMapInfiniteLoopExample {
private static final Map<Integer, Integer> map = new HashMap<>();
public static void main(String[] args) throws InterruptedException {
// 创建两个线程同时对 HashMap 进行插入操作
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
map.put(i, i);
}
});
Thread thread2 = new Thread(() -> {
for (int i = 1000; i < 2000; i++) {
map.put(i, i);
}
});
// 启动线程
thread1.start();
thread2.start();
// 等待线程完成
thread1.join();
thread2.join();
System.out.println("Map size: " + map.size());
}
}
在上述代码中,如果 HashMap 发生扩容,并且两个线程同时对同一个桶进行操作,可能会导致链表形成环形结构。此时,如果另一个线程试图遍历这个桶中的链表,就会陷入死循环,导致 CPU 使用率飙升,程序无法继续执行。我们通过画图来说明死循环的形成过程。
2.5.1 jdk7 HashMap的基本结构
如上图所示,在JDK1.7中HashMap的底层数据实现是数组+链表。
2.5.2 头插法
如上图所示,在JDK7中HashMap的插入是头插法,这也是造成死循环的根本原因。在JDK8中改成了尾插法,这个我们稍后再详细看。
2.5.3 HashMap正常扩容过程
如上图所示,扩容的过程概括来说就是先生成一个更大容量的HashMap,然后把旧HashMap中的数据迁移到新HashMap,问题就发生在迁移的过程中。前面提到JDK7 HashMap 是采用的头插法,这样原先是A->B->C,迁移到新的HashMap之后就会变成C->B->A。
2.5.4 死循环形成
按照我们上面的代码举例,有两个线程并发在往HashMap中put元素,发现需要扩容,然后都进行扩容。此时T1和T2指向的是链表的头结点元素A,而T1和T2的下一个节点,也就是T1.next和T2.next指向的是B节点。
假设由于某种原因,T2线程挂起了,T1线程先进行了扩容,扩容完成后如上图右侧所示。因为是头插法,所以HashMap的顺序已经发生了改变,但线程T2对于发生的一切是不可知的,所以它的指向元素依然没变,如上图展示的那样,T2指向的是A元素,T2.next指向的节点是B元素。
这个时候T2线程恢复了,准备开始执行扩容。因为T1执行完扩容之后B节点的下一个节点是A,而T2线程指向的首节点是A,第二个节点是B,这个顺序刚好和T1扩完容完之后的节点顺序是相反的。T1执行完之后的顺序是B到A,而T2的顺序是A到B,这样A节点和B节点就形成死循环了。
JDK7 HashMap造成死循环的源码如下,大家可以对照的上图和源码自行走一遍流程:
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
3. JDK 8 中的 HashMap 实现
在 JDK 8 中,HashMap 的实现进行了优化,主要引入了红黑树来替代链表,以提高在哈希冲突严重时的性能。
- 通过引入红黑树来替代长链表,在哈希冲突严重时,显著提高了查找、插入和删除操作的性能
- 在 JDK 7 中,如果所有键都映射到同一个桶,链表会变得很长,导致性能退化到 O(n)。通过引入红黑树,最坏情况下的时间复杂度也能保持在 O(log n)。
- 在多线程环境下,链表的转换和扩容过程更加稳定,减少了死循环的风险。
3.1 数组、链表和红黑树
HashMap 仍然使用一个数组来存储链表或红黑树的头节点。当链表长度超过一定阈值(默认是 8)时,链表会转换为红黑树
3.2 红黑树
当链表中的元素数量超过阈值时,链表会转换为红黑树。红黑树的查找、插入和删除操作的时间复杂度为 O(log n),比链表的 O(n) 更高效
3.3 哈希冲突处理
仍然使用链表来处理哈希冲突,但当链表长度超过阈值时,转换为红黑树。
3.4 扩容
扩容机制与 JDK 7 类似,但在重新分配键值对时,红黑树的节点会保持树的结构。
4. jdk8 HashMap 在多线程环境下还有可能死循环吗?
在 JDK 8 中,HashMap 引入了一些新的机制和优化,例如使用红黑树来替代长链表,以提高性能和解决哈希冲突严重时的最坏情况。然而,HashMap 仍然不是线程安全的,在多线程环境下使用时,仍然可能会出现一些问题,包括死循环或数据丢失。主要有以下几种情况:
4.1 扩容(Rehashing)
与 JDK 7 类似,JDK 8 中的 HashMap 在扩容过程中,如果多个线程同时进行插入操作,可能会导致数据结构的不一致,甚至形成环形链表,导致死循环
4.2 红黑树转换
当链表长度超过一定阈值(默认是 8)时,链表会转换为红黑树。如果多个线程同时对同一个桶进行操作,可能会导致红黑树结构的不一致,从而引发不可预知的问题
4.3 代码例子
以下是一个简单的示例,展示了在多线程环境下使用 JDK 8 的 HashMap 可能导致的问题:
import java.util.HashMap;
import java.util.Map;
public class HashMapInfiniteLoopExample {
private static final Map<Integer, Integer> map = new HashMap<>();
public static void main(String[] args) throws InterruptedException {
// 创建两个线程同时对 HashMap 进行插入操作
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
map.put(i, i);
}
});
Thread thread2 = new Thread(() -> {
for (int i = 10000; i < 20000; i++) {
map.put(i, i);
}
});
// 启动线程
thread1.start();
thread2.start();
// 等待线程完成
thread1.join();
thread2.join();
System.out.println("Map size: " + map.size());
}
}
4.4 解决方法
为了避免上述问题,可以使用线程安全的集合类,例如 ConcurrentHashMap。ConcurrentHashMap 通过使用分段锁或其他并发控制机制,确保在多线程环境下的安全性。
以下是使用 ConcurrentHashMap 的示例代码:
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class ConcurrentHashMapExample {
private static final Map<Integer, Integer> map = new ConcurrentHashMap<>();
public static void main(String[] args) throws InterruptedException {
// 创建两个线程同时对 ConcurrentHashMap 进行插入操作
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
map.put(i, i);
}
});
Thread thread2 = new Thread(() -> {
for (int i = 10000; i < 20000; i++) {
map.put(i, i);
}
});
// 启动线程
thread1.start();
thread2.start();
// 等待线程完成
thread1.join();
thread2.join();
System.out.println("Map size: " + map.size());
}
}