面试题答案
一键面试高并发场景下HashMap扩容可能出现的问题
- 死循环:
- 原理:在JDK 1.7及之前版本,HashMap采用头插法扩容。高并发下,多个线程同时进行扩容操作时,新的Entry链表顺序会与旧链表顺序相反。当一个线程已经完成部分链表迁移,另一个线程继续迁移时,就可能出现链表成环的情况,进而导致在获取元素时陷入死循环。例如,线程A将节点a迁移到新表,线程B在迁移节点b时,因为头插法会改变链表顺序,使得b成为新链表头,然后线程A继续迁移,可能会使a又成为新链表头,最终形成环。
- 实现角度:在
transfer
方法(JDK 1.7中扩容时调用)中,通过e.next = newTable[i]; newTable[i] = e;
这样的头插法代码实现链表迁移,高并发下容易出现上述问题。
- 数据丢失:
- 原理:在高并发场景下,多个线程同时进行put操作,并且在put过程中触发扩容。假设线程A和线程B都要put新元素,在判断是否需要扩容时都通过了检查,然后都开始进行扩容操作。线程A先完成扩容并将元素插入新表,线程B完成扩容后可能会覆盖线程A插入的元素,导致数据丢失。
- 实现角度:例如在
put
方法中,在判断容量是否足够后,没有进行适当的同步,多个线程可能同时执行扩容相关操作,如resize
方法中的transfer
操作,导致数据覆盖。
有效的解决方案
- 使用ConcurrentHashMap:
- 原理:在JDK 1.7中,ConcurrentHashMap采用分段锁机制,将整个哈希表分成多个段(Segment),每个段独立加锁,不同段的操作可以并发执行,减少了锁竞争。在JDK 1.8中,采用Node数组加链表或红黑树的数据结构,并且利用CAS(Compare - And - Swap)和synchronized关键字来实现线程安全。在扩容时,它采用了一种更加细粒度的控制方式,允许并发扩容。例如,在扩容时,会将数据迁移到新的数组,并且在迁移过程中,通过设置一些标志位来控制并发操作,避免死循环和数据丢失。
- 实现角度:JDK 1.8中ConcurrentHashMap的
put
方法,在链表或红黑树节点操作时,使用synchronized关键字对节点加锁,在数组扩容等操作时,使用CAS操作来保证数据一致性。扩容时,采用多线程分段迁移数据,不会出现死循环和数据丢失问题。
- 使用Collections.synchronizedMap包装HashMap:
- 原理:
Collections.synchronizedMap
返回的是一个线程安全的Map,它通过对整个Map的方法进行同步,使用synchronized
关键字,在调用put、get等方法时,会对整个Map对象加锁,这样在同一时间只有一个线程可以操作Map,从而避免了高并发下的问题。 - 实现角度:例如
put
方法实现如下:
- 原理:
public V put(K key, V value) {
synchronized (mutex) {return m.put(key, value);}
}
这里mutex
是一个对象锁,m
是被包装的HashMap。通过对put
方法加锁,保证了高并发下操作的原子性,避免了死循环和数据丢失,但由于锁粒度大,并发性能相对较低。