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


138

在某些情况下,将构造一个映射,并且一旦对其进行初始化,就永远不会再对其进行修改。但是,它将从多个线程访问(仅通过get(key))。java.util.HashMap以这种方式使用安全吗?

(当前,我很乐意使用java.util.concurrent.ConcurrentHashMap,并没有提高性能的需要,但是只是HashMap想知道一个简单的方法是否足够。因此,这个问题不是 “我应该使用哪个?”,也不是一个性能问题。相反,问题是“安全吗?”)


4
关于相互排斥正在运行的线程,此处的许多答案是正确的,但有关内存更新的答案却不正确。我已经相应地投票赞成/反对,但是仍然有很多错误的答案得到正面的投票。
Heath Borders

@Heath Borders,如果实例a是静态初始化的不可修改的HashMap,则对于并发读取应该是安全的(因为其他线程不会因为没有更新而错过更新),对吗?
卡卡奥

如果它是静态初始化的,并且从未在static块之外进行修改,则可以这样做,因为所有静态初始化都由进行同步ClassLoader。单独值得一个单独的问题。我仍然会显式同步它和配置文件,以验证它是否引起了实际的性能问题。
Heath Borders

@HeathBorders-“内存更新”是什么意思?JVM是一个正式的模型,它定义了可见性,原子性,事前发生的关系等事物,但没有使用诸如“内存更新”之类的术语。您应该进行澄清,最好使用JLS中的术语。
BeeOnRope'2

2
@Dave-我假设您8年后仍未在寻找答案,但根据记录,几乎所有答案中的关键困惑是它们专注于您对地图对象执行的操作。您已经说明过,您永远不会修改对象,所以这是无关紧要的。唯一潜在的“疑难杂症”,那么就是你如何发布参考Map,你没有解释。如果您不安全地进行操作,那将是不安全的。如果您安全地进行操作,则为。我的答案中有细节。
BeeOnRope'2

Answers:


55

当且仅当对的引用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. 通过volatile字段(JLS 17.4.5)或由于此规则而通过AtomicX类交换引用
  4. 将值初始化为最终字段(JLS 17.5)。

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

public static volatile HashMap<Object, Object> MAP;

那么一切都是洁净的:看到非null值的读者必然与该存储与to 发生事前关系,MAP因此看到与该地图初始化关联的所有存储。

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

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

如果要发布的对象不是固有不变的(如在声明的所有字段中一样final),并且:

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

而已!

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

最后,使用volatile确实会产生一些影响:在许多体系结构(例如x86,特别是那些不允许读取传递读取的结构)上都不需要硬件障碍,但是在编译时可能不会发生一些优化和重新排序-但这效果一般很小。作为交换,您实际上得到的不仅仅是所需的内容-您不仅可以安全地发布一个HashMap,还可以存储更多未修改的HashMaps,以使用相同的参考文献,并确保所有读者都能看到安全发布的地图。

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


[1]直接引用shipilev


一个这听起来很复杂,但我的意思是,你可以在指定施工时间参考-无论是在申报点或在构造函数(成员字段)或静态初始化(静态字段)。

b(可选)您可以使用一种synchronized方法来获取/设置或一个AtomicReference或某物,但是我们正在谈论的是您可以做的最少工作。

c某些内存模型非常弱的体系结构(我正在看着,Alpha)在读取之前可能需要某种类型的读取屏障final-但是今天这些很少见。


never modify HashMap我的意思不是说state of the map object线程安全。如果官方文件没有说它是线程安全的,上帝知道图书馆的实施。
姜YD

@JiangYD-在某些情况下,您就对了,那里是一个灰色区域:当我们说“修改”时,我们真正的意思是内部执行某些操作的任何操作可能与其他线程上的读或写竞争。这些写操作可能是内部实现的细节,因此,即使是看起来像“只读”的操作,get()实际上也可能执行某些写操作,例如更新某些统计信息(或在LinkedHashMap按访问顺序进行的情况下更新访问顺序)。因此,编写良好的课程应该提供一些文档,以明确说明...
BeeOnRope

...显然,从线程安全的角度来看,“只读”操作实际上是内部只读的。例如,在C ++标准库中,有一条总括的规则,即标记成员函数const在这种意义上是真正的只读(在内部,它们仍然可以执行写操作,但是必须将它们设为线程安全的)。constJava中没有关键字,并且我不知道有任何书面保证。但是一般而言,标准库类的行为符合预期,并且记录了异常(请参见LinkedHashMap示例,其中get明确提到了RO ops 不安全)。
BeeOnRope

@JiangYD-最后,回到您的原始问题,因为HashMap我们实际上在文档中拥有此类的线程安全行为:如果多个线程同时访问哈希映射,并且至少一个线程在结构上修改了该映射,它必须在外部同步。(结构修改是添加或删除一个或多个映射的任何操作;仅更改与实例已经包含的键相关联的值不是结构修改。)
BeeOnRope

因此,HashMap对于我们希望是只读的方法,它们是只读的,因为它们不会在结构上修改HashMap。当然,此保证可能不适用于任意其他Map实现,但问题是HashMap具体有关的。
BeeOnRope

70

涉及Java内存模型的神杰里米·曼森(Jeremy Manson)在这个主题上有一个由三部分组成的博客-因为从本质上讲,您是在问“访问不变的HashMap是否安全”这个问题,答案是肯定的。但是您必须回答该问题的谓词-“我的HashMap是不可变的”。答案可能会让您感到惊讶-Java具有一组相对复杂的确定不变性的规则。

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

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

第2部分有关Java的不可变性: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


3
这是一个好点,但是我依赖于静态初始化,在此期间没有引用会逸出,因此它应该是安全的。
Dave L.

5
我看不到这是一个高度评价的答案(甚至答案)。它甚至没有回答一个问题,也没有提到决定其是否安全的一项关键原则:安全出版。“答案”可以归结为“很复杂”,这是您可以阅读的三个(复杂)链接。
BeeOnRope

他确实在第一句话的结尾回答了这个问题。关于答案,他提出了一个问题,即不变性(在问题的第一段中提到的)并不简单,还有宝贵的资源可以进一步解释该主题。这些要点并不衡量它是否是答案,而是衡量答案是否对他人“有用”。答案被接受意味着您的答案是OP所寻找的答案。
杰西

@Jesse他没有在第一句话的末尾回答问题,他在回答“是否可以安全访问不可变对象”这个问题,正如他在下一个句子中指出的那样,这可能适用于OP的问题,也可能不适用。本质上,这几乎是一个仅链接的“自己搞定”类型的答案,这对于SO来说不是一个好答案。至于投票,我认为这是10.5岁和一个经常被搜索的话题。在过去的几年中,它只收到了很少的净投票,所以也许有人来了:)。
BeeOnRope

35

从同步的角度来看,读取是安全的,但从内存的角度来看,则不是。在Java开发人员中,包括在Stackoverflow上,这是一个普遍被误解的东西。(观察该答案的等级以作证明。)

如果您正在运行其他线程,如果当前线程没有写出内存,则他们可能看不到HashMap的更新副本。内存写操作是通过使用synced或volatile关键字,或通过使用某些Java并发结构进行的。

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


对不起,双重提交的希思(Heath),我只在提交我的通知后才注意到您的。:)
亚历山大

2
我很高兴这里有其他人真正了解记忆效应。
Heath Borders

1
确实,尽管没有线程会在正确初始化该对象之前看到该对象,所以在这种情况下,我认为这不是问题。
Dave L.

1
这完全取决于对象的初始化方式。
比尔·米歇尔

1
问题是,一旦HashMap初始化,他就不打算进一步更新它。从那以后,他只想将其用作只读数据结构。我认为这样做是安全的,前提是存储在他的Map中的数据是不可变的。
Binita Bharati 2015年

9

经过一番寻找之后,我在java doc中找到了这个(重点是我的):

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

这似乎暗示着它是安全的,假设存在相反的说法。


1
尽管这是一个很好的建议,但正如其他答案所指出的那样,在不可变且安全发布的地图实例的情况下,答案会更加细微。但是,只有知道自己在做什么,才应该这样做。
亚历·米勒

1
希望有这样的问题,我们中的更多人可以知道我们在做什么。
Dave L.

这是不正确的。作为其他答案的状态,在最后一次修改和所有后续的“线程安全”读取之间必须发生一个事件。通常,这意味着您必须在创建对象并进行修改安全地发布该对象。请参阅第一个标记为正确的答案。
markspace

9

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

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


1
实际上,我已经看到这种挂起JVM而不消耗CPU(这可能更糟)
Peter Lawrey 09年

2
认为这段代码已被重写,以致无法获得无限循环。但是,出于其他原因,您仍然不应该试图从不同步的HashMap中进行获取和放置。
亚历克斯·米勒

@AlexMiller甚至除了其他原因(我假设您指的是安全发布)之外,我都不认为实现更改不应成为放宽访问限制的原因,除非文档中明确允许。碰巧的是,Java 8 的HashMap Javadoc仍然包含以下警告:Note that this implementation is not synchronized. If multiple threads access a hash map concurrently, and at least one of the threads modifies the map structurally, it must be synchronized externally.
shmosel 2015年

8

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


我不确定访问该线程的线程是否会获得任何锁定,但是我确定在初始化该对象之前,他们不会获得对该对象的引用,因此我认为他们不能拥有陈旧的副本。
Dave L.

@Alex:对HashMap的引用可能会不稳定,以创建相同的内存可见性保证。@戴夫:这可以看到新的OBJ引用前的构造函数的工作变得对你的线程是可见的。
克里斯·韦斯特

@Christian在一般情况下,当然可以。我是说在这段代码中不是。
Dave L.

获取RANDOM锁不会保证要清除的整个线程cpu缓存。它取决于JVM的实现,并且很可能不是通过这种方式完成的。
皮埃尔

我同意Pierre,但我认为获取任何锁都不够。您必须在相同的锁上同步才能使更改可见。
damluar's

5

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

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


该答案的质量很低,与@taylor gauthier的答案相同,但细节较少。
尼克斯(Snicolas)'16

1
嗯...不是一个混蛋,但是你倒向了。泰勒说:“不,去看一下这篇博客,答案可能会让你感到惊讶”,而这个答案实际上增加了一些我不知道的新东西。构造函数。这个答案很好,我很高兴阅读。
阿贾克斯

??这是我在浏览评分较高的答案后找到的唯一正确答案。密钥已安全发布,这是唯一提到的答案。
BeeOnRope

3

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

有关并发使用地图时如何严重失败的示例,以及我提到的建议解决方法,请查看此Bug游行条目:bug_id = 6423457


2
这是“安全的”,因为它可以实现不变性,但是不能解决线程安全性问题。如果使用UnmodifiableMap包装器可以安全地访问地图,则无需使用它即可安全,反之亦然。
Dave L.

2

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声明为as final且其初始化是在所有者的类构造函数中完成的,因此,保证以后构造者完成该操作时,读取此映射的任何线程都可以看到它,前提是没有其他线程尝试修改该映射的内容。


1

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

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

就是说,只要您不执行其他任何操作,从HashMap进行的并发读取都是安全的。

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

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

我的目的不是与有关Java内存模型的更详细的答案相矛盾。该答案旨在指出,除了并发问题之外,ConcurrentHashMap和HashMap之间至少还有一个API差异,这甚至可能破坏单线程程序,而该程序将另一个程序替换为另一个程序。]


感谢您的警告,但没有尝试使用空键或值。
Dave L.

以为不会。集合中的null是Java的疯狂角落。
史蒂夫·杰索普

我不同意这个答案。“从HashMap并行读取是安全的”本身是错误的。它没有说明读取是针对可变或不可变的映射进行的。为正确起见,它应显示为“从不变的HashMap进行并行读取是安全的”
Taylor Gautier

2
并非根据您自己链接的文章:要求是不得更改映射(以前的更改必须对所有读者线程可见),而不是不可变的(这是Java中的技术术语,是Java的足够但不是必要的安全条件)。
史蒂夫·杰索普

还要注意...初始化类隐式地在同一锁上同步(是的,您可以在静态字段初始化程序中进行死锁),因此,如果初始化是静态发生的,那么在初始化完成之前,其他任何人都无法看到它,因为它们将必须在获得相同锁的ClassLoader.loadClass方法中被阻止...并且如果您想知道不同的类加载器具有相同字段的不同副本,那将是正确的...但是与之正交。比赛条件的概念;类加载器的静态字段共享一个内存屏障。
Ajax

0

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

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

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

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


2
我认为在某些类中,例如,由于内部使用了临时实例变量,简单地并发读取会给您带来麻烦。因此,可能需要仔细检查源代码,而不是快速扫描锁定/互斥代码。
Dave L.

0

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

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

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也是易变的。并且如果该方法是构造函数。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.