【问题标题】:Consistency of memory after Java parallel stream worker threads have exitedJava 并行流工作线程退出后内存的一致性
【发布时间】:2020-06-05 21:13:09
【问题描述】:

给定以下代码:

final int n = 50;
final int[] addOne = new int[n];
IntStream.range(0, n)
        .parallel()
        .forEach(i -> addOne[i] = i + 1);
// (*) Are the addOne[i] values all visible here?
for (int value : addOne) {
    System.out.println(value);
}

问题:工作线程退出后(即(*)点),能否保证主线程看到工作线程写入的所有数组内容?

我有兴趣了解 Java 内存模型对上述问题的看法。这与 并发 问题本身无关(即 Java 中的并行流可以以任何顺序处理其元素的事实)。为了抢占一些回复,我知道如果不使用 AtomicReferenceArray<E> 之类的东西,就不可能保证两个不同线程之间的内存排序语义可以访问 Java 中的相同数组元素。出于此问题的目的,假设并行工作人员不会使用 Atomic* 类。更重要的是,请注意,没有两个工作线程会尝试写入同一个数组元素,因为所有 i 值都是唯一的。因此线程之间的内存排序语义在这里并不重要,重要的是工作线程写入数组元素的任何值在并行流结束后是否始终对主线程可见。

在主线程中初始化数组元素和启动并行工作线程之间存在计算“障碍”(工作人员最初总是会看到初始值为零的元素)。并且有一个完成屏障,等待所有工作人员在流结束时完成,然后再将控制权交还给主线程。所以实际上问题归结为当在并行流的末尾施加计算障碍时,是否可以假设总排序或隐式“内存刷新障碍”

问另一种方式,主线程是否有可能在点(*) 之后读取某个元素的默认初始化值0?或者 CPU 缓存层次结构是否始终确保主线程将看到工作线程写入数组的最新值,即使该值尚未从 CPU 缓存中刷新回 RAM?

出于这个问题的目的,我假设在并行流完成后将控制权返回给主线程需要零时间,因此不会发生导致数组值刷新到 RAM 的竞争条件取决于关闭并行流所需的时间,或者由于关闭并行流所必须进行的缓存驱逐量。

【问题讨论】:

标签: java multithreading concurrency memory-model java-memory-model


【解决方案1】:

JMM 说:

所有实例字段、静态字段和数组元素都存储在堆内存中。在本章中,我们使用术语变量来指代字段和数组元素

这意味着您需要确保写入和读取数组元素之间存在发生前的关系。

方法java.util.stream.IntStream#forEach的Javadoc说:

对于并行流管道,此操作不能保证尊重流的遇到顺序,因为这样做会牺牲并行性的好处。对于任何给定的元素,可以在库选择的任何时间和任何线程中执行操作。 如果动作访问共享状态,它负责提供所需的同步

这意味着您应该在写入和读取数组元素之间强制执行发生前的关系,因此不能保证主线程会看到工作线程写入的所有数组内容。

PS:Streams 是一个复杂的框架,实际上,我不确定它在您的特定情况下是否真的不安全,但是合同说不能保证您是否访问共享状态(您的数组在调用者线程和工人),最好遵守合同。

【讨论】:

  • 你引用的第二部分说元素可以按任何顺序处理,这与Java内存模型无关,只是并行流的实现保留了处理元素的权利它想要的任何订单。当流完成时,所有元素都将被处理,但这留下了内存一致性问题未解决。您说:“您应该在写入和读取数组元素之间强制执行发生前的关系”。流的末尾已经有一个完成障碍。这不会产生先发生的关系吗?
  • @LukeHutchison,您错过了“如果操作访问共享状态,它负责提供所需的同步”部分,您的数组元素是共享状态。关于处理订单的那部分对您的问题并不重要。
  • 我明白你在说什么,但共享状态只影响在给定时间除了一个作者任何数量的读者的情况。一旦您混合了读取器和写入器,和/或有多个写入器用于一块内存,您就必须为共享状态提供同步。这是并发的标准和通用原则,与 Java 的内存模型本身无关。我想知道的是,是否可以假设 CPU 缓存在流的末尾是一致的,因此全局线程会看到最新的缓存值。
  • @LukeHutchison,每个元素有两个写入者,首先,每个元素在数组初始化期间在主线程设置为0,然后由一个工作线程更改。
  • 您可以拥有任意数量的作者。但是你不能有两个或更多的并发 writer。在所有发生的初始化写入之间存在严格的总排序(“发生在之后”),然后所有工作线程启动。在流完成后,工作线程写入和读取之间也有严格的总排序。不同的工人写之间没有任何顺序。但是对于任何一个特定的数组元素,在值被初始化、然后被覆盖、然后被读取之间存在一个总排序。总订单没有混淆。
【解决方案2】:

审核答案:

Fork-Join 池是在 parallel() 之后执行管道的位置,其中包含 fork() invoke()join() 步骤,并且该序列中的最后一步 join() 在语义上等同于 Thread.join(),这意味着在 Fork-Join 池执行的并行任务和它之后的语句之间存在 happens-before 语义。

【讨论】:

  • 不需要自定义线程池。并行流永远不会将控制权返回给调用线程,直到所有工作线程在完成所有流元素的处理后都变得静止。是的,Future<T> 可用于在作者和读者之间产生绝对排序,但这不是我在这里要问的问题。
  • @LukeHutchison 我明白你现在的意思了——写下存在潜在操作重新排序的可见性以及什么保证它。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2011-02-15
  • 2015-12-06
  • 1970-01-01
  • 2017-05-08
  • 1970-01-01
  • 2021-07-16
  • 1970-01-01
相关资源
最近更新 更多