事实证明,答案和随后的讨论只是巩固了我最初的推理。我现在有一些东西可以证明:
- 以读线程在写线程开始执行之前完全执行的情况为例;
- 请注意此特定运行创建的同步顺序;
- 现在在挂钟时间移动线程,以便它们并行执行,但保持相同的同步顺序。
由于 Java 内存模型没有参考挂钟时间,因此不会有任何障碍。您现在有两个线程与读取线程并行执行,观察到写入线程没有执行任何操作。QED。
示例 1:一写一读线程
为了使这一发现最大程度地尖锐和真实,请考虑以下程序:
static volatile int sharedVar;
public static void main(String[] args) throws Exception {
final long startTime = System.currentTimeMillis();
final long[] aTimes = new long[5], bTimes = new long[5];
final Thread
a = new Thread() { public void run() {
for (int i = 0; i < 5; i++) {
sharedVar = 1;
aTimes[i] = System.currentTimeMillis()-startTime;
briefPause();
}
}},
b = new Thread() { public void run() {
for (int i = 0; i < 5; i++) {
bTimes[i] = sharedVar == 0?
System.currentTimeMillis()-startTime : -1;
briefPause();
}
}};
a.start(); b.start();
a.join(); b.join();
System.out.println("Thread A wrote 1 at: " + Arrays.toString(aTimes));
System.out.println("Thread B read 0 at: " + Arrays.toString(bTimes));
}
static void briefPause() {
try { Thread.sleep(3); }
catch (InterruptedException e) {throw new RuntimeException(e);}
}
就 JLS 而言,这是一个合法的输出:
Thread A wrote 1 at: [0, 2, 5, 7, 9]
Thread B read 0 at: [0, 2, 5, 7, 9]
请注意,我不依赖currentTimeMillis
. 报道的时间是真实的。然而,实现确实选择了仅在读取线程的所有操作之后才使写入线程的所有操作可见。
示例2:读写两个线程
现在@StephenC 争辩说,许多人会同意他的观点,即使没有明确提及, happens-before仍然意味着时间排序。因此,我提出了我的第二个程序,它展示了这种情况的确切程度。
public static void main(String[] args) throws Exception {
final long startTime = System.currentTimeMillis();
final long[] aTimes = new long[5], bTimes = new long[5];
final int[] aVals = new int[5], bVals = new int[5];
final Thread
a = new Thread() { public void run() {
for (int i = 0; i < 5; i++) {
aVals[i] = sharedVar++;
aTimes[i] = System.currentTimeMillis()-startTime;
briefPause();
}
}},
b = new Thread() { public void run() {
for (int i = 0; i < 5; i++) {
bVals[i] = sharedVar++;
bTimes[i] = System.currentTimeMillis()-startTime;
briefPause();
}
}};
a.start(); b.start();
a.join(); b.join();
System.out.format("Thread A read %s at %s\n",
Arrays.toString(aVals), Arrays.toString(aTimes));
System.out.format("Thread B read %s at %s\n",
Arrays.toString(bVals), Arrays.toString(bTimes));
}
只是为了帮助理解代码,这将是一个典型的真实结果:
Thread A read [0, 2, 3, 6, 8] at [1, 4, 8, 11, 14]
Thread B read [1, 2, 4, 5, 7] at [1, 4, 8, 11, 14]
另一方面,您永远不会期望看到这样的事情,但按照 JMM 的标准,它仍然是合法的:
Thread A read [0, 1, 2, 3, 4] at [1, 4, 8, 11, 14]
Thread B read [5, 6, 7, 8, 9] at [1, 4, 8, 11, 14]
JVM 实际上必须预测线程 A 在时间 14 将写入什么,以便知道让线程 B 在时间 1 读取什么。这样做的合理性甚至可行性都值得怀疑。
由此,我们可以定义JVM 实现可以采取的以下现实自由:
线程的任何不间断释放操作序列的可见性可以安全地推迟到中断它的获取操作之前。
术语发布和获取在JLS §17.4.4中定义。
这条规则的一个推论是,一个只写而从不读任何东西的线程的动作可以无限期地推迟,而不会违反之前发生的关系。
清除易变概念
volatile
修饰符实际上是关于两个不同的概念:
- 硬保证对它的操作将尊重发生前的顺序;
- 运行时尽最大努力及时发布写入的软承诺。
请注意,JLS 并未以任何方式指定第 2 点,它只是出于普遍预期。显然,违背承诺的实现仍然是合规的。随着时间的推移,随着我们转向大规模并行架构,这一承诺可能确实被证明是相当灵活的。因此,我希望将来将保证与承诺的合并证明是不够的:根据要求,我们将需要一个没有另一个,一个具有不同风格的另一个,或者任何数量的其他组合。