如何寫出線程不安全的代碼
什麼是線程安全性
很多時候,我們的代碼,在單線程的環境下是可以運行的非常完美,然而,一旦把代碼放到多線程的環境下去接受蹂躪,結果常常是慘不忍睹的。
《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,它看上去只是一個操作,實際上包含了三個動作:
- 讀取count
- 將count加一
- 將count的值到內存中
這是一個「讀取-修改-寫入」的操作序列,因此假設現在count是9,然後:
- 線程A進入service方法,讀到count值是9
- 在A修改完count的值但是還沒寫入內存之前,線程B也進入service方法,並且讀取了count值,這時候線程B讀取到的count還是9
- 最後,兩個線程都對值為9的count,進行了加一的操作,兩次請求下來,計數器只增加了一次。
顯然,這個類,在多線程的環境下,沒有表現出我們預期的行為,所以稱它為線程不安全。
意外懷孕
這一次,我們需要寫一個單例,單例很簡單呀,不就是構造函數私有化么:
public class UnsafeSingleton { private UnsafeSingleton instance = null; public 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並發編程實踐》里提到的知識,我買了中文版和英文版。這是一本很難啃的書,我會一如既往的用通俗易懂的語言來和大家分享我的學習心得。
參考
- 《Java並發編程實踐》
- how-is-listing-3-7-working-in-java-concurrency-in-practice
- not-thread-safe-object-publishing
推薦閱讀:
※非同步調用和單線程,多線程的疑惑?
※Qt 多線程串口通信問題?
※linux線程是如何進行切換的?
※在不改變方法簽名(method signature)的情況下, 請描述這段代碼的問題以及如何解決?
※測試線程同步中出現的阻塞問題?
TAG:Java | Java并发编程书籍 | 多线程 |