请考虑以下代码(乍看之下并不太像)。
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!!!");
}
});
}
我的问题是:为了绝对确定“问题!!!”将不会打印出来,是否需要将NumberContainer类中的“值”变量标记为易失性?
让我解释一下我目前的理解。
在第一个并行流中,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,导致代码失败!
我上面的描述正确吗?如果是这样,谁能告诉我两次增量操作之间需要什么样的时间延迟才能保证线程之间的缓存一致性?或者,如果我的理解是错误的,那么有人可以告诉我是什么机制导致线程并行缓存在第一并行流和第二并行流之间被“刷新”吗?
答案 0 :(得分:5)
它不需要任何延迟。在您离开ParallelStream
的{{1}}时,所有任务都已完成。这就在增量和forEach
的结尾之间建立了先发生关系。所有forEach
调用的排序方式是从同一线程进行调用,检查类似,所有forEach
调用后接。
forEach
回到您有关线程的问题,这里的技巧是,线程无关。内存模型取决于 happens-before 关系,并且fork-join任务确保对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
}
的调用与操作体之间的 happens-before 关系,在操作体与forEach
的返回之间(即使返回值为forEach
)
另请参见Memory visibility in Fork-join
@erickson在评论中提到,
如果您不能通过先发生后关系来确定正确性, 没有足够的时间是“足够的”。这不是墙上时钟的定时问题;您 需要正确应用Java内存模型。
此外,从“刷新”内存的角度考虑问题是错误的,因为还有更多的事情可能会影响您。例如,冲洗很简单:我没有检查过,但是可以打赌,完成任务只是内存障碍;但是您会得到错误的数据,因为编译器决定优化非易失性读取(变量不是volatile,并且在此线程中未更改,因此它不会更改,因此我们可以将其分配给寄存器 et voila ),以“事前发生”关系允许的任何方式对代码进行重新排序,等等。
最重要的是,所有这些优化都会随着时间的推移而变化,因此,即使您转到生成的程序集(可能会因加载模式而异)并检查了所有内存障碍,也不能保证您的代码会除非您可以证明读操作是在写操作之后发生的,否则在这种情况下,Java内存模型就在您身边(假设JVM中没有错误)。
对于巨大的痛苦,Void
的主要目标是使同步变得微不足道,因此请尽情享受。 (似乎)是通过标记ForkJoinTask
易失性来完成的,但这是您不应该关心或依赖的实现细节。