使用 VMLens 進行並發 Java 單元測試
1. 簡介
雖然為單執行緒 Java 編寫單元測試很常見,但並發 Java 的單元測試仍然很少使用。
透過使用VMLens (一種確定性地對並發 Java 進行單元測試的開源工具),我們現在可以改變這種狀況。
在下面的教學中,我們將學習如何使用 VMLens 為並發 Java 編寫單元測試。
2. 設定
舉個例子,我們實作一個BankAccount
類別。我們希望在多個線程中並行更新並獲取當前金額:
public class RegularFieldBankAccount {
private int amount;
public void update(int delta) {
amount += delta;
}
// standard getter
}
首先,我們需要在pom.xml
中加入 Maven 依賴項和外掛程式:
<dependency>
<groupId>com.vmlens</groupId>
<artifactId>api</artifactId>
<version>1.2.10</version>
<scope>test</scope>
</dependency>
<plugin>
<groupId>com.vmlens</groupId>
<artifactId>vmlens-maven-plugin</artifactId>
<version>1.2.10</version>
<executions>
<execution>
<id>test</id>
<goals>
<goal>test</goal>
</goals>
</execution>
</executions>
</plugin>
vmlens-maven-plugin
擴展了maven-surefire-plugin
。因此,我們可以像配置 Maven Surefire 插件一樣配置 VMLens 插件。我們可以在 Maven Central 倉庫中找到[com.vmlens:api](https://mvnrepository.com/artifact/com.vmlens/api)
和[vmlens-maven-plugin:vmlens-maven-plugin](https://mvnrepository.com/artifact/com.vmlens/vmlens-maven-plugin)
的最新版本。
我們也可以按照此處所述將 VMLens 與 Gradle 一起使用或獨立使用。
3. 測試
為了測試我們確實可以從多個執行緒更新銀行帳戶,我們讓主執行緒和一個新啟動的執行緒並行呼叫 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 計算所有線程交錯。
4. 數據競爭
執行測試會導致以下錯誤,即資料爭用:
當兩個執行緒同時存取相同欄位且未進行適當的同步時,就會發生資料爭用。同步操作包括存取 volatile 欄位或使用同步區塊等操作。我們從追蹤中觀察到,不同執行緒對 amount 欄位的讀寫操作之間沒有任何同步操作。
當發生資料爭用時,無法保證讀取執行緒一定能夠看到最新的寫入值。這是因為編譯器會重新排序指令,而 CPU 核心會快取欄位值。只有中間進行同步操作,才能確保執行緒讀取到最新的值。
要編寫並發類,我們需要消除資料競爭。
5.非原子方法
為了修復這個錯誤,我們在字段聲明中添加一個 volatile 修飾符:
public class VolatileFieldBankAccount {
private volatile int amount;
// Methods same as above
}
運行測試,我們得到以下錯誤:
Expected: is <15>
but: was <10>
VMLens 追蹤顯示了金額未正確更新的原因:
amount += delta
操作不是一個原子操作,而是三個獨立的操作:
- 從字段中讀取值
- 更新值
- 將新值寫回字段
追蹤顯示,首先是主線程,然後是 Thread-8 讀取了該欄位。然後,先是 Thread-8,然後是主執行緒寫入該欄位。因此,對 Thread-8 的更新遺失了,導致值不正確。
6.原子方法
問題在於更新方法不是原子的。讀取金額和寫入金額應該是一次不可分割的操作。
因此,在消除資料競爭之後,我們需要使方法具有原子性。我們可以透過在 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;
}
}
}
該課程現已通過測試。
7.如何實作並發 Java 的單元測試?
根據 Vladimir Khorikov 撰寫的《單元測試:原則、實踐和模式》,
單元測試是一種自動化測試,
- 驗證一小段程式碼(也稱為單元),
- 很快就完成了,
- 並以孤立的方式進行。
測試並發 Java 的問題在於,我們需要測試所有執行緒交錯。而且,線程交錯的數量會隨著衝突同步操作的數量而呈指數級增長。這意味著單元測試非常適合測試並發 Java。
單元測試速度很快,可以重複使用多次。單元測試只驗證一小段程式碼,這使得我們可以將程式碼的其餘部分視為黑盒。這減少了我們需要測試的執行緒交叉次數。
8. 測試什麼?
我們需要測試類別中的方法是否具有原子性。為了測試這一點,我們需要並行執行所有更新方法。並且,所有更新方法和所有讀取方法也需要並行執行。
最好的方法是為更新和讀取方法的每種組合編寫單獨的測試。
因此,對於我們的例子,我們仍然需要對讀取和更新的組合進行測試:
@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(更新後的值)。
9. 結論
在本文中,我們描述了 VMLens 的功能。
要測試並發類,我們需要測試該類別的方法是否具有原子性,且不存在資料爭用。為此,我們為每種更新和讀取方法的組合編寫一個測試。在測試中,我們並行呼叫這些方法,並使用 VMLens 遍歷所有執行緒交錯。
與往常一樣,本文中使用的所有程式碼範例均可在 GitHub 上找到。