1. 程式人生 > >深入理解 Java 虛擬機器(十二)Java 記憶體模型與執行緒

深入理解 Java 虛擬機器(十二)Java 記憶體模型與執行緒

執行緒安全

Java 語言中的執行緒安全

根據執行緒安全的強度排序,Java 語言中各種操作共享的資料可以分為 5 類:不可變、絕對執行緒安全、相對執行緒安全、執行緒相容、執行緒對立。

不可變

不可變的物件一定是執行緒安全的,如果共享資料是一個基本資料型別,那麼使用 final 關鍵字就可以保證它是執行緒安全的;如果是一個物件,那麼還需要保證物件的行為不會影響它的狀態,比如 String 類,substring()、replace()、concat 都不會影響它原來的值。最簡單的實現方式是為物件中帶有狀態的變數都宣告為 final。

除了 String 之外,列舉型別以及 Number 的部分子類,比如 Long、Double、BigInteger、BigDecimal 等也都是不可變的,但 AtomicInteger、AtomicLong 則不是。

絕對執行緒安全

絕對執行緒安全的定義很嚴格,通常需要付出很大的代價,甚至有時候是不切實際的代價。

相對執行緒安全

相對執行緒安全就是通常意義上的執行緒安全,它需要保證對單個物件單獨的操作是執行緒安全的,在呼叫的時候不需要做額外的保障措施,但對於一些特定順序的連續呼叫,就可能需要在呼叫端使用額外的同步手段來保證呼叫的正確性。

比如:

private static Vector<Integer> vector = new Vector<Integer>();

    public static void main(String[] args) {
        while (true) {
            for (int i = 0; i < 10; i++) {
                vector.add(i);
            }

            Thread removeThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < vector.size(); i++) {
                        vector.remove(i);
                    }
                }
            });

            Thread printThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < vector.size(); i++) {
                        System.out.println((vector.get(i)));
                    }
                }
            });

            removeThread.start();
            printThread.start();

            //不要同時產生過多的執行緒,否則會導致作業系統假死
            while (Thread.activeCount() > 20);
        }
    }
}

Vector 操作是執行緒安全的,但在多執行緒的環境中,如果不在方法呼叫端做額外的同步措施,上面這段程式碼仍然是不安全的。改進措施如下:

    Thread removeThread = new Thread(new Runnable() {
        @Override
        public void run() {
            synchronized (vector) {
                for (int i = 0; i < vector.size(); i++) {
                    vector.remove(i);
                }
            }
        }
    });

    Thread printThread = new Thread(new Runnable() {
        @Override
        public void run() {
            synchronized (vector) {
                for (int i = 0; i < vector.size(); i++) {
                    System.out.println((vector.get(i)));
                }
            }
        }
    });

執行緒相容

執行緒相容指物件本身並不是執行緒安全的,但可以通過在呼叫端正確地使用同步手段來保證物件在併發環境下可以安全地使用。平時說一個類不是執行緒安全的,絕大多數時候指的就是這種情況。

執行緒對立

執行緒對立指無論呼叫端是否採取了同步措施,都無法再多執行緒環境中併發使用的程式碼。這種情況通常是有害的,應儘量避免。

一個執行緒對立的例子是 Thread 的 resume() 和 suspend() 方法,如果併發執行,無論是否進行了同步,目標執行緒都存在死鎖的風險,如果 suspend() 中斷的執行緒就是即將要執行 resume() 的那個執行緒,那麼肯定產生死鎖,因此,suspend() 和 resume() 方法已經被 JDK 宣告廢棄了。常見的執行緒對立的操作還有 System.setIn()、System.setOut()、System.runFinalizersOnExit() 等。

執行緒安全的實現方法

互斥同步

同步是指在多個執行緒訪問共享資料時,保證共享資料在同一時刻只能被一個(使用訊號量的時候是一些)執行緒使用。而互斥是實現同步的一種手段,主要實現方式有:臨界區、互斥量、訊號量。

Java 中最簡單的互斥同步手段是 synchronized 關鍵字,synchronized 編譯後會在同步塊的前後形成 moniterenter、moniterexit 兩個位元組碼指令。在執行 moniterenter 指令時,首先要嘗試獲取物件的鎖,如果這個鎖未被鎖定,或者當前執行緒已經擁有了那個物件的鎖,則鎖的計數器加 1;相應的,執行 moniterexit 指令時,鎖的計數器減 1。如果獲取鎖失敗,則當前執行緒會阻塞等待,直到物件鎖被另外一個執行緒釋放。

synchronized 對同一條執行緒來說是可重入的,但在執行結束之前,會阻塞其它執行緒的進入,而執行緒的阻塞需要作業系統幫忙完成,切換使用者態到核心態,這種狀態裝換需要消耗很多的處理器時間,因此 synchronized 是一個重量級操作。

除了 synchronized 之外,還可以使用 java.util.concurrent 包中的 ReentrantLock 來實現同步,用法類似。相比 synchronized,ReentrantLock 增加了一些高階功能:等待可中斷、公平鎖、鎖可以繫結多個條件。

  1. 等待可中斷指當前持有鎖的執行緒長期不釋放鎖的時候,正在等待的執行緒可以選擇放棄等待,轉而處理其它事情。

  2. 公平鎖指多個執行緒在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖。synchronized、ReentrantLock 都是預設非公平的,但 ReentrantLock 可以通過傳遞引數給構造方法來使用公平鎖。

  3. 鎖可以繫結多個條件是指一個 ReentrantLock 物件可以同時繫結多個 Condition 物件,在 synchronized 中,wait()、notify()、notifyAll() 可以實現一個隱含的條件,如果要關聯多個,就只能額外新增新的鎖,而 ReentrantLock只需多次呼叫 newCondition() 即可。

JDK 1.6 後,synchronized 和 ReentrantLock 效能上基本持平,且虛擬機器在未來的效能改進中肯定會偏向於原生的 synchronized,因此優先考慮使用 synchronized 來實現同步。

非阻塞同步

互斥同步也稱為阻塞同步,屬於一種悲觀的策略,總是認為只要不去做正確的同步措施(例如加鎖),那就肯定會出問題,無論共享資料是否會出現競爭,都要進行加鎖、使用者態核心態轉換、維護鎖計數器和檢查是否有被阻塞的執行緒需要喚醒等操作。

隨著硬體指令集的發展,現在有了另一個選擇:基於衝突檢測的樂觀併發策略,就是先進行操作,如果沒有其它執行緒爭用共享資料,則操作成功;如果產生了衝突,就採取其它的補償措施,最常見的補償措施是不斷地重試,直到成功為止,這種同步方式一般都不需要掛起執行緒,稱為非阻塞同步。

非阻塞同步需要保證操作和衝突檢測這兩個步驟具備原子性,這是靠硬體來完成的,硬體保證一個從語義上看起來需要多次操作的行為只通過一條處理器指令就能完成,常用的有:

  1. 測試並設定(Test-and-Set)
  2. 獲取並增加(Fetch-and-Increment)
  3. 交換(Swap)
  4. 比較並交換(Compare-and-Swap,CAS)
  5. 載入連結/條件儲存(Load-Linked/Store-Conditional,LL/SC)

後兩個指令是現代處理器新增的,CAS 指令需要 3 個運算元:記憶體為止 V、舊的預期值 A、新值 B,CAS 指令執行時,當且僅當 V 符合預期值 A,處理器才會使用新值 B 更新 V 的值,否則不執行更新,無論是否更新成功,都會返回 V 的舊值,上述處理過程是一個原子操作。

JDK 1.5 後,Java 才可以使用 CAS 操作,封裝在 sun.misc.Unsafe 類的 compareAndSwapInt() 等方法裡面,使用者程式無法直接呼叫,但可以使用 AtomicInteger 等類,AtomicInteger 內部就是通過 Unsafe 類實現的,比如 incrementAndGet():

    public final int incrementAndGet() {
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
    }
    
    public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }

因此,前面用於介紹執行緒安全的程式碼示例可以修改為:

public class AtomicTest {

    public static AtomicInteger race = new AtomicInteger(0);

    public static void increase() {
        race.incrementAndGet();
    }

    private static final int THREADS_COUNT = 20;

    public static void main(String[] args) throws Exception {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }

        while (Thread.activeCount() > 1)
            Thread.yield();

        System.out.println(race);
    }
}

這樣就能得到正確的結果了。

但 CAS 也有它的問題,如果 V 初次讀取的時候是 A 值,準備賦值的時候仍然為 A 值,但這不等於它的值沒有被其它執行緒修改過,如果它被修改為 B,再被改回為 A,那麼 CAS 操作就會誤認為它沒有被修改過,這個漏洞稱為 CAS 的 “ABA” 問題。

無同步方案

有一些程式碼天生就是執行緒安全的,這裡介紹兩類:

  1. 可重入程式碼。也叫做純程式碼,可以在程式碼執行的任何時刻中斷它,轉而去執行另一段程式碼,而在控制權返回後,原來的程式不會出現任何錯誤。如果一個方法,它的返回結果是可預測的,只要輸入了相同的資料,就能返回相同的結果,那麼它就滿足可重入性的要求。

  2. 執行緒本地儲存。如果操作某個共享資料的程式碼能夠保證在同一執行緒中執行,那麼就可以把共享資料的可見範圍限制在同一個執行緒之內,這樣,無須同步也能保證執行緒之間不出現資料爭用的問題。可以通過 ThreadLocal 來實現。

鎖優化

自旋鎖與自適應自旋

互斥同步對效能最大的影響是阻塞的實現,掛起執行緒和恢復執行緒都需要轉入核心態中完成。但同時,共享資料的鎖定狀態往往只會持續很短的一段時間,為了這段時間去掛起執行緒和恢復執行緒並不值得,因此,多個處理器或多條執行緒並行執行的時候,可以讓後面請求鎖的那個執行緒稍等一下,但不放棄處理器的執行時間,看看持有鎖的執行緒是否很快就會釋放鎖。執行緒等待的實現方式是讓執行緒執行一個忙迴圈(自旋),這項技術就稱為自旋鎖。

如果鎖佔用的時間很短,那麼自旋等待的效果就會很好,反之就會消耗許多處理器資源,因此,自旋等待的時間有一定的限度,超時就會掛起執行緒。在 JDK 1.6 中引入了自適應的自選鎖,自適應意味著自旋的時間不固定,而是根據前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定,如果對於某個鎖,自旋很少成功獲得過,那麼以後獲取這個鎖的時候就可以省略掉自旋過程,以避免浪費處理器資源。

鎖消除

鎖消除是指虛擬機器即時編譯器在執行時,對一些程式碼要求同步,但是被檢測到不可能存在共享資料競爭的鎖進行消除。鎖消除的主要判定依據來源於逃逸分析的資料支援。

比如,對於某些程式碼,同步措施可能不是程式設計師自己加入的:

public String contactString(String s1, String s2, String s3) {
    return s1 + s2 + s3;
}

這段程式碼在 JDK 1.5 之前會被轉化為 StringBuffer 的拼接操作:

public String contactString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

StringBuffer 是自動具備同步措施的,但上面的程式碼並不存在共享資料競爭的情況,因此,雖然 StringBuffer 使用了鎖,但在編譯後,這段代就會忽略掉所有的同步而直接執行。

鎖粗化

原則上,編寫程式碼的時候總是推薦將同步塊的作用範圍限制得儘量小,但如果一系列的操作都對同一個物件反覆加鎖和解鎖,甚至加鎖操作出現在迴圈體中,那麼即使沒有執行緒競爭,頻繁地互斥同步操作也會導致不必要的效能損耗(比如上面 StringBuffer 的例子),這種情況下,就會把加鎖同步的範圍擴充套件到整個操作序列的外部。

輕量級鎖

HotSpot 虛擬機器的物件頭分為兩部分資訊,第一部分用於儲存物件自身的執行時資料,如雜湊嗎、GC 分代年齡等,這部分資料官方稱為 “Mark Word”,它是實現輕量級鎖和偏向鎖的關鍵。另一部分用於儲存指向方法區物件型別資料的指標,如果是陣列物件,還會有一個額外的部分用於儲存陣列長度。

考慮到虛擬機器的空間效率,Mark Word 被設計成一個非固定的資料結構以便在極小的空間記憶體儲儘量多的資訊,它會根據物件的狀態複用自己的儲存空間。例如,在 32 位的 HotSpot 虛擬機器中物件未被鎖定的狀態下,Mard Word 的 32bit 中的 25bit 用於儲存物件雜湊嗎,4bit 用於儲存物件分代年齡,2bit 用於儲存鎖標誌位,1bit 固定為 0,在其它狀態下物件的儲存內容如下表所示:

儲存內容 標誌位 狀態
物件雜湊嗎、物件分代年齡 01 未鎖定
指向鎖記錄的指標 00 輕量級鎖定
指向重量級鎖的指標 10 膨脹(重量級鎖定)
空,不需要記錄資訊 11 GC 標記
偏向執行緒 ID、偏向時間戳、物件分代年齡 01 可偏向

程式碼進入同步塊的時候,如果此同步物件沒有被鎖定,虛擬機器首先將在當前執行緒建立一個名為鎖記錄(Lock Record)的空間,用於儲存物件目前的 Mark Word 的拷貝(這份拷貝稱為 Displaced Mark Word);然後虛擬機器將使用 CAS 操作嘗試將物件的 Mark Word 更新為指向 Lock Record 的指標,如果這個更新動作成功了,那麼這個執行緒就擁有了該物件的鎖,並且物件 Mark Word 的鎖標誌位轉變為 00,表示此物件處於輕量級鎖定狀態。

在這裡插入圖片描述

如果這個更新動作失敗了,虛擬機器首先會檢查物件的 Mark Word 是否指向當前執行緒的棧幀,如果是,說明當前執行緒已經擁有了這個物件的鎖,那就可以直接進入同步塊繼續執行,否則說明這個鎖物件已經被其它執行緒搶佔了。如果有兩條以上的執行緒競爭同一個鎖,那麼輕量級鎖就不再有效,需要膨脹為重量級鎖,鎖標誌的狀態值變為 10,Mark Word 儲存的就是指向重量級鎖(互斥量)的指標,後面等待鎖的執行緒也要進入阻塞狀態。

解鎖過程也是通過 CAS 操作來進行的,如果物件的 Mark Word 仍然指向執行緒的鎖記錄,那麼就用 CAS 操作把物件當前的 Mark Word 和 Displaced Mark Word 替換回來,如果替換成功,那麼整個同步過程就完成了。如果替換失敗,說明有其它執行緒嘗試過獲取該鎖,那麼在釋放鎖的同時,還需要喚醒被掛起的執行緒。

輕量級鎖提升程式同步效能的依據是:對於絕大部分的鎖,在整個同步週期內部是不存在競爭的(個人理解:雖然可能有多個執行緒訪問,但出現彼此競爭鎖的情況很少出現)。此時輕量級鎖使用 CAS 操作避免了使用互斥量的開銷,但如果存在鎖競爭,輕量級鎖會比傳統的重量級鎖更慢,因為還多了一個 CAS 操作。

偏向鎖

偏向鎖的目的是消除資料在無競爭狀態下的同步原語,進一步提高程式的執行效能。輕量級鎖是在無競爭的情況下使用 CAS 操作去除互斥量,偏向鎖則是在無競爭的情況下把整個同步都消除掉。

偏向鎖的意思是這個鎖會偏向於第一個獲得它的執行緒,如果在接下來的執行過程中,該鎖沒有被其它的執行緒獲取,則持有偏向鎖的執行緒將永遠不需要再進行同步。

當鎖物件第一次被執行緒獲取的時候,虛擬機器會把物件頭中的標誌位設為 “01”,即偏向模式,同時使用 CAS 操作把獲取到這個鎖的 ID 記錄在物件的 Mark Word 中,如果 CAS 操作成功,持有偏向鎖的執行緒以後每次進入這個鎖相關的同步塊時,虛擬機器都可以不再進行任何同步操作。當有另一個執行緒去嘗試獲取這個鎖,偏向模式就宣告結束,根據鎖物件目前是否處於被鎖定的狀態,撤銷偏向後恢復到未鎖定或輕量級鎖定的狀態。

總結

思維導圖

在這裡插入圖片描述