Synchronized關鍵字底層原理

1、爲什麼會出現線程安全問題?

非線程安全問題是指多個線程對同一個對象中的同一個變量進行讀寫操作時出現的值被更改或者值不同步的情況。

public class TestThreadSafe {

    private static int count;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread("t1"){
            @Override
            public void run() {
                for(int i=0;i<5000;i++){
                    count++;
                }
            }
        };

        Thread t2 = new Thread("t2"){
            @Override
            public void run() {
                for(int i=0;i<5000;i++){
                    count--;
                }
            }
        };
        t1.start();
        t2.start();

        //讓主線程同步等待t1、t2線程的結果的返回,注意join()不能解決線程安全問題,因爲t1、t2線程
        t1.join();
        t2.join();

        log.debug("count:{}",count);//結果可能爲正數、0、負數
    }
}

什麼原因導致了線程安全的問題?

//i++對應字節碼指令
getstatic i 	// 獲取靜態變量i的值
iconst_1 		// 準備常量1
iadd 			// 自增
putstatic i 	// 將修改後的值存入靜態變量i

//i--對應字節碼指令
getstatic i 	// 獲取靜態變量i的值
iconst_1 		// 準備常量1
isub 			// 自減
putstatic i 	// 將修改後的值存入靜態變量i

如果是單線程,那麼以上8條指令會按照順序同步執行,不會出現指令交錯執行的問題:
在這裏插入圖片描述

但是對於多線程,以上8條指令可能會出現指令交錯執行的問題,出現負數的情況:

在這裏插入圖片描述

出現正數的情況:
在這裏插入圖片描述
總結:一個程序運行多個線程是沒有問題的,問題出在多個線程訪問了共享資源,多個線程訪問共享資源其實也沒有問題,問題在於多個線程在對共享資源進行讀寫操作時發生指令交錯就會出現線程安全問題。

一段代碼塊中如果出現對共享資源的多線程讀寫操作,這段代碼就稱爲臨界區

多個線程在臨界區內執行,由於代碼的執行序列不同而導致結果無法預測,稱之爲發生了競態條件

2、synchronized怎麼實現線程安全?

synchronized俗稱對象鎖,它採用互斥的方式讓同一時刻至多隻有一個線程能夠持有對象鎖,其他線程再想獲取這個對象鎖就會阻塞住,這樣就能保證擁有鎖的線程可以安全的執行臨界區內的代碼,不用擔心線程上下文切換

synchronized 實際是用對象鎖保證了臨界區內代碼的原子性,臨界區內的代碼對外是不可分割的,不會被線程切
換所打斷 。

雖然 java 中互斥和同步都可以採用 synchronized 關鍵字來完成,但它們還是有區別的:互斥是保證臨界區的競態條件發生,同一時刻只能有一個線程執行臨界區代碼。同步是由於線程執行的先後、順序不同、需要一個線程等待其它線程運行到某個點

public class TestThreadSafe {

    private static int count;
    static Object object = new Object();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread("t1"){
            @Override
            public void run() {
                for(int i=0;i<5000;i++){
                    //對臨界區的代碼加上synchronized
                    //當一個線程想要執行臨界區的代碼時需要先獲得對象鎖,如果其他線程已經獲得了該對象鎖,那麼當前線程就會被阻塞。
                    synchronized (object){
                        count++;
                    }
                }
            }
        };

        Thread t2 = new Thread("t2"){
            @Override
            public void run() {
                for(int i=0;i<5000;i++){
                    //對臨界區的代碼加上synchronized
                    synchronized (object){
                        count--;
                    }
                }
            }
        };
        t1.start();
        t2.start();

        t1.join();
        t2.join();

        log.debug("count:{}",count);
    }
}

3、synchronized使用場景?

Java中的每一個對象都可以作爲鎖。具體表現爲以下3種形式:

❑ 對於普通同步方法,鎖是當前實例對象。

public class SynchronizedDemo {
    public synchronized void methodOne() {
    }
}

❑ 對於靜態同步方法,鎖是當前類的Class對象。

public class SynchronizedDemo {
    public static synchronized void methodOne() {
    }
}

❑ 對於同步方法塊,鎖是synchonized括號裏配置的對象。

public class SynchronizedDemo {

    public void methodThree() {
        // 對當前實例對象this加鎖
        synchronized (this) {
        }
    }

    public void methodFour() {
        // 對class對象加鎖
        synchronized (SynchronizedDemo.class) {
        }
    }
}

4、Synchronized 底層?同步代碼塊和同步方法

1、synchronized關鍵字的實現

synchronized不論是修飾代碼塊還是修飾方法都是通過持有對象鎖來實現同步的。而這個對象的markword就指向了一個Monitor(鎖/監視器)

//obj對象鎖
synchronized (obj){
     //臨界區代碼
}

1、java對象頭的markword結構:

我們都知道對象是放在堆內存中的,對象大致可以分爲三個部分,分別是對象頭,實例變量和填充字節,對象頭分成兩個部分:mark word和 klass word

在這裏插入圖片描述

其中32位虛擬機 對象頭的Mark word爲:
在這裏插入圖片描述

鎖的類型和狀態在對象頭Mark Word中都有記錄。在申請鎖、鎖升級等過程中JVM都需要讀取對象的Mark Word數據。對於重量級鎖對象的markword包含兩個部分:指向重量級鎖的指針和標誌位

由此看來,monitor鎖對象地址存在於每個Java對象的對象頭中

2、Monitor結構:

每一個鎖都對應一個monitor對象,在HotSpot虛擬機中它是由ObjectMonitor實現的(C++實現)

//部分屬性
ObjectMonitor() {
    _count        = 0;  //鎖計數器
    _owner        = NULL;
    _WaitSet      = NULL; //處於wait狀態的線程,會被加入到_WaitSet
    _EntryList    = NULL ; //處於等待鎖block狀態的線程,會被加入到該列表
  }

3、synchronized底層原理 = java對象頭markword + 操作系統對象monitor:

每個 Java 對象都可以關聯一個 Monitor 對象,如果使用 synchronized 給對象上鎖(重量級)之後,該對象頭的Mark Word 中就被設置指向 Monitor 對象的指針

在這裏插入圖片描述

  1. synchronized無論是加在同步代碼塊還是方法上,效果都是加在對象上,其原理都是對一個對象上鎖
  2. 如何給這個obj上鎖呢?當一個線程Thread-1要執行臨界區的代碼時,首先會通過obj對象的markword指向一個monitor鎖對象
  3. 當Thread-1線程持有monitor對象後,就會把monitor中的owner變量設置爲當前線程Thread-1,同時計數器count+1表示當前對象鎖被一個線程獲取。
  4. 當另一個線程Thread-2想要執行臨界區的代碼時,要判斷monitor對象的屬性Owner是否爲null,如果爲null,Thread-2線程就持有了對象鎖可以執行臨界區的代碼,如果不爲null,Thread-2線程就會放入monitor的EntryList阻塞隊列中,處於阻塞狀態Blocked。
  5. 當Thread-0將臨界區的代碼執行完畢,將釋放monitor(鎖)並將owner變量置爲null,同時計算器count-1,並通知EntryList阻塞隊列中的線程,喚醒裏面的線程

2、jvm指令分析synchronized同步代碼塊原理

public class TestSynchronized {
    static final Object obj = new Object();
    static int i=0;
    public static void main(String[] args) {
        synchronized (obj){
            i++;
        }
    }
}

對應的字節碼爲:

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: getstatic     #2         // 獲取obj對象
         3: dup
         4: astore_1
                  
         5: monitorenter		//將obj對象的markword置爲monitor指針
         6: getstatic     #3                  
         9: iconst_1
        10: iadd
        11: putstatic     #3                  
        14: aload_1
        15: monitorexit			//同步代碼塊正常執行時,將obj對象的markword重置,喚醒EntryList
        16: goto          24
                  
        19: astore_2
        20: aload_1
        21: monitorexit			//同步代碼塊出現異常時,將obj對象的markword重置,喚醒EntryList
        22: aload_2
        23: athrow
        24: return
      Exception table:
         from    to  target type
             6    16    19   any  //監測6-16行jvm指令,如果出現異常就會到第19行
            19    22    19   any

於這兩條指令的作用,我們直接參考JVM規範中描述:

monitorenter 指令:

每個對象有一個監視器鎖(monitor)。當monitor被佔用時就會處於鎖定狀態,線程執行monitorenter指令時嘗試獲取monitor的所有權,過程如下:

  1. 如果monitor的進入數爲0,則該線程進入monitor,然後將進入數設置爲1,該線程即爲monitor的所有者
  2. 如果線程已經佔有該monitor,只是重新進入,則進入monitor的進入數加1.
  3. 如果其他線程已經佔用了monitor,則該線程進入阻塞狀態,直到monitor的進入數爲0,再重新嘗試獲取monitor的所有權。

monitorexit指令:

執行monitorexit的線程必須是持有obj鎖對象的線程

指令執行時,monitor的進入數減1,如果減1後進入數爲0,那線程釋放monitor,不再是這個monitor的所有者。其他被這個monitor阻塞的線程可以嘗試去獲取這個 monitor 的所有權。

Synchronized的語義底層是通過一個monitor的對象來完成,其實wait/notify等方法也依賴於monitor對象,這就是爲什麼只有在同步的塊或者方法中才能調用wait/notify等方法,否則會拋出IllegalMonitorStateException的異常的原因。

3、jvm指令分析synchronized同步方法原理

public class TestSynchronized {
    static int i=0;
    public synchronized  void add(){
        i++;
    }
}

對應的字節碼指令:

 public synchronized void add();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field i:I
         3: iconst_1
         4: iadd
         5: putstatic     #2                  // Field i:I
         8: return

從反編譯的結果來看,方法的同步並沒有通過指令monitorenter和monitorexit來完成不過相對於普通方法,其常量池中多了ACC_SYNCHRONIZED標示符。JVM就是根據該標示符來實現方法的同步的:當方法調用時,調用指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標誌是否被設置,如果設置了,執行線程將先獲取monitor,獲取成功之後才能執行方法體,方法執行完後再釋放monitor。在方法執行期間,其他任何線程都無法再獲得同一個monitor對象。 其實本質上沒有區別,只是方法的同步是一種隱式的方式來實現,無需通過字節碼來完成。

5、Synchronized 鎖優化,無鎖、偏向鎖 、輕量級鎖、重量級鎖 ?

Java SE1.6中爲了減少獲得鎖和釋放鎖帶來的性能消耗而引入自旋鎖**、適應性自旋鎖、**鎖消除、鎖粗化、偏向鎖、輕量級鎖等技術。鎖有四種狀態,並且會因實際情況進行膨脹升級,其膨脹方向是:無鎖——>偏向鎖——>輕量級鎖——>重量級鎖,並且膨脹方向不可逆。

1、輕量級鎖synchronized

輕量級鎖的使用場景:如果一個對象雖然有多線程要加鎖,但加鎖的時間是錯開的(也就是沒有競爭),那麼可以使用輕量級鎖來優化。輕量級鎖對使用者是透明的,即語法仍然是 synchronized

輕量級鎖考慮的是競爭鎖對象的線程不多,而且線程持有鎖的時間也不長的情景。因爲阻塞線程需要CPU從用戶態轉到內核態,代價較大,如果剛剛阻塞不久這個鎖就被釋放了,那這個代價就有點得不償失了,因此這個時候就乾脆不阻塞這個線程,讓它自旋這等待鎖釋放。

假設有兩個方法同步塊,利用同一個對象加鎖

static final Object obj = new Object();
public static void method1() {
    synchronized( obj ) {
        // 同步塊 A
        method2();
    }
}
public static void method2() {
    synchronized( obj ) {
    	// 同步塊 B
    }
}
  1. 創建鎖記錄(Lock Record)對象,每個線程的棧幀中都會存放一個鎖記錄的結構,這個鎖記錄內部存儲的是obj對象的markword。
    在這裏插入圖片描述

  2. 讓鎖記錄中的Object reference指向鎖對象,並嘗試使用CAS(compare and swap) 替換obj的markword,將markword的值存入鎖記錄中:

在這裏插入圖片描述
3. 如果CAS交換成功,對象頭中就存放了Thread-0棧幀中的鎖記錄地址和狀態00,表示這時由該線程對對象加鎖,如圖:

在這裏插入圖片描述

  1. 如果CAS失敗,有兩種情況:

    如果是其它線程已經持有了該 Object 的輕量級鎖,這時表明有競爭,進入鎖膨脹過程
    如果是自己執行了 synchronized 鎖重入,那麼再添加一條 Lock Record 作爲重入的計數

在這裏插入圖片描述

  1. 當退出 synchronized 代碼塊(解鎖時)如果有取值爲 null 的鎖記錄,表示有重入,這時重置鎖記錄,表示重入計數減 一。

在這裏插入圖片描述

  1. 當退出synchronized代碼塊(解鎖時)鎖記錄的值不爲null,這時使用CAS將markword的值恢復給obj的對象頭。成功就說明解鎖成功,失敗說明輕量級鎖進入了鎖膨脹已經升級爲了重量級鎖,進入重量級鎖解鎖流程

2、鎖膨脹:輕量級-重量級

如果在嘗試加輕量級鎖的過程中,CAS 操作無法成功,這時一種情況就是有其它線程爲此對象加上了輕量級鎖(有競爭),這時需要進行鎖膨脹,將輕量級鎖變爲重量級鎖。

static Object obj = new Object();
public static void method1() {
    synchronized( obj ) {
    	// 同步塊
    }
}
  1. 當Thread-1對對象obj加輕量級鎖時,Thread-0已經對該對象加了輕量級鎖

    在這裏插入圖片描述

  2. 這是Thread-1加輕量級鎖失敗,進入鎖膨脹流程,即爲obj對象申請Monitor鎖,讓obj指向重量級鎖地址,然後自己進入monitor的EntryList阻塞:

在這裏插入圖片描述

  1. 當 Thread-0 退出同步塊解鎖時,使用 cas 將 Mark Word 的值恢復給對象頭,失敗。這時會進入重量級解鎖
    流程,即按照 Monitor 地址找到 Monitor 對象,設置 Owner 爲 null,喚醒 EntryList 中 blocked的線程

3、自旋優化

重量級鎖競爭的時候,還可以使用自旋來進行優化,如果當前線程自旋成功(即這時候持鎖線程已經退出了同步
塊,釋放了鎖),這時當前線程就可以避免阻塞。

如果Thread-1在嘗試加輕量級鎖的過程中,CAS 操作失敗,這時一種情況就是有其它線程爲此對象加上了輕量級鎖(有競爭),這時可以使用自旋鎖來優化等待其他線程釋放鎖,自旋鎖就是讓當前線程循環不斷的CAS。

但是如果自旋的時間太長也不行,因爲自旋是要消耗CPU的,因此自旋的次數是有限制的,比如10次或者100次,如果自旋次數到了其他還沒有釋放鎖,或者·Thread-1還在執行,Thread-2還在自旋等待,這時又有一個線程Thread-3過來競爭這個鎖對象,那麼這個時候輕量級鎖就會膨脹爲重量級鎖。重量級鎖把除了擁有鎖的線程都阻塞,防止CPU空轉。

優點:開啓自旋鎖後能減少線程的阻塞,在對於鎖的競爭不激烈且佔用鎖時間很短的代碼塊來說,能提升很大的性能,在這種情況下自旋的消耗小於線程阻塞掛起的消耗。

缺點:在線程競爭鎖激烈,或持有鎖的線程需要長時間執行同步代碼塊的情況下,使用自旋會使得cpu做的無用功太多。

自旋會佔用 CPU 時間,單核 CPU 自旋就是浪費,多核 CPU 自旋才能發揮優勢。
在 Java 6 之後自旋鎖是自適應的,比如對象剛剛的一次自旋操作成功過,那麼認爲這次自旋成功的可能性會
高,就多自旋幾次;反之,就少自旋甚至不自旋,總之,比較智能。Java 7 之後不能控制是否開啓自旋功能

4、偏向鎖

輕量級鎖在沒有競爭時(就自己這個線程),每次重入仍然需要執行 CAS 操作。Java 6 中引入了偏向鎖來做進一步優化:

  1. 當Thread-0第一次指向臨界區的代碼時,會使用CAS將線程ID(ThreadID)設置到obj對象的Markword中
  2. 當Thread-0線程再次獲取鎖時,先比較java對象頭markword中ThreadID和當前線程的ThreadID是否一致,如果一致,表示沒有競爭,不用重新進行CAS操作,以後只要不發生競爭,這個U對象就歸該線程所有。
  3. 如果對象頭Markword中的ThreadID和當前線程的ThreadID不一致,說明存在競爭,就會撤銷偏向鎖,升級爲重量級鎖。
static final Object obj = new Object();
public static void m1() {
    synchronized( obj ) {
        // 同步塊 A
        m2();
    }
}
public static void m2() {
    synchronized( obj ) {
        // 同步塊 B
        m3();
    }
}
public static void m3() {
    synchronized( obj ) {
        偏向狀態
        // 同步塊 C
    }
}

輕量級鎖在沒有競爭時(就自己這個線程),每次重入仍然需要執行 CAS 操作,降低了性能:
在這裏插入圖片描述

具體表現爲:每次進行鎖重入時,都需要進行一次CAS操作
在這裏插入圖片描述

如果改成偏向鎖:

在這裏插入圖片描述

5、鎖消除

消除鎖是虛擬機另外一種鎖的優化,這種優化更徹底,在JIT編譯時,對運行上下文進行掃描,去除不可能存在競爭的鎖。

保證數據的完整性,我們在進行操作時需要對這部分操作進行同步控制,但是在有些情況下,JVM檢測到不可能存在共享數據競爭,這是JVM會對這些同步鎖進行鎖消除。鎖消除的依據是逃逸分析的數據支持。

6、鎖粗化

鎖粗化是虛擬機對另一種極端情況的優化處理,通過擴大鎖的範圍,避免反覆加鎖和釋放鎖。

我們知道在使用同步鎖的時候,需要讓同步塊的作用範圍儘可能小—僅在共享數據的實際作用域中才進行同步,這樣做的目的是爲了使需要同步的操作數量儘可能縮小,如果存在鎖競爭,那麼等待鎖的線程也能儘快拿到鎖。但是如果一系列的連續加鎖解鎖操作,可能會導致不必要的性能損耗,所以引入鎖粗話的概念。
鎖粗話概念比較好理解,就是將多個連續的加鎖、解鎖操作連接在一起,擴展成一個範圍更大的鎖。

6、Synchronized修飾方法和代碼塊區別 ?

主要是鎖不同:

修飾方法時,對於靜態方法,是把 class 作爲鎖;對於非靜態方法,是把 this 對象當做鎖;

修飾代碼塊時,是把任何對象作爲鎖,如果鎖對象爲空,會拋出 NullPointerException,但是修飾方法不會;

在鎖的作用區域上,修飾方法時是整個方法體;而修飾代碼塊時只有對應的代碼塊。後者更加靈活和細粒度。

可以把修飾方法看作是修飾代碼塊的一種特殊形式,一種快捷方式。

7、synchronized可重入嗎,怎麼實現的 ?

可重入,具體實現:

static final Object obj = new Object();
public static void m1() {
    synchronized( obj ) {
        // 同步塊 A
        m2();
    }
}
public static void m2() {
    synchronized( obj ) {
        // 同步塊 B
        m3();
    }
}
public static void m3() {
    synchronized( obj ) {
        偏向狀態
        // 同步塊 C
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章