156

有一种情况,地图会被构造出来,一旦初始化,就再也不会被修改了。但是,它将被多个线程访问(仅通过 get(key))。以这种方式使用 a 是否安全java.util.HashMap

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

4

12 回答 12

70

杰里米·曼森(Jeremy Manson),Java 内存模型之神,有一篇关于这个主题的三部分博客——因为本质上你是在问“访问不可变的 HashMap 是否安全”这个问题——答案是肯定的。但是您必须回答该问题的谓词 - “我的 HashMap 是否不可变”。答案可能会让您大吃一惊——Java 有一组相对复杂的规则来确定不变性。

有关该主题的更多信息,请阅读 Jeremy 的博客文章:

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

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

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

于 2008-09-19T18:56:36.713 回答
63

当且仅当对 的引用HashMap安全发布时,您的习语才是安全的。与任何与HashMap自身内部相关的内容不同,安全发布处理的是构造线程如何使对映射的引用对其他线程可见。

基本上,这里唯一可能的竞争是在它的构建HashMap和任何可能在它完全构建之前访问它的读取线程之间。大部分讨论是关于地图对象的状态会发生什么,但这无关紧要,因为您从不修改它 - 所以唯一有趣的部分是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,因此读取线程可以自由地看到部分构造的映射。这几乎可以做任何事情,甚至在实践中它也会做一些事情,比如将阅读线程放入无限循环中。

为了安全地发布地图,您需要在对 的引用(即发布)的编写和该引用的后续读者(即消费)之间建立发生前的关系。方便的是,只有几种容易记住的方法可以实现这一点[1]HashMap

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

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

public static volatile HashMap<Object, Object> MAP;

那么一切都是 kosher:看到非空值的读者必然与 store 有发生前的关系,MAP因此可以看到与地图初始化相关联的所有商店。

其他方法会更改方法的语义,因为 (2)(使用静态初始化程序)和 (4)(使用final)都暗示您不能MAP在运行时动态设置。如果您不需要这样做,那么只需声明MAP为 astatic final HashMap<>即可保证安全发布。

在实践中,安全访问“从未修改过的对象”的规则很简单:

如果您发布的对象本质上不是不可变的(如在声明的所有字段中final)并且:

  • 您已经可以创建将在声明时分配的对象a:只需使用一个final字段(包括static final静态成员)。
  • 在引用已经可见之后,您想稍后分配对象:使用 volatile 字段b

就是这样!

在实践中,它非常有效。例如,使用static final字段允许 JVM 假定该值在程序的生命周期内保持不变并对其进行大量优化。成员字段的使用final允许大多数架构以与普通字段读取等效的方式读取字段,并且不会抑制进一步的优化c

最后,使用volatile确实会产生一些影响:在许多架构(例如 x86,特别是那些不允许读取通过读取的架构上)不需要硬件屏障,但是在编译时可能不会发生一些优化和重新排序 - 但这一般影响不大。作为交换,您实际上得到的比您所要求的要多——您不仅可以安全地发布一个HashMap,您还可以存储尽可能多的未修改HashMap的 s 到同一参考,并确保所有读者都会看到安全发布的地图.

有关更多详细信息,请参阅ShipilevManson 和 Goetz 的此常见问题解答


[1] 直接引自shipilev


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

b或者,您可以使用一种synchronized方法来获取/设置,或者一个AtomicReference或其他东西,但我们谈论的是您可以做的最少工作。

c 一些内存模型非常弱的架构(我在看,Alpha)在读取之前可能需要某种类型的读取屏障final——但这些在今天非常罕见。

于 2017-02-01T21:45:51.173 回答
37

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

如果您有其他线程正在运行,如果当前线程没有内存写入,他们可能看不到 HashMap 的更新副本。内存写入是通过使用 synchronized 或 volatile 关键字,或者通过使用一些 java 并发结构来实现的。

有关详细信息,请参阅Brian Goetz 关于新 Java 内存模型的文章

于 2008-09-19T19:10:10.317 回答
10

多看几眼后,我在java doc中找到了这个(强调我的):

请注意,此实现不同步。 如果多个线程同时访问一个哈希映射,并且至少有一个线程在结构上修改了映射,则它必须在外部同步。(结构修改是添加或删除一个或多个映射的任何操作;仅更改与实例已包含的键关联的值不是结构修改。)

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

于 2008-09-19T18:20:40.137 回答
8

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

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

于 2008-09-19T21:13:09.287 回答
7

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

于 2008-09-19T19:16:10.410 回答
5

根据http://www.ibm.com/developerworks/java/library/j-jtp03304/ # Initialization safety 您可以将 HashMap 设置为 final 字段,并在构造函数完成后将其安全发布。

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

于 2012-08-30T16:08:32.307 回答
5

这个问题在 Brian Goetz 的“Java Concurrency in Practice”一书(清单 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并且它的初始化是在所有者的类构造函数中完成的,因此以后读取此映射的任何线程都可以保证在构造函数完成时看到它,前提是没有其他线程会尝试修改映射的内容。

于 2019-11-19T12:31:42.587 回答
2

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

有关如果同时使用地图可能会失败的严重程度以及我提到的建议解决方法的示例,请查看此错误游行条目:bug_id=6423457

于 2009-11-09T16:37:06.927 回答
1

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

因此,在不太可能的情况下,您的现有代码可能会在设置期间将 null 添加到集合中(可能是在某种失败情况下),按照描述替换集合将改变功能行为。

也就是说,只要你什么都不做,从 HashMap 并发读取是安全的。

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

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

我的意图不是与那些关于 Java 内存模型的更详细的答案相矛盾。这个答案旨在指出,即使除了并发问题,ConcurrentHashMap 和 HashMap 之间至少存在一个 API 差异,这甚至可能破坏一个用另一个替换一个的单线程程序。]

于 2008-09-19T18:38:06.257 回答
0

http://www.docjar.com/html/api/java/util/HashMap.java.html

这是HashMap的来源。如您所知,那里绝对没有锁定/互斥代码。

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

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

于 2008-09-19T18:19:21.857 回答
0

如果初始化和每个 put 是同步的,那么您就可以保存了。

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

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

}

以下代码被保存,因为 volatile 的编写将负责同步。

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 也是 volatile。如果方法是构造函数。

于 2016-09-28T11:38:27.630 回答