我有一个用于多线程的静态HashMap<UUID, MyObject> ALL = new HashMap<>();
。
为了重现错误,我编写了以下代码:
HashMap<Integer, String> list = new HashMap<>();
list.put(1, "str 1");
list.put(2, "str 2");
new Thread(() -> {
while(true) {
ArrayList<String> val;
synchronized(list) {
val = new ArrayList<>(list.values());
}
System.out.println(val.toString());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
new Thread(() -> {
while(true) {
list.put(new Random().nextInt(), "some str");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
但是,几秒钟后(大约 10 秒),我在 Java 16 和 Java 17 中收到此错误:
java.lang.ArrayIndexOutOfBoundsException: Index 2 out of bounds for length 2
at java.util.HashMap.valuesToArray(HashMap.java:973) ~[?:?]
at java.util.HashMap$Values.toArray(HashMap.java:1050) ~[?:?]
at java.util.ArrayList.<init>(ArrayList.java:181) ~[?:?]
使用Java 8,我得到这个:
Exception in thread "Thread-0" java.util.ConcurrentModificationException
at java.util.HashMap$HashIterator.nextNode(HashMap.java:1473)
at java.util.HashMap$ValueIterator.next(HashMap.java:1502)
at java.util.AbstractCollection.toArray(AbstractCollection.java:141)
at java.util.ArrayList.<init>(ArrayList.java:178)
为了进行测试,我删除了synchronized
关键字,然后在Java 17中重试,得到以下结果:
java.util.ConcurrentModificationException: null
at java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1631) ~[?:?]
at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509) ~[?:?]
at java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499) ~[?:?]
at java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:150) ~[?:?]
at java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:173) ~[?:?]
at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234) ~[?:?]
at java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596) ~[?:?]
这些错误似乎很奇怪,尤其是第一个错误。我怀疑它们来自 JRE 本身。我正在使用 Java 17.0.1 build 17.0.1+12-LTS-39。
如何从另一个线程获取所有值?
首先,你应该使用更好的变量名。 即使是完全没有信息性的名称也比使用list
作为HashMap
的变量名称更好。HashMap
不是列表,当您迭代它时,它甚至不像(正确的)列表。 该变量名称只是误导。
因此,代码的问题在于它没有正确同步。 编写的版本在更新HashMap
时使用synchronized
,但在您访问它时不使用。 为了在关系需要使此代码正常工作之前正确发生,读取器和更新程序线程都需要使用synchronized
。
如果没有链发生,Java 内存模型不能保证一个线程执行的原始写入操作对另一个线程可见。 在这种情况下,这意味着读取器执行HashMap
操作都可能遇到过时的值。 这可能会导致各种事情出错1,包括不正确的结果、ArrayIndexOutOfBoundsException
s、NullPointerException
s 甚至无限循环。
此外,如果您同时迭代和更新HashMap
则可能会获得ConcurrentModificationException
...即使操作的完成方式可确保在链存在之前发生。
总之。。。此代码是错误的。
1 - 实际的故障模式和频率可能取决于诸如 JVM 版本、硬件(包括内核数)以及应用程序中发生的任何其他情况等因素。 您可以尝试调查行为的各种事情都可能导致故障改变......或者走开。
那么,如何解决它呢?
嗯,有两种方法:
-
确保读取器和更新程序线程都从
synchronized
块内部访问HashMap
。 在读取器情况下,请确保将迭代地图值视图的整个操作放入synchronized
块中。 (否则你会得到CME)缺点是阅读器会阻止更新程序,反之亦然。 这可能会导致任一线程中的"滞后"。 (这可能是您担心的更新程序。 对于该线程,"滞后"将与映射中的条目数成正比......以及您对地图条目执行的操作。
这或多或少等同于使用
Collections.synchronizedMap
包装器。 您将获得相同数量的"滞后"。 请注意 javadoc 中关于使用同步映射包装器进行迭代的重要警告。 (查找"当务之急...">) -
将
HashMap
更改为ConcurrentHashMap
。 这将消除在synchronized
块内执行操作的需要。ConcurrentHashMap
类是线程安全的...从某种意义上说,您无需担心内存模型引起的异常和 heisenbug。缺点是迭代
ConcurrentHashMap
不会为您提供映射状态的干净快照。 如果某个条目在迭代开始时存在,并且在迭代结束时尚未删除,则保证可以看到它。 但是,如果添加或删除条目,您可能会或可能不会看到它们。
将Map
变量list
声明为volatile
并不能解决此问题。 这样做只会在读取和写入引用变量之前发生。 但它并没有在HashMap
上的操作之间的关系之前发生任何事情. 因此,如果读取器和更新程序线程碰巧同时运行,就会发生不好的事情。
实际上,添加volatile
会使问题发生的频率降低,并且更难重现或测试。 IMO,它使问题变得更糟。
(此外,如果list
是一个局部变量,就像在你的例子中一样,无论如何它都不能声明为volatile
。
Q:是否有具有O(1)
操作的解决方案,可以为您提供干净的地图快照语义而没有延迟?
答:AFAIK,没有发明/发现这样的数据结构。 当然,Java SE 中没有具有这些属性的Map
实现。
"我有一个用于多线程的
static HashMap<UUID, MyObject> ALL = new HashMap<>();
">
错误在哪里?? ;)(1. 静态 2.哈希映射(非线程安全) 3.多线程)
TLDR
尝试:
static Map<UUID, MyObject> ALL = java.util.Collections.synchronizedMap(new HashMap<>());
(到"在多线程中使用";)。
Javadoc: https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Collections.html#synchronizedMap(java.util.Map)