【译】Java并发编程之可见性错误详解转载
检测可见性错误的概率很随机。
运气好,90%的案例中可以检测到可见性错误。运气差,检测错误的机会低于百万分之一。
那么,什么是可见性错误?
可见性错误
当线程读取陈旧的值时,就会发生可见性错误。在以下示例中,线程向另一个线程发出信号,以停止处理其while循环:
public class Termination {
private int v;
public void runTest() throws InterruptedException {
Thread workerThread = new Thread( () -> {
while(v == 0) {
// spin
}
});
workerThread.start();
v = 1;
workerThread.join(); // test might hang up here
}
public static void main(String[] args) throws InterruptedException {
for(int i = 0 ; i < 1000 ; i++) {
new Termination().runTest();
}
}
}
问题是,工作线程可能永远不会看到变量v的更新,因此永远运行。
读取陈旧值的一个原因是CPU内核的缓存。现代CPU的每个核心都有自己的缓存。因此,如果读取和写入线程在不同的内核上运行,则读取线程会看到缓存值,而不是写入线程写入的值。以下显示了英特尔奔腾4 CPU内部的核心和缓存:
英特尔奔腾4 CPU的每个核心都有自己的1级和2级缓存。所有内核共享一个大型的3级缓存。这些缓存的原因是性能。
以下数字显示了访问内存所需的时间,来自计算机架构、定量方法、JL Hennessy、DA Patterson,第5版,第72页:
- CPU寄存器~300皮秒
- 1级缓存~1纳秒
- 主内存~50-100纳秒
读取和写入普通字段不会使缓存无效,因此,如果不同核心上的两个线程读取和写入同一变量,它们会看到陈旧的值。让我们看看是否可以重现这个错误。
如何重现?
如果你运行了上述示例,测试不会挂断的可能性很大。该测试需要如此少的CPU周期,以至于两个线程通常在同一内核上运行,当两个线程在同一内核上运行时,它们会读取和写入到相同的缓存。
幸运的是,OpenJDK提供了一个工具jcstress,它有助于进行此类测试。jcstress使用多个技巧,以便测试的线程在不同的核心上运行。在这里,上述示例被重写为jcstress测试:
@JCStressTest(Mode.Termination)
@Outcome(id = "TERMINATED", expect = Expect.ACCEPTABLE, desc = "Gracefully finished.")
@Outcome(id = "STALE", expect = Expect.ACCEPTABLE_INTERESTING, desc = "Test hung up.")
@State
public class APISample_03_Termination {
int v;
@Actor
public void actor1() {
while (v == 0) {
// spin
}
}
@Signal
public void signal() {
v = 1;
}
}
这个测试来自jcstress示例。通过用注释@JCStressTest
注释该类,我们告诉jcstress,该类是一个jcstress测试。jcstress在一个单独的线程中运行带有@Actor
和@Signal
注释的方法。jcstress首先启动演员线程,然后运行信号线程。如果测试在合理的时间内退出,jcstress会记录“终止”结果;否则,结果“陈旧”。
jcstress使用不同的JVM参数多次运行测试用例。以下是我开发机器上这次测试的结果,这是一个使用测试模式应力的英特尔i5 4核CPU。
对于JVM参数-XX:-TieredCompilation,线程在90%的情况下挂断,但对于JVM标志-XX:TieredStopAtLevel=1和-Xint,线程在所有运行中都终止。
在确认我们的示例确实包含错误后,我们如何修复它?
如何避免?
Java有专门的指令,可以保证线程始终看到最新的写入值。其中一条指令是易失性字段修饰符。当读取易失性字段时,线程保证看到最后一个写入值。该保证不仅适用于字段的值,也适用于写入线程在写入易失变量之前写入的所有值。从上述示例中将字段修饰符volatile
添加到字段v中,确保while循环始终终止,即使使用jcstress的测试运行。
public class Termination {
volatile int v;
// methods omitted
}
volatile
场修饰符并不是提供这种可见性保证的唯一指令。例如,软件包java.util.concurrent中的同步语句和类提供了相同的保证。Brian Goetz等人的《实践中的Java并发》一书是一本关于避免可见性错误的技术的好读物。
在了解了为什么会发生可见性错误以及如何重现和避免它们后,让我们看看如何找到它们。
如何查找?
Java语言规范第17章。线程和锁正式定义了Java指令的可见性保证。该规范定义了所谓的“发生前”关系,以定义可见性保证:
“两个操作可以由发生前关系排序。如果一个动作发生在另一个动作之前,那么第一个动作在第二个动作之前是可见的,并有序的。”
从波动场读取和写入会产生这样的事前关系:
在每次读取该字段之前,都会发生写入到不稳定字段(§8.3.1.4)。
使用此规范,我们可以检查程序是否包含可见性错误,在规范中称为“数据竞赛”。
“当程序包含两个不按先发生关系排序的冲突访问(§17.4.1)时,它被称为包含数据竞赛。如果至少有一个访问是写入,则对同一变量的两个访问(读取或写入)称为冲突。”
看看我们的示例,我们发现读取和写入共享变量v之间没有发生前关系,因此此示例包含根据规范的数据竞赛。
当然,这种推理可以自动化。以下两个工具使用此规则自动检测可见性错误:
- ThreadSanitizer使用C++内存模型的规则来查找C++应用程序中的可见性错误。C++内存模型由形式规则组成,用于指定类似于Java语言规范对Java指令的C++指令的可见性保证。Java增强提案草案JEP草案:Java Thread Sanitizer,将ThreadSanitizer纳入OpenJDK JVM。ThreadSanitizer的使用应通过命令行标志启用。
- vmlens是我为测试并发Java而编写的工具,它使用Java语言规范自动检查Java测试运行是否包含可见性错误。