从多个线程获取 java.util.HashMap 的值是否安全(不需要修改) ?

有一种情况下,映射将被构造,一旦初始化,它将永远不会再被修改。但是,将从多个线程访问它(通过 get (key))。这样使用 java.util.HashMap安全吗?

(目前,我很高兴使用 java.util.concurrent.ConcurrentHashMap,并没有测量需要提高性能,但我只是好奇一个简单的 HashMap是否足够。因此,这个问题是 没有“我应该使用哪一个?”也不是性能问题。相反,问题是“这样做安全吗?”)

79977 次浏览

Http://www.docjar.com/html/api/java/util/hashmap.java.html

下面是 HashMap 的源代码,正如你所看到的,这里绝对没有锁/互斥对象代码。

这意味着,虽然在多线程情况下可以从 HashMap 读取数据,但如果有多个写操作,我肯定会使用 ConcurrentHashMap。

有趣的是.NET HashTable 和 Dictionary < K,V > 都内置了同步代码。

经过一番寻找,我在 爪哇医生(强调地雷)中发现了这个:

请注意,此实现不是 如果多个线程 并发访问散列映射,并在 至少有一个线程修改 从结构上来说,一定是 外部同步 修改是任何操作 添加或删除一个或多个映射; 只是改变相关的价值 用一个已经有实例的密钥 包含不是一个结构 修改)

这似乎意味着它将是安全的,假设相反的陈述是真实的。

请注意,即使在单线程代码中,用 HashMap 替换 ConcurrentHashMap 也可能不安全。ConcurrentHashMap 禁止将 null 作为键或值。HashMap 没有禁止他们(不要问)。

因此,在不太可能的情况下,您现有的代码可能会在设置过程中向集合添加一个 null (可能是在某种故障情况下) ,替换所描述的集合将改变函数行为。

也就是说,如果您不执行其他任何操作,那么从 HashMap 并发读取是安全的。

编辑: 通过“并发读取”,我的意思是没有并发修改。

其他答案解释了如何确保这一点。一种方法是使映射不可变,但是没有必要。例如,JSR133内存模型显式地将启动线程定义为同步操作,这意味着在启动线程 B 之前在线程 A 中所做的更改在线程 B 中是可见的。

我的意图并不是要反驳那些关于 Java 内存模型的更详细的答案。这个答案旨在指出,即使不考虑并发性问题,ConcurrentHashMap 和 HashMap 之间至少有一个 API 差异,这可能会破坏甚至一个单线程程序用另一个程序代替一个程序。]

Jeremy Manson,Java 内存模型之神,在这个话题上有三个部分的博客-因为本质上你是在问“访问一个不可变的 HashMap 是否安全”-这个问题的答案是肯定的。但是你必须回答这个问题的谓词“ Is my HashMap immutable”。答案可能会让你感到惊讶—— Java 有一套相对复杂的规则来确定不可变性。

关于这个话题的更多信息,请阅读 Jeremy 的博客文章:

关于 Java 中的不可变性的第1部分: Http://jeremymanson.blogspot.com/2008/04/immutability-in-java.html

关于 Java 中的不可变性的第2部分: Http://jeremymanson.blogspot.com/2008/07/immutability-in-java-part-2.html

关于 Java 中的不可变性的第3部分: Http://jeremymanson.blogspot.com/2008/07/immutability-in-java-part-3.html

从同步角度而不是从内存角度来看,读操作是安全的。这在 Java 开发人员中被广泛误解,包括 Stackoverflow。(请留意 这个答案的评级以作证明。)

如果有其他线程正在运行,则如果当前线程没有写出内存,则它们可能不会看到 HashMap 的更新副本。内存写通过使用同步关键字或易失关键字,或者通过使用某些 Java 并发结构来实现。

详情请参阅 Brian Goetz 关于新的 Java 内存模型的文章

不过,还有一个重要的转折。访问映射是安全的,但通常不能保证所有线程看到的 HashMap 的状态(以及值)完全相同。这可能发生在多处理器系统上,一个线程(例如,填充它的那个线程)对 HashMap 的修改可能位于该 CPU 的缓存中,并且不会被运行在其他 CPU 上的线程看到,直到执行了确保缓存一致性的内存隔离操作。Java 语言规范在这一点上是明确的: 解决方案是获取一个发出内存隔离操作的锁(synized (...))。因此,如果您确定在填充 HashMap 之后,每个线程都获得了任何锁,那么从这一点开始可以从任何线程访问 HashMap,直到再次修改 HashMap。

需要注意的是,在某些情况下,来自非同步 HashMap 的 get ()可能导致无限循环。如果并发 put ()导致 Map 的重新散列,就会发生这种情况。

Http://lightbody.net/blog/2005/07/hashmapget_can_cause_an_infini.html

所以,您描述的场景是,您需要将大量数据放入一个 Map 中,然后当您完成填充它时,您将其视为不可变的。一种“安全”的方法(意味着强制将其视为不可变的)是在准备使其不可变时用 Collections.unmodifiableMap(originalMap)替换引用。

要了解如果同时使用地图会导致多么严重的失败,以及我提到的建议的解决方案,请查看这个 bug 游行条目: Bug _ id = 6423457

根据 http://www.ibm.com/developerworks/java/library/j-jtp03304/ # 初始化安全性,您可以将 HashMap 设置为最终字段,构造函数完成后将安全地发布它。

在新的内存模型下,构造函数中最后一个字段的写入与另一个线程中对该对象的共享引用的初始负载之间存在类似于事先发生的关系。 ...

如果初始化和每个放置是同步的,则保存。

下面的代码被保存,因为类加载器将负责同步:

public static final HashMap<String, String> map = new HashMap<>();
static {
map.put("A","A");


}

下面的代码将被保存,因为易失性的写入将负责同步。

class Foo {
volatile HashMap<String, String> map;
public void init() {
final HashMap<String, String> tmp = new HashMap<>();
tmp.put("A","A");
// writing to volatile has to be after the modification of the map
this.map = tmp;
}
}

如果成员变量是 final,那么这也会起作用,因为 final 也是易变的。如果方法是构造函数。

你的习语是安全的 如果,也只有如果的参考 HashMap安全出版安全刊物并不涉及 HashMap本身的内部结构,而是处理构造线程如何使对映射的引用对其他线程可见。

基本上,这里唯一可能的竞赛是构造 HashMap和任何可能在完全构造之前访问它的读线程之间的竞赛。大多数讨论都是关于 map 对象的状态发生了什么,但是这是不相关的,因为您从来没有修改过它-所以唯一有趣的部分是 HashMap引用是如何发布的。

例如,假设你这样发布地图:

class SomeClass {
public static HashMap<Object, Object> MAP;


public synchronized static setMap(HashMap<Object, Object> m) {
MAP = m;
}
}

... 在某些时候,setMap()被映射调用,其他线程使用 SomeClass.MAP访问映射,并像下面这样检查 null:

HashMap<Object,Object> map = SomeClass.MAP;
if (map != null) {
.. use the map
} else {
.. some default behavior
}

这是 不安全,即使它可能看起来好像它是。问题在于,SomeObject.MAP的集合和另一个线程上的后续读取之间没有 发生之前关系,因此读取线程可以自由地查看部分构造的映射。这几乎可以做 什么都行,甚至在实践中它做事情喜欢 将读线程放入一个无限循环中

为了安全地发布地图,您需要在 参考文献的写作HashMap(即 出版物)和该引用的后续读者(即消耗)之间建立一个 以前发生过关系。方便的是,只有几种简单易记的方法可以实现 完成[1]:

  1. 通过正确锁定的字段(JLS 17.4.5)交换引用
  2. 使用静态初始化器进行初始化存储(JLS 12.4)
  3. 通过易失性字段(JLS 17.4.5)交换引用,或者作为此规则的结果,通过 AtomicX 类交换引用
  4. 将值初始化为 final 字段(JLS 17.5)。

对于您的场景来说,最有趣的是(2)、(3)和(4)。特别是,(3)直接应用于上面的代码: 如果将 MAP的声明转换为:

public static volatile HashMap<Object, Object> MAP;

那么一切都是合法的: 看到 非空值的读者必然与存储到 MAP以前发生过关系,因此可以看到与映射初始化相关联的所有存储。

其他方法会改变方法的语义,因为(2)(使用静态初始化器)和(4)(使用 期末考试)都意味着不能在运行时动态设置 MAP。如果你没有 需要做到这一点,那么只需声明 MAP作为一个 static final HashMap<>,你保证安全的出版物。

实际上,这些规则对于安全访问“从未修改过的对象”很简单:

如果发布的对象不是 本质上是不可改变的(与声明为 final的所有字段一样) ,并且:

  • 您已经可以创建在声明 时分配的对象: 只需使用 final字段(包括用于静态成员的 static final)。
  • 您希望稍后在引用已经可见之后分配该对象: 请使用 volativefieldB

就是这样!

在实践中,这是非常有效的。例如,使用 static final字段允许 JVM 假设该值在程序生命周期内不变,并对其进行大量优化。使用 final成员字段允许 大部分架构以相当于普通字段读取的方式读取字段,并且不会抑制进一步的优化。

最后,volatile的使用确实有一些影响: 许多架构(例如 x86,特别是那些不允许读通过读的架构)不需要硬件障碍,但是一些优化和重新排序可能不会在编译时发生——但是这种影响通常很小。作为交换,你实际上得到了比你要求的更多的东西——你不仅可以安全地发布一个 HashMap,你可以存储更多的未修改的 HashMap,因为你想要同样的参考,并且可以保证所有的读者都会看到一个安全地发布的地图。

更多血淋淋的细节,请参考 希皮列夫这个常见问题的曼森和戈茨


[1]直接引用 Shipilev


A 这听起来很复杂,但是我的意思是,您可以在构造时分配引用——可以在声明点,也可以在构造函数(成员字段)或静态初始化器(静态字段)中。

可选地,您可以使用 synchronized方法来获取/设置,或者使用 AtomicReference或其他方法,但是我们讨论的是您可以完成的最小工作量。

C 一些内存模型非常薄弱的架构(我正在查看 ,Alpha)可能需要某种类型的读屏障才能进行 final读取-但是这种情况在今天非常罕见。

Brian Goetz 的“ Java 并发实践”一书(清单16.8,第350页)提出了这个问题:

@ThreadSafe
public class SafeStates {
private final Map<String, String> states;


public SafeStates() {
states = new HashMap<String, String>();
states.put("alaska", "AK");
states.put("alabama", "AL");
...
states.put("wyoming", "WY");
}


public String getAbbreviation(String s) {
return states.get(s);
}
}

由于 states被声明为 final,并且它的初始化是在所有者的类构造函数中完成的,任何后来读取这个映射的线程都保证在构造函数完成时看到它,前提是没有其他线程会尝试修改映射的内容。