仅在添加到 HashSet 时同步是不是是线程安全的?
Posted
技术标签:
【中文标题】仅在添加到 HashSet 时同步是不是是线程安全的?【英文标题】:Is it thread-safe to synchronize only on add to HashSet?仅在添加到 HashSet 时同步是否是线程安全的? 【发布时间】:2019-12-13 01:58:35 【问题描述】:想象一下有一个主线程,它创建一个 HashSet 并启动许多工作线程,将 HashSet 传递给它们。
就像下面的代码:
void main()
final Set<String> set = new HashSet<>();
final ExecutorService threadExecutor =
Executors.newFixedThreadPool(10);
threadExecutor.submit(() -> doJob(set));
void doJob(final Set<String> pSet)
// do some stuff
final String x = ... // doesn't matter how we received the value.
if (!pSet.contains(x))
synchronized (pSet)
// double check to prevent multiple adds within different threads
if (!pSet.contains(x))
// do some exclusive work with x.
pSet.add(x);
// do some stuff
我想知道仅在 add 方法上同步是否是线程安全的?如果contains
没有同步,会不会有什么问题?
我的直觉告诉我这很好,在离开同步块后对 set 所做的更改应该对所有线程可见,但 JMM 有时可能违反直觉。
附:我不认为它与How to lock multiple resources in java multithreading 重复 尽管两者的答案可能相似,但这个问题涉及更多特殊情况。
【问题讨论】:
在java中你可以创建并发集。有关详细信息,请参阅此答案:***.com/questions/6992608/… 不安全。所做的更改始终对所有线程可见。只有使用同步块,您才是安全的。如果不这样做,您可以访问部分更新的内部结构。 How to lock multiple resources in java multithreading的可能重复 查看重复链接。在 Java 中,读取和写入都必须同步。如果您不这样做,JVM 可以随意优化读取,包括不检查来自另一个线程的更新。所以基本上你的 HashSet 的所有方法都必须同步,否则你会遇到麻烦。 @rghome,markspace,谢谢您的回答! “访问部分更新的内部结构。”让我明白了。 【参考方案1】:我想知道仅在
add
方法上同步是否是线程安全的?如果contains
也没有同步,是否有任何可能的问题?
简短回答:否和是。
有两种解释方式:
直观的解释
Java 同步(以各种形式)可以防范许多事情,包括:
两个线程同时更新共享状态。 一个线程尝试读取状态,而另一个线程正在更新它。 线程看到 stale 值,因为内存缓存尚未写入主内存。在您的示例中,在add
上进行同步足以确保两个线程无法同时更新HashSet
,并且两个调用都将在最新的HashSet
状态下运行。
但是,如果 contains
也未同步,contains
调用可能与 add
调用同时发生。这可能导致contains
调用看到HashSet
的中间状态,从而导致不正确的结果,或更糟。如果调用不是同时发生的,也可能发生这种情况,因为更改没有立即刷新到主内存和/或读取线程没有从主内存读取。
内存模型解释
JLS 指定了 Java 内存模型,它规定了多线程应用程序必须满足的条件,以保证一个线程看到另一个线程进行的内存更新。该模型用数学语言表达,不易理解,但要点是当且仅当从写入到后续读取之间存在一连串发生在关系链时,才能保证可见性。如果写入和读取在不同的线程中,那么线程之间的同步是这些关系的主要来源。例如在
// thread one
synchronized (sharedLock)
sharedVariable = 42;
// thread two
synchronized (sharedLock)
other = sharedVariable;
假设线程一代码在线程二代码之前运行,那么线程一释放锁和线程二获取锁之间存在happen before关系。有了这个和“程序顺序”的关系,我们可以建立一个从42
的写入到other
的赋值的链。这足以保证other
将被分配42
(或者可能是变量的后续值),而不是sharedVariable
在写入42
之前的任何值。
如果没有synchronized
块在同一个锁上同步,第二个线程可能会看到sharedVariable
的陈旧值;即在分配42
之前写入的一些值。
【讨论】:
【参考方案2】:该代码对于synchronized (pSet)
部分是线程安全的:
if (!pSet.contains(x))
synchronized (pSet)
// Here you are sure to have the updated value of pSet
if (!pSet.contains(x))
// do some exclusive work with x.
pSet.add(x);
因为在pSet
对象上的synchronized
语句内部:
pSet
的更新状态也由与 synchronized 关键字的发生前关系保证。
所以无论第一个if (!pSet.contains(x))
语句为等待线程返回的值是什么,当这个等待线程唤醒并进入synchronized
语句时,它将设置最后更新的值pSet
。因此,即使前一个线程添加了相同的元素,第二个 if (!pSet.contains(x))
也会返回 false
。
但是对于第一条语句if (!pSet.contains(x))
,这段代码不是线程安全的,它可以在Set
的写入期间执行。
根据经验,不应该使用不是为线程安全而设计的集合来执行并发的写入和读取操作,因为集合的内部状态可能处于正在进行/不一致的状态,以用于同时发生的读取操作写操作。
虽然一些非线程安全的集合实现实际上接受了这样的用法,但这并不能保证它总是正确的。所以你应该使用线程安全的Set
实现来保证整个事情线程安全。
例如:
Set<String> pSet = ConcurrentHashMap.newKeySet();
这在引擎盖下使用ConcurrentHashMap
,因此没有用于读取的锁和用于写入的最小锁(仅在要修改的条目上而不是整个结构上)。
【讨论】:
感谢您的回答!因此,正如我从另一个答案中得到的那样,此代码仅在使用线程安全Set
实现时才是安全的,因为在使用 HashSet 的最坏情况下,外部contains
(外部同步块)可能会发现设置为部分更新状态并崩溃, 我对吗?还是这种特殊情况不需要使用线程安全的Set
实现?
这不仅仅是一个“你......是否有更新的价值......”的问题,这也是一个contains(x)
是否可以为某些x
返回true
的问题是从未在集合中,或false
表示肯定在集合中的对象,或者重叠的contains(...)
和add(...)
调用之间的交互是否会抛出Error
,或 segfault 并使 JVM 崩溃。 HashSet
的 Javadoc 非常明确,在多线程环境中,对任何给定实例的 all 访问必须是 synchronized
。多线程的规则 #1 是,信任并遵守库文档。
@Solomon Slow 我完全同意,由于读取操作,最好的规定是使用 Set 线程安全实现。那是我最后推荐的。我的观点是区分这两点 1) 双锁同步的工作原理是 OP 在 Set 中添加的内容,这要归功于 contains() 与对象上的监视器 2) 但另一方面,之前的读取操作可能会导致另一个原因是:在无线程安全集合上进行并发读写。 PS:我重新制定了关于没有线程安全集合的一般规定。【参考方案3】:
否,
您不知道在另一个线程 add 期间 Hashset 可能处于什么状态。可能正在进行基本更改,例如拆分桶,因此 contains 可能在另一个线程添加期间返回 false,即使该元素将在单线程 HashSet 中存在.在这种情况下,您将尝试再次添加一个元素。
更糟糕的情况:contains 可能会因为两个线程同时使用的内存中 HashSet 的临时无效状态而陷入无限循环或抛出异常。
【讨论】:
以上是关于仅在添加到 HashSet 时同步是不是是线程安全的?的主要内容,如果未能解决你的问题,请参考以下文章