1. 程式人生 > >Synchronized實現原理和鎖優化

Synchronized實現原理和鎖優化

Synchronized及其實現原理

Synchronized的基本使用

Synchronized是Java中解決併發問題的一種最常用的方法,也是最簡單的一種方法。Synchronized的作用主要有三個:(1)確保執行緒互斥的訪問同步程式碼(2)保證共享變數的修改能夠及時可見(3)有效解決重排序問題。

從語法上講,Synchronized總共有三種用法:

  1. 修飾普通方法
  2. 修飾靜態方法
  3. 修飾程式碼塊

接下來我就通過幾個例子程式來說明一下這三種使用方式(為了便於比較,三段程式碼除了Synchronized的使用方式不同以外,其他基本保持一致)。

沒有同步的情況:

程式碼段一:

public class SynchronizedTest {
    public
void method1(){ System.out.println("Method 1 start"); try { System.out.println("Method 1 execute"); Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("Method 1 end"); } public
void method2(){ System.out.println("Method 2 start"); try { System.out.println("Method 2 execute"); Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("Method 2 end"); } public
static void main(String[] args) { final SynchronizedTest test = new SynchronizedTest(); new Thread(new Runnable() { @Override public void run() { test.method1(); } }).start(); new Thread(new Runnable() { @Override public void run() { test.method2(); } }).start(); } }

執行結果如下,執行緒1和執行緒2同時進入執行狀態,執行緒2執行速度比執行緒1快,所以執行緒2先執行完成,這個過程中執行緒1和執行緒2是同時執行的。

Method 1 start
Method 1 execute
Method 2 start
Method 2 execute
Method 2 end
Method 1 end

對普通方法同步:

程式碼段二:

public class SynchronizedTest {
    public synchronized void method1(){
        System.out.println("Method 1 start");
        try {
            System.out.println("Method 1 execute");
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Method 1 end");
    }

    public synchronized void method2(){
        System.out.println("Method 2 start");
        try {
            System.out.println("Method 2 execute");
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Method 2 end");
    }

    public static void main(String[] args) {
        final SynchronizedTest test = new SynchronizedTest();

        new Thread(new Runnable() {
            @Override
            public void run() {
                test.method1();
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                test.method2();
            }
        }).start();
    }
}

執行結果如下,跟程式碼段一比較,可以很明顯的看出,執行緒2需要等待執行緒1的method1執行完成才能開始執行method2方法。

Method 1 start
Method 1 execute
Method 1 end
Method 2 start
Method 2 execute
Method 2 end

靜態方法(類)同步

程式碼段三:

 public class SynchronizedTest {
     public static synchronized void method1(){
         System.out.println("Method 1 start");
         try {
             System.out.println("Method 1 execute");
             Thread.sleep(3000);
         } catch (InterruptedException e) {
             e.printStackTrace();
         }
         System.out.println("Method 1 end");
     }

     public static synchronized void method2(){
         System.out.println("Method 2 start");
         try {
             System.out.println("Method 2 execute");
             Thread.sleep(1000);
         } catch (InterruptedException e) {
             e.printStackTrace();
         }
         System.out.println("Method 2 end");
     }

     public static void main(String[] args) {
         final SynchronizedTest test = new SynchronizedTest();
         final SynchronizedTest test2 = new SynchronizedTest();

         new Thread(new Runnable() {
             @Override
             public void run() {
                 test.method1();
             }
         }).start();

         new Thread(new Runnable() {
             @Override
             public void run() {
                 test2.method2();
             }
         }).start();
     }
 }

執行結果如下,對靜態方法的同步本質上是對類的同步(靜態方法本質上是屬於類的方法,而不是物件上的方法),所以即使test和test2屬於不同的物件,但是它們都屬於SynchronizedTest類的例項,所以也只能順序的執行method1和method2,不能併發執行。

Method 1 start
Method 1 execute
Method 1 end
Method 2 start
Method 2 execute
Method 2 end

程式碼塊同步

程式碼段四:

public class SynchronizedTest {
    public void method1(){
        System.out.println("Method 1 start");
        try {
            synchronized (this) {
                System.out.println("Method 1 execute");
                Thread.sleep(3000);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Method 1 end");
    }

    public void method2(){
        System.out.println("Method 2 start");
        try {
            synchronized (this) {
                System.out.println("Method 2 execute");
                Thread.sleep(1000);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Method 2 end");
    }

    public static void main(String[] args) {
        final SynchronizedTest test = new SynchronizedTest();

        new Thread(new Runnable() {
            @Override
            public void run() {
                test.method1();
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                test.method2();
            }
        }).start();
    }
}

執行結果如下,雖然執行緒1和執行緒2都進入了對應的方法開始執行,但是執行緒2在進入同步塊之前,需要等待執行緒1中同步塊執行完成。

Method 1 start
Method 1 execute
Method 2 start
Method 1 end
Method 2 execute
Method 2 end

Synchronized 原理

我們通過反編譯下面的程式碼來看看Synchronized是如何實現對程式碼塊進行同步的:

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("Method 1 start");
        }
    }
}

反編譯結果:

反編譯

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

monitorenter :

Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
• If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
• If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
• If another thread already owns the monitor associated with objectref, the thread blocks until the monitor's entry count is zero, then tries again to gain ownership.

這段話的大概意思為:

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

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

The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.
The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.

這段話的大概意思為:

執行monitorexit的執行緒必須是objectref所對應的monitor的所有者

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

通過這兩段描述,我們應該能很清楚的看出Synchronized的實現原理,Synchronized的語義底層是通過一個monitor的物件來完成,其實wait/notify等方法也依賴於monitor物件,這就是為什麼只有在同步的塊或者方法中才能呼叫wait/notify等方法,否則會丟擲java.lang.IllegalMonitorStateException的異常的原因。

我們再來看一下同步方法的反編譯結果:

原始碼:

public class SynchronizedMethod {
    public synchronized void method() {
        System.out.println("Hello World!");
    }
}

反編譯結果:

反編譯

從反編譯的結果來看,方法的同步並沒有通過指令monitorenter和monitorexit來完成(理論上其實也可以通過這兩條指令來實現),不過相對於普通方法,其常量池中多了ACC_SYNCHRONIZED標示符。JVM就是根據該標示符來實現方法的同步的:當方法呼叫時,呼叫指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標誌是否被設定,如果設定了,執行執行緒將先獲取monitor,獲取成功之後才能執行方法體,方法執行完後再釋放monitor。在方法執行期間,其他任何執行緒都無法再獲得同一個monitor物件。 其實本質上沒有區別,只是方法的同步是一種隱式的方式來實現,無需通過位元組碼來完成。

執行結果解釋

有了對Synchronized原理的認識,再來看上面的程式就可以迎刃而解了。

程式碼段2結果:

雖然method1和method2是不同的方法,但是這兩個方法都進行了同步,並且是通過同一個物件去呼叫的,所以呼叫之前都需要先去競爭同一個物件上的鎖(monitor),也就只能互斥的獲取到鎖,因此,method1和method2只能順序的執行。

程式碼段3結果:

雖然test和test2屬於不同物件,但是test和test2屬於同一個類的不同例項,由於method1和method2都屬於靜態同步方法,所以呼叫的時候需要獲取同一個類上monitor(每個類只對應一個class物件),所以也只能順序的執行。

程式碼段4結果:

對於程式碼塊的同步實質上需要獲取Synchronized關鍵字後面括號中物件的monitor,由於這段程式碼中括號的內容都是this,而method1和method2又是通過同一的物件去呼叫的,所以進入同步塊之前需要去競爭同一個物件上的鎖,因此只能順序執行同步塊。

Java物件頭

synchronized使用的鎖是存放在Java物件頭裡面,具體位置是物件頭裡面的MarkWord,MarkWord裡預設資料是儲存物件的HashCode等資訊,但是會隨著物件的執行改變而發生變化,不同的鎖狀態對應著不同的記錄儲存方式,可能值如下所示:

這裡寫圖片描述 
無鎖狀態 : 物件的HashCode + 物件分代年齡 + 狀態位001

Monitor Record

Monitor Record是執行緒私有的資料結構,每一個執行緒都有一個可用monitor record列表,同時還有一個全域性的可用列表。每一個被鎖住的物件都會和一個monitor record關聯(物件頭的MarkWord中的LockWord指向monitor record的起始地址),同時monitor record中有一個Owner欄位存放擁有該鎖的執行緒的唯一標識,表示該鎖被這個執行緒佔用。如下圖所示為Monitor Record的內部結構

Monitor Record
Owner
EntryQ
RcThis
Nest
HashCode
Candidate

Owner:初始時為NULL表示當前沒有任何執行緒擁有該monitor record,當執行緒成功擁有該鎖後儲存執行緒唯一標識,當鎖被釋放時又設定為NULL;

EntryQ:關聯一個系統互斥鎖(semaphore),阻塞所有試圖鎖住monitor record失敗的執行緒。

RcThis:表示blocked或waiting在該monitor record上的所有執行緒的個數。

Nest:用來實現重入鎖的計數。

HashCode:儲存從物件頭拷貝過來的HashCode值(可能還包含GC age)。

Candidate:用來避免不必要的阻塞或等待執行緒喚醒,因為每一次只有一個執行緒能夠成功擁有鎖,如果每次前一個釋放鎖的執行緒喚醒所有正在阻塞或等待的執行緒,會引起不必要的上下文切換(從阻塞到就緒然後因為競爭鎖失敗又被阻塞)從而導致效能嚴重下降。Candidate只有兩種可能的值0表示沒有需要喚醒的執行緒1表示要喚醒一個繼任執行緒來競爭鎖。


總結

Synchronized是Java併發程式設計中最常用的用於保證執行緒安全的方式,其使用相對也比較簡單。但是如果能夠深入瞭解其原理,對監視器鎖等底層知識有所瞭解,一方面可以幫助我們正確的使用Synchronized關鍵字,另一方面也能夠幫助我們更好的理解併發程式設計機制,有助我們在不同的情況下選擇更優的併發策略來完成任務。對平時遇到的各種併發問題,也能夠從容的應對。

===================

synchronized內部實現原理其實上面都有講了,轉載這篇文章主要是鎖優化講的挺好。
在這裡我先對下面內容做一個解讀:

偏向鎖: 偏向鎖認為,獲取鎖的總是同一個執行緒

輕量級鎖:輕量級鎖認為,大多數情況下不會出現鎖競爭,即使出現了鎖競爭,獲取鎖的執行緒也能很快釋放鎖。獲取不到鎖的執行緒可以通過自旋等待一段時間,不會陷入阻塞狀態。

偏向鎖的流程

在鎖物件的物件頭中有一個ThreadId欄位,如果欄位是空,第一次獲取鎖的時候就把自身的ThreadId寫入到鎖的ThreadId欄位內,把鎖內的是否是偏向鎖狀態位置設定為1。下次獲取鎖的時候,直接檢視ThreadId是否和自身執行緒Id一致,如果一致就認為當前執行緒已經取得了鎖無需再次獲取鎖,略過了輕量級鎖和重量級鎖的加鎖階段,提高了效率。

輕量級鎖流程

執行緒在執行同步塊之前,JVM會在當前執行緒的棧幀中建立用於儲存鎖記錄的空間,並把物件頭中的Mark Word複製到鎖記錄空間中,官方稱為Displaced Mark Word,然後執行緒嘗試持有CAS把物件頭中的Mark Word替換成指向鎖記錄的指標,如果成功,當前執行緒獲得鎖,如果失敗表示有其他執行緒競爭鎖,當前執行緒嘗試使用自旋來獲取鎖,獲取失敗就升級成重量級鎖。

===============================================

在java中存在兩種鎖機制,分別是synchronized和Lock。Lock介面和實現類是JDK5新增的內容,而synchronized在JDK6開始提供了一系列的鎖優化,下面總結一下synchronized的實現原理和涉及的一些鎖優化機制

1.synchronized內部實現原理

synchronized關鍵字在應用層的語義是可以把任何一個非null物件作為鎖,當synchronized作用在方法上時,鎖住的是物件例項(this),作用在靜態方法上鎖住的就是物件對應的Classs例項,由於Class例項存在於永久代,因此靜態方法鎖相當於類的一個全域性鎖,當synchronized作用在一個物件例項上,鎖住的就是一個程式碼塊 
ps:在HotSpot JVM中 monitor被稱作物件監視器

當有多個執行緒同時請求某個物件監視器時,物件監視器會設定幾種狀態來區分請求的執行緒:

  1. Contention List:所有請求鎖的執行緒被首先放置在該競爭佇列中
  2. Entry List:Contention List 中有機會獲得鎖的執行緒被放置到Entry List
  3. Wait Set:呼叫wait()方法被阻塞的執行緒被放置到Wait Set中
  4. OnDeck:任何一個時候只能有一個執行緒競爭鎖 該執行緒稱作OnDeck
  5. Owner:獲得鎖的執行緒成為Owner
  6. !Owner:釋放鎖的執行緒

轉換關係如下圖:

這裡寫圖片描述

新請求鎖的執行緒被首先加入到Contention List中,當某個擁有鎖定執行緒(Owner狀態)呼叫unlock之後,如果發現Entry List為空就從ContentionList中移動執行緒到Entry List中

Contention List和Entry List的實現方式

1.Contention List虛擬佇列

Contention List並不是一個真正的Queue,而是一個虛擬佇列,原因是Contention List是由Node和next指標邏輯構成,並不存在一個Queue的資料結構。Contention List是一個後進先出的佇列,每次新增Node時都會在隊頭進行,通過CAS改變第一個節點的指標在新增節點,同時設定新增節點的next指向後繼節點,而取執行緒操作發生在隊尾。

只有Owner執行緒才能從隊尾取元素,執行緒出隊操作無競爭,避免CAS的ABA問題

這裡寫圖片描述

2.Entry List

EntryList與ContentionList邏輯上同屬等待佇列,ContentionList會被執行緒併發訪問,為了降低對 ContentionList隊尾的爭用,而建立EntryList。Owner執行緒在unlock時會從Contention List中遷移執行緒到Entry List,並會指定Entry List中某個執行緒(一般是第一個)為OnDeck執行緒。Owner執行緒並不是把鎖傳遞給 OnDeck執行緒,只是把競爭鎖的權利交給OnDeck,OnDeck執行緒需要重新競爭鎖。這樣做雖然犧牲了一定的公平性,但極大的提高了整體吞吐量,在 Hotspot中把OnDeck的選擇行為稱之為“競爭切換”

OnDeck執行緒在獲得鎖後變成Owner執行緒,無法獲得鎖則會繼續留在Entry List中,但是在Entry List中的位置不會發生改變。如果Owner執行緒被wait方法阻塞,就轉移到WaitSet佇列;如果在某個時刻被notify/notifyAll喚醒就會再次被轉移到Entry List中

2.sychronized中的鎖機制

介紹鎖機制之前先介紹看一下同步的原理

同步的原理

JVM規範規定JVM基於進入和退出Monitor物件來實現方法同步和程式碼塊同步,但兩者的實現細節不一樣。

程式碼塊同步是使用monitorenter和monitorexit指令實現,而方法同步是使用另外一種方式實現的,細節在JVM規範裡並沒有詳細說明,但是方法的同步同樣可以使用這兩個指令來實現。

monitorenter指令是在編譯後插入到同步程式碼塊的開始位置,而monitorexit是插入到方法結束處和異常處, JVM要保證每個monitorenter必須有對應的monitorexit與之配對。

任何物件都有一個 monitor 與之關聯,當且一個monitor 被持有後,它將處於鎖定狀態。執行緒執行到 monitorenter 指令時,將會嘗試獲取物件所對應的 monitor 的所有權,即嘗試獲得物件的鎖。

java物件頭的概念

java物件的記憶體佈局包括物件頭,資料和填充資料

陣列型別的物件頭使用3個字寬儲存,非陣列型別使用2個字寬儲存,一個字寬等於四位元組(32位)

這裡寫圖片描述

Java物件頭裡的Mark Word裡預設儲存物件的HashCode,分代年齡和鎖標記位

這裡寫圖片描述

在執行期間隨著鎖標誌位的變化儲存的資料也會變化

這裡寫圖片描述

64位JVM下Mark Word大小的64位的 儲存結構如下

這裡寫圖片描述

偏向鎖的鎖標記位和無鎖是一樣的,都是01,但是有單獨一位偏向標記設定是否偏向鎖。

輕量級鎖00,重量級鎖10,GC標記11,無鎖 01.

1.自旋鎖 Spin Lock

處於Contention List,Entry List和Wait Set中的執行緒均屬於阻塞狀態,阻塞操作由作業系統完成(在Linux系統下通過pthread_mutex_lock函式),執行緒被阻塞後進入核心排程狀態,這個會導致在使用者態和核心態之間來回切換,嚴重影響鎖的效能。

解決上述問題的方法就是自旋,原理是: 
當發生爭用時,若Owner執行緒能在很短的時間內釋放鎖,則那些正在爭用執行緒可以稍微等等(自旋),在Owner執行緒釋放鎖之後,爭用執行緒可能會立即獲得鎖,避免了系統阻塞

但是Owner執行的時間可能會超出臨界值,爭用執行緒自旋一段時間無法獲得鎖的話會停止自旋進入阻塞狀態。 
因此自旋鎖對於執行時間很短的程式碼塊有效能提高。

執行緒自旋的時候可以執行幾次for迴圈,可以執行幾條空的彙編指令,目的是佔著CPU不放,等待獲取鎖的機會。 
因此自旋的時間很重要,如果過長會影響整體效能,過短達不到延遲阻塞的目的。HotSpot認為最佳的時間是一個執行緒上下文切換的時間,但是目前只實現了通過彙編暫停集合CPU週期。

其他自旋鎖的優化:

  • 如果平均負載小於CPU的個數則一直自旋
  • 如果超過CPU個數一半個執行緒正在自旋,則後面的執行緒會直接阻塞
  • 如果正在自旋的執行緒發現Owner發生了變化則延遲自旋時間(自旋計數)或進入阻塞
  • 如果CPU處於節點模式就停止自旋
  • 自旋時間的最壞情況是CPU的儲存延遲(CPU A儲存了一個數據,到CPU B得知這個資料直接的時間差)
  • 自旋時會適當放棄執行緒優先順序之間的差異

那synchronized實現何時使用了自旋鎖? 
答案是線上程進入ContentionList時,也即第一步操作前。 
執行緒在進入等待佇列時首先進行自旋嘗試獲得鎖,如果不成功再進入等待佇列。這對那些已經在等待佇列中的執行緒來說,稍微顯得不公平。 
還有一個不公平的地方是自旋執行緒可能會搶佔了Ready執行緒的鎖。自旋鎖由每個監視物件維護,每個監視物件一個

2.偏向鎖(Biased Lock)

主要解決無競爭下的鎖效能問題

按照之前HotSpot設計,每次加鎖/解鎖都會涉及到一些CAS操作(比如等待佇列的CAS操作)。CAS操作會延遲本地呼叫,因此偏向鎖的想法是一旦執行緒第一次獲得了監視物件,之後讓監視物件偏向這個執行緒,之後的多次呼叫可以避免CAS操作。如果在執行過程中,遇到了其他執行緒搶佔鎖,則持有偏向鎖的執行緒會被掛起,JVM會嘗試消除它身上的偏向鎖,把鎖恢復到標準的輕量級鎖。

流程是這樣的 偏向鎖->輕量級鎖->重量級鎖

簡單的加鎖機制:

每個鎖都關聯一個請求計數器和一個佔有她的執行緒,當請求計數器為0時,這個鎖可以被認為是unheld的,當一個執行緒請求一個unheld的鎖時,JVM記錄鎖的擁有者,並把鎖的請求計數加1,如果同一個執行緒再次請求這個鎖是,請求計數器就會加一,當執行緒退出synchronized塊時,計數器減一。當計數器為0時,釋放鎖。

偏向鎖的流程

在鎖物件的物件頭中有一個ThreadId欄位,如果欄位是空,第一次獲取鎖的時候就把自身的ThreadId寫入到鎖的ThreadId欄位內,把鎖內的是否是偏向鎖狀態位置設定為1。下次獲取鎖的時候,直接檢視ThreadId是否和自身執行緒Id一致,如果一致就認為當前執行緒已經取得了鎖無需再次獲取鎖,略過了輕量級鎖和重量級鎖的加鎖階段,提高了效率。

但是偏向鎖也有一個問題,就是當鎖有競爭關係的時候,需要解除偏向鎖,使鎖進入競爭的狀態

這裡寫圖片描述

對於偏向鎖的搶佔問題,一旦偏向鎖衝突,雙方都會升級會輕量級鎖。之後就會進入輕量級的鎖狀態

這裡寫圖片描述

偏向鎖使用的是一種等到競爭出現才釋放鎖的機制,所以在其他執行緒嘗試獲取競爭偏向鎖時,持有偏向鎖的執行緒才會釋放鎖,釋放鎖需要等到全域性安全點(在該時間點上沒有位元組碼在執行)

消除偏向鎖的過程是: 
先暫停偏向鎖的執行緒,嘗試直接切換,如果不成功,就繼續執行,並且標記物件不適合偏向鎖,鎖升級成輕量級鎖。

關閉偏向鎖: 
偏向鎖在jdk6和7中是預設開啟的,但是總是在程式啟動幾秒鐘後才啟用 
可以使用JVM引數來關閉延遲-XX:BiasedLockingStartupDelay=0 
同時也可以使用引數來關閉偏向鎖-XX:-UseBiasedLockin