博客 / 詳情

返回

解密synchronized:從對象頭到內存屏障,搞懂Java鎖的底層原理

解密synchronized:從對象頭到內存屏障,搞懂Java鎖的底層原理

一、引言

在 Java 併發編程的領域中,多線程環境下的數據一致性和線程安全是至關重要的課題。而 synchronized 關鍵字,作為 Java 語言提供的內置同步機制,就如同一位忠誠的守護者,在保障多線程安全方面發揮着不可或缺的作用。自 JDK 1.0 誕生以來,synchronized 就一直是 Java 併發編程的基礎工具,歷經多個版本的迭代與優化,從曾經被認為是 “性能殺手”,逐步進化為如今智能高效的鎖機制 ,其重要性不言而喻。它就像是多線程編程世界裏的基石,為眾多開發者構建線程安全的程序提供了堅實的支撐。
儘管在 JDK 1.5 之後,諸如 ReentrantLock 等更高級的併發工具相繼出現,在某些特定場景下能夠提供更靈活、高效的同步控制,但 synchronized 憑藉其簡單易用、語義清晰以及 JVM 層面的深度優化等特性,依然在 Java 併發編程中佔據着舉足輕重的地位。無論是在日常的業務開發,還是在一些對性能和穩定性要求極高的系統中,synchronized 的身影隨處可見。例如,在銀行賬户的餘額更新操作中,多個線程可能同時嘗試對賬户餘額進行增減,如果沒有合適的同步機制,就可能導致餘額數據的不一致。此時,synchronized 就可以發揮作用,確保同一時刻只有一個線程能夠執行餘額更新操作,從而保證數據的準確性和一致性。
然而,在實際應用中,很多開發者僅僅停留在會使用 synchronized 關鍵字的層面,對其底層原理知之甚少。當面對複雜的併發場景和性能優化問題時,這種一知半解往往會成為解決問題的阻礙。例如,在高併發環境下,頻繁地使用 synchronized 可能會導致線程阻塞和性能下降,但如果不瞭解其底層原理,就很難找到有效的優化策略。因此,深入探究 synchronized 的底層原理,不僅能夠幫助我們更加精準地使用這一強大的工具,在編寫多線程代碼時避免潛在的風險和問題,還能讓我們在面對性能瓶頸時,能夠從根源上進行分析和優化,提升系統的整體性能和穩定性。
接下來,就讓我們揭開 synchronized 神秘的面紗,從對象頭、Monitor、內存屏障等多個關鍵角度,深入剖析其底層實現機制,搞懂 Java 鎖的底層原理。

二、synchronized 基礎回顧

2.1 用法介紹

在 Java 中,synchronized 關鍵字有三種常見的用法,分別是修飾實例方法、靜態方法和代碼塊,它們在實現線程同步時各有特點和適用場景。
修飾實例方法:當 synchronized 修飾一個實例方法時,它鎖定的是當前對象(this)。這意味着,對於同一個對象實例,在同一時刻,只能有一個線程能夠進入並執行該同步實例方法,其他線程必須等待該線程執行完畢並釋放鎖後,才有機會獲取鎖並執行方法。這種方式適用於對對象的實例變量進行同步訪問的場景,確保多個線程對實例變量的操作是線程安全的。例如:
public class InstanceSyncDemo {
    private int count = 0;

    // 修飾實例方法,鎖是當前對象this
    public synchronized void increment() {
        count++;
        System.out.println(Thread.currentThread().getName() + " - count: " + count);
    }

    public static void main(String[] args) {
        InstanceSyncDemo demo = new InstanceSyncDemo();
        // 創建5個線程併發調用increment方法
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                demo.increment();
            }, "Thread-" + i).start();
        }
    }
}

 

在上述代碼中,increment方法被synchronized修飾,當多個線程嘗試調用demo.increment()時,由於鎖的存在,它們會依次執行,不會出現競態條件導致count變量的更新錯誤。
修飾靜態方法:當 synchronized 修飾靜態方法時,它鎖定的是當前類的 Class 對象。因為靜態方法屬於類,而不是類的實例,所以無論創建多少個類的實例對象,在同一時刻,只能有一個線程能夠進入並執行該同步靜態方法。這種方式適用於對類的靜態變量或全局資源進行同步訪問的場景,保證所有實例對這些資源的操作是線程安全的。例如:
public class StaticSyncDemo {
    private static int total = 0;

    // 修飾靜態方法,鎖是當前類的Class對象
    public static synchronized void addTotal() {
        total++;
        System.out.println(Thread.currentThread().getName() + " - total: " + total);
    }

    public static void main(String[] args) {
        // 創建5個線程併發調用addTotal方法
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                StaticSyncDemo.addTotal();
            }, "Thread-" + i).start();
        }
    }
}

 

在這段代碼中,addTotal方法是靜態同步方法,多個線程調用StaticSyncDemo.addTotal()時,會競爭類的 Class 對象鎖,從而保證了total靜態變量的線程安全更新。
修飾代碼塊:synchronized 修飾代碼塊時,可以更加靈活地控制鎖的範圍和鎖對象。它可以指定任意對象作為鎖,當線程進入同步代碼塊時,會獲取指定對象的鎖,執行完代碼塊後釋放鎖。這種方式適用於只需要對部分代碼進行同步,或者需要針對不同的資源使用不同鎖的場景,能夠有效減少鎖的粒度,提高併發性能。例如:
public class BlockSyncDemo {
    private Object lock = new Object();
    private List<String> list = new ArrayList<>();

    public void addElement(String element) {
        // 非同步操作,可以並行執行
        System.out.println(Thread.currentThread().getName() + " is preparing to add element...");
        // 同步代碼塊,鎖是lock對象
        synchronized (lock) {
            list.add(element);
            System.out.println(Thread.currentThread().getName() + " added element: " + element);
        }
        // 非同步操作,可以並行執行
        System.out.println(Thread.currentThread().getName() + " finished adding element.");
    }

    public static void main(String[] args) {
        BlockSyncDemo demo = new BlockSyncDemo();
        // 創建5個線程併發調用addElement方法
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                demo.addElement("Element-" + i);
            }, "Thread-" + i).start();
        }
    }
}

 

在上述代碼中,addElement方法中的同步代碼塊使用lock對象作為鎖,只有獲取到lock鎖的線程才能執行代碼塊內的list.add(element)操作,保證了list集合的線程安全操作,而方法中的其他非同步代碼可以並行執行,提高了程序的併發性能。

2.2 作用概述

synchronized 關鍵字在多線程編程中起着至關重要的作用,其核心作用是保證多線程環境下的原子性、可見性和有序性,從而有效解決數據競爭和線程安全問題。
原子性:synchronized 保證了被其修飾的代碼塊或方法在同一時刻只能被一個線程執行,即一個線程在執行同步代碼時,其他線程無法中斷它,從而確保了操作的原子性。例如,對於count++這樣的複合操作,在多線程環境下如果不進行同步,可能會出現數據不一致的情況,但使用 synchronized 修飾包含count++的方法或代碼塊後,就可以保證這個操作是原子的,要麼完整執行,要麼不執行 。
可見性:當一個線程釋放 synchronized 鎖時,會將其工作內存中的變量值刷新到主內存中;而當另一個線程獲取到該鎖時,會從主內存中讀取最新的變量值。這就保證了不同線程之間對共享變量的可見性,使得一個線程對共享變量的修改能夠及時被其他線程看到。例如,在一個多線程程序中,線程 A 修改了共享變量data,並釋放了鎖,那麼線程 B 在獲取鎖後,就能夠讀取到線程 A 修改後的data值。
有序性:synchronized 通過內存屏障和 happens-before 規則保證了一定程度的有序性。它確保了在釋放鎖之前的所有操作,對於隨後獲取同一把鎖的線程來説都是可見的,並且這些操作不會被重排序到鎖的範圍之外。例如,線程 A 在持有鎖的情況下執行了操作 1 和操作 2,然後釋放鎖,線程 B 獲取鎖後,一定能按照線程 A 執行的順序看到操作 1 和操作 2 的結果 。
通過保證原子性、可見性和有序性,synchronized 有效地解決了多線程環境下的數據競爭問題,確保了程序在併發場景下的正確性和穩定性。無論是在簡單的多線程數據訪問,還是複雜的併發業務邏輯處理中,synchronized 都發揮着不可或缺的作用,為開發者提供了一種簡單而強大的線程同步機制。

三、深入底層:對象頭與 Monitor

3.1 Java 對象內存佈局

在 Java 中,對象在內存中的佈局由三個主要部分組成:對象頭(Object Header)、實例數據(Instance Data)和對齊填充(Padding)。理解這些組成部分對於深入掌握 synchronized 的底層原理至關重要。
對象頭:對象頭是對象在內存中的起始部分,它包含了兩部分關鍵信息:Mark Word 和類型指針(Klass Pointer)。如果對象是數組,還會額外包含一個記錄數組長度的字段。Mark Word 用於存儲對象的運行時數據,如哈希碼(hashCode)、GC 分代年齡、鎖狀態標誌位等,這些信息會根據對象的狀態動態變化。類型指針則指向對象所屬類的元數據,JVM 通過它來確定對象的類型。在 64 位 JVM 中,默認開啓指針壓縮(-XX:+UseCompressedClassPointers)時,類型指針佔 4 字節;未開啓時佔 8 字節 ,而 Mark Word 固定佔 8 字節。
實例數據:這部分存儲了對象的所有成員變量,包括從父類繼承而來的變量。其存儲順序遵循一定的規則,基本類型變量按照它們的大小和聲明順序進行排列,相同寬度的字段會盡量分配在一起,以提高內存訪問效率。父類的字段會排在子類字段之前。引用類型變量在開啓指針壓縮時佔 4 字節,未開啓時佔 8 字節。
對齊填充:由於 HotSpot JVM 要求對象的大小必須是 8 字節的整數倍,當對象頭和實例數據的總大小不是 8 的倍數時,就需要通過對齊填充來補足。這部分填充數據本身不存儲任何有效信息,只是為了滿足內存對齊的要求,確保對象在內存中的訪問效率 。
例如,一個簡單的 Java 類User,包含一個int類型的id和一個String類型的name
public class User {
    private int id;
    private String name;

    public User(int id, String name) {
        this.id = id;
        this.name = name;
    }
}

 

在 64 位 JVM 且開啓指針壓縮的情況下,User對象的內存佈局如下:對象頭(Mark Word 8 字節 + 類型指針 4 字節 = 12 字節),實例數據(int類型的id 4 字節 + String引用類型 4 字節 = 8 字節),總大小為 20 字節,不是 8 的倍數,因此需要 4 字節的對齊填充,最終User對象佔用 24 字節的內存空間 。
對象頭中的 Mark Word 對於 synchronized 鎖機制起着核心作用,它存儲的鎖狀態標誌位等信息,直接決定了對象當前的鎖狀態,進而影響着線程對對象的訪問方式和同步控制 。

3.2 對象頭中的 Mark Word

Mark Word 是 Java 對象頭中極為關鍵的部分,它在 64 位 JVM 中佔 8 字節,以緊湊的方式存儲了對象的多種運行時數據,這些數據會根據對象的狀態動態變化,尤其是與鎖相關的信息,在 synchronized 鎖機制中扮演着核心角色。
在不同的鎖狀態下,Mark Word 的結構和存儲內容各不相同:
  • 無鎖狀態:此時 Mark Word 存儲對象的哈希碼(hashCode)、分代年齡(用於垃圾回收)以及鎖狀態標誌位(值為 01)。其中,哈希碼是對象的一個標識,在對象調用hashCode()方法時生成並存儲在此;分代年齡則記錄了對象經歷垃圾回收的次數,用於判斷對象是否應該晉升到老年代。
  • 偏向鎖狀態:當對象處於偏向鎖狀態時,Mark Word 存儲偏向線程 ID、偏向時間戳、分代年齡以及鎖狀態標誌位(值為 01,此時表示偏向鎖)。偏向鎖的設計目的是為了優化只有一個線程頻繁訪問同步塊的場景,它會偏向於第一個獲取鎖的線程,在後續該線程再次訪問時,只需簡單判斷線程 ID 是否一致,無需進行復雜的加鎖解鎖操作,從而提高性能。
  • 輕量級鎖狀態:輕量級鎖適用於線程交替執行同步塊的場景。在這種狀態下,Mark Word 存儲指向當前線程棧中鎖記錄(Lock Record)的指針以及鎖狀態標誌位(值為 00)。當線程進入同步塊時,如果發現對象處於無鎖狀態,會通過 CAS(Compare and Swap)操作嘗試將 Mark Word 替換為指向自己棧中鎖記錄的指針,若成功則獲取到輕量級鎖。
  • 重量級鎖狀態:當出現多個線程競爭鎖,且輕量級鎖無法滿足需求時,鎖會膨脹升級為重量級鎖。此時 Mark Word 存儲指向 Monitor 對象的指針以及鎖狀態標誌位(值為 10)。Monitor 是 Java 虛擬機實現的一種同步機制,它負責管理線程的阻塞和喚醒,當線程競爭重量級鎖失敗時,會被放入 Monitor 的等待隊列中,等待鎖的釋放 。
以一個簡單的對象Object obj = new Object();為例,在初始狀態下,它處於無鎖狀態,Mark Word 存儲着對象的哈希碼和分代年齡等信息。當一個線程首次訪問synchronized(obj)代碼塊時,如果偏向鎖開啓(JDK 1.6 及以後默認開啓),對象會進入偏向鎖狀態,Mark Word 中記錄下該線程的 ID。若此時有另一個線程也嘗試訪問該同步塊,就會發生鎖競爭,偏向鎖失效,鎖升級為輕量級鎖,Mark Word 的內容也相應變為指向新線程棧中鎖記錄的指針。如果競爭進一步加劇,輕量級鎖自旋一定次數後仍無法獲取鎖,就會升級為重量級鎖,Mark Word 指向 Monitor 對象 。
Mark Word 的這種動態變化機制,使得 Java 虛擬機能夠根據不同的競爭情況,靈活地選擇合適的鎖策略,從而在保證線程安全的前提下,儘可能地提高性能。

3.3 Monitor:鎖的調度中心

Monitor,常被譯為監視器或管程,是 Java 實現線程同步的核心機制之一,它就像是一個鎖的調度中心,協調着多個線程對共享資源的訪問。每個 Java 對象都可以關聯一個 Monitor 對象,當使用 synchronized 關鍵字對對象進行加鎖(尤其是重量級鎖)時,對象頭的 Mark Word 會指向 Monitor 對象的引用地址 。
Monitor 的核心結構包含以下幾個關鍵部分:
  • _owner:指向當前持有鎖的線程。初始時為 NULL,表示當前沒有任何線程擁有該 Monitor。當一個線程成功獲取到鎖時,_owner 就會被設置為該線程的引用;當鎖被釋放時,_owner 又會被重置為 NULL 。
  • _count:用於記錄鎖的重入次數。當一個線程再次進入已經持有鎖的同步塊時,_count 會遞增;當線程退出同步塊時,_count 遞減。只有當_count 為 0 時,鎖才會被完全釋放 。
  • _WaitSet:這是一個等待集合,當獲取到鎖的線程調用了對象的 wait () 方法時,該線程會釋放鎖並進入_WaitSet 等待。在等待期間,線程會被阻塞,直到其他線程調用 notify () 或 notifyAll () 方法喚醒它 。
  • _EntryList:這是一個競爭集合,存放着等待獲取鎖的線程。當一個線程嘗試獲取被其他線程持有的鎖時,如果獲取失敗,就會進入_EntryList 等待,處於這個隊列中的線程會不斷嘗試競爭鎖,直到獲取成功 。
其工作原理如下:當一個線程訪問被 synchronized 修飾的代碼塊時,它首先嚐試獲取對象關聯的 Monitor 的鎖。如果此時_owner 為 NULL,説明沒有其他線程持有鎖,該線程可以成功獲取鎖,並將_owner 設置為自己,同時_count 置為 1 。如果_owner 不為 NULL,即鎖已被其他線程持有,那麼當前線程會進入_EntryList 等待。當持有鎖的線程執行完同步代碼塊,釋放鎖時,會檢查_WaitSet 中是否有等待的線程。如果有,會選擇一個線程(使用 notify () 方法)或全部線程(使用 notifyAll () 方法)將其從_WaitSet 移動到_EntryList,這些線程將有機會重新競爭鎖 。
例如,假設有三個線程 ThreadA、ThreadB 和 ThreadC,都試圖訪問同一個被 synchronized 修飾的代碼塊。ThreadA 首先獲取到鎖,此時_owner 指向 ThreadA,_count 為 1。ThreadB 和 ThreadC 隨後嘗試獲取鎖,但由於 ThreadA 持有鎖,它們會進入_EntryList 等待。如果 ThreadA 在同步塊中調用了 wait () 方法,它會釋放鎖,_owner 變為 NULL,_count 為 0,ThreadA 進入_WaitSet 等待。此時,ThreadB 和 ThreadC 有機會競爭鎖,假設 ThreadB 競爭成功,_owner 指向 ThreadB,_count 為 1。如果 ThreadB 調用 notify () 方法,ThreadA 會從_WaitSet 被喚醒並進入_EntryList,再次參與鎖的競爭 。
Monitor 通過這種方式,有效地管理了線程的同步訪問,確保了在多線程環境下共享資源的安全訪問。

3.4 代碼示例:對象頭與 Monitor 的關係

為了更直觀地理解對象頭與 Monitor 在 synchronized 機制中的關係,我們通過一段代碼示例,並藉助 jol-core 庫來查看對象頭信息在加鎖前後的變化。
首先,引入 jol-core 庫的依賴:
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.17</version>
</dependency>

 

然後,編寫如下代碼:
import org.openjdk.jol.info.ClassLayout;

public class SynchronizedObjectHeaderDemo {
    public static void main(String[] args) throws InterruptedException {
        Object lock = new Object();
        // 查看初始狀態下對象頭信息
        System.out.println("初始狀態下對象頭信息:");
        System.out.println(ClassLayout.parseInstance(lock).toPrintable());

        synchronized (lock) {
            // 查看加鎖後對象頭信息
            System.out.println("\n加鎖後對象頭信息:");
            System.out.println(ClassLayout.parseInstance(lock).toPrintable());
        }

        // 查看解鎖後對象頭信息
        System.out.println("\n解鎖後對象頭信息:");
        System.out.println(ClassLayout.parseInstance(lock).toPrintable());
    }
}

 

在上述代碼中,我們創建了一個 Object 對象lock,並在三個階段分別打印其對象頭信息:初始狀態、加鎖後以及解鎖後。
運行代碼,輸出結果類似如下:
初始狀態下對象頭信息:
java.lang.Object object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   4        (object header)           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
  4   4        (object header)           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
  8   4        (object header)           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
 12   4    (loss due to the next object alignment) 
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

加鎖後對象頭信息:
java.lang.Object object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   4        (object header)           00 f0 0b 00 (00000000 11110000 00001011 00000000) (112640)
  4   4        (object header)           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
  8   4        (object header)           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
 12   4    (loss due to the next object alignment) 
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

解鎖後對象頭信息:
java.lang.Object object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   4        (object header)           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
  4   4        (object header)           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
  8   4        (object header)           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
 12   4    (loss due to the next object alignment) 
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

 

從輸出結果可以看出,初始狀態下,對象頭的 Mark Word 處於無鎖狀態。當進入 synchronized 代碼塊加鎖後,Mark Word 的內容發生了變化,表明鎖狀態已改變,此時 Mark Word 指向了與 Monitor 相關的信息(具體值根據實際情況而定) 。當退出 synchronized 代碼塊解鎖後,Mark Word 又恢復到了無鎖狀態 。
結合 Monitor 原理分析,當線程進入 synchronized (lock) 代碼塊時,會嘗試獲取lock對象關聯的 Monitor 的鎖。如果獲取成功,對象頭的 Mark Word 會記錄相關的鎖信息,指向 Monitor 對象。在同步塊執行期間,其他線程若嘗試獲取鎖,會進入 Monitor 的_EntryList 等待。當線程執行完同步塊釋放鎖時,Mark Word 的鎖信息被清除,恢復到無鎖狀態,同時 Monitor 會根據_WaitSet 和_EntryList 的情況,決定是否喚醒其他等待的線程 。通過這個示例,我們可以清晰地看到對象頭與 Monitor 在 synchronized 機制中的緊密協作關係。

四、鎖升級:從 “温柔” 到 “強硬” 的策略

4.1 鎖升級的四個階段

在 JDK 1.6 之後,synchronized 關鍵字為了在不同的競爭場景下都能保持較好的性能,引入了鎖升級機制 。其鎖升級過程包含四個階段:無鎖、偏向鎖、輕量級鎖和重量級鎖 。
初始狀態下,對象處於無鎖狀態,此時對象頭的 Mark Word 存儲着對象的哈希碼、分代年齡等信息 。當第一個線程訪問被 synchronized 修飾的代碼塊時,如果偏向鎖開啓(JDK 1.6 及以後默認開啓),對象會進入偏向鎖狀態 。在偏向鎖狀態下,Mark Word 會記錄下偏向線程的 ID,當該線程再次進入同步塊時,只需簡單比對線程 ID,無需進行復雜的加鎖操作,大大提高了性能 。
然而,當有第二個線程嘗試獲取鎖時,偏向鎖就會失效,鎖會升級為輕量級鎖 。輕量級鎖適用於線程交替執行同步塊的場景,它通過 CAS 操作在用户態嘗試獲取鎖,如果獲取失敗,線程會進行自旋等待,而不是立即阻塞,避免了用户態到內核態的頻繁切換 。
如果自旋次數達到一定閾值後,仍然無法獲取到鎖,或者競爭進一步加劇,有更多線程參與競爭,輕量級鎖就會升級為重量級鎖 。重量級鎖依賴操作系統的互斥量(Mutex)來實現線程的阻塞與喚醒,競爭失敗的線程會被阻塞,等待鎖的釋放,由操作系統進行調度喚醒,這種方式的開銷較大 。
需要注意的是,鎖升級是單向不可逆的,一旦鎖從低級別升級到更高級別,就不會再降級,除非對象被回收再重新分配 。例如,一個對象從偏向鎖升級為輕量級鎖後,即使後續沒有線程競爭,它也不會再回到偏向鎖狀態 。

4.2 各階段詳解

  • 無鎖狀態:這是對象的初始狀態,此時對象頭的 Mark Word 中存儲的是對象的哈希碼(HashCode)、分代年齡(用於垃圾回收)以及鎖狀態標誌位(值為 01,表示無鎖狀態) 。在無鎖狀態下,多個線程可以自由地訪問對象,不存在任何同步限制,因為沒有線程持有鎖,所以也不會有線程競爭的問題 。例如,在一個多線程環境中,多個線程可以同時讀取一個無鎖對象的屬性,不會產生數據不一致的問題 。
  • 偏向鎖:偏向鎖的設計目的是為了優化只有一個線程頻繁訪問同步塊的場景 。當一個線程首次訪問被 synchronized 修飾的代碼塊時,如果對象處於可偏向狀態(偏向鎖標誌位為 1,鎖標誌位為 01,且線程 ID 為空),JVM 會通過 CAS 操作將當前線程的 ID 寫入 Mark Word 中,並將偏向鎖標誌位設置為已偏向狀態 。此後,當該線程再次進入同步塊時,只需比較 Mark Word 中的線程 ID 是否與自己一致,如果一致,就可以直接進入同步塊,無需進行任何加鎖操作,這種方式幾乎沒有額外的開銷 。比如,在一個單例模式的實現中,如果使用 synchronized 來保證單例對象的創建線程安全,當第一個線程創建完單例對象後,後續該線程對單例對象的訪問就可以通過偏向鎖來實現高效的同步,因為不會有其他線程競爭鎖 。
  • 輕量級鎖:當有第二個線程嘗試獲取偏向鎖時,就會觸發偏向鎖的撤銷,鎖升級為輕量級鎖 。輕量級鎖的工作原理是,線程在自己的棧幀中創建一個 Lock Record(鎖記錄),然後將對象頭中的 Mark Word 複製到 Lock Record 中,接着使用 CAS 操作嘗試將對象頭的 Mark Word 替換為指向自己棧中 Lock Record 的指針 。如果 CAS 操作成功,説明該線程獲取到了輕量級鎖,可以進入同步塊執行;如果 CAS 操作失敗,説明存在競爭,該線程會進行自旋等待,在一定次數的自旋內,如果獲取到了鎖,就可以進入同步塊;如果自旋結束後仍未獲取到鎖,就會觸發鎖膨脹,升級為重量級鎖 。輕量級鎖通過自旋的方式避免了線程的立即阻塞,適用於線程交替執行同步塊的場景,減少了線程上下文切換的開銷 。例如,在一個多線程處理任務的場景中,如果多個線程依次處理不同的任務,並且每個任務的執行時間較短,使用輕量級鎖就可以在保證線程安全的前提下,提高程序的併發性能 。
  • 重量級鎖:當輕量級鎖的自旋次數超過閾值,或者競爭非常激烈時,鎖就會升級為重量級鎖 。重量級鎖依賴操作系統的互斥量(Mutex)來實現線程的同步,當一個線程獲取到重量級鎖後,其他線程如果嘗試獲取該鎖,就會被阻塞,放入 Monitor 的_EntryList 等待隊列中,等待鎖的釋放 。當持有鎖的線程執行完同步代碼塊,釋放鎖時,會從_EntryList 中喚醒一個或多個等待線程,這些線程將重新競爭鎖 。由於線程的阻塞和喚醒需要操作系統的介入,涉及用户態到內核態的切換,所以重量級鎖的開銷較大 。在高併發且競爭激烈的場景下,如多個線程同時對一個共享資源進行頻繁的讀寫操作,就可能會使用到重量級鎖來保證數據的一致性和線程安全 。

4.3 JDK 17 + 的重大變化

從 JDK 17 開始,Java 虛擬機做出了一個重大的改變,即徹底移除了偏向鎖 。這一決策背後有着多方面的原因 。
首先,偏向鎖的維護成本較高 。在現代高併發應用中,偏向鎖的撤銷過程較為複雜,需要觸發 STW(Stop-The-World)來暫停所有應用線程,以便進行偏向鎖的撤銷操作,這在一定程度上會影響系統的性能和響應時間 。例如,當一個對象的偏向鎖被撤銷時,JVM 需要遍歷線程棧,檢查是否有線程持有該偏向鎖,並且需要更新對象頭中的信息,這些操作都需要在 STW 期間完成,對系統的影響較大 。
其次,隨着像 ConcurrentHashMap 等高性能併發數據結構的廣泛使用,以及許多現代應用(如 Web 框架、響應式編程)中對 synchronized 的使用逐漸減少,偏向鎖帶來的實際性能收益已經不如 JDK 6 時代那麼明顯 。在這些場景中,無競爭同步操作的需求相對較少,偏向鎖的優化效果難以充分體現 。
此外,偏向鎖的實現代碼增加了 JVM 鎖機制的複雜性,使得 JVM 在維護和升級其他同步優化(如鎖粗化、鎖消除)時變得更加困難 。
移除偏向鎖後,現在的鎖升級路徑簡化為:無鎖 → 輕量級鎖 → 重量級鎖 。雖然移除了偏向鎖,但 Java 虛擬機在無鎖競爭場景下的 CAS 操作成本已經極低,所以整體性能幾乎不受影響 。在實際應用中,開發者無需再擔心偏向鎖的相關問題,可以更加專注於業務邏輯的實現 。

4.4 代碼示例:鎖升級過程演示

為了更直觀地展示 synchronized 的鎖升級過程,我們通過一段代碼示例,並結合 JVM 參數和工具來觀察鎖狀態的變化 。
public class LockUpgradeDemo {
    private static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        // 開啓偏向鎖,JDK 17之前有效
        // -XX:+UseBiasedLocking
        // 打印對象頭信息,用於觀察鎖狀態
        // -XX:+PrintObjectLayout

        // 第一個線程獲取鎖,觸發偏向鎖
        Thread thread1 = new Thread(() -> {
            synchronized (lock) {
                System.out.println("Thread1 獲取鎖,此時應為偏向鎖");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        thread1.start();
        thread1.join();

        // 第二個線程獲取鎖,偏向鎖失效,升級為輕量級鎖
        Thread thread2 = new Thread(() -> {
            synchronized (lock) {
                System.out.println("Thread2 獲取鎖,此時應為輕量級鎖");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        thread2.start();
        thread2.join();

        // 多個線程同時競爭鎖,輕量級鎖升級為重量級鎖
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                synchronized (lock) {
                    System.out.println(Thread.currentThread().getName() + " 獲取鎖,此時應為重量級鎖");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }, "Thread-" + i).start();
        }
    }
}

 

在上述代碼中,我們創建了一個 Object 對象lock作為鎖對象 。首先,thread1線程獲取鎖,此時對象應該處於偏向鎖狀態 。然後,thread2線程嘗試獲取鎖,這會導致偏向鎖失效,鎖升級為輕量級鎖 。最後,通過啓動 5 個線程同時競爭鎖,模擬高併發場景,使輕量級鎖升級為重量級鎖 。
為了觀察鎖狀態的變化,我們可以在運行代碼時添加相應的 JVM 參數 。在 JDK 17 之前,可以使用-XX:+UseBiasedLocking開啓偏向鎖,使用-XX:+PrintObjectLayout打印對象頭信息,從而查看對象在不同階段的鎖狀態 。在 JDK 17 及之後,雖然偏向鎖已被移除,但仍然可以通過-XX:+PrintObjectLayout來觀察輕量級鎖和重量級鎖的狀態變化 。通過這種方式,我們可以更加深入地理解 synchronized 的鎖升級機制 。

五、synchronized 與三大特性的保障

5.1 原子性:不可分割的操作

原子性是指一個操作是不可中斷的,要麼全部執行成功,要麼全部不執行,不存在部分執行的情況 。在多線程環境下,這一特性對於保證數據的一致性至關重要。synchronized 關鍵字通過 monitorenter 和 monitorexit 指令來實現原子性 。當線程執行到 monitorenter 指令時,它會嘗試獲取對象的鎖,如果獲取成功,則進入同步塊,其他線程無法同時進入該同步塊,直到當前線程執行完同步塊並執行 monitorexit 指令釋放鎖 。
以對共享變量的操作代碼示例來分析原子性保障機制:
public class AtomicityDemo {
    private static int sharedVariable = 0;

    public static void increment() {
        synchronized (AtomicityDemo.class) {
            sharedVariable++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                increment();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                increment();
            }
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println("最終的共享變量值: " + sharedVariable);
    }
}

 

在上述代碼中,increment方法通過synchronized關鍵字修飾,當thread1線程執行到synchronized (AtomicityDemo.class)時,會執行 monitorenter 指令,嘗試獲取AtomicityDemo.class對象的鎖 。如果獲取成功,其他線程(如thread2)就無法同時進入該同步塊 。在同步塊內執行sharedVariable++操作,這個操作雖然包含讀取、加 1 和寫入三個步驟,但由於 synchronized 的原子性保障,這三個步驟被視為一個不可分割的整體,不會被其他線程打斷 。當thread1執行完同步塊,執行 monitorexit 指令釋放鎖後,thread2才有機會獲取鎖並執行同步塊內的代碼 。通過這種方式,確保了sharedVariable的遞增操作在多線程環境下的原子性,最終輸出的sharedVariable值為 2000,而不會出現小於 2000 的情況 。

5.2 可見性:一個線程修改,其他線程立即可見

可見性是指當一個線程修改了共享變量的值,其他線程能夠立即看到這個修改 。在多線程編程中,由於每個線程都有自己的工作內存,線程對共享變量的操作首先是在自己的工作內存中進行,然後再同步回主內存 。如果沒有適當的同步機制,就可能出現一個線程修改了共享變量,但其他線程無法及時看到這個修改的情況 。
synchronized 關鍵字通過內存屏障來實現可見性 。當線程進入同步塊時,會插入 LoadLoad 屏障和 LoadStore 屏障 。LoadLoad 屏障確保在讀取共享變量之前,先讀取主內存中的最新值,而不是從工作內存中讀取舊值;LoadStore 屏障確保在讀取共享變量之後,對其他共享變量的寫入操作不會被重排序到讀取之前 。當線程退出同步塊時,會插入 StoreStore 屏障和 StoreLoad 屏障 。StoreStore 屏障確保在寫入共享變量之後,之前對其他共享變量的寫入操作都已經完成;StoreLoad 屏障確保在寫入共享變量之後,對其他共享變量的讀取操作不會被重排序到寫入之前 。通過這些內存屏障的插入,保證了一個線程對共享變量的修改能夠及時被其他線程看到 。
例如,假設有兩個線程ThreadAThreadB,共享變量sharedData
public class VisibilityDemo {
    private static int sharedData = 0;
    private static final Object lock = new Object();

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            synchronized (lock) {
                sharedData = 100;
                System.out.println("ThreadA 修改了 sharedData 為: " + sharedData);
            }
        });

        Thread threadB = new Thread(() -> {
            synchronized (lock) {
                System.out.println("ThreadB 讀取到的 sharedData 為: " + sharedData);
            }
        });

        threadA.start();
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        threadB.start();
    }
}

 

在上述代碼中,ThreadA線程在同步塊內修改了sharedData的值為 100,當它退出同步塊時,通過內存屏障將修改後的值刷新到主內存 。ThreadB線程在進入同步塊時,通過內存屏障從主內存中讀取到ThreadA修改後的sharedData值,從而保證了可見性 。運行結果中,ThreadB讀取到的sharedData值為 100,而不會是舊值 0 。

5.3 有序性:禁止指令重排破壞邏輯

有序性是指程序執行的順序按照代碼的先後順序執行 。在多線程環境下,由於指令重排的存在,可能會導致程序的執行順序與代碼的編寫順序不一致,從而引發一些難以調試的問題 。synchronized 關鍵字通過 as-if-serial 語義和 Acquire/Release 屏障來保證有序性 。
as-if-serial 語義保證了單線程環境下,程序的執行結果與代碼的編寫順序一致,即編譯器和處理器不會對單線程程序進行重排,使得單線程程序的執行結果可預測 。對於 synchronized 同步塊,在進入同步塊時,會插入 Acquire 屏障,禁止同步塊內的讀寫操作與同步塊外的讀寫操作進行重排,確保在進入同步塊之前,所有在同步塊外的讀寫操作都已經完成 。在退出同步塊時,會插入 Release 屏障,禁止同步塊內的寫操作與同步塊外的讀寫操作進行重排,確保在退出同步塊之後,所有在同步塊內的寫操作都已經完成,對其他線程可見 。
需要注意的是,synchronized 並不禁止同步塊內部的指令重排,它只保證最終的執行結果是正確的 。例如,在同步塊內有a = 1; b = 2;這樣的代碼,編譯器和處理器可能會對這兩條指令進行重排,但由於最終的結果是a為 1,b為 2,不會影響程序的正確性 。
以如下代碼示例説明:
public class OrderlinessDemo {
    private static int x = 0, y = 0;
    private static final Object lock = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (lock) {
                x = 1;
                y = 2;
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (lock) {
                System.out.println("y = " + y + ", x = " + x);
            }
        });

        thread1.start();
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        thread2.start();
    }
}

 

在上述代碼中,thread1線程在同步塊內先對x賦值為 1,再對y賦值為 2 。由於 synchronized 的有序性保障,thread2線程在進入同步塊時,一定能看到thread1線程按照順序執行後的結果,即y為 2,x為 1 。運行結果中,thread2輸出的yx的值一定是 2 和 1,而不會出現y為 0,x為 1 或者其他不符合順序的情況 。

六、內存屏障:幕後的 “協調者”

6.1 四種內存屏障的作用

在多線程編程的複雜世界中,內存屏障扮演着至關重要的角色,它是確保多線程環境下數據可見性和指令執行順序的關鍵機制 。Java 內存模型(JMM)定義了四種類型的內存屏障,它們各自有着獨特的作用,通過協同工作,有效地解決了多線程編程中因緩存一致性和指令重排而引發的問題 。
LoadLoad 屏障:其作用是確保屏障之前的讀操作(Load)先於屏障之後的讀操作完成 。例如,在以下代碼中:
int a = sharedVar1; // 讀操作1
// LoadLoad屏障
int b = sharedVar2; // 讀操作2
假設sharedVar1sharedVar2是共享變量,並且讀操作 2 依賴於讀操作 1 的結果或者需要確保讀操作 1 先完成,那麼在這兩個讀操作之間插入 LoadLoad 屏障就顯得尤為重要 。LoadLoad 屏障會強制處理器檢查緩存狀態(基於 MESI 協議),如果發現緩存行處於 Invalid 狀態,則重新從內存或其他處理器的緩存中加載最新數據,以確保讀操作 2 讀取的是最新值,防止了編譯器和處理器對這兩個讀操作進行重排序 。
StoreStore 屏障:該屏障的作用是確保屏障之前的寫操作(Store)先於屏障之後的寫操作完成,並且屏障之前的寫操作結果對其他處理器可見(即刷新到主內存) 。例如:
sharedVar1 = 10; // 寫操作1
// StoreStore屏障
sharedVar2 = 20; // 寫操作2
在這個例子中,如果寫操作 2 依賴於寫操作 1 的結果,或者需要確保寫操作 1 的結果對後續寫操作可見,就需要插入 StoreStore 屏障 。它會強制將寫緩衝區(Store Buffer)中的數據刷新到緩存(或主內存),確保其他處理器能看到寫操作 1 的結果,從而防止寫操作被重排序 。
LoadStore 屏障:LoadStore 屏障的作用是確保屏障之前的讀操作(Load)先於屏障之後的寫操作(Store)完成 。例如:
int value = sharedVar; // 讀操作
// LoadStore屏障
anotherVar = value + 1; // 寫操作,依賴於讀操作的結果
當讀操作後面跟着一個依賴於讀操作結果的寫操作時,插入 LoadStore 屏障是必要的 。它會阻止處理器將寫操作重排序到讀操作之前,同時確保讀操作完成後再執行寫操作,避免使用過期的數據執行寫操作 。
StoreLoad 屏障:這是四種內存屏障中最為嚴格的一種,它確保屏障之前的所有寫操作(Store)完成並對其他處理器可見後,才執行屏障之後的讀操作(Load) 。例如:
sharedVar = 30; // 寫操作
// StoreLoad屏障
int result = anotherVar; // 讀操作
常見於 volatile 變量的寫操作之後,或者鎖釋放(unlock)時 。它會強制將寫緩衝區(Store Buffer)中的數據全部刷新到主內存,並讓當前處理器丟棄緩存中失效的數據(即重新從主內存加載),從而確保讀操作讀取的是最新值 。由於需要刷新整個寫緩衝區並可能使緩存失效,StoreLoad 屏障的開銷是四種屏障中最大的 。
這四種內存屏障通過禁止重排序和強制刷新緩存 / 寫緩衝區,有效地解決了多核 CPU 的可見性與有序性問題 。在 Java 中,volatile 變量的讀寫以及 synchronized 關鍵字的加鎖解鎖操作,都會自動插入相應的內存屏障,以保證多線程編程的正確性 。

6.2 synchronized 中的內存屏障應用

synchronized 關鍵字在保障多線程安全的過程中,內存屏障發揮着不可或缺的作用,它通過在進入和退出同步塊時插入特定的內存屏障,來確保可見性和有序性 。
結合之前的代碼示例:
public class SynchronizedMemoryBarrierDemo {
    private static int sharedData = 0;
    private static final Object lock = new Object();

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            synchronized (lock) {
                sharedData = 100;
                System.out.println("ThreadA 修改了 sharedData 為: " + sharedData);
            }
        });

        Thread threadB = new Thread(() -> {
            synchronized (lock) {
                System.out.println("ThreadB 讀取到的 sharedData 為: " + sharedData);
            }
        });

        threadA.start();
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        threadB.start();
    }
}

 

threadA線程進入synchronized(lock)同步塊時,會插入 LoadLoad 屏障和 LoadStore 屏障 。LoadLoad 屏障確保在讀取共享變量sharedData之前,先讀取主內存中的最新值,而不是從工作內存中讀取舊值,避免了讀取到過期數據 。LoadStore 屏障則確保在讀取共享變量sharedData之後,對其他共享變量的寫入操作不會被重排序到讀取之前,保證了讀操作和後續寫操作的順序性 。
threadA線程退出同步塊時,會插入 StoreStore 屏障和 StoreLoad 屏障 。StoreStore 屏障確保在寫入共享變量sharedData之後,之前對其他共享變量的寫入操作都已經完成,保證了寫操作的順序性 。StoreLoad 屏障則確保在寫入共享變量sharedData之後,對其他共享變量的讀取操作不會被重排序到寫入之前,同時將修改後的值刷新到主內存,保證了其他線程能夠讀取到最新的值 。
threadB線程進入同步塊時,同樣會插入 LoadLoad 屏障和 LoadStore 屏障,確保從主內存中讀取到threadA修改後的sharedData值 。通過這些內存屏障的協同工作,synchronized 保證了多線程環境下共享變量sharedData的可見性和有序性,使得threadB能夠讀取到threadA修改後的最新值 。在多線程編程中,正確理解和運用 synchronized 中的內存屏障機制,對於編寫高效、健壯的併發程序至關重要 。

七、總結與展望

通過對 synchronized 底層原理的深入剖析,我們從對象頭中的 Mark Word 存儲的鎖狀態信息,到 Monitor 對線程同步的協調管理,再到鎖升級機制在不同競爭場景下的智能切換,以及內存屏障對可見性和有序性的保障,全面瞭解了 synchronized 是如何在多線程環境中確保數據一致性和線程安全的 。http://www.rhkb.cn/news/1171839.html  http://www.rhkb.cn/news/1171844.html  http://www.rhkb.cn/news/1171845.html   http://www.rhkb.cn/news/1171850.html  http://www.rhkb.cn/news/1171858.html
理解 synchronized 的底層原理對於 Java 併發編程至關重要 。它不僅幫助我們在編寫多線程代碼時,能夠根據具體的業務場景選擇合適的同步策略,避免因鎖的不當使用而導致的性能瓶頸和線程安全問題 。例如,在低競爭場景下,偏向鎖和輕量級鎖能夠提供高效的同步,減少線程上下文切換的開銷;而在高競爭場景下,雖然重量級鎖的開銷較大,但能確保數據的一致性和線程安全 。同時,深入理解原理也有助於我們更好地閲讀和分析併發相關的代碼,快速定位和解決併發編程中出現的問題 。
展望未來,隨着硬件技術的不斷髮展和 Java 虛擬機的持續優化,Java 鎖機制有望迎來更多的創新和改進 。一方面,隨着多核 CPU 性能的不斷提升,鎖機制可能會更加充分地利用硬件特性,進一步提高併發性能 。例如,未來可能會出現更加智能的鎖升級和降級策略,根據實時的線程競爭情況動態調整鎖的類型,在保證線程安全的前提下,最大限度地減少鎖的開銷 。另一方面,隨着 Java 虛擬機對垃圾回收算法和內存管理的不斷優化,可能會對鎖機制產生積極的影響,使鎖的實現更加高效和穩定 。
在 Java 併發編程的領域中,synchronized 作為基礎且重要的同步工具,其底層原理的探索永無止境 。我們應持續關注 Java 技術的發展動態,不斷學習和掌握新的知識和技能,以更好地應對併發編程中的各種挑戰 。
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.