上週,由於要測試一個方法的在併發場景下的結果是否是符合預期,我寫了一段單元測試的代碼。寫完以後截了個圖發了一個朋友圈,不少人表示短短的幾行代碼,涉及到好幾個知識點。java
還有人給出了一些優化的建議。那麼,這是怎樣的一段代碼呢?涉及到哪些知識,又有哪些能夠優化的點呢?程序員
讓咱們來看一下。數據庫
先說一下背景,也就是要知道咱們單元測試要測的這個方法具體是什麼樣的功能。咱們要測試的服務是AssetService,被測試的方法是update方法。數組
update方法主要作兩件事,第一個是更新Asset、第二個是插入一條AssetStream。安全
更新Asset方法中,主要是更新數據庫中的Asset的信息,這裏爲了防止併發,使用了樂觀鎖。markdown
插入AssetStream方法中,主要是插入一條AssetStream的流水信息,爲了防止併發,這裏在數據庫中增長了惟一性約束。多線程
爲了保證數據一致性,咱們經過本地事務將這兩個操做包在同一個事務中。併發
如下是主要的代碼,固然,這個方法中還會有一些前置的冪等性校驗、參數合法性校驗等,這裏就都省略了:ide
@Service
public class AssetServiceImpl implements AssetService {
@Autowired
private TransactionTemplate transactionTemplate;
@Override
public String update(Asset asset) {
//參數檢查、冪等校驗、從數據庫取出最新asset等。
return transactionTemplate.execute(status -> {
updateAsset(asset);
return insertAssetStream(asset);
});
}
}
複製代碼
由於這個方法可能會在併發場景中執行,因此該方法經過事務+樂觀鎖+惟一性約束作了併發控制。關於這部分的細節就很少講了,你們感興趣的話後面我再展開關於如何防併發的內容。工具
由於上面這個方法是可能在併發場景中被調用的,因此須要在單測中模擬併發場景,因而,我就寫了如下的單元測試的代碼:
public class AssetServiceImplTest {
private static ThreadFactory namedThreadFactory = new ThreadFactoryBuilder()
.setNameFormat("demo-pool-%d").build();
private static ExecutorService pool = new ThreadPoolExecutor(5, 100,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(128), namedThreadFactory, new ThreadPoolExecutor.AbortPolicy());
@Autowired
AssetService assetService;
@Test
public void test_updateConcurrent() {
Asset asset = getAsset();
//參數的準備
//...
//併發場景模擬
CountDownLatch countDownLatch = new CountDownLatch(10);
AtomicInteger failedCount =new AtomicInteger();
//併發批量修改,只有一條能夠修改爲功
for (int i = 0; i < 10; i++) {
pool.execute(() -> {
try {
String streamNo = assetService.update(asset);
} catch (Exception e) {
System.out.println("Error : " + e);
failedCount.getAndIncrement();
} finally {
countDownLatch.countDown();
}
});
}
try {
//主線程等子線程都執行完以後查詢最新的資產
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
Assert.assertEquals(failedCount.intValue(), 9);
// 從數據庫中反查出最新的Asset
// 再對關鍵字段作注意校驗
}
}
複製代碼
以上,就是我作了簡化以後的單元測試的部分代碼。由於要測併發場景,因此這裏面涉及到了不少併發相關的知識。
不少人以前和我說,併發相關的知識本身瞭解的不少,可是好像沒什麼機會寫併發的代碼。其實,單元測試就是個很好的機會。
咱們來看看上面的代碼涉及到哪些知識點?
以上這段單元測試的代碼中涉及到幾個知識點,我這裏簡單說一下。
這裏面由於要模擬併發的場景,因此須要用到多線程, 因此我這裏使用了線程池,並且我沒有直接用Java提供的Executors類建立線程池。
而是使用guava提供的ThreadFactoryBuilder來建立線程池,使用這種方式建立線程時,不只能夠避免OOM的問題,還能夠自定義線程名稱,更加方便的出錯的時候溯源。(關於線程池建立的OOM問題)
由於個人單元測試代碼中,但願在全部的子線程都執行以後,主線程再去檢查執行結果。
因此,如何使主線程阻塞,直到全部子線程執行完呢?這裏面用到了一個同步輔助類CountDownLatch。
用給定的計數初始化 CountDownLatch。因爲調用了 countDown() 方法,因此在當前計數到達零以前,await 方法會一直受阻塞。(多線程中CountDownLatch的用法)
由於我在單測代碼中,建立了10個線程,可是我須要保證只有一個線程能夠執行成功。因此,我須要對失敗的次數作統計。
那麼,如何在併發場景中作計數統計呢,這裏用到了AtomicInteger,這是一個原子操做類,能夠提供線程安全的操做方法。
由於咱們模擬了多個線程併發執行,那麼就必定會存在部分線程執行失敗的狀況。
由於方法底層沒有對異常進行捕獲。因此須要在單測代碼中進行異常的捕獲。
try {
String streamNo = assetService.update(asset);
} catch (Exception e) {
System.out.println("Error : " + e);
failedCount.increment();
} finally {
countDownLatch.countDown();
}
複製代碼
這段代碼中,try、catch、finall都用上了,並且位置是不能調換的。失敗次數的統計必定要放到catch中,countDownLatch的countDown也必定要放到finally中。
這個相信你們都比較熟悉,這就是JUnit中提供的斷言工具類,在單元測試時能夠用作斷言。這就不詳細介紹了。
以上代碼涉及到了不少知識點,可是,難道就沒有什麼優化點了嗎?
首先說一下,其實單元測試的代碼對性能、穩定性之類的要求並不高,所謂的優化點,也並非必要的。這裏只是說討論下,若是真的是要作到精益求精,還有什麼點能夠優化呢?
個人朋友圈的網友@zkx 提出,可使用LongAdder代替AtomicInteger。
java.util.concurrency.atomic.LongAdder是Java8新增的一個類,提供了原子累計值的方法。並且在其Javadoc中也明確指出其性能要優於AtomicLong。
首先它有一個基礎的值base,在發生競爭的狀況下,會有一個Cell數組用於將不一樣線程的操做離散到不一樣的節點上去(會根據須要擴容,最大爲CPU核數,即最大同時執行線程數),sum()會將全部Cell數組中的value和base累加做爲返回值。
核心的思想就是將AtomicLong一個value的更新壓力分散到多個value中去,從而下降更新熱點。因此在激烈的鎖競爭場景下,LongAdder性能更好。
朋友圈網友 Cafebabe 和 @普渡衆生的面癱青年 都提到同一個優化點,那就是如何增長併發競爭。
這個問題其實我在發朋友圈以前就有想到過,心中早已經有了答案,只不過有兩位朋友可以幾乎同時提到這一點仍是很不錯的。
咱們來講說問題是什麼。
咱們爲了提高併發,使用線程池建立了多個線程,想讓多個線程併發執行被測試的方法。
可是,咱們是在for循環中依次執行的,那麼理論上這10次update方法的調用是順序執行的。
固然,由於有CPU時間片的存在,這10個線程會爭搶CPU,真正執行的過程當中仍是會發生併發衝突的。
可是,爲了穩妥起見,咱們仍是須要儘可能模擬出多個線程同時發起方法調用的。
優化的方法也比較簡單,那就是在每個update方法被調用以前都wait一下,直到全部的子線程都建立成功了,再開始一塊兒執行。
這就還能夠用都到咱們前面講過的CountDownLatch。
因此,最終優化後的單測代碼以下:
//主線程根據此CountDownLatch阻塞
CountDownLatch mainThreadHolder = new CountDownLatch(10);
//併發的多個子線程根據此CountDownLatch阻塞
CountDownLatch multiThreadHolder = new CountDownLatch(1);
//失敗次數計數器
LongAdder failedCount = new LongAdder();
//併發批量修改,只有一條能夠修改爲功
for (int i = 0; i < 10; i++) {
pool.execute(() -> {
try {
//子線程等待,等待主線程通知後統一執行
multiThreadHolder.await();
//調用被測試的方法
String streamNo = assetService.update(asset);
} catch (Exception e) {
//異常發生時,對失敗計數器+1
System.out.println("Error : " + e);
failedCount.increment();
} finally {
//主線程的阻塞器奇數-1
mainThreadHolder.countDown();
}
});
}
//通知全部子線程能夠執行方法調用了
multiThreadHolder.countDown();
try {
//主線程等子線程都執行完以後查詢最新的資產池計劃
mainThreadHolder.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
//斷言,保證失敗9次,則成功一次
Assert.assertEquals(failedCount.intValue(), 9);
// 從數據庫中反查出最新的Asset
// 再對關鍵字段作注意校驗
複製代碼
以上,就是關於個人一次單元測試的代碼所涉及到的知識點,以及目前所能想到的相關的優化點。
最後,想問一下,對於這部分代碼,你以爲還有什麼能夠優化的地方嗎?
關於做者:Hollis,一個對Coding有着獨特追求的人,阿里巴巴技術專家,《程序員的三門課》聯合做者,《Java工程師成神之路》系列文章做者。
關注公衆號【Hollis】,後臺回覆"成神導圖"能夠咯領取Java工程師進階思惟導圖。