【问题标题】:Does ConcurrentMap.remove() provide a happens-before edge before get() returns null?在 get() 返回 null 之前,ConcurrentMap.remove() 是否提供先发生边缘?
【发布时间】:2016-09-06 06:12:27
【问题描述】:

在调用ConcurrentMap.remove() 之前线程中的操作是否保证happen-before 在看到从另一个线程中删除之后的操作?

Documentation 表示关于放入集合中的对象:

将对象放入任何并发集合之前线程中的操作发生在另一个线程中从集合中访问或删除该元素之后的操作。

示例代码:

{
    final ConcurrentMap map = new ConcurrentHashMap();
    map.put(1, new Object());

    final int[] value = { 0 };

    new Thread(() -> {
        value[0]++;
        value[0]++;
        value[0]++;
        value[0]++;
        value[0]++;

        map.remove(1); // A

    }).start();

    new Thread(() -> {
        if (map.get(1) == null) { // B
            System.out.println(value[0]); // expect 5
        }

    }).start();
}

A 是否与 B 处于 happens-before 关系中?因此,程序是否应该只打印 5?

【问题讨论】:

  • 鉴于您引用的文档,您为什么会有不同的想法?
  • @shmosel 这个特定的文本说明了放置对象之前的操作发生在之前,但没有关于移除对象之前的操作。
  • 您从其他并发集合类型中挑选出ConcurrentMap 有什么特别的原因吗?
  • @shmosel 我不确定我是否理解你的问题,但这是我在问题出现时使用的,也是我目前对答案感兴趣的。

标签: java concurrency


【解决方案1】:

您发现这些并发工具的一个有趣的微妙方面很容易被忽视。

首先,不可能提供关于删除和检索null 引用的一般保证,因为后者仅证明不存在映射而不是先前的删除,即线程可以在键有映射之前读取映射的初始状态,这当然不能与之后发生的操作建立 happens-before 关系地图的构造。

另外,如果有多个线程删除同一个键,则在检索 null 时,您不能假设 happens-before 关系,因为您不知道哪个删除已完成.此问题类似于两个线程插入相同值的情况,但后者可以在应用程序端通过仅执行可区分值的插入或遵循对正在执行的值对象执行所需修改的通常模式来解决插入并仅查询检索到的对象。对于删除,没有这样的修复。

在您的特殊情况下,map.put(1, new Object()) 操作与第二个线程的开始之间存在 happens-before 关系,因此如果第二个线程在查询键 @987654327 时遇到 null @,很明显,它见证了您的代码的唯一删除,但规范并没有费心为这种特殊情况提供明确的保证。

相反,Java 8’s ConcurrentHashMap 的规范说,

检索反映了最近完成更新操作在开始时保持的结果。 (更正式地说,给定键的更新操作与报告更新值的键的任何(非空)检索具有 happens-before 关系。)

明确排除 null 检索。

我认为,使用当前 (Java 8) ConcurrentHashMap 实现,您的代码不会中断,因为它相当保守,因为它使用 volatile 语义执行对其内部支持数组的所有访问。但这只是当前的实现,如上所述,您的代码是一种特殊情况,可能会随着实际应用程序的每次更改而损坏。

【讨论】:

  • +1:总而言之,我同意您的推理,但是您的第二和第三段做出了概括性的陈述,尽管它们仅适用于某些用法(可能包括从我的示例中推导出的用法)。例如如果我使用地图对某些关键部分进行排除,则 absence 正是我想要证明的,并且这本身应该没问题,如果不是因为删除可能会或可能不会保证在关键部分完成的工作被传播。 gist.github.com/atnak/f64b8cdb6fe5042cfdc05f7c25411996?ts=4
  • @antak:当然,如果您想证明缺席,那么一切都很好。这里的重点是,缺席不会与移除建立 happens-before 关系。请注意,通过存在或不存在映射来保护临界区与由Lock 保护的临界区没有什么不同。一个成功的锁操作并不足以证明之前的解锁操作的存在,你必须查询共享状态来找出哪些操作已经完成。
  • 用Lock 保护临界区是不同。一个成功的锁定操作足以证明如果有一个先前的解锁操作,解锁线程之前对共享状态所做的任何事情解锁已经已经发生并且没有进行。在没有映射的情况下,查询共享状态甚至是不安全的(正如您的答案所确定的那样?),因为对其执行的操作可能正在进行或似乎正在进行。共享状态本身需要为 volatile 或具有其他一些同步属性才能安全。
  • 没错。 Lock 的状态和ConcurrentMap 中映射的存在/不存在都可用于确保关键部分的互斥,但只有Lock 允许您使用 happens-before 关系访问共享状态。
【解决方案2】:

不,你的顺序错了。

从put() 到随后的get() 之间有一个happens-before 边。该边缘不是对称的,并且在另一个方向上不起作用。从get() 到另一个get() 或remove(),或从put() 到另一个put(),没有happens-before边。

在这种情况下,您在地图中放置了一个对象。然后你修改另一个对象。那是不行的。在第二个线程中对get() 的写入没有优势,因此第二个线程可能看不到这些写入。

在英特尔硬件上,我认为这将始终有效。但是,Java 内存模型并不能保证这一点,因此如果您将此代码移植到不同的硬件上,请务必小心。

【讨论】:

  • 这似乎没有回答这个问题。问题不是问get() happen-before 是另一个get() 还是remove()。 put() 也是一个删除(它返回前一个值),因此在放置对象之前的操作发生在同一元素上的另一个 put() 之前。你说放置一个对象然后修改另一个对象是不行的,但我认为这没有抓住重点。如果所述动作是在之前 put()ing 一个完全不同的对象执行的,那么它们将对访问该元素的另一个线程可见。但是,这不是我要问的。
  • 你能说一下在 JLS 中的什么地方,它指定读/写障碍可以仅限于一个对象。
  • @antak 当我看到你做的是你修改一个对象(数组)然后你期望从remove() 到get() 的边缘之前发生。规范没有说明这一点。即使您对 put() 也被删除是正确的,也没关系,因为在您写入之后没有 put()。
  • @PeterLawrey 没关系,因为那不是他正在做的事情。他期待从remove() 到get() 的发生前边缘,并且规范对该操作保持沉默。因此,不能保证。
【解决方案3】:

A 不需要在 B 之前发生。

只有原始的put 发生在两者之前。因此,B 处的 null 意味着 A 发生了。

但是没有提到线程本地内存缓存的写回和++和remove的指令顺序。 volatile 未使用;而是使用 Map 和数组来希望保持线程数据同步。在写回数据时,有序关系应该再次保持。

据我了解,A 可以删除并被写回,然后最后一个 ++ 发生,并且在 B 处打印类似 4 的内容。我会将 volatile 添加到数组中。地图本身会很好。

我不太确定,但由于我没有看到相应的答案,所以我伸出了脖子。 (自学。)

【讨论】:

    【解决方案4】:

    由于 ConcurrentHashMap 是一个线程安全的集合,如果语句 map.remove(1) 改变了映射,它必须有一个读屏障和一个写屏障。表达式map.get(1) 必须有一个或一个读屏障,或者这两个操作都不是线程安全的。

    实际上直到 Java 7 的 ConcurrentHashMap 都使用分区锁,因此它几乎对每个操作都有一个读/写屏障。

    ConcurrentSkipListMap 不必使用锁,但要执行任何线程安全的写操作,就需要写屏障。

    这意味着您的测试应始终按预期运行。

    【讨论】:

    • 通过读/写屏障,我假设您的意思是一种包含 javac / JVM 可以做的所有事情的一致性机制,包括指令重新排序和值缓存,而不是 CPU 指令。当我看到这个术语时,我认为您的答案可能取决于平台,但您最近的几次编辑已经在一定程度上澄清了这一点。
    • “线程安全”可能意味着不同的东西,具体取决于上下文。 ConcurrentHashMap 明确允许弱一致性迭代和重叠读写。除非有明确的记录,否则为什么要假设写入和读取之间存在先于发生的关系?事实上,问题中的引用(在ConcurrentMap 接口上重复)似乎暗示这些关系不能被视为理所当然,它们仅在特定情况下得到保证。
    • 以下是 ConcurrentHashMap 文档的摘录:“检索反映了最近完成的更新操作在开始时保持的结果。(更正式地说,给定键的更新操作会发生- 在与报告更新值的该键的任何(非空)检索的关系之前。)"
    • 这是 ConcurrentHashMap 的摘录:嗯,对 non-null 检索键的强调特别表明 happens-before 报告 null 时不应视为理所当然...
    • 只有Java 8之前的ConcurrentHashMap使用了分区锁,从Java 8开始,它的工作方式就大不相同了。一个很好的例子,说明为什么不依赖特定实现的行为。
    猜你喜欢
    • 2016-06-05
    • 2021-12-24
    • 2011-03-26
    • 2011-01-11
    • 2017-08-03
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多