如何寫出執行緒不安全的程式碼
本文釋出於專欄Effective Java,如果您覺得看完之後對你有所幫助,歡迎訂閱本專欄,也歡迎您將本專欄分享給您身邊的工程師同學。
什麼是執行緒安全性
很多時候,我們的程式碼,在單執行緒的環境下是可以執行的非常完美,然而,一旦把程式碼放到多執行緒的環境下去接受蹂躪,結果常常是慘不忍睹的。
《Java併發程式設計實踐》中,給出了執行緒安全性的解釋:
A class is thread-safe when it continues to behave correctly when accessed from multiple threads.
當一個類,不斷被多個執行緒呼叫,仍能表現出正確的行為時,那它就是執行緒安全的。
這裡的關鍵在於對“正確的行為
消失的請求數
假設我們需要給Servlet增加一個統計請求數的功能,於是我們使用了一個long變數作為計數器,並在每次請求時都給這個計數器加一(本文的所有程式碼,可到Github下載):
public class UnsafeCountingServlet extends GenericServlet implements Servlet {
private long count = 0;
public long getCount() {
return count;
}
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
++count;
// To something else...
}
}
在單執行緒的環境下,這份程式碼絕對正確,然而,當有多個執行緒同時訪問時,問題就暴露了。
關鍵就在於++count,它看上去只是一個操作,實際上包含了三個動作:
1. 讀取count
2. 將count加一
3. 將count的值到記憶體中
這是一個“讀取-修改-寫入
1. 執行緒A進入service方法,讀到count值是9
2. 在A修改完count的值但是還沒寫入記憶體之前,執行緒B也進入service方法,並且讀取了count值,這時候執行緒B讀取到的count還是9
3. 最後,兩個執行緒都對值為9的count,進行了加一的操作,兩次請求下來,計數器只增加了一次。
顯然,這個類,在多執行緒的環境下,沒有表現出我們預期的行為,所以稱它為執行緒不安全。
意外懷孕
這一次,我們需要寫一個單例,單例很簡單呀,不就是建構函式私有化麼:
public class UnsafeSingleton {
private static UnsafeSingleton instance = null;
private UnsafeSingleton() {
}
public static UnsafeSingleton getInstance() {
if (instance == null)
instance = new UnsafeSingleton();
return instance;
}
}
如果只有一個執行緒呼叫我們的程式碼,那這個類,永遠不會生出二胎。但是,放在多執行緒的環境下,它就可能會意外懷孕了:
- 執行緒A呼叫getInstance方法,這時候instance是null,進入if程式碼塊
- 線上程A執行new UnsafeSingleton()之前,執行緒B先跨一步,執行if判斷,這時候instance還是null,嗯,執行緒B也進去了
- 接下來,兩個執行緒都會執行new UnsafeSingleton()…悲劇就這樣發生了
預期中的計劃生育失敗,我們再一次寫出了執行緒不安全的程式碼。
考題洩漏
如果說前面兩種破壞方式都太過明顯,很難在程式碼review中逃過法眼的話,接下來這種方式,就顯得非常高階了。
public class ThisEscape {
private final List<Event> listOfEvents;
public ThisEscape(EventSource source) {
source.registerListener(new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
});
listOfEvents = new ArrayList<Event>();
}
void doSomething(Event e) {
listOfEvents.add(e);
}
interface EventSource {
void registerListener(EventListener e);
}
interface EventListener {
void onEvent(Event e);
}
interface Event {
}
}
這個類的建構函式接收了一個事件源,在建構函式中,會給事件源新增一個監聽器。咋看之下,你也許不會發現這段程式碼有什麼問題,其實這裡面暗藏著NullPointerException:
- 執行緒A將事件源傳入建構函式,並且執行了registerListener的程式碼
- 線上程A給listOfEvents初始化之前,執行緒B觸發了事件源,由於執行緒A已經往事件源註冊了監聽器,因此會執行onEvent函式,也就是doSomething(e);
- 而此時listOfEvents還沒被初始化,因此listOfEvents.add(e)報空指標異常
這一切的根源都在於,ThisEscape的建構函式,在ThisEscape還沒例項化完成之前,就把this物件洩漏出去,使得外部可以呼叫例項物件的方法,這就像還沒開考,就把考題給公佈出去了,因此稱之為,考題洩漏。
《Java併發程式設計實踐》將這種誤把物件釋出出去的行為,稱為物件逸出(Escape)。
半成品
物件逸出是指不想釋出物件,卻不小心釋出了。還有一種是,想釋出物件,卻在物件還沒製造好之前,就給了對方使用半成品的機會:
public class StuffIntoPublic {
public Holder holder;
public void initialize() {
holder = new Holder(42);
}
}
public class Holder {
private int n;
public Holder(int n) {
this.n = n;
}
public void assertSanity() {
if (n != n)
throw new AssertionError("This statement is false.");
}
}
很難想象,什麼情況下n != n會成立,並丟擲異常。大家可以先參考StackOverflow裡的解釋,主要是涉及到Java的指令重排,後面會給大家詳細講解。
小結
這篇文章給大家解釋了什麼是執行緒安全,並且舉了四個執行緒不安全的例子來加深大家對執行緒安全的理解:消失的請求數、意外懷孕、考題洩漏、半成品。這四個例子,分別對應三種常見的執行緒不安全情形:
- 讀取-修改-寫入: 對應上面“消失的請求數”的例子
- 先檢查後執行:對應上面“意外懷孕”的例子
- 釋出未完整構造的物件:對應上面“考題洩漏”和“半成品”兩個例子
絕大多數的執行緒不安全問題,都可以歸結為這三種情形。而這三種情形,其實又可以再縮減為兩種:物件建立時和物件建立後。不僅僅是在物件建立後的業務邏輯中要考慮執行緒的安全性,在物件建立的過程中,也要考慮執行緒安全。
後記
這篇文章裡只是解釋了為什麼這些程式碼會有執行緒安全問題,並沒有跟大家說如何對程式碼進行修改,使之成為“執行緒安全”,我會在後面的文章中和大家一起詳細探討。
有人可能會說,執行緒安全嘛,加同步鎖不就可以啦,其實不然,光光同步鎖,就有很多可以探究的了:
- 同步鎖的原理是什麼
- 鎖的重入(Reentrancy)是什麼
- 同步鎖的本質?
- …
更何況,解決併發問題,也絕對不是加鎖這麼簡單,我們還需要了解:
- volatile關鍵字的含義
- 指令重排是什麼
- 如何安全的釋出物件
- 如何設計一個執行緒安全的類
- …
再者,解決了執行緒安全,我們還需要考慮執行緒的生命週期管理、執行緒使用的效能問題等:
- 如何取消一個執行緒
- 如何關閉一個有很多執行緒的服務
- 如何設計執行緒池的大小
- ThreadPoolExecutor,Future等Java執行緒框架的使用
- 執行緒被中斷了如何處理
- 執行緒池資源不夠了,有什麼處理策略
- 死鎖的N種情形
- …
乃至我們學習Java併發程式設計最最初始的問題:
- 我們為什麼要學習併發程式設計
- 併發和非同步的關係
這些,都是我新的一年裡要和大家一起分享的,分享的內容主要基於《Java併發程式設計實踐》裡提到的知識,我買了中文版和英文版。這是一本很難啃的書,我會一如既往的用通俗易懂的語言來和大家分享我的學習心得。