要测试并发类,我们需要测试该类的方法是否具有原子性,并且不存在数据争用。为此,我们为每种更新和读取方法的组合编写一个测试。在测试中,我们并行调用这些方法,并使用 VMLens遍历所有线程交错。
虽然为单线程 Java 编写单元测试很常见,但并发 Java 的单元测试仍然很少使用。
通过使用VMLens(一种确定性地对并发 Java 进行单元测试的开源工具),我们现在可以改变这种状况。
在下面的教程中,我们将学习如何使用 VMLens 为并发 Java 编写单元测试。
设置
举个例子,我们实现一个BankAccount类。我们希望在多个线程中并行更新并获取当前金额:
public class RegularFieldBankAccount {
private int amount;
public void update(int delta) {
amount += delta;
}
// standard getter
}
首先,我们需要在pom.xml中添加 Maven 依赖项和插件:
com.vmlens
api
1.2.10
test
com.vmlens
vmlens-maven-plugin
1.2.10
test
test
vmlens -maven-plugin扩展了maven-surefire-plugin。因此,我们可以像配置 Maven Surefire 插件一样配置 VMLens 插件。我们可以在 Maven Central 仓库中找到com.vmlens:api和vmlens-maven-plugin:vmlens-maven-plugin的最新版本。
我们还可以按照此处所述将 VMLens 与 Gradle 一起使用或独立使用。
测试
为了测试我们确实可以从多个线程更新银行账户,我们让主线程和一个新启动的线程并行调用 update 方法。我们用一个while循环包围它,遍历所有线程交错执行:
@Test
public void whenParallelUpdate_thenAmountSumOfBothUpdates() throws InterruptedException {
try (AllInterleavings allInterleavings = new AllInterleavings("bankAccount.updateUpdate")) {
while (allInterleavings.hasNext()) {
RegularFieldBankAccount bankAccount = new RegularFieldBankAccount();
Thread first = new Thread() {
@Override
public void run() {
bankAccount.update(5);
}
};
first.start();
bankAccount.update(10);
first.join();
int amount = bankAccount.getAmount();
assertThat(amount, is(15));
}
}
}
测试并发 Java 的问题在于我们需要测试线程的所有可能的执行顺序。
通过使用while循环,我们指示 VMLens 测试代码中指定位置的所有线程交错。
VMLens 作为字节码代理运行。VMLens 跟踪所有同步操作和字段访问。基于这些信息,VMLens 计算所有线程交错。
数据竞争
运行测试会导致以下错误,即数据争用:
并发 Java 的单元测试发现数据竞争
当两个线程同时访问同一字段且未进行适当的同步时,就会发生数据争用。同步操作包括访问 volatile 字段或使用同步块等操作。我们从跟踪中观察到,不同线程对 amount 字段的读写操作之间没有任何同步操作。
当发生数据争用时,无法保证读取线程一定能够看到最新的写入值。这是因为编译器会重新排序指令,并且 CPU 核心会缓存字段值。只有中间进行同步操作,才能确保线程读取到最新的值。
要编写并发类,我们需要消除数据竞争。
非原子方法
为了修复这个错误,我们在字段声明中添加一个 volatile 修饰符:
public class VolatileFieldBankAccount {
private volatile int amount;
// Methods same as above
}
运行测试,我们得到以下错误:
Expected: is <15>
but: was <10>
VMLens 跟踪显示了金额未正确更新的原因:
并发 Java 的单元测试发现读取修改写入竞争条件
amount += delta操作不是一个原子操作,而是三个独立的操作:
- 从字段中读取值
- 更新值
- 将新值写回字段
原子方法
问题在于更新方法不是原子的。读取金额和写入金额应该是一次不可分割的操作。
因此,在消除数据竞争之后,我们需要使方法具有原子性。我们可以通过在 update 方法中使用 synchronized 块来实现这一点:
public class AtomicBankAccount {
private final Object LOCK = new Object();
private volatile int amount;
public int getAmount() {
return amount;
}
public void update(int delta) {
synchronized (LOCK) {
amount += delta;
}
}
}
已通过测试。
如何实现并发 Java 的单元测试?
根据 Vladimir Khorikov 撰写的《单元测试:原则、实践和模式》,
- 单元测试是一种自动化测试:验证一小段代码(也称为单元),很快就完成了并以孤立的方式进行。
单元测试速度很快,可以重复使用多次。单元测试只验证一小段代码,这使得我们可以将代码的其余部分视为黑盒。这减少了我们需要测试的线程交叉次数。
测试什么?
我们需要测试类中的方法是否具有原子性。为了测试这一点,我们需要并行执行所有更新方法。并且,所有更新方法和所有读取方法也需要并行执行。
最好的方法是为更新和读取方法的每种组合编写单独的测试。
因此,对于我们的例子,我们仍然需要对读取和更新的组合进行测试:
@Test
public void whenParallelUpdateAndGet_thenResultEitherAmountBeforeOrAfterUpdate() throws InterruptedException {
try (AllInterleavings allInterleavings = new AllInterleavings("bankAccount.updateGetAmount")) {
while (allInterleavings.hasNext()) {
RegularFieldBankAccount bankAccount = new RegularFieldBankAccount();
Thread first = new Thread() {
@Override
public void run() {
bankAccount.update(5);
}
};
first.start();
int amount = bankAccount.getAmount();
assertThat(amount, anyOf(is(0), is(5)));
first.join();
}
}
}
由于方法getAmount()是在更新之前或之后执行的,因此金额可以是 0(更新前的值)或 5(更新后的值)。