Java中的多线程状态可见性:有没有办法将JVM转换为最坏情况?

14

假设我们的代码有两个线程(A和B)在某处引用了该类的同一个实例:

public class MyValueHolder {

    private int value = 1;

    // ... getter and setter

}

当线程A执行myValueHolder.setValue(7)时,并不能保证线程B会读取到这个值:myValueHolder.getValue()可能会永远返回1,这只是理论上的可能性。

但实际上,硬件迟早会清除第二级缓存,因此线程B迟早(通常更快)会读取到7

是否有办法让JVM模拟这种最坏情况,即对于线程B永远返回1的情况?这将非常有用,可以在这种情况下使用我们现有的测试来测试多线程代码。


1
Brian Goetz的书《Java并发实践》非常清楚地解释了可见性问题以及如何避免它(使用volatile、synchronized、locks等),但是这很困难且需要大量工作。任何非平凡的多线程项目都可能受到与可见性相关的竞态条件的影响。这种“模拟最坏情况”的开关将会把它们全部暴露出来。 - Geoffrey De Smet
7个回答

28

jcstress 的维护者在这里。有多种方法可以回答这个问题。

  1. 最简单的解决方案是将getter包装在循环内,并让JIT进行提升。对于非易失性字段读取,这是允许的,并模拟了编译器优化中的可见性失败。
  2. 更复杂的技巧涉及获取OpenJDK的调试版本,并使用 -XX:+StressLCM -XX:+StressGCM,从而进行指令调度模糊测试。在你的产品具有常规测试的情况下,所涉及的负载可能会浮动到您可以检测到的位置。
  3. 我不确定是否存在实际的硬件能够将写入的值保持足够长的时间以对缓存一致性进行不透明处理,但使用jcstress构建测试用例相对容易。您必须记住,(1)中的优化也可能发生,因此我们需要采用一些技巧来防止这种情况发生。我认为类似这个应该可以工作。

太好了!特别是第二点看起来非常有趣,因为我正在寻找一种方法在不进行低级更改的情况下,在我们的整个代码库上运行现有的测试套件。通过使用那些VM选项运行测试套件,如果我们的代码中存在并发错误,它会增加多线程测试失败的机会吗?是否有计划在默认JDK构建中提供这些VM选项? - Geoffrey De Smet
1
@GeoffreyDeSmet:是的,我们在jcstress运行中广泛使用-XX:+StressLCM -XX:+StressGCM,因为它为生成的代码提供了模糊测试,否则(幸运的是)不会被触及。尽管我们主要关注VM问题,但这种模糊测试可能会显示应用程序级别代码中的问题。需要注意的是:由于这实际上是编译器选项,您必须在多个VM调用中运行测试以获得不同的模糊结果。(而且,这些选项仅适用于非产品构建--不能冒险出现回归)。 - Aleksey Shipilev

3
希望能有一个 Java 编译器,故意执行尽可能多的奇怪 (但允许的) 转换,以便更容易地破坏线程不安全的代码,就像 C 语言中的 Csmith。不幸的是,据我所知,这样的编译器并不存在。
同时,您可以尝试使用 jcstress 库在多个架构上测试您的代码,如果可能,使用 较弱的内存模型 (即非 x86),以尝试破坏您的代码:
“Java Concurrency Stress tests (jcstress)” 是一种实验性的测试工具和测试套件,有助于研究 JVM、类库和硬件并发支持的正确性。
但最终,不幸的是,证明一段代码 100% 正确的唯一方法是代码检查 (我不知道有哪个静态代码分析工具能够检测所有竞争条件)。

*我没有使用过它,也不清楚jcstress和java-concurrency-torture库哪一个更为更新(我会认为是jcstress)。


2

很遗憾,测试多线程代码在真实机器上将仍然困难。

正如你所说,硬件会清除二级缓存,JVM对此没有控制权。 JSL仅指定必须发生的内容,这是一个情况,在该情况下B可能永远看不到value的更新值。

在真实机器上强制执行此操作的唯一方法是以某种方式更改代码,使您的测试策略无效,即最终测试不同的代码。

但是,您可能能够在模拟器上运行此代码,该模拟器模拟不清除二级缓存的硬件。听起来需要付出很大努力!


2
我认为你所指的原则是“虚假共享”,在这种情况下,不同的CPU必须同步它们的缓存,否则可能会出现您描述的数据不匹配的情况。英特尔网站上有一篇非常好的关于虚假共享的文章。英特尔在该文章中描述了一些有用的工具,可以诊断此问题。以下是相关引用:
“避免虚假共享的主要方法是通过代码检查。线程访问全局或动态分配的共享数据结构的实例是虚假共享的潜在来源。请注意,虚假共享可能会因为线程访问完全不同的全局变量而被掩盖,这些全局变量恰好相对靠近内存。线程本地存储或局部变量可以排除为虚假共享的来源。”
尽管文章中描述的方法并不是你所要求的(强制JVM产生最坏的行为),如前所述,这实际上是不可能的。我知道的最好的方法是使用该文章中描述的方法来尝试诊断和避免虚假共享。
在网络上还有其他解决这个问题的资源。例如,这篇文章提出了一种避免Java中虚假共享的方法建议。 我没有尝试过这种方法,所以我不能保证它的可靠性,但我认为作者的想法是正确的。你可以考虑尝试他的建议。

2
我之前在内存模型列表中建议使用最差情况的JVM进行测试,但这个想法似乎不太受欢迎。
那么如何获得“最差情况的JVM行为”,利用现有技术,即如何测试问题场景并使其每次都失败。您可以尝试找到可能性最小的内存模型设置,但这不太可能完美无缺。
我经常考虑的是使用分布式JVM,类似于我认为Terracotta在幕后工作的方式,这样您的应用程序现在可以在多个JVM(远程或本地)上运行(同一应用程序中的线程在不同实例中运行)。在这种设置中,跨JVM线程通信发生在内存屏障上,例如synchronized关键字在有错误代码的情况下缺失(它符合Java内存模型),并且应用程序已配置,即您说此类线程在此处运行。您的测试不需要进行任何代码更改,只需进行配置,任何有序的Java应用程序都应该可以直接运行,但是这种设置对于顺序不良的应用程序非常容忍(通常是一个问题...现在是一项资产,即内存模型表现出非常弱但合法的行为)。在上面的示例中,如果将代码加载到集群中,如果两个线程在不同的节点上运行,则setValue对另一个线程没有可见效果,除非代码被更改并使用了synchronized、volatile等等,然后代码才能按预期工作。
上面的示例中,如果正确配置,则测试将每次失败,没有正确的“发生在之前排序”,这对于测试来说可能非常有用。完全覆盖计划中的缺陷是,您需要每个应用程序线程一个节点(可以是同一台机器或多个集群中的多个节点)或多个测试运行。如果您有数千个线程,则可能会受到限制,尽管希望它们会被汇集并缩小以用于E2E测试场景,或在云中运行。如果没有其他问题,这种设置可能在演示问题时很有用。 跨JVM的线程间通信

1
你给出的例子在http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.4中被描述为不正确的同步。我认为这总是不正确的,迟早会导致错误。大多数情况下是后者:-)。
为了找到这样不正确的同步代码块,我使用以下算法:
使用工具记录所有字段修改的线程。如果一个字段被多个线程修改而没有同步,那么就会发生数据竞争。
我将这个算法实现在http://vmlens.com内,这是一种查找java程序中数据竞争的工具。

有趣!是否有一个适用于vmlens的maven插件,可以在surefire运行的单元测试期间自动激活它?有计划开源吗? :) 这将成为findbugs插件的有用伴侣。 - Geoffrey De Smet

-2
这里有一个简单的方法:只需将setValue的代码注释掉即可。测试后可以取消注释。由于在许多类似情况下需要模拟失败的机制,因此建议为所有这些情况构建一个通用机制。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接