程式碼審查:從 ArrayList 說執行緒安全
阿新 • • 發佈:2021-03-13
本文從程式碼審查過程中發現的一個 ArrayList 相關的「執行緒安全」問題出發,來剖析和理解執行緒安全。
## 案例分析
前兩天在程式碼 Review 的過程中,看到有小夥伴用了類似以下的寫法:
```java
List resultList = new ArrayList<>();
paramList.parallelStream().forEach(v -> {
String value = doSomething(v);
resultList.add(value);
});
```
印象中 ArrayList 是執行緒不安全的,而這裡會多執行緒改寫同一個 ArrayList 物件,感覺這樣的寫法會有問題,於是看了下 ArrayList 的實現來確認問題,同時複習下相關知識。
先貼個概念:
> **執行緒安全** 是程式設計中的術語,指某個函式、函式庫在多執行緒環境中被呼叫時,能夠正確地處理多個執行緒之間的共享變數,使程式功能正確完成。 ——維基百科
我們來看下 ArrayList 原始碼裡與本話題相關的關鍵資訊:
```java
public class ArrayList extends AbstractList
implements List, RandomAccess, Cloneable, java.io.Serializable
{
// ...
/**
* The array buffer into which the elements of the ArrayList are stored.
* The capacity of the ArrayList is the length of this array buffer...
*/
transient Object[] elementData; // non-private to simplify nested class access
/**
* The size of the ArrayList (the number of elements it contains).
*/
private int size;
// ...
/**
* Appends the specified element to the end of this list...
*/
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
// ...
}
```
從中我們可以關注到關於 ArrayList 的幾點資訊:
1. 使用陣列儲存資料,即 `elementData`
2. 使用 int 成員變數 `size` 記錄實際元素個數
3. `add` 方法邏輯與執行順序:
- 執行 `ensureCapacityInternal(size + 1)`:確認 `elementData` 的容量是否夠用,不夠用的話擴容一半(申請一個新的大陣列,將 `elementData` 裡的原有內容 copy 過去,然後將新的大陣列賦值給 `elementData`)
- 執行 `elementData[size] = e;`
- 執行 `size++`
為了方便理解這裡討論的「執行緒安全問題」,我們選一個最簡單的執行路徑來分析,假設有 A 和 B 兩個執行緒同時呼叫 `ArrayList.add` 方法,而此時 `elementData` 容量為 8,`size` 為 7,足以容納一個新增的元素,那麼可能發生什麼現象呢?
![Thread Safety ArrayList Add](https://cdn.jsdelivr.net/gh/mzlogin/mzlogin.github.io@master/images/posts/java/thread-safety-arraylist-add.png)
一種可能的執行順序是:
- 執行緒 A 和 B 同時執行了 `ensureCapacityInternal(size + 1)`,因 `7 + 1` 並沒超過 `elementData` 的容量 8,所以並未擴容
- 執行緒 A 先執行 `elementData[size++] = e;`,此時 `size` 變為 8
- 執行緒 B 執行 `elementData[size++] = e;`,因為 `elementData` 陣列長度為 8,卻訪問 `elementData[8]`,陣列下標越界
程式會丟擲異常,無法正常執行完,根據前文提到的執行緒安全的定義,很顯然這已經是屬於執行緒不安全的情況了。
## 構造示例程式碼驗證
有了以上的理解之後,我們來寫一段簡單的示例程式碼,驗證以上問題確實可能發生:
```java
List resultList = new ArrayList<>();
List paramList = new ArrayList<>();
int length = 10000;
for (int i = 0; i < length; i++) {
paramList.add(i);
}
paramList.parallelStream().forEach(resultList::add);
```
執行以上程式碼有可能表現正常,但更可能是遇到以下異常:
```
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException
at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
at java.util.concurrent.ForkJoinTask.getThrowableException(ForkJoinTask.java:598)
at java.util.concurrent.ForkJoinTask.reportException(ForkJoinTask.java:677)
at java.util.concurrent.ForkJoinTask.invoke(ForkJoinTask.java:735)
at java.util.stream.ForEachOps$ForEachOp.evaluateParallel(ForEachOps.java:160)
at java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateParallel(ForEachOps.java:174)
at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:233)
at java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:418)
at java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:583)
at concurrent.ConcurrentTest.main(ConcurrentTest.java:18)
Caused by: java.lang.ArrayIndexOutOfBoundsException: 1234
at java.util.ArrayList.add(ArrayList.java:465)
at java.util.stream.ForEachOps$ForEachOp$OfRef.accept(ForEachOps.java:184)
at java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1384)
at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:482)
at java.util.stream.ForEachOps$ForEachTask.compute(ForEachOps.java:291)
at java.util.concurrent.CountedCompleter.exec(CountedCompleter.java:731)
at java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:289)
at java.util.concurrent.ForkJoinPool$WorkQueue.runTask(ForkJoinPool.java:1067)
at java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1703)
at java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:172)
```
從我這裡試驗的情況來看,`length` 值小的時候,因為達到容量邊緣需要擴容的次數少,不易重現,將 `length` 值調到比較大時,異常丟擲率就很高了。
實際上除了丟擲這種異常外,以上場景還可能造成資料覆蓋/丟失、ArrayList 裡實際存放的元素個數與 size 值不符等其它問題,感興趣的同學可以繼續挖掘一下。
## 解決方案
對這類問題常見的有效解決思路就是對共享的資源訪問加鎖。
我提出程式碼審查的修改意見後,小夥伴將文首程式碼裡的
```java
List resultList = new ArrayList<>();
```
修改為了
```java
List resultList = Collections.synchronizedList(new ArrayList<>());
```
這樣實際最終會使用 `SynchronizedRandomAccessList`,看它的實現類,其實裡面也是加鎖,它內部持有一個 List,用 synchronized 關鍵字控制對 List 的讀寫訪問,這是一種思路——使用執行緒安全的集合類,對應的還可以使用 Vector 等其它類似的類來解決問題。
另外一種方思路是手動對關鍵程式碼段加鎖,比如我們也可以將
```java
resultList.add(value);
```
修改為
```java
synchronized (mutex) {
resultList.add(value);
}
```
## 小結
Java 8 的並行流提供了很方便的並行處理、提升程式執行效率的寫法,我們在編碼的過程中,對用到多執行緒的地方要保持警惕,有意識地預防此類問題。
對應的,我們在做程式碼審查的過程中,也要對涉及到多執行緒使用的場景時刻繃著一根弦,在程式碼合入前把好關,將隱患拒之門外。
## 參考
- [執行緒安全——維基百科](https://zh.wikipedia.org/zh-hans/%E7%BA%BF%E7%A8%8B%E5%AE%89%E5