Synchronized原理分析

文章簡介

synchronized想必大家都不陌生,用來解決線程安全問題的利器。同時也是Java高級程序員面試比較常見的面試題。這篇文正會帶大家徹底瞭解synchronized的實現。

內容導航

  1. 什麼時候需要用Synchronized
  2. synchronized的使用
  3. synchronized的實現原理分析

什麼時候需要用Synchronized

想必大家對synchronized都不陌生,主要作用是在多個線程操作共享數據的時候,保證對共享數據訪問的線程安全性。

比如在下面這個圖片中,兩個線程對於i這個共享變量同時做i++遞增操作,那麼這個時候對於i這個值來說就存在一個不確定性,也就是說理論上i的值應該是2,但是也可能是1。而導致這個問題的原因是線程並行執行i++操作並不是原子的,存在線程安全問題。所以通常來說解決辦法是通過加鎖來實現線程的串行執行,而synchronized就是java中鎖的實現的關鍵字。
圖片描述

synchronized在併發編程中是一個非常重要的角色,在JDK1.6之前,它是一個重量級鎖的角色,但是在JDK1.6之後對synchronized做了優化,優化以後性能有了較大的提升(這塊會在後面做詳細的分析)。

先來看一下synchronized的使用

Synchronized的使用

synchronized有三種使用方法,這三種使用方法分別對應三種不同的作用域,代碼如下

修飾普通同步方法

將synchronized修飾在普通同步方法,那麼該鎖的作用域是在當前實例對象範圍內,也就是說對於 SyncDemosd=newSyncDemo();這一個實例對象sd來說,多個線程訪問access方法會有鎖的限制。如果access已經有線程持有了鎖,那這個線程會獨佔鎖,直到鎖釋放完畢之前,其他線程都會被阻塞

public SyncDemo{
   Object lock =new Object();
    //形式1
    public synchronized void access(){
       //
    }
    //形式2,作用域等同於形式1
    public void access1(){
       synchronized(lock){
         //
       }
    }
    //形式3,作用域等同於前面兩種
    public void access2(){
       synchronized(this){
          //
       }
    }
}

修飾靜態同步方法

修飾靜態同步方法或者靜態對象、類,那麼這個鎖的作用範圍是類級別。舉個簡單的例子,

SyncDemo sd=SyncDemo();
SyncDemo sd2=new SyncDemo();} 

兩個不同的實例sd和sd2, 如果sd這個實例訪問access方法並且成功持有了鎖,那麼sd2這個對象如果同樣來訪問access方法,那麼它必須要等待sd這個對象的鎖釋放以後,sd2這個對象的線程才能訪問該方法,這就是類鎖;也就是說類鎖就相當於全局鎖的概念,作用範圍是類級別。

這裏拋一個小問題,大家看看能不能回答,如果不能也沒關係,後面會講解;問題是如果sd先訪問access獲得了鎖,sd2對象的線程再訪問access1方法,那麼它會被阻塞嗎?
public SyncDemo{
    static Object lock=new Object();
    //形式1
    public synchronized static void access(){
       //
    }
    //形式2等同於形式1
    public void access1(){
       synchronized(lock){
          //
       }
    }
    //形式3等同於前面兩種
    public void access2(){
        synchronzied(SyncDemo.class){
          //
        }
    }
}

步方法塊

public SyncDemo{
   Object lock=new Object();
   public void access(){
       //do something
       synchronized(lock){
         //
       }
   }
}
通過演示3種不同鎖的使用,讓大家對synchronized有了初步的認識。當一個線程視圖訪問帶有synchronized修飾的同步代碼塊或者方法時,必須要先獲得鎖。當方法執行完畢退出以後或者出現異常的情況下會自動釋放鎖。如果大家認真看了上面的三個案例,那麼應該知道鎖的範圍控制是由對象的作用域決定的。對象的作用域越大,那麼鎖的範圍也就越大,因此我們可以得出一個初步的猜想,synchronized和對象有非常大的關係。那麼,接下來就去剖析一下鎖的原理

Synchronized的實現原理分析

當一個線程嘗試訪問synchronized修飾的代碼塊時,它首先要獲得鎖,那麼這個鎖到底存在哪裏呢?

對象在內存中的佈局

synchronized實現的鎖是存儲在Java對象頭裏,什麼是對象頭呢?在Hotspot虛擬機中,對象在內存中的存儲佈局,可以分爲三個區域:對象頭(Header)、實例數據(Instance Data)、對齊填充(Padding)

圖片描述
當我們在Java代碼中,使用new創建一個對象實例的時候,(hotspot虛擬機)JVM層面實際上會創建一個 instanceOopDesc對象。

Hotspot虛擬機採用OOP-Klass模型來描述Java對象實例,OOP(Ordinary Object Point)指的是普通對象指針,Klass用來描述對象實例的具體類型。Hotspot採用instanceOopDesc和arrayOopDesc來描述對象頭,arrayOopDesc對象用來描述數組類型

instanceOopDesc的定義在Hotspot源碼中的 instanceOop.hpp文件中,另外,arrayOopDesc的定義對應 arrayOop.hpp

class instanceOopDesc : public oopDesc {
 public:
  // aligned header size.
  static int header_size() { return sizeof(instanceOopDesc)/HeapWordSize; }
  // If compressed, the offset of the fields of the instance may not be aligned.
  static int base_offset_in_bytes() {
    // offset computation code breaks if UseCompressedClassPointers
    // only is true
    return (UseCompressedOops && UseCompressedClassPointers) ?
             klass_gap_offset_in_bytes() :
             sizeof(instanceOopDesc);
  }
  static bool contains_field_offset(int offset, int nonstatic_field_size) {
    int base_in_bytes = base_offset_in_bytes();
    return (offset >= base_in_bytes &&
            (offset-base_in_bytes) < nonstatic_field_size * heapOopSize);
  }
};
#endif // SHARE_VM_OOPS_INSTANCEOOP_HPP

從instanceOopDesc代碼中可以看到 instanceOopDesc繼承自oopDesc,oopDesc的定義載Hotspot源碼中的 oop.hpp文件中

class oopDesc {
  friend class VMStructs;
 private:
  volatile markOop  _mark;
  union _metadata {
    Klass*      _klass;
    narrowKlass _compressed_klass;
  } _metadata;
  // Fast access to barrier set.  Must be initialized.
  static BarrierSet* _bs;
  ...
}

在普通實例對象中,oopDesc的定義包含兩個成員,分別是 _mark和 _metadata

_mark表示對象標記、屬於markOop類型,也就是接下來要講解的Mark World,它記錄了對象和鎖有關的信息

_metadata表示類元信息,類元信息存儲的是對象指向它的類元數據(Klass)的首地址,其中Klass表示普通指針、 _compressed_klass表示壓縮類指針

Mark Word

在前面我們提到過,普通對象的對象頭由兩部分組成,分別是markOop以及類元信息,markOop官方稱爲Mark Word
在Hotspot中,markOop的定義在 markOop.hpp文件中,代碼如下

class markOopDesc: public oopDesc {
 private:
  // Conversion
  uintptr_t value() const { return (uintptr_t) this; }
 public:
  // Constants
  enum { age_bits                 = 4,  //分代年齡
         lock_bits                = 2, //鎖標識
         biased_lock_bits         = 1, //是否爲偏向鎖
         max_hash_bits            = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
         hash_bits                = max_hash_bits > 31 ? 31 : max_hash_bits, //對象的hashcode
         cms_bits                 = LP64_ONLY(1) NOT_LP64(0),
         epoch_bits               = 2 //偏向鎖的時間戳
  };
...

Mark word記錄了對象和鎖有關的信息,當某個對象被synchronized關鍵字當成同步鎖時,那麼圍繞這個鎖的一系列操作都和Mark word有關係。Mark Word在32位虛擬機的長度是32bit、在64位虛擬機的長度是64bit。
Mark Word裏面存儲的數據會隨着鎖標誌位的變化而變化,Mark Word可能變化爲存儲以下5中情況

32位虛擬機中的定義

64位虛擬機中的定義

鎖標誌位的表示意義

  1. 鎖標識 lock=00 表示輕量級鎖
  2. 鎖標識 lock=10 表示重量級鎖
  3. 偏向鎖標識 biased_lock=1表示偏向鎖
  4. 偏向鎖標識 biased_lock=0且鎖標識=01表示無鎖狀態

到目前爲止,我們再總結一下前面的內容,synchronized(lock)中的lock可以用Java中任何一個對象來表示,而鎖標識的存儲實際上就是在lock這個對象中的對象頭內。大家懂了嗎?

其實前面只提到了鎖標誌位的存儲,但是爲什麼任意一個Java對象都能成爲鎖對象呢?

首先,Java中的每個對象都派生自Object類,而每個Java Object在JVM內部都有一個native的C++對象 oop/oopDesc進行對應。
其次,線程在獲取鎖的時候,實際上就是獲得一個監視器對象(monitor) ,monitor可以認爲是一個同步對象,所有的Java對象是天生攜帶monitor.
在hotspot源碼的 markOop.hpp文件中,可以看到下面這段代碼。

ObjectMonitor* monitor() const {
    assert(has_monitor(), "check");
    // Use xor instead of &~ to provide one extra tag-bit check.
    return (ObjectMonitor*) (value() ^ monitor_value);
  }

多個線程訪問同步代碼塊時,相當於去爭搶對象監視器修改對象中的鎖標識,上面的代碼中ObjectMonitor這個對象和線程爭搶鎖的邏輯有密切的關係(後續會詳細分析)

鎖的升級

前面提到了鎖的幾個概念,偏向鎖、輕量級鎖、重量級鎖。在JDK1.6之前,synchronized是一個重量級鎖,性能比較差。從JDK1.6開始,爲了減少獲得鎖和釋放鎖帶來的性能消耗,synchronized進行了優化,引入了 偏向鎖和 輕量級鎖的概念。所以從JDK1.6開始,鎖一共會有四種狀態,鎖的狀態根據競爭激烈程度從低到高分別是:無鎖狀態->偏向鎖狀態->輕量級鎖狀態->重量級鎖狀態。這幾個狀態會隨着鎖競爭的情況逐步升級。爲了提高獲得鎖和釋放鎖的效率,鎖可以升級但是不能降級。
下面就詳細講解synchronized的三種鎖的狀態及升級原理

偏向鎖

在大多數的情況下,鎖不僅不存在多線程的競爭,而且總是由同一個線程獲得。因此爲了讓線程獲得鎖的代價更低引入了偏向鎖的概念。偏向鎖的意思是如果一個線程獲得了一個偏向鎖,如果在接下來的一段時間中沒有其他線程來競爭鎖,那麼持有偏向鎖的線程再次進入或者退出同一個同步代碼塊,不需要再次進行搶佔鎖和釋放鎖的操作。偏向鎖可以通過 -XX:+UseBiasedLocking開啓或者關閉

偏向鎖的獲取

偏向鎖的獲取過程非常簡單,當一個線程訪問同步塊獲取鎖時,會在對象頭和棧幀中的鎖記錄裏存儲偏向鎖的線程ID,表示哪個線程獲得了偏向鎖,結合前面分析的Mark Word來分析一下偏向鎖的獲取邏輯

  1. 首先獲取目標對象的Mark Word,根據鎖的標識爲和epoch去判斷當前是否處於可偏向的狀態
  2. 如果爲可偏向狀態,則通過CAS操作將自己的線程ID寫入到MarkWord,如果CAS操作成功,則表示當前線程成功獲取到偏向鎖,繼續執行同步代碼塊
  3. 如果是已偏向狀態,先檢測MarkWord中存儲的threadID和當前訪問的線程的threadID是否相等,如果相等,表示當前線程已經獲得了偏向鎖,則不需要再獲得鎖直接執行同步代碼;如果不相等,則證明當前鎖偏向於其他線程,需要撤銷偏向鎖。
CAS:表示自旋鎖,由於線程的阻塞和喚醒需要CPU從用戶態轉爲核心態,頻繁的阻塞和喚醒對CPU來說性能開銷很大。同時,很多對象鎖的鎖定狀態指會持續很短的時間,因此引入了自旋鎖,所謂自旋就是一個無意義的死循環,在循環體內不斷的重行競爭鎖。當然,自旋的次數會有限制,超出指定的限制會升級到阻塞鎖。

偏向鎖的撤銷

當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放偏向鎖,撤銷偏向鎖的過程需要等待一個全局安全點(所有工作線程都停止字節碼的執行)。

  1. 首先,暫停擁有偏向鎖的線程,然後檢查偏向鎖的線程是否爲存活狀態
  2. 如果線程已經死了,直接把對象頭設置爲無鎖狀態
  3. 如果還活着,當達到全局安全點時獲得偏向鎖的線程會被掛起,接着偏向鎖升級爲輕量級鎖,然後喚醒被阻塞在全局安全點的線程繼續往下執行同步代碼

偏向鎖的獲取流程圖

圖片描述

輕量級鎖

前面我們知道,當存在超過一個線程在競爭同一個同步代碼塊時,會發生偏向鎖的撤銷。偏向鎖撤銷以後對象會可能會處於兩種狀態

  1. 一種是不可偏向的無鎖狀態,簡單來說就是已經獲得偏向鎖的線程已經退出了同步代碼塊,那麼這個時候會撤銷偏向鎖,並升級爲輕量級鎖
  2. 一種是不可偏向的已鎖狀態,簡單來說就是已經獲得偏向鎖的線程正在執行同步代碼塊,那麼這個時候會升級到輕量級鎖並且被原持有鎖的線程獲得鎖

那麼升級到輕量級鎖以後的加鎖過程和解鎖過程是怎麼樣的呢?

輕量級鎖加鎖

  1. JVM會先在當前線程的棧幀中創建用於存儲鎖記錄的空間(LockRecord)
  2. 將對象頭中的Mark Word複製到鎖記錄中,稱爲Displaced Mark Word.
  3. 線程嘗試使用CAS將對象頭中的Mark Word替換爲指向鎖記錄的指針
  4. 如果替換成功,表示當前線程獲得輕量級鎖,如果失敗,表示存在其他線程競爭鎖,那麼當前線程會嘗試使用CAS來獲取鎖,當自旋超過指定次數(可以自定義)時仍然無法獲得鎖,此時鎖會膨脹升級爲重量級鎖

圖片描述

輕量鎖解鎖

  1. 嘗試CAS操作將所記錄中的Mark Word替換回到對象頭中
  2. 如果成功,表示沒有競爭發生
  3. 如果失敗,表示當前鎖存在競爭,鎖會膨脹成重量級鎖
一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處於重量級鎖狀態,其他線程嘗試獲取鎖時,都會被阻塞,也就是 BLOCKED狀態。當持有鎖的線程釋放鎖之後會喚醒這些現場,被喚醒之後的線程會進行新一輪的競爭
圖片描述

重量級鎖

重量級鎖依賴對象內部的monitor鎖來實現,而monitor又依賴操作系統的MutexLock(互斥鎖)

大家如果對MutexLock有興趣,可以抽時間去了解,假設Mutex變量的值爲1,表示互斥鎖空閒,這個時候某個線程調用lock可以獲得鎖,而Mutex的值爲0表示互斥鎖已經被其他線程獲得,其他線程調用lock只能掛起等待

爲什麼重量級鎖的開銷比較大呢?

原因是當系統檢查到是重量級鎖之後,會把等待想要獲取鎖的線程阻塞,被阻塞的線程不會消耗CPU,但是阻塞或者喚醒一個線程,都需要通過操作系統來實現,也就是相當於從用戶態轉化到內核態,而轉化狀態是需要消耗時間的

總結

到目前爲止,我們分析了synchronized的使用方法、以及鎖的存儲、對象頭、鎖升級的原理。 轉載:https://segmentfault.com/a/1190000017255044

發佈了54 篇原創文章 · 獲贊 31 · 訪問量 10萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章