在 Java 的面试中,关于 HashMap
在多线程环境下的表现常常是一个热门话题。特别是 JDK1.7 及之前版本的 HashMap
在多线程扩容时可能会导致死循环的问题。接下来,我们将深入剖析这个问题,并通过源码解读和示例代码进行详细解释。
HashMap 的基本结构
首先,我们了解一下 HashMap
的基本结构。HashMap
是基于数组和链表实现的哈希表。每个哈希桶(bucket)中存储的是一个链表或红黑树(在 JDK1.8 以后),用来解决哈希冲突。
java
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
// other methods
}
扩容问题
HashMap
的扩容发生在容量超过阈值时(默认是容量的 0.75 倍)。在 JDK1.7 及之前的版本中,HashMap
扩容时会重新计算每个元素的哈希值,并将其放置在新的数组位置。
JDK1.7 中的链表头插法
在 JDK1.7 中,扩容过程使用了链表的头插法,这可能导致多线程操作下链表形成环形结构。
java
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 = e.hash & (newCapacity - 1);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
问题分析
在多线程环境下,如果两个线程同时对同一个桶进行扩容操作,可能会导致链表形成环形结构。例如,假设两个线程同时对桶中的链表进行操作:
- 线程 A 读取链表中的元素,并准备插入新的哈希桶。
- 线程 B 也读取同一个链表中的元素,并准备插入新的哈希桶。
- 线程 A 插入元素到新的哈希桶,并更新链表的头节点。
- 线程 B 插入元素,但由于链表已经被线程 A 修改,导致链表形成环形结构。
这时,如果有线程试图遍历这个链表,由于链表已经形成环形结构,会导致死循环。
JDK1.8 中的改进
为了避免这个问题,JDK1.8 中的 HashMap
改用了尾插法,使得插入的节点总是放在链表的末尾,避免了链表中的环形结构。
java
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
newCap = oldCap << 1;
newThr = oldThr << 1;
} else {
newCap = (oldThr > 0) ? oldThr : DEFAULT_INITIAL_CAPACITY;
newThr = (int)(newCap * DEFAULT_LOAD_FACTOR);
}
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
threshold = newThr;
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else {
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
} else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loHead != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiHead != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
尾插法原理
尾插法在插入节点时,总是将新节点插入到链表的末尾。这避免了链表在多线程操作下倒置的问题,从而避免了环形链表的形成。
多线程环境下的解决方案
尽管 JDK1.8 中改进了 HashMap
的扩容机制,但在多线程环境下使用 HashMap
仍然不是安全的,因为仍然可能存在数据覆盖的问题。因此,在多线程环境下,推荐使用 ConcurrentHashMap
。
ConcurrentHashMap
通过分段锁(JDK1.7)和 CAS 操作(JDK1.8)来保证线程安全。
JDK1.7 中的分段锁
JDK1.7 中,ConcurrentHashMap
使用了分段锁的机制,每一个分段(Segment)相当于一个小的哈希表,并且拥有自己的锁。这样在多线程访问时,不同分段上的操作可以并行进行,从而提高了并发性能。
JDK1.8 中的 CAS 操作
在 JDK1.8 中,ConcurrentHashMap
摒弃了分段锁,转而使用 CAS 操作和内置锁来保证并发安全。具体实现中,使用了 Unsafe
类的 compareAndSwap
方法来实现无锁的线程安全操作。
java
private final boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
性能对比示例
为了更直观地理解 HashMap
和 ConcurrentHashMap
在多线程环境下的表现,我们可以编写一个简单的性能对比示例。
java
import java.util.HashMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class MapPerformanceTest {
private static final int THREAD_COUNT = 1000;
private static final int OPERATIONS = 100000;
public static void main(String[] args) throws InterruptedException {
HashMap<Integer, Integer> hashMap = new HashMap<>();
ConcurrentHashMap<Integer, Integer> concurrentHashMap = new ConcurrentHashMap<>();
System.out.println("Testing HashMap...");
long hashMapTime = testMapPerformance(hashMap);
System.out.println("HashMap Time: " + hashMapTime + " ms");
System.out.println("Testing ConcurrentHashMap...");
long concurrentHashMapTime = testMapPerformance(concurrentHashMap);
System.out.println("ConcurrentHashMap Time: " + concurrentHashMapTime + " ms");
}
private static long testMapPerformance(final java.util.Map<Integer, Integer> map) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT);
long startTime = System.currentTimeMillis();
for (int i = 0; i < THREAD_COUNT; i++) {
executorService.execute(() -> {
for (int j = 0; j < OPERATIONS; j++) {
int key = (int) (Math.random() * OPERATIONS);
map.put(key, key);
}
});
}
executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.HOURS);
long endTime = System.currentTimeMillis();
return endTime - startTime;
}
}
通过这个示例,我们可以直观地看到在多线程环境下,ConcurrentHashMap
的性能远优于 HashMap
。
结论
在多线程环境中,JDK1.7 及之前版本的 HashMap
的确存在死循环的问题,JDK1.8 通过改进扩容机制避免了这个问题。然而,为了保证线程安全,推荐在多线程环境下使用 ConcurrentHashMap
,其通过分段锁和 CAS 操作提供了更高效的并发性能。