HashMap中唯一键的线程安全性



关于这个话题已经有很多讨论,例如:

ConcurrentHashMap和Collections.synchronizedMap(Map)有什么区别?

但是我还没有找到我的特定用例的答案。

一般来说,不能假设HashMap是线程安全的。如果同时从不同的线程对同一个键进行写操作,就会发生混乱。但如果我知道所有线程都有唯一的键呢?

这个代码是线程安全的还是我需要添加阻塞机制(或使用并发映射)?

Map<int, String> myMap = new HashMap<>();
for (int i = 1 ; i > 6 ; i++) {
new Thread(() -> {
myMap.put(i, Integer.toString(i));
}).start();
}

答案很简单:HashMap完全没有线程安全保证。

事实上,有明确的文档说明它不是线程安全的:

如果多个线程同时访问一个哈希表,并且至少有一个线程在结构上修改了哈希表,那么它必须在外部同步

所以在没有任何同步的情况下从多个线程访问一个线程是一种灾难。

已经看到了每个线程使用不同的关键原因问题的情况(如迭代同时发生导致无限循环)。

想想重新哈希:当达到阈值时,内部桶数组需要调整大小。这是一个有点冗长的操作(与单个put相比)。在此期间,如果另一个线程也尝试put,则可能发生各种奇怪的事情(甚至可能触发第二次重新哈希!)。

此外,没有可靠的方法来证明您的特定用例是安全的,因为您可以运行的所有测试都可能只是"偶然的"。工作。换句话说:即使你认为你已经用单元测试覆盖了它,你也不能指望它能正常工作。

因为不是每个人都相信,你可以很容易地用下面的代码自己测试:

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
class HashMapDemonstration {
public static void main(String[] args) throws InterruptedException {
int threadCount = 10;
int valuesPerThread = 1000;
Map<Integer, Integer> map = new HashMap<>();
List<Thread> threads = new ArrayList<>(threadCount);
for (int i = 0; i < threadCount; i++) {
Thread thread = new Thread(new MyUpdater(map, i*valuesPerThread, (i+1)*valuesPerThread - 1));
thread.start();
threads.add(thread);
}
for (Thread thread : threads) {
thread.join();
}
System.out.printf("%d threads with %d values per thread with a %s produced %d entries, should be %d%n",
threadCount, valuesPerThread, map.getClass().getName(), map.size(), threadCount * valuesPerThread);
}
}
class MyUpdater implements Runnable {
private final Map<Integer, Integer> map;
private final int startValue;
private final int endValue;
MyUpdater(Map<Integer, Integer> map, int startValue, int endValue) {
this.map = map;
this.startValue = startValue;
this.endValue = endValue;
System.out.printf("Creating updater for values %d to %d%n", startValue, endValue);
}
@Override
public void run() {
for (int i = startValue; i<= endValue; i++) {
map.put(i, i);
}
}
}

这正是OP提到的程序类型:每个线程只会写入其他线程从未接触过的键。但是,最终的Map不会包含所有条目:

Creating updater for values 0 to 999
Creating updater for values 1000 to 1999
Creating updater for values 2000 to 2999
Creating updater for values 3000 to 3999
Creating updater for values 4000 to 4999
Creating updater for values 5000 to 5999
Creating updater for values 6000 to 6999
Creating updater for values 7000 to 7999
Creating updater for values 8000 to 8999
Creating updater for values 9000 to 9999
10 threads with 1000 values per thread with a java.util.HashMap produced 9968 entries, should be 10000

注意,每次运行时,最终Map中的实际条目数会有所不同。它有时甚至打印10000(因为它不是线程安全的!)。

请注意,这种故障模式(丢失条目)绝对不是唯一可能的模式:基本上任何都可能发生。

我想具体回应这个短语。

但是如果我知道我所有的线程都有唯一的键呢?

你对地图的实现做了一个假设。实现可能会发生变化。如果文档中的实现不是线程安全的,则必须考虑Java内存模型(JMM),该模型几乎不保证线程之间的内存可见性。

这是做了很多假设和很少的保证。您不应该依赖这些假设,即使它恰好在您的机器上,在特定的用例中,在特定的时间工作。

简而言之:如果一个非线程安全的实现在多个线程中使用,你必须用确保线程安全的结构包围它。总是这样。

但是,为了好玩,让我们描述一下在每个线程只使用一个唯一键的特殊情况下可能出现的问题。

当添加或删除一个键时,即使是唯一的,也有需要在内部重新组织哈希映射的情况。第一种是在哈希冲突的情况下,1必须更新键值项的链表。第二个是映射决定调整其内部条目表的大小。修改了内部结构,包括上面提到的链表。

由于JMM,它在很大程度上不能保证另一个线程看到的重组。这意味着当重组发生时,如果另一个线程恰好在get(key)中间,则行为是未定义的。如果另一个线程同时执行put(key,value),您可能会以两个线程同时尝试调整映射大小而告终。坦率地说,我甚至不想去想这会造成多大的混乱!


1多个键可以有相同的哈希码。因为映射没有无限的存储空间,所以哈希码通常也会被内部表项的大小包裹起来,比如(hashCode % sizeOfTable),这可能会导致不同的哈希码使用相同的"条目"的情况。