Java - 连续并行流之间的缓存一致性?

2022-09-04 22:52:08

考虑下面的代码段(乍一看并不完全相同)。

static class NumberContainer {

    int value = 0;

    void increment() {
        value++;
    }

    int getValue() {
        return value;
    }
}

public static void main(String[] args) {

    List<NumberContainer> list = new ArrayList<>();
    int numElements = 100000;
    for (int i = 0; i < numElements; i++) {
        list.add(new NumberContainer());
    }

    int numIterations = 10000;
    for (int j = 0; j < numIterations; j++) {
        list.parallelStream().forEach(NumberContainer::increment);
    }

    list.forEach(container -> {
        if (container.getValue() != numIterations) {
            System.out.println("Problem!!!");
        }
    });
}

我的问题是:为了绝对确定不会打印“问题!!!”,数字容器类中的“value”变量是否需要标记为易失性?

让我解释一下我目前是如何理解这一点的。

  • 在第一个并行流中,NumberContainer-123(比如说)由ForkJoinWorker-1(比如说)递增。因此,ForkJoinWorker-1 将具有 NumberContainer-123.value 的最新缓存,即 1。(但是,其他分叉连接工作线程将具有 NumberContainer-123.value 的过期缓存 - 它们将存储值 0。在某些时候,这些其他工作线程的缓存将被更新,但这不会立即发生。

  • 第一个并行流完成,但不会终止常见的分叉连接池工作线程。然后,第二个并行流启动,使用完全相同的常见分叉连接池工作线程。

  • 现在,假设在第二个并行流中,递增NumberContainer-123的任务分配给ForkJoinWorker-2(例如)。ForkJoinWorker-2 将有自己的缓存值 NumberContainer-123.value。如果在 NumberContainer-123 的第一个和第二个增量之间经过了很长一段时间,那么推测 ForkJoinWorker-2 的 NumberContainer-123.value 缓存将是最新的,即值 1 将被存储,一切都很好。但是,如果 NumberContainer-123 非常短,那么在第一个和第二个增量之间经过的时间会怎样呢?然后,也许ForkJoinWorker-2的NumberContainer-123.value缓存可能已过期,存储值0,导致代码失败!

我上面的描述是否正确?如果是这样,任何人都可以告诉我,为了保证线程之间的缓存一致性,两个递增操作之间需要什么样的时间延迟?或者,如果我的理解是错误的,那么有人可以告诉我是什么机制导致线程本地缓存在第一个并行流和第二个并行流之间被“刷新”?


答案 1

它不应该有任何延迟。当你离开时,所有的任务都已完成。这将在 的增量和结束之间建立先发生关系。所有调用都是通过从同一线程调用来排序的,并且检查同样发生在所有调用之后ParallelStreamforEachforEachforEachforEach

int numIterations = 10000;
for (int j = 0; j < numIterations; j++) {
    list.parallelStream().forEach(NumberContainer::increment);
    // here, everything is "flushed", i.e. the ForkJoinTask is finished
}

回到你关于线程的问题,这里的诀窍是,线程是无关紧要的。内存模型取决于发生之前的关系,而 fork-join 任务确保对操作正文的调用与操作正文之间以及操作正文与返回者之间的发生之前关系(即使返回的值为forEachforEachVoid)

另请参阅分叉联接中的内存可见性

正如@erickson评论中提到的,

如果你不能通过事前关系建立正确性,那么再多的时间也不够“不够”。这不是一个挂钟计时问题;您需要正确应用 Java 内存模型。

此外,从“刷新”内存的角度来考虑它是错误的,因为还有更多的事情会影响你。例如,刷新是微不足道的:我没有检查,但可以打赌任务完成时只有一个记忆障碍;但是你可能会得到错误的数据,因为编译器决定优化非易失性读取(变量不是易失性的,并且在这个线程中不会改变,所以它不会改变,所以我们可以将其分配给寄存器,),以发生之前关系允许的任何方式重新排序代码,等等。

最重要的是,所有这些优化都可以并且会随着时间的推移而改变,所以即使你去了生成的程序集(可能会根据负载模式而变化)并检查了所有内存障碍,它也不保证你的代码会工作,除非你能证明你的读取发生在你的写入之后,在这种情况下,Java内存模型是站在你这边的(假设JVM中没有错误)。

至于巨大的痛苦,这是使同步变得微不足道的目标,所以享受。它(似乎)是通过标记易失性来完成的,但这是一个你不应该关心或依赖的实现细节。ForkJoinTaskjava.util.concurrent.ForkJoinTask#status


答案 2