我是怎樣測試Java類的執行緒安全性的
執行緒安全性是Java等語言/平臺中類的一個重要標準,在Java中,我們經常線上程之間共享物件。由於缺乏執行緒安全性而導致的問題很難除錯,因為它們是偶發的,而且幾乎不可能有目的地重現。如何測試物件以確保它們是執行緒安全的?
假如有一個記憶體書架
package com.mzc.common.thread; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * <p class="detail"> * 功能: 記憶體書架 * </p> * * @author Moore * @ClassName Books. * @Version V1.0. * @date 2019.12.10 14:00:13 */ public class Books { final Map<Integer, String> map = new ConcurrentHashMap<>(); /** * <p class="detail"> * 功能: 存書,並返回書的id * </p> * * @param title : * @return int * @author Moore * @date 2019.12.10 14:00:16 */ int add(String title) { final Integer next = this.map.size() + 1; this.map.put(next, title); return next; } /** * <p class="detail"> * 功能: 根據書的id讀取書名 * </p> * * @param id : * @return string * @author Moore * @date 2019.12.10 14:00:16 */ String title(int id) { return this.map.get(id); } }
首先,我們把一本書放進書架,書架會返回它的ID。然後,我們可以通過它的ID來讀取書名,像這樣:
Books books = new Books(); String title = "Elegant Objects"; int id = books.add(title); assert books.title(id).equals(title);
這個類似乎是執行緒安全的,因為我們使用的是執行緒安全的ConcurrentHashMap,而不是更原始和非執行緒安全的HashMap,對吧?我們先來測試一下:
public class BooksTest { @Test public void addsAndRetrieves() { Books books = new Books(); String title = "Elegant Objects"; int id = books.add(title); assert books.title(id).equals(title); } }
檢視測試結果:
測試通過了,但這只是一個單執行緒測試。讓我們嘗試從幾個並行執行緒中進行相同的操作(我使用的是Hamcrest):
/** * <p class="detail"> * 功能: 多執行緒測試 * </p> * * @throws ExecutionException the execution exception * @throws InterruptedException the interrupted exception * @author Moore * @date 2019.12.10 14:16:34 */ @Test public void addsAndRetrieves2() throws ExecutionException, InterruptedException { Books books = new Books(); int threads = 10; ExecutorService service = Executors.newFixedThreadPool(threads); Collection<Future<Integer>> futures = new ArrayList<>(threads); for (int t = 0; t < threads; ++t) { final String title = String.format("Book #%d", t); futures.add(service.submit(() -> books.add(title))); } Set<Integer> ids = new HashSet<>(); for (Future<Integer> f : futures) { ids.add(f.get()); } assertThat(ids.size(), equalTo(threads)); }
首先,我通過執行程式建立執行緒池。然後,我通過Submit()提交10個Callable型別的物件。他們每個都會在書架上新增一本唯一的新書。所有這些將由池中的10個執行緒中的某些執行緒以某種不可預測的順序執行。
然後,我通過Future型別的物件列表獲取其執行者的結果。最後,我計算建立的唯一圖書ID的數量。如果數字為10,則沒有衝突。我使用Set集合來確保ID列表僅包含唯一元素。
我們看一下這樣改造後的執行結果:
測試也通過了,但是,它不夠強壯。這裡的問題是它並沒有真正從多個並行執行緒測試這些書。在兩次呼叫commit()之間經過的時間足夠長,可以完成books.add()的執行。這就是為什麼實際上只有一個執行緒可以同時執行的原因。
我們可以通過修改一些程式碼再來檢查它:
@Test public void addsAndRetrieves3() { Books books = new Books(); int threads = 10; ExecutorService service = Executors.newFixedThreadPool(threads); AtomicBoolean running = new AtomicBoolean(); AtomicInteger overlaps = new AtomicInteger(); Collection<Future<Integer>> futures = new ArrayList<>(threads); for (int t = 0; t < threads; ++t) { final String title = String.format("Book #%d", t); futures.add( service.submit( () -> { if (running.get()) { overlaps.incrementAndGet(); } running.set(true); int id = books.add(title); running.set(false); return id; } ) ); } assertThat(overlaps.get(), greaterThan(0)); }
看一下測試結果:
執行錯誤,說明插入的書和返回的id數量是不衝突的。
通過上面的程式碼,我試圖瞭解執行緒之間的重疊頻率以及並行執行的頻率。但是基本上概率為0,所以這個測試還沒有真正測到我想測的,還不是我們想要的,它只是把十本書一本一本地加到書架上。
再來:
可以看到,如果我把執行緒數增加到1000,它們會開始重疊或者並行執行。
但是我希望即使執行緒數只有10個的時候,也會出現重疊並行的情況。怎麼辦呢?為了解決這個問題,我使用CountDownLatch:
@Test public void addsAndRetrieves4() throws ExecutionException, InterruptedException { Books books = new Books(); int threads = 10; ExecutorService service = Executors.newFixedThreadPool(threads); CountDownLatch latch = new CountDownLatch(1); AtomicBoolean running = new AtomicBoolean(); AtomicInteger overlaps = new AtomicInteger(); Collection<Future<Integer>> futures = new ArrayList<>(threads); for (int t = 0; t < threads; ++t) { final String title = String.format("Book #%d", t); futures.add( service.submit( () -> { latch.await(); if (running.get()) { overlaps.incrementAndGet(); } running.set(true); int id = books.add(title); running.set(false); return id; } ) ); } latch.countDown(); Set<Integer> ids = new HashSet<>(); for (Future<Integer> f : futures) { ids.add(f.get()); } assertThat(overlaps.get(), greaterThan(0)); }
現在,每個執行緒在接觸書本之前都要等待鎖許可權。當我們通過Submit()提交所有內容時,它們將保留並等待。然後,我們用countDown()釋放鎖,它們才同時開始執行。
檢視執行結果:
通過執行結果可以知道,現線上程數還是為10,但是執行緒的重疊數是大於0的,所以assertTrue執行通過,ids也不等於10了,也就是沒有像以前那樣得到10個圖書ID。顯然,Books類不是執行緒安全的!
在修復優化該類之前,教大家一個簡化測試的方法,使用來自Cactoos的RunInThreads,它與我們上面所做的完全一樣,但程式碼是這樣的:
@Test public void addsAndRetrieves5() { Books books = new Books(); MatcherAssert.assertThat( t -> { String title = String.format( "Book #%d", t.getAndIncrement() ); int id = books.add(title); return books.title(id).equals(title); }, new RunsInThreads<>(new AtomicInteger(), 10) ); }
assertThat()的第一個引數是Func(一個函式介面)的例項,接受AtomicInteger(RunsThreads的第一個引數)並返回布林值。此函式將在10個並行執行緒上執行,使用與上述相同的基於鎖的方法。
這個RunInThreads看起來非常緊湊,用起來也很方便,推薦給大家,可以用起來的。只需要在你的專案中新增一個依賴:
<dependency> <groupId>org.llorllale</groupId> <artifactId>cactoos-matchers</artifactId> <version>0.18</version> </dependency>
最後,為了使Books類成為執行緒安全的,我們只需要向其方法add()中同步新增就可以了。或者,聰明的碼小夥伴們,你們有更好的方案嗎?歡迎留言,大家一起討論。
文章同步公眾號:碼之初,每天推送Java技術文章,期待您的關注!
原創不易,轉載請註明出處,謝