JAVA併發之Synchronized(悲觀鎖)

一、關鍵字介紹

synchronized是Java中的關鍵字,是一種同步鎖。可修飾實例方法,靜態方法,代碼塊。
synchronized是一種悲觀鎖。

二、使用場景

synchronized可以修飾實例方法,靜態方法,代碼塊。
修飾實例方法:對當前實例加鎖,進入同步代碼前要獲得當前實例的鎖
修飾靜態方法:對當前類對象加鎖,進入同步代碼前要獲得當前類對象的鎖
修飾代碼塊:指定加鎖對象,對給定對象加鎖,進入同步代碼庫前要獲得給定對象的鎖。
具體區別詳見代碼示例:

0.未加鎖時狀態

public class TestSyn implements Runnable {
    public static final Object lockHelper = new Object();

    static int count = 0;

    public void increase() {
        for (int i = 0; i < 10000; i++) {
            count++;
        }
    }

    @Override
    public void run() {
        increase();
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new TestSyn());
        Thread t2 = new Thread(new TestSyn());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

該方法啓動兩線程對同一個變量進行操作,期望結果爲20000,但本地運行結果爲:17168(每次運行結果不同,但都是小於20000),是由於兩個線程同時對該變量進行操作所導致的。加鎖即可解決同時進行操作的問題。

1.實例方法

對當前實例加鎖,進入同步代碼前要獲得當前實例的鎖

public class TestSyn implements Runnable {
    public static final Object lockHelper = new Object();

    static int count = 0;

    public synchronized void increase() {
        for (int i = 0; i < 10000; i++) {
            count++;
        }
    }

    @Override
    public void run() {
        increase();
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new TestSyn());
        Thread t2 = new Thread(new TestSyn());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

此處已加鎖,但本地運行結果爲:15317(每次運行結果不同,但都是小於20000),說明沒有鎖住。分析代碼,syn修飾實例方法,鎖住的是對象,但是啓動線程時是新new的對象,這也就意味着存在着兩個不同的實例對象鎖,導致兩個線程都拿到了各自的鎖,同時進入了increase方法,無法保證線程安全。
對demo進行改進如下(使用同一個對象啓動不同線程):

public class TestSyn implements Runnable {
    public static final Object lockHelper = new Object();

    static int count = 0;

    public synchronized void increase() {
        for (int i = 0; i < 10000; i++) {
            count++;
        }
    }

    @Override
    public void run() {
        increase();
    }

    public static void main(String[] args) throws InterruptedException {
        TestSyn ts = new TestSyn();
        Thread t1 = new Thread(ts);
        Thread t2 = new Thread(ts);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

輸出結果與期望一致,爲20000,說明鎖已生效。

2. 靜態方法

對當前類對象加鎖,進入同步代碼前要獲得當前類對象的鎖

public class TestSyn implements Runnable {
    public static final Object lockHelper = new Object();

    static int count = 0;

    public synchronized static void increase() {
        for (int i = 0; i < 10000; i++) {
            count++;
        }
    }

    @Override
    public void run() {
        increase();
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new TestSyn());
        Thread t2 = new Thread(new TestSyn());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

運行結果爲20000,證明鎖生效。此處雖然與1的失敗demo大體一樣,但是成功,是因爲syn修飾的是靜態方法,鎖的是類對象,雖然有兩個不同實體,但是是同一個類對象,保證線程安全。

3. 修飾一個代碼塊

指定加鎖對象,對給定對象加鎖,進入同步代碼庫前要獲得給定對象的鎖。

public class TestSyn implements Runnable {
    public static final Object lockHelper = new Object();

    static int count = 0;

    public void increase() {
        System.out.println("其他無需保證線程安全的耗時操作");
        synchronized (lockHelper) {
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        }
    }

    @Override
    public void run() {
        increase();
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new TestSyn());
        Thread t2 = new Thread(new TestSyn());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

運行結果爲20000,證明鎖生效。同2,此處鎖住的是類的靜態變量,所有此類的對象共用一個靜態變量,所以能成功鎖住,保證線程安全。
與2相比的優點在於,可以將鎖粒度降低,只鎖需要保證線程安全的代碼,其他無需保證線程安全且耗時的操作可以同步進行,增加代碼執行效率。

三、底層實現原理

先說結論:syn的底層實現原理是基於Java對象頭與Monitor

java對象頭


synchronized用的鎖是存在Java對象頭裏的,那麼什麼是Java對象頭呢?Hotspot虛擬機的對象頭主要包括兩部分數據:Mark Word(標記字段)、Klass Pointer(類型指針)。其中Klass Point是是對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例,Mark Word用於存儲對象自身的運行時數據,它是實現輕量級鎖和偏向鎖的關鍵。

Mark Word用於存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程 ID、偏向時間戳等等。Java對象頭一般佔有兩個機器碼(在32位虛擬機中,1個機器碼等於4字節,也就是32bit),但是如果對象是數組類型,則需要三個機器碼,因爲JVM虛擬機可以通過Java對象的元數據信息確定Java對象的大小,但是無法從數組的元數據來確認數組的大小,所以用一塊來記錄數組長度。下圖是Java對象頭的存儲結構(32位虛擬機):
這裏寫圖片描述
Monitor


Monitor 是線程私有的數據結構,每一個線程都有一個可用monitor record列表,同時還有一個全局的可用列表。每一個被鎖住的對象都會和一個monitor關聯(對象頭的MarkWord中的LockWord指向monitor的起始地址),同時monitor中有一個Owner字段存放擁有該鎖的線程的唯一標識,表示該鎖被這個線程佔用。

下面是鎖代碼塊 示例代碼以及對應class文件信息:

public class Test {
    private final static Object lockHelper = new Object();

    public static void main(String[] args) {
        System.out.println("Hello World");
        synchronized (lockHelper) {
            System.out.println("insert Syn...");
        }
    }
}

這裏寫圖片描述

以上,可知同步語句塊的實現使用的是monitorenter 和 monitorexit 指令。
其中monitorenter指令指向同步代碼塊的開始位置,monitorexit指令則指明同步代碼塊的結束位置,當執行monitorenter指令時,當前線程將試圖獲取 objectref(即對象鎖) 所對應的 monitor 的持有權,當 monitor 的進入計數器爲 0,那線程可以成功取得 monitor,並將計數器值設置爲 1,取鎖成功。如果當前線程已經擁有 objectref 的 monitor 的持有權,那它可以重入這個 monitor,重入時計數器的值也會加 1。倘若其他線程已經擁有 monitor 的所有權,那當前線程將被阻塞,直到正在執行線程執行完畢,即monitorexit指令被執行,執行線程將釋放 monitor(鎖)並設置計數器值爲0 ,其他線程將有機會持有 monitor 。值得注意的是編譯器將會確保無論方法通過何種方式完成,方法中調用過的每條 monitorenter 指令都有執行其對應 monitorexit 指令,而無論這個方法是正常結束還是異常結束。爲了保證在方法異常完成時 monitorenter 和 monitorexit 指令依然可以正確配對執行,編譯器會自動產生一個異常處理器,這個異常處理器聲明可處理所有的異常,它的目的就是用來執行 monitorexit 指令。從字節碼中也可以看出多了一個monitorexit指令,它就是異常結束時被執行的釋放monitor 的指令。(摘自https://blog.csdn.net/javazejian/article/details/72828483
這便是synchronized鎖在同步代碼塊上實現的基本原理。

下面是鎖方法 示例代碼以及對應class文件信息:

public class Test {
    private final static Object lockHelper = new Object();

    public synchronized void test(){
        System.out.println("test syn");
    }
}

這裏寫圖片描述

以上,synchronized修飾的方法並沒有monitorenter指令和monitorexit指令,取得代之的確實是ACC_SYNCHRONIZED標識,該標識指明瞭該方法是一個同步方法,JVM通過該ACC_SYNCHRONIZED訪問標誌來辨別一個方法是否聲明爲同步方法,從而執行相應的同步調用。這便是synchronized鎖在同步方法上實現的基本原理。

四、syn的優化(JDK1.6以後)

在早期,synchronized屬於重量級鎖,效率低下,因爲monitor是依賴於底層的操作系統來實現的,而操作系統實現線程之間的切換時需要從用戶態(運行用戶程序)轉換到核心態(運行操作系統程序),這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高。在JDK6上,實現引入了大量的優化,如自旋鎖、適應性自旋鎖、鎖消除、鎖粗化、偏向鎖、輕量級鎖等技術來減少鎖操作的開銷。
鎖主要存在四中狀態,依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態,他們會隨着競爭的激烈而逐漸升級。注意鎖可以升級不可降級,這種策略是爲了提高獲得鎖和釋放鎖的效率。

自旋鎖


背景:線程的阻塞和喚醒需要CPU從用戶態轉爲核心態,頻繁的阻塞和喚醒降低性能。同時我們發現在許多應用上面,對象鎖的鎖狀態只會持續很短一段時間,爲了這一段很短的時間頻繁地阻塞和喚醒線程是非常不值得的。故有自旋鎖。
核心思想:讓該線程等待一段時間【執行一段無意義的循環(自旋))】,不會被立即掛起,看持有鎖的線程是否會很快釋放鎖。在JDK1.6中自旋的默認次數爲10次,可以通過參數-XX:PreBlockSpin來調整。

適應自旋鎖


背景:自旋雖然可以避免線程切換帶來的開銷,但佔用了處理器的時間。如果持有鎖的線程很快就釋放了鎖,那麼自旋的效率就非常好,反之,自旋的線程就會白白消耗掉處理的資源,浪費性能上。所以說,自旋等待的時間(自旋的次數)必須要有一個限度,如果自旋超過了定義的時間仍然沒有獲取到鎖,則應該被掛起。 如果手動調整自旋次數,因爲無法得知一個合適的數字,會帶來諸多不便。如設置爲15次,結果17次時才釋放鎖,這種情況下本該繼續等待兩次,但是卻被掛起,導致性能不到最優。於是JDK1.6引入自適應的自旋鎖。
核心思想:若自旋成功,則下次自旋的次數會更多(上次成功,此次很可能也成功)。反之,若某個鎖很少自旋成功,則以後獲取鎖時減少自旋次數甚至省略掉自旋過程,以免浪費處理器資源。

鎖消除


背景:在有些情況下,某些代碼不可能存在共享數據競爭,此時同步會導致不必要的性能降低。
核心思想:JVM檢測到不可能存在共享數據競爭,這是JVM會對這些同步鎖進行鎖消除。鎖消除的依據是逃逸分析的數據支持。 變量是否逃逸,對於虛擬機來說需要使用數據流分析來確定,但是對於我們程序員來說這還不清楚麼?我們會在明明知道不存在數據競爭的代碼塊前加上同步嗎?但是我們有時在使用一些內置API時,會存在隱形加鎖(如StringBuffer的append()、Vector的add())。
示例

    public void test() {
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < 100; i++) {
            sb.append(i);
        }
    }

在上述代碼中,StringBuffer 線程安全,故它的add方法加鎖public synchronized StringBuffer append(int i)
但是此處sb爲局域變量,不可能引發線程安全問題,故此處可以做鎖消除。

鎖粗化


背景:在使用同步鎖的時候,需要讓同步塊的作用範圍儘可能小—僅在共享數據的實際作用域中才進行同步,這樣做的目的是爲了使需要同步的操作數量儘可能縮小,如果存在鎖競爭,那麼等待鎖的線程也能儘快拿到鎖。 在大多數的情況下,上述觀點是正確的。但是如果一系列的連續加鎖解鎖操作,可能會導致不必要的性能損耗,所以引入鎖粗化的概念。
核心思想:將多個連續的加鎖、解鎖操作連接在一起,擴展成一個範圍更大的鎖。如上面實例:vector每次add的時候都需要加鎖操作,JVM檢測到對同一個對象(vector)連續加鎖、解鎖操作,會合並一個更大範圍的加鎖、解鎖操作,即加鎖解鎖操作會移到for循環之外。

偏向鎖


背景:大多數情況下,鎖總是由同一線程多次獲得,因此爲了減少同一線程獲取鎖的代價而引入偏向鎖。
核心思想:如果一個線程獲得了鎖,那麼鎖就進入偏向模式,此時Mark Word 的結構也變爲偏向鎖結構,當這個線程再次請求鎖時可直接獲取鎖,省去了大量有關鎖申請的操作,從而提高程序的性能。
結果:對於鎖競爭較少的場合,偏向鎖有很好的優化。但是對於鎖競爭比較激烈的場合,偏向鎖就失效了(大多情況下每次申請鎖的線程不同),這種情況下使用偏向鎖會降低性能(Mark Word改變結構)。偏向鎖失敗後,會先升級爲輕量級鎖。
實現

  • 獲取鎖
    獲取鎖的步驟如下:
    a.檢測Mark Word是否爲可偏向狀態(1|01【Mark Word最後兩位,下文同】);
    b.若爲可偏向狀態,則測試線程ID是否爲當前線程ID,如果是,則執行步驟e,否則執行步驟c;
    c.如果線程ID不爲當前線程ID,則通過CAS操作競爭鎖,競爭成功,則將Mark Word的線程ID替換爲當前線程ID,否則執行線程d;
    d.通過CAS競爭鎖失敗,證明當前存在多線程競爭情況,當到達全局安全點,獲得偏向鎖的線程被掛起,偏向鎖升級爲輕量級鎖,然後被阻塞在安全點的線程繼續往下執行同步代碼塊;
    e.執行同步代碼塊

  • 釋放鎖
    偏向鎖的釋放採用了一種只有競爭纔會釋放鎖的機制,線程是不會主動去釋放偏向鎖,需要等待其他線程來競爭。偏向鎖的撤銷需要等待全局安全點(這個時間點是上沒有正在執行的代碼)。其步驟如下:
    a.暫停擁有偏向鎖的線程,判斷鎖對象石是否還處於被鎖定狀態;
    b.撤銷偏向鎖,恢復到無鎖狀態(01)或者輕量級鎖的狀態;

具體過程如下圖所示:
這裏寫圖片描述

輕量級鎖


背景:偏向鎖失敗,鎖升級爲輕量級鎖,此時Mark Word 的結構也變爲輕量級鎖的結構。
核心思想:根據經驗得知:對絕大部分的鎖,在整個同步週期內都不存在競爭。
結果:輕量級鎖所適用於是線程交替執行同步塊的場合。
實現
- 獲取鎖
獲取鎖的步驟如下:
a.判斷當前對象是否處於無鎖狀態(0|01),若是,則JVM首先將在當前線程的棧幀中建立一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝;否則執行c;
b.JVM利用CAS操作嘗試將對象的Mark Word更新爲指向Lock Record的指正,如果成功表示競爭到鎖,則將鎖標誌位變成00(表示此對象處於輕量級鎖狀態),執行同步操作;如果失敗則執行步驟c;
c.判斷當前對象的Mark Word是否指向當前線程的棧幀,如果是則表示當前線程已經持有當前對象的鎖,則直接執行同步代碼塊;否則只能說明該鎖對象已經被其他線程搶佔了,這時輕量級鎖需要進行自適應旋轉等待獲取鎖。

  • 釋放鎖
    輕量級鎖的釋放也是通過CAS操作來進行的,主要步驟如下:
    a.取出在獲取輕量級鎖保存在Displaced Mark Word中的數據;
    b.用CAS操作將取出的數據替換當前對象的Mark Word中,如果成功,則說明釋放鎖成功,否則執行(3);
    c.如果CAS操作替換失敗,說明有其他線程嘗試獲取該鎖,則需要在釋放鎖的同時需要喚醒被掛起的線程。

具體過程如下圖所示:
這裏寫圖片描述

重量級鎖


即早期的synchronized,通過monitor實現,monitor依賴於底層的操作系統來實現的,而操作系統實現線程之間的切換時需要從用戶態轉換到核心態,切換成本高。

各鎖的優缺點及適用場景


這裏寫圖片描述

參考資料


  1. 周志明:《深入理解Java虛擬機:JVM高級特性與最佳實踐》
  2. blog:深入分析synchronized的實現原理
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章