ConcurrentHashMap是完全安全的吗?

59

这是来自JavaDoc关于ConcurrentHashMap的一段内容。它说检索操作通常不会阻塞,因此可能与更新操作重叠。这是否意味着get()方法不是线程安全的?

  

“然而,尽管所有操作都是线程安全的,但检索操作不涉及锁定,并且没有任何支持以防止所有访问方式锁定整个表格的支持。在依赖于其线程安全性但不依赖于其同步详细信息的程序中,此类与Hashtable完全互操作。

     

检索操作(包括get)通常不会阻塞,因此可能与更新操作(包括put和remove)重叠。检索反映了最近完成的更新操作的结果,在它们的开始时保持。”


如果我理解正确的话,这意味着检索将始终返回最后一次更新完成的结果 - 即在检索开始时。这意味着完整的更新可以在检索开始和完成之间发生,但它不会改变该检索的结果。 - Aurand
6个回答

72

get()方法是线程安全的,其他用户已经就此问题给出了有用的答案。

然而,尽管ConcurrentHashMap是线程安全的插入式HashMap替代品,但重要的是要意识到,如果您正在执行多个操作,则可能需要显着更改代码。例如,考虑以下代码:

if (!map.containsKey(key)) 
   return map.put(key, value);
else
   return map.get(key);
在多线程环境中,这是一个竞态条件。您必须使用 ConcurrentHashMap.putIfAbsent(K key, V value),并注意返回值,该返回值告诉您是否成功进行了放置操作。阅读文档以获取更多详细信息。
回答一条要求澄清为什么这是竞态条件的评论。
想象一下有两个线程 A 和 B 将在映射中放置两个不同的值,分别是 v1 和 v2,具有相同的键。键最初不存在于映射中。它们交替执行:
- 线程 A 调用 `containsKey` 并发现该键不存在,但立即被挂起。 - 线程 B 调用 `containsKey` 并发现该键不存在,并有时间插入其值 `v2`。 - 线程 A 恢复并插入 `v1`,“平静地”覆盖(因为`put` 是线程安全的)由线程 B 插入的值。
现在线程 B “认为”它已经成功地插入了自己的值 `v2`,但是映射包含了 `v1`。这真的是一场灾难,因为线程 B 可能会调用 `v2.updateSomething()` 并且将“认为”映射的消费者(例如,其他线程)可以访问该对象并且会看到可能重要的更新(“例如:此访问者 IP 地址正在尝试执行 DOS,请从现在开始拒绝所有请求”)。相反,该对象很快就会被垃圾回收并且丢失。

“不是HashMap的线程安全替代品。” 哦,它确实是。 您是正确的,多个操作存在竞争条件,但这并不妨碍ConcurrentHashMap成为线程安全的。 - Gray
1
所谓的“drop-in replacement”是指:您将HashMap更改为ConcurrentHashMap,地图上的所有常见操作(例如手工制作的“put if absent” - 我已经看到了无数次)都会自动变成线程安全的。 - gd1
同意。但是你的说法“ConcurrentHashMap不是HashMap的线程安全替代品”至少是极其误导性的。我会说这是不正确的。 - Gray
5
我会把它改成 "尽管 ConcurrentHashMap 可以作为线程安全的 HashMap 直接替代,但是需要注意如果要进行多个操作..." - Gray
为什么上面的代码片段存在竞态条件?ConcurrentHashMap.put()不是线程安全的吗?我很困惑。 - java seeker
显示剩余4条评论

22

它是线程安全的。然而,确保线程安全的方式可能与您期望的方式不同。您可以从以下"提示"中看到:

在依赖于其线程安全性但不依赖于其同步细节的程序中,此类与 Hashtable 完全可互操作。

要了解完整的故事,您需要了解 ConcurrentMap 接口。

原始的 Map 提供一些非常基本的读取/更新方法。即使我能够制作线程安全的 Map 实现,但很多情况下人们无法使用我的 Map 而不考虑我的同步机制。这是一个典型的例子:

if (!threadSafeMap.containsKey(key)) {
   threadSafeMap.put(key, value);
}

即使该映射本身是线程安全的,但这段代码并不是线程安全的。两个线程同时调用containsKey()可能会认为没有这样的键,因此它们都插入到Map中。

为了解决这个问题,我们需要显式地进行额外的同步。假设我的Map的线程安全性是通过同步关键字实现的,则需要执行以下操作:

synchronized(threadSafeMap) {
    if (!threadSafeMap.containsKey(key)) {
       threadSafeMap.put(key, value);
    }
}

这种额外的代码需要你了解地图的“同步细节”。在上面的例子中,我们需要知道同步是通过“synchronized”实现的。

ConcurrentMap接口更进一步地定义了一些涉及多次访问映射的常见“复杂”操作。例如,上面的示例被公开为putIfAbsent()。通过这些“复杂”操作,ConcurrentMap的用户(在大多数情况下)不需要对多次访问映射进行同步操作。因此,Map的实现可以执行更复杂的同步机制以获得更好的性能。 ConcurrentHashMap就是一个很好的例子。它是线程安全的,因为对Map的并发访问不会破坏内部数据结构或导致任何意外的更新丢失等问题。

考虑到以上所有内容,Javadoc的含义也将更加清晰:

“检索操作(包括get)通常不阻塞”,因为ConcurrentHashMap不使用“synchronized”来确保线程安全性。 get本身的逻辑会处理线程安全性;如果您进一步查看Javadoc:

  

表被分区以尝试允许指定数量的并发更新而没有竞争

不仅检索是非阻塞的,即使更新也可以同时发生。但是,非阻塞/并发更新并不意味着它是线程不安全的。这只是意味着它使用了一些方式来实现线程安全性,而不是简单的“synchronized”。

然而,因为内部同步机制未公开,如果您想要进行除ConcurrentMap提供的操作之外的某些复杂操作,则可能需要考虑更改您的逻辑或考虑不使用ConcurrentHashMap。例如:

// only remove if both key1 and key2 exists
if (map.containsKey(key1) && map.containsKey(key2)) {
    map.remove(key1);
    map.remove(key2);
}

11

ConcurrentHashMap.get()是线程安全的,这意味着:

  • 它不会抛出任何异常,包括ConcurrentModificationException
  • 它将返回一个在过去某个(近期的)时间内是正确的结果。这意味着连续两次调用get可能会返回不同的结果。当然,其他任何Map也是如此。

调用 'get()' 并保存结果。再次调用 'get()' 并比较结果。这就是 'back-to-back' 的意思。 - Lee Meador
2
如果在调用get()之间进行了修改映射的操作,如put()或其他操作,则连续调用get()可能会返回不同的结果。 - Gray
2
我不太同意你的回答,因为:1. 不抛出任何异常并不意味着它是线程安全的,而ConcurrentModificationException也与线程安全无关。2. “连续两次调用得到相同结果”从未是Map的契约,并且它也与线程安全无关。 - Adrian Shum
1
@MiserableVariable 但是即使他是新手,也不意味着向他提供错误的答案是可以接受的(抱歉,我看不出答案是“简化”,而只是不相关和不正确的)。 - Adrian Shum
1
我认为这与HashEntry中的值对象是易变的有关。 - AKS
显示剩余2条评论

9
HashMap 根据 hashCode 将数据分配到不同的 "buckets" 中。 ConcurrentHashMap 利用了这一点。它的同步机制基于阻塞单个bucket而不是整个Map,这样就可以让多个线程同时写入到不同的buckets中(一个线程每次只能写入一个bucket)。
ConcurrentHashMap 中读取数据几乎不需要使用同步机制。当获取key对应的value时,如果该value为 null ,则会使用同步机制。由于ConcurrentHashMap不能将 null 作为值存储(除了key以外,value也不能为 null),这表明在另一个线程初始化map entry(键值对)期间,读取线程在读取过程中遇到了 null。这种情况下,读取线程需要等待直到entry完全被写入。
因此,read() 方法的结果基于map的当前状态。如果读取正在更新中的key对应的value,则很可能会获取旧的value,因为写入过程还没有完成。

5
在ConcurrentHashMap中,get()方法是线程安全的,因为它读取的值是volatile的。如果某个key的value为null,则get()方法会等待获取锁,然后再读取更新后的value。
当put()方法更新ConcurrentHashMap时,它将该key的value设置为null,然后创建一个新条目并更新ConcurrentHashMap。这个null值被get()方法用作另一个线程正在使用相同的key更新ConcurrentHashMap的信号。

当put()方法更新CHM时,它会将该键的值设置为null。请问能否提供更多的澄清信息?有相关链接或代码吗? - Abdullah Khan

4
这意味着当一个线程正在更新,另一个线程正在读取时,不能保证首先调用ConcurrentHashMap方法的操作会首先发生。举个例子,如果一个线程想要了解Bob的位置,同时另一个线程更新说他已经“在屋里了”,你无法预测读取线程将获取Bob的状态是“在屋里”还是“在外面”。即使更新线程首先调用该方法,读取线程也可能得到“在外面”的状态。
这些线程不会互相引起问题。代码是线程安全的。
一个线程不会进入无限循环,也不会开始生成奇怪的NullPointerExceptions,也不会半老不新地变成“itside”。

1
一个双重检查锁定模式会解决这个问题吗?通过强制正在查找“内部”值的线程检查两次,作者线程有机会更新该值吗?这样就可以阻止写入但不阻止读取? - viki.omega9

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接