卡内基梅隆大学近期推出了Fray,这是一款用于Java等JVM程序的并发测试工具,旨在捕获并复现并发Bug。Fray基于一项研究论文,并使用Kotlin编写。虽然它无法发现所有的并发问题,但其采用了“影子锁”等前沿研究技术,通过引入额外的锁来按特定顺序管理共享资源的访问,从而最大限度地提高问题检出率。
Fray支持包括JDK 25在内的Java版本,并已在JDK、Lucene、Kafka、Flink和Guava等项目中成功发现Bug。该框架能够检测多线程问题,但无法识别由并发内存写入引起的缺陷。
配置与集成
对于Maven项目,需要配置以下插件和依赖:
<plugin>
<groupId>org.pastalab.fray.maven</groupId>
<artifactId>fray-plugins-maven</artifactId>
<version>0.6.9</version>
<executions>
<execution>
<id>prepare-fray</id>
<goals>
<goal>prepare-fray</goal>
</goals>
</execution>
</executions>
</plugin>
<dependency>
<groupId>org.pastalab.fray</groupId>
<artifactId>fray-junit</artifactId>
<version>0.6.9</version>
<scope>test</scope>
</dependency>
对于Gradle项目,则可使用如下配置:
plugins {
id("org.pastalab.fray.gradle") version "0.6.9"
}
配置完成后,可通过以下命令运行测试:
./gradlew frayTest
此外,也可参照GitHub上的IDE文档,在IntelliJ中运行Fray测试。
编写并发测试
配置好构建系统后,即可使用JUnit 5编写测试。需要使用 @ExtendWith(FrayTestExtension.class) 注解测试类,并使用 @ConcurrencyTest 注解具体的测试方法:
@ExtendWith(FrayTestExtension.class)
public class MyFirstTest {
@ConcurrencyTest
public void myTest() {
// 测试逻辑
}
}
实战示例:检测死锁
以下是一个简化的 BankAccount 类,其同步代码块在多线程访问时可能引发死锁:
public class BankAccount {
public BankAccount(double balance) {
this.balance = balance;
}
private double balance;
public void transfer(double amount, BankAccount toAccount) {
synchronized (this) {
synchronized (toAccount) {
this.balance -= amount;
toAccount.balance += amount;
}
}
}
}
为了检测这个死锁,可以创建一个Fray测试,并显式设置迭代次数(例如10次)。完整的参数列表可在GitHub的 ConcurrencyTest.kt 文件中找到。
@ExtendWith(FrayTestExtension.class)
public class BankAccountTest {
public void myBankAccountTest() throws InterruptedException {
BankAccount bankAccount1 = new BankAccount(5000);
BankAccount bankAccount2 = new BankAccount(6000);
Thread thread1 = new Thread(() -> {
bankAccount1.transfer(100, bankAccount2);
});
Thread thread2 = new Thread(() -> {
bankAccount2.transfer(50, bankAccount1);
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
}
@ConcurrencyTest(
iterations = 10,
)
public void runMyBankAccountTestUsingFray() throws InterruptedException {
myBankAccountTest();
}
}
运行上述测试,在10次迭代中的第4次会触发错误:
[ERROR] Errors:
[ERROR] org.example.BankAccountTest.runMyBankAccountTestUsingFray
[INFO] Run 1: PASS
[INFO] Run 2: PASS
[INFO] Run 3: PASS
[ERROR] Run 4: BankAccountTest.runMyBankAccountTestUsingFray:33->myBankAccountTest:25->Object.wait:-1 » Deadlock
[INFO] Run 5: PASS
...
对于JUnit之外的其他测试框架,可以使用 FrayInTestLauncher 类:
public void myTest() {
FrayInTestLauncher.INSTANCE.launchFrayTest(() -> {
// 测试逻辑
});
}
失败复现与调试
Fray在测试失败时会自动生成一个测试用例以复现失败。详细信息会记录在报告文件夹中(Maven项目通常在 target/fray/fray-report)。有两种方式可以利用此报告复现失败。
第一种方式是使用与原始失败相同的调度器和随机选择记录重新运行测试(基于“反馈引导的自适应测试”论文)。为此,需要在 @ConcurrencyTest 注解中设置回放文件路径:
@ConcurrencyTest(
replay = "[path to report]/recording"
)
再次运行测试会立即产生相同的错误:org.pastalab.fray.runtime.DeadlockException。修复示例应用中的死锁后,测试即可通过。
第二种方式是使用原始执行中观察到的精确线程调度重新执行。这需要先通过JVM参数 -Dfray.recordSchedule=true 记录调度。记录后,在注解中使用 ReplayScheduler 类:
@ConcurrencyTest(
scheduler = ReplayScheduler.class,
replay = "[path to report]/recording"
)
其他工具
除了Fray,检测Java代码并发问题的框架还有VMLens、Java Concurrency Stress (jcstress) 以及IntelliJ IDEA的Lincheck。更多关于Fray的详细信息,可查阅其使用指南或技术报告。