1. 程式人生 > >如何寫出執行緒不安全的程式碼

如何寫出執行緒不安全的程式碼

本文釋出於專欄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的值到記憶體中

這是一個“讀取-修改-寫入

”的操作序列,因此假設現在count是9,然後:
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;
    }
}

如果只有一個執行緒呼叫我們的程式碼,那這個類,永遠不會生出二胎。但是,放在多執行緒的環境下,它就可能會意外懷孕了:

  1. 執行緒A呼叫getInstance方法,這時候instance是null,進入if程式碼塊
  2. 線上程A執行new UnsafeSingleton()之前,執行緒B先跨一步,執行if判斷,這時候instance還是null,嗯,執行緒B也進去了
  3. 接下來,兩個執行緒都會執行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:

  1. 執行緒A將事件源傳入建構函式,並且執行了registerListener的程式碼
  2. 線上程A給listOfEvents初始化之前,執行緒B觸發了事件源,由於執行緒A已經往事件源註冊了監聽器,因此會執行onEvent函式,也就是doSomething(e);
  3. 而此時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的指令重排,後面會給大家詳細講解。

小結

這篇文章給大家解釋了什麼是執行緒安全,並且舉了四個執行緒不安全的例子來加深大家對執行緒安全的理解:消失的請求數、意外懷孕、考題洩漏、半成品。這四個例子,分別對應三種常見的執行緒不安全情形:

  1. 讀取-修改-寫入: 對應上面“消失的請求數”的例子
  2. 先檢查後執行:對應上面“意外懷孕”的例子
  3. 釋出未完整構造的物件:對應上面“考題洩漏”和“半成品”兩個例子

絕大多數的執行緒不安全問題,都可以歸結為這三種情形。而這三種情形,其實又可以再縮減為兩種:物件建立時物件建立後不僅僅是在物件建立後的業務邏輯中要考慮執行緒的安全性,在物件建立的過程中,也要考慮執行緒安全

後記

這篇文章裡只是解釋了為什麼這些程式碼會有執行緒安全問題,並沒有跟大家說如何對程式碼進行修改,使之成為“執行緒安全”,我會在後面的文章中和大家一起詳細探討。

有人可能會說,執行緒安全嘛,加同步鎖不就可以啦,其實不然,光光同步鎖,就有很多可以探究的了:

  1. 同步鎖的原理是什麼
  2. 鎖的重入(Reentrancy)是什麼
  3. 同步鎖的本質?

更何況,解決併發問題,也絕對不是加鎖這麼簡單,我們還需要了解:

  1. volatile關鍵字的含義
  2. 指令重排是什麼
  3. 如何安全的釋出物件
  4. 如何設計一個執行緒安全的類

再者,解決了執行緒安全,我們還需要考慮執行緒的生命週期管理、執行緒使用的效能問題等:

  1. 如何取消一個執行緒
  2. 如何關閉一個有很多執行緒的服務
  3. 如何設計執行緒池的大小
  4. ThreadPoolExecutor,Future等Java執行緒框架的使用
  5. 執行緒被中斷了如何處理
  6. 執行緒池資源不夠了,有什麼處理策略
  7. 死鎖的N種情形

乃至我們學習Java併發程式設計最最初始的問題:

  1. 我們為什麼要學習併發程式設計
  2. 併發和非同步的關係

這些,都是我新的一年裡要和大家一起分享的,分享的內容主要基於《Java併發程式設計實踐》裡提到的知識,我買了中文版和英文版。這是一本很難啃的書,我會一如既往的用通俗易懂的語言來和大家分享我的學習心得。

參考