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

Ken*_*ong 12 java caching volatile

考虑下面的一段代码(乍一看似乎不太正常).

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!!!");
        }
    });
}
Run Code Online (Sandbox Code Playgroud)

我的问题是:为了绝对肯定"问题!!!" 将不会打印,NumberContainer类中的"value"变量是否需要标记为volatile?

让我解释一下我目前如何理解这一点.

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

  • 第一个并行流完成,但不会杀死常见的fork-join池工作线程.然后使用完全相同的公共fork-join池工作线程启动第二个并行流.

  • 现在假设,在第二个并行流中,递增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,导致代码失败!

我的描述是否正确?如果是这样,有人可以告诉我两个递增操作之间需要什么样的时间延迟才能保证线程之间的缓存一致性?或者,如果我的理解是错误的,那么有人可以告诉我是什么机制导致线程局部缓存在第一个并行流和第二个并行流之间"刷新"?

alf*_*alf 5

它不应该有任何延迟.通过你在外面的时间ParallelStreamforEach,所有的任务已经完成.这确定了增量和结束之间发生的关系forEach.所有forEach调用都是通过从同一个线程调用来排序的,同样,检查所有forEach调用之后发生.

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
}
Run Code Online (Sandbox Code Playgroud)

回到关于线程的问题,这里的技巧是,线程是无关紧要的.内存模型取决于before-before关系,而fork-join任务确保在调用与操作体之间以及操作体与返回之间发生关系(即使返回值为)forEachforEachVoid

另请参阅Fork-join中的内存可见性

正如@erickson在评论中提到的,

如果你不能通过以前发生过的关系建立正确性,那么没有多少时间是"足够的".这不是挂钟计时问题; 您需要正确应用Java内存模型.

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

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

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

  • 我要回答这一点回家,"这里的诀窍是,线程无关紧要,"但我只是在这里评论:如果你不能通过`before-before`关系建立正确性,*没有金额时间*是"足够的".这不是挂钟计时问题; 您需要正确应用Java内存模型. (3认同)