这是一个老问题了,现在都Java21了,又翻出来Java7的问题,算是对历史的总结把。
背过面试八股文的都知道,HashMap是非线程安全的,多线程下要用ConcurrentHashMap之类的。但是实际工作中,还是会碰到在多线程中使用HashMap。可能是写的时候迷糊了,也可能是代码升级时没有注意,比如原本是单线程的,后来性能不行改成多线程了。多线程下使用HashMap,偶尔会出现服务hang死的情况,重启就好,测试环境还复现不了,纯偶发,即Race Condition(竞态条件)。
竞态条件(Race Condition)是指在多线程或者多进程的程序中,由于多个线程或进程之间执行顺序的不确定性,导致程序出现意料之外的结果或者行为。这种情况通常发生在多个线程或进程同时访问共享资源时,其中一个线程或进程修改了共享资源的状态,但其他线程或进程并没有意识到这个修改,导致它们基于过期的状态做出了错误的决策。竞争条件是一种常见的并发编程错误,需要在程序设计和实现时特别注意。
竞态条件可能会导致程序崩溃、数据损坏、死锁等问题。为了避免竞争条件,可以使用同步机制(如锁、信号量等)来保证只有一个进程或线程可以访问共享资源。
那我们来分析下,HashMap是怎么形成竞态条件的。
先梳理一下源码
put方法是入口:
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
// 计算hash值
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;
}
检查容量是否超过阈值,超过了就扩容:
void addEntry(int hash, K key, V value, int bucketIndex) {
// 判断当前的size是否超过阈值,如果超过了,重新resize,也就是扩容
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
// size未超过阈值或者扩容完成后,增加节点
createEntry(hash, key, value, bucketIndex);
}
将新增元素插入链表中:
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
扩容重建新的hash表,并将老表中的数据迁移到新表中:
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// 创建新的hash表
Entry[] newTable = new Entry[newCapacity];
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;
// 将老表中的数据迁移到新表中
transfer(newTable, rehash);
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
老表数据迁移到新表,就是循环遍历旧数据,然后插入的新表中:
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;
}
}
}
上面的这个代码没有什么问题,有问题也不会坚持这么多版本了。
那问题出在哪呢?出在了多线程上,接下来我们画图看看。
看图说话
本节中的例子和图片思路来源于酷壳。
假设我们的hash算法就是key mode表格大小,hash表size是2,当前元素key是3、5、7,mod 2时hash值都是1。执行resize成4时,所有元素迁移到新表数据,过程如下(下图来源于酷壳):
示例
上面的过程是正常运行的结果,如果在迁移时有两个现成同时操作(能过到同时迁移,说明前置的都是同时执行的),就会出现问题,还是通过示意图的方式:
怎么解决呢?
想要成功的解决竞态条件问题,保证程序可以正确的按逻辑顺序运行,从理论上应该满足以下四个条件:
- 不会有两个及以上进程同时出现在他们的critical section;
- 不要做任何关于CPU速度和数量的假设;
- 任何进程在运行到critical section之外时都不能阻塞其他进程;
- 不会有进程永远等在critical section之前。
"Critical section" 是计算机科学中的一个概念,用于描述多个进程或线程访问共享资源时的同步问题。在一个程序中,当多个进程或线程需要同时访问共享资源(如共享内存或文件),为了避免数据竞争和其他同步问题,需要将访问共享资源的代码段限制在一个受保护的区域内,这个区域就称为 "critical section"。在这个区域内,只有一个进程或线程可以访问共享资源,其他进程或线程必须等待,直到进程或线程退出该区域。这样可以确保共享资源的一致性和正确性。
为了避免这种情况的发生,我们可以采用以下几种方法:
- 使用ConcurrentHashMap:ConcurrentHashMap是Java提供的一种线程安全的哈希表实现。它采用了分段锁的机制,在多线程环境下能够保证高效并发访问。如果我们需要在多线程环境下使用哈希表,建议使用ConcurrentHashMap来代替HashMap。
- 使用Collections.synchronizedMap:Collections.synchronizedMap是Java提供的一种线程安全的Map实现。它通过对Map的所有方法进行同步,来保证线程安全。但是,由于它采用了同步的机制,因此在高并发环境下可能会出现性能瓶颈。
- 使用Lock:我们也可以使用Lock来手动控制对HashMap的访问。具体来说,我们可以在对HashMap进行操作时,先获取一个锁,然后再进行操作。这种方式需要我们手动控制锁的释放,因此比较容易出现死锁的情况。