1. 程式人生 > 其它 >Java併發程式設計底層實現原理

Java併發程式設計底層實現原理

Java程式碼在編譯後會變成Java位元組碼,位元組碼被類載入器載入到JVM中,JVM執行位元組碼,最終需要轉換為彙編指令在CPU上執行,Java中所有的併發機制依賴於JVM的實現和CPU的指定。

volatile 的應用

在併發程式設計中synchronized和volatile關鍵字都扮演著重要的角色,volatile是輕量級的synchronized,它在多處理器開發中保證了共享變數的“可見性”。可見性的意思是當一個執行緒修改了一個共享變數時,另一個執行緒能讀到這個修改的值,如果volatile關鍵字使用得當的話,它會比synchronized使用的成本更低,因為它不會引起上下文切換和排程

連線CPU的相關術語與說明

volatile是如何保證記憶體可見性的

原理:被volatile修飾的變數,通過jvm最終生成的彙編指令會多出一行彙編程式碼,這行程式碼是Lock字首的。Lock 字首的指令在多核處理器下會引發兩件事情

將處理器快取行的資料寫回到系統記憶體
這個寫回記憶體的操作會使在其他CPU快取了該記憶體地址的資料無效
為了提高處理速度,處理器不直接和記憶體進行通訊,而是先將系統記憶體中的資料讀到記憶體快取(L1 L2或其他)後在進行操作,但操作完不知道何時寫會記憶體。如果對申明瞭volatile的變數進行操作,JVM就會向處理器傳送一條Lock字首的指令,將這個變數所在快取行的資料寫回到系統記憶體。但是,就算寫回到記憶體,如果其他處理器快取的值還是舊值,再執行計算操作就會有問題。所以,在多處理器下,為了保證各個處理器的快取是一致的,就會實現快取一致性協議,每個處理器通過嗅探在匯流排傳播的資料來檢查自己的快取是否過期了,當處理器發現自己快取的資料對應的記憶體地址被修改,就會將當前處理器的快取行設定為無效,當處理器對這個資料進行修改時,就會重新從記憶體中讀取資料到快取中。

synchronized的實現原理與應用

在多執行緒併發程式設計中synchronized一直是元老角色,很多人稱呼它為重量級。但是隨著Java SE 1.6對synchronized進行了各種優化之後,有些情況synchronized它不在那麼重了。接下來闡述的知識點是關於偏向鎖、輕量級鎖,以及鎖的儲存結構和升級過程。
synchronized在Java中三種表現形式:

  • 對於普通同步方法,鎖的是當前例項物件
  • 對於靜態同步方法,鎖的是類的class物件
  • 對於同步方法塊,鎖的是synchronized括號裡配置的物件
    當一個執行緒試圖訪問同步程式碼塊時,它首先必須得到鎖,退出或者丟擲異常時,必須釋放鎖。name鎖到底存在哪裡呢?鎖裡面存的又是什麼資訊呢?
    在JVM規範中可以看到synchronized在JVM中的實現原理,JVM基於進入和退出Monitor物件來實現方法同步和程式碼塊同步,兩者實現的細節不一樣。程式碼塊使用monitorenter和monitorexit指令實現,而方法同步使用的是另外一種情況,這個在JVM規範中並沒有講解。但是,方法的同步也可使用這兩個指令來實現。
    monitorenter指令是在編譯後插入到同步程式碼塊的開始位置
    monitorexit指令插入到方法結束的位置和異常處
    JVM要保證每個monitorenter必須有與之對應的monitorexit配對
    任何物件都有一個monitor與之關聯
    當一個monitor被持有後,它將處於鎖定狀態
    執行緒執行monitorenter指令時,將會嘗試獲取物件對應的monitor的所有權,即嘗試獲得物件的鎖

Java物件頭

synchronize用的鎖存在Java物件頭裡。如果物件是陣列型別,則虛擬機器用3個字寬(Word)儲存物件頭,如果物件是非資料型別,則虛擬機器用2個字寬儲存物件頭 。在32位虛擬機器中1字寬等於4位元組,即32bit。
Java物件頭的長度

鎖升級與對比

Java SE 1.6 為了減少獲得鎖和釋放鎖帶來效能消耗,引入了“偏向鎖”和“輕量級鎖”,在Java SE 1.6中,鎖一共有四種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態,這幾個狀態會隨著競爭情況逐漸升級。注意:鎖的升級是不可逆的,意味著偏向鎖升級為輕量級鎖之後是不能降級為偏向鎖的。

偏向鎖

HotSpot的作者研究發現,大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一個執行緒多次獲取的,為了讓執行緒獲得鎖的代價更低,引入了偏向鎖。 注意:這個是設計偏向鎖的原因和解決思路
偏向鎖的獲取
當一個執行緒訪問同步程式碼塊並獲取鎖時,會在對線頭和棧幀中的鎖記錄中儲存鎖偏向的執行緒ID,以後該執行緒在進入和退出同步程式碼塊的時候,不需要進行CAS操作來加鎖和解鎖,只需要簡單的測試一下對線頭Mark Word裡是否儲存了當前執行緒的偏向鎖。如果測試成功,表示執行緒已經獲得了鎖。如果測試失敗,則需要在測試一下Mark Word中偏向鎖標識是否被設定成立1(表示當前是偏向鎖):如果沒有設定則使用CAS競爭鎖;如果這事了,則嘗試使用CAS將當前物件頭的偏向鎖指向當前執行緒。
偏向鎖的撤銷(非常妙這裡)
偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他執行緒嘗試競爭偏向鎖是,持有偏向鎖的執行緒才會釋放偏向鎖。偏向鎖的撤銷,需要等待全域性安全點(在這個時間點上沒有正在執行的位元組碼)。它首先會暫停擁有偏向鎖的執行緒,然後檢查持有偏向鎖的執行緒是否活著,如果執行緒不處於活動狀態,則將物件頭設定成無鎖狀態;如果執行緒仍然活著,擁有偏向鎖的棧會被執行,遍歷偏向物件的鎖記錄,棧中的鎖記錄和物件頭的Mark Word要麼重新偏向其他執行緒,要麼恢復到無鎖標記活著標記該物件不適合作為偏向鎖,最後喚醒暫停的執行緒
關閉偏向鎖
Java 1.6 1.7偏向鎖是預設開啟的,但是它在應用程式啟動幾秒鐘後才會啟用,我們可以修改JVM引數來關閉延遲,或者確定應用程式裡所有的鎖通常情況下都是出於競爭狀態,可以直接關閉偏向鎖

輕量級鎖

輕量級鎖加鎖
執行緒在執行同步程式碼塊之前,JVM會在當前執行緒的棧幀中建立用於儲存鎖記錄的空間,並將物件頭中Mark Word複製到鎖記錄中,官方稱為Displaced Mark Word。然後執行緒嘗試使用CAS將物件頭中的Mark Word替換為指向鎖記錄的指標。如果成功,當前執行緒獲得鎖;如果失敗,表示其他執行緒競爭鎖,當前執行緒便嘗試自旋獲得鎖。
輕量級鎖解鎖
輕量級鎖解鎖時,使用的是CAS操作將Displaced Mark Word替換回物件頭,如果成功則表示沒有競爭;如果失敗,表示當前鎖存在競爭,鎖就會膨脹為重量級鎖。 注意:由於自旋過程消耗CPU,為了避免無用的自旋,一當升級為重量級鎖,那麼就不會再恢復到輕量級鎖狀態。當前鎖出於重量級鎖狀態時,其他執行緒嘗試獲取鎖,都會被阻塞,當持有鎖的執行緒釋放鎖後會喚醒這些執行緒,重新進行鎖的爭奪。

鎖的優缺點對比

鎖的優缺點對比

Java中如何實現原子操作

在Java中可以通過鎖和迴圈CAS的方式實現原子操作

使用迴圈CAS實現原子操作

JVM中的CAS操作利用了處理器提供的CMPXCHG指令實現。自旋CAS實現的基本思路就是迴圈進行CAS操作知道成功為止,示例程式碼實現一個安全的計數器和非安全的計數器。

package com.liziba;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
/**
 * @auther LiZiBa
 * @date 2021/2/28 17:39
 * @description: 計數器實現
 **/
public class Counter {
    // 安全計數器統計數
    private AtomicInteger atomicInteger = new AtomicInteger(0);
    // 非安全計數器統計數
    private int i = 0;
    public static void main(String[] args) {
        final Counter cas = new Counter();
        List<Thread> threads = new ArrayList<>(600);
        long start = System.currentTimeMillis();
        for (int j = 0; j < 100; j++) {
            Thread t = new Thread(() -> {
                for (int i = 0; i < 10000; i++) {
                    // 非安全計數器
                    cas.count();
                    // 安全計數器
                    cas.safeCount();
                }
            });
            threads.add(t);
        }
        // 啟動執行緒
        threads.forEach(t -> t.start());
        // join等待所有執行緒執行完畢
        for (Thread t : threads) {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 輸出不安全計數器結果、安全計數器結果、程式執行時間
        System.out.println(cas.i);
        System.out.println(cas.atomicInteger.get());
        System.out.println(System.currentTimeMillis() - start);
    }
    /**
     * 安全計數器
     */
    private void safeCount() {
        i++;
    }
    /**
     * 非安全計數器
     */
    private void count() {
        for (;;) {
            int i = atomicInteger.get();
            // CAS 增加
            // 注意使用 ++i
            boolean set = atomicInteger.compareAndSet(i, ++i);
            // 設定成功退出死迴圈
            if (set) {
                break;
            }
        }
    }
}

JDK 1.5開始,JDK併發包提供了一些類支援原子操作,如AtomicBoolean,AtomicInteger,AtomicLong,對應不同型別的原子操作,這些類提供了非常有用的工具方法,比如原子自增和自減等等。

CAS 實現原子操作的三大問題

ABA問題:因為CAS需要在操作值的時候,檢查值是否發生了變化,如果沒有變化則更新,但是如果一個值從A修改為B又修改為A,那麼使用CAS就無法發現值發生了變化,但實際上發生了變化。解決方案如下
使用版本號解決,將原本的A–>B–>A問題變成1A–>2B–>3A則可以解決
使用JDK Atomic包裡提供的AtomicStampedReference來解決ABA問題,這個類compareAndSet方法會首先比較當前引用是否等於預期引用,並且檢查當前標誌是否等於預期標誌,如果全部相等則以原子方式替換,原始碼如下。

迴圈時間長開銷大:自旋CAS如果長時間不成功,會給CPU帶來非常大的開銷。解決這個問題需要JVM能支援處理器提供的pause指令,效率會有一定的提升。pause指令的兩個作用如下
延遲流水線執行指令(de-pipeline),使CPU不會消耗過多執行資源
避免在退出迴圈時記憶體順序衝突(Memory Order Violation)而引起CPU流水線被清空
只能保證一個共享變數的原子操作:對多個共享變數進行操作的時候CAS無法保證原子性,解決方案如下
使用鎖
變數合併
使用JDK提供的AtomicReference類來保證引用物件之間的原子性,將多個變數放置於物件中

使用鎖機制實現原子操作

鎖機制保證了只有獲得了鎖的執行緒才能操作鎖定的記憶體區域。JVM內部實現了很多鎖,偏向鎖、輕量級鎖、重量級鎖。但是除了偏向鎖,JVM實現鎖的方式都使用了迴圈CAS機制,即一個執行緒想進入同步程式碼塊的時候,使用迴圈CAS的方式獲取鎖,當它退出同步程式碼塊的時候使用迴圈CAS來釋放鎖