使用VMLens实现并发Java单元测试的指南

在本文中,我们介绍了VMLens 的功能。

要测试并发类,我们需要测试该类的方法是否具有原子性,并且不存在数据争用。为此,我们为每种更新和读取方法的组合编写一个测试。在测试中,我们并行调用这些方法,并使用 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操作不是一个原子操作,而是三个独立的操作:

  1. 从字段中读取值
  2. 更新值
  3. 将新值写回字段
跟踪显示,首先是主线程,然后是 Thread-8 读取了该字段。然后,先是 Thread-8,然后是主线程写入该字段。因此,对 Thread-8 的更新丢失了,导致值不正确。

原子方法
问题在于更新方法不是原子的。读取金额和写入金额应该是一次不可分割的操作。

因此,在消除数据竞争之后,我们需要使方法具有原子性。我们可以通过在 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 撰写的《单元测试:原则、实践和模式》,

  • 单元测试是一种自动化测试:验证一小段代码(也称为单元),很快就完成了并以孤立的方式进行。
测试并发 Java 的问题在于,我们需要测试所有线程交错。而且,线程交错的数量会随着冲突同步操作的数量呈指数增长。这意味着单元测试非常适合测试并发 Java。

单元测试速度很快,可以重复使用多次。单元测试只验证一小段代码,这使得我们可以将代码的其余部分视为黑盒。这减少了我们需要测试的线程交叉次数。

测试什么?
我们需要测试类中的方法是否具有原子性。为了测试这一点,我们需要并行执行所有更新方法。并且,所有更新方法和所有读取方法也需要并行执行。

最好的方法是为更新和读取方法的每种组合编写单独的测试。

因此,对于我们的例子,我们仍然需要对读取和更新的组合进行测试:


@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(更新后的值)。