一篇文章看懂Java併發和執行緒安全

調皮仔3683發表於2018-02-02

一、前言

長久以來,一直想剖析一下Java執行緒安全的本質,但是苦於有些微觀的點想不明白,便擱置了下來,前段時間慢慢想明白了,便把所有的點串聯起來,趁著思路清晰,整理成這樣一篇文章。

二、導讀

1、為什麼有多執行緒?

2、執行緒安全描述的本質問題是什麼?

3、Java記憶體模型(JMM)資料可見性問題、指令重排序、記憶體屏障

三、揭曉答案

1、為什麼有多執行緒

談到多執行緒,我們很容易與高效能畫上等號,但是並非如此,舉個簡單的例子,從1加到100,用四個執行緒計算不一定比一個執行緒來得快。因為執行緒的建立和上下文切換,是一筆巨大的開銷。

那麼設計多執行緒的初衷是什麼呢?來看一個這樣的實際例子,計算機通常需要與人來互動,假設計算機只有一個執行緒,並且這個執行緒在等待使用者的輸入,那麼在等待的過程中,CPU什麼事情也做不了,只能等待,造成CPU的利用率很低。如果設計成多執行緒,在CPU在等待資源的過程中,可以切到其他的執行緒上去,提高CPU利用率。

現代處理器大多含有多個CPU核心,那麼對於運算量大任務,可以用多執行緒的方式拆解成多個小任務併發的執行,提高計算的效率。

總結起來無非兩點,提高CPU的利用率、提高計算效率。

2、執行緒安全的本質

我們先來看一個例子:

public class Add {
    private int count = 0;

    public static void main(String[] args) {
        CountDownLatch countDownLatch = new CountDownLatch(4);
        Add add = new Add();
        add.doAdd(countDownLatch);
        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(add.getCount());

    }
    public void doAdd(CountDownLatch countDownLatch) {
        for (int i = 0; i < 4; i++) {
            new Thread(new Runnable() {
                public void run() {
                    for (int j = 0; j < 25; j++) {
                        count++;
                    }
                    countDownLatch.countDown();
                }
            }).start();
        }
    }

    public int getCount() {
        return count;
    }

}

上面是一個把變數自增100次的例子,只不過用了4個執行緒,每個執行緒自增25次,用CountDownLatch等4個執行緒執行完,列印出最終結果。實際上,我們希望程式的結果是100,但是列印出來的結果並非總是100。

這就引出了執行緒安全所描述的問題,我們先用通俗的話來描述一下執行緒安全:

執行緒安全就是要讓程式執行出我們想要的結果,或者話句話說,讓程式像我們看到的那樣執行。

解釋一下我總結的這句話,我們先new出了一個add物件,呼叫了物件的doAdd方法,本來我們希望每個執行緒有序的自增25次,最終得到正確的結果。如果程式增的像我們預先設定的那樣執行,那麼這個物件就是執行緒安全的。

下面我們來看看Brian Goetz對執行緒安全的描述:當多執行緒訪問一個物件時,如果不用考慮這些執行緒在執行時環境下的排程和交替,也不需要進行額外的同步,或者在呼叫方進行任何其他的協調操作,呼叫這個物件的行為都可以獲得正確的結果,那麼這個物件就是執行緒安全的。

下面我們就來分析這段程式碼為什麼不能確保總是得到正確的結果。

3、Java記憶體模型(JMM)資料可見性問題、指令重排序、記憶體屏障

先從計算機的硬體效率說起,CPU的計算速度比記憶體快幾個數量級,為了平衡CPU和記憶體之間的矛盾,引入的快取記憶體,每個CPU都有快取記憶體,甚至是多級快取L1、L2和L3,那麼快取與記憶體的互動需要快取一致性協議,這裡就不深入講解。那麼最終處理器、快取記憶體、主記憶體的互動關係如下:

那麼Java的記憶體模型(Java Memory Model,簡稱JMM)也定義了執行緒、工作記憶體、主記憶體之間的關係,非常類似於硬體方面的定義。

這裡順帶提一下,Java虛擬機器執行時記憶體的區域劃分

方法區:儲存類資訊、常量、靜態變數等,各執行緒共享

虛擬機器棧:每個方法的執行都會建立棧幀,用於儲存區域性變數、運算元棧、動態連結等,虛擬機器棧主要儲存這些資訊,執行緒私有

本地方法棧:虛擬機器使用到的Native方法服務,例如c程式等,執行緒私有

程式計數器:記錄程式執行到哪一行了,相當於當前執行緒位元組碼的行號計數器,執行緒私有

堆:new出的例項物件都儲存在這個區域,是GC的主戰場,執行緒共享。

所以對於JMM定義的主記憶體,大部分時候可以對應堆記憶體、方法區等執行緒共享的區域,這裡只是概念上對應,其實程式計數器、虛擬機器棧等也有部分是放在主記憶體的,具體看虛擬機器的設計。

好了,瞭解了JMM記憶體模型,我們來分析一下,上面的程式為什麼沒得到正確的結果。請看下圖,執行緒A、B同時去讀取主記憶體的count初始值存放在各自的工作記憶體裡,同時執行了自增操作,寫回主記憶體,最終得到了錯誤的結果。

我們再來深入分析一下,造成這個錯誤的本質原因:

(1)、可見性,工作記憶體的最新值不知道什麼時候會寫回主記憶體

(2)、有序性,執行緒之間必須是有序的訪問共享變數,我們用“視界”這個概念來描述一下這個過程,以B執行緒的視角看,當他看到A執行緒運算好之後,把值寫回之記憶體之後,馬上去讀取最新的值來做運算。A執行緒也應該是看到B運算完之後,馬上去讀取,在做運算,這樣就得到了正確的結果。

接下來,我們來具體分析一下,為什麼要從可見性和有序性兩個方面來限定。

給count加上volatile關鍵字,就保證了可見性。

private volatile int count = 0;

volatile關鍵字,會在最終編譯出來的指令上加上lock字首,lock字首的指令做三件事情

(1)、防止指令重排序(這裡對本問題的分析不重要,後面會詳細來講)

(2)、鎖住匯流排或者使用鎖定快取來保證執行的原子性,早期的處理可能用鎖定匯流排的方式,這樣其他處理器沒辦法通過匯流排訪問記憶體,開銷比較大,現在的處理器都是用鎖定快取的方式,在配合快取一致性來解決。

(3)、把緩衝區的所有資料都寫回主記憶體,並保證其他處理器快取的該變數失效

既然保證了可見性,加上了volatile關鍵詞,為什麼還是無法得到正確的結果,原因是count++,並非原子操作,count++等效於如下步驟:

(1)、 從主記憶體中讀取count賦值給執行緒副本變數:

       temp=count

(2)、執行緒副本變數加1

       temp=temp+1

(3)、執行緒副本變數寫回主記憶體

        count=temp

就算是真的嚴苛的給匯流排加鎖,導致同一時刻,只能有一個處理器訪問到count變數,但是在執行第(2)步操作時,其他cpu已經可以訪問count變數,此時最新運算結果還沒刷回主記憶體,造成了錯誤的結果,所以必須保證順序性。

那麼保證順序性的本質,就是保證同一時刻只有一個CPU可以執行臨界區程式碼。這時候做法通常是加鎖,鎖本質是分兩種:悲觀鎖和樂觀鎖。如典型的悲觀鎖synchronized、JUC包下面典型的樂觀鎖ReentrantLock。

總結一下:要保證執行緒安全,必須保證兩點:共享變數的可見性、臨界區程式碼訪問的順序性。

文章來源:https://my.oschina.net/u/1778239/blog/1610185

參考視訊:http://www.roncoo.com/course/view/b6f89747a8284f44838b2c4da6c8677b


相關文章