「阿里面試系列」分析Synchronized原理,讓面試官仰望
JAVA架構 2018-12-18 08:01:00
文章簡介
synchronized想必大家都不陌生,用來解決執行緒安全問題的利器。同時也是Java高階程式設計師面試比較常見的面試題。這篇文正會帶大家徹底瞭解synchronized的實現。
擴充套件閱讀:
「阿里面試系列」面試加分項,從jvm層面瞭解執行緒的啟動和停止
內容導航
- 什麼時候需要用Synchronized
- synchronized的使用
- synchronized的實現原理分析
什麼時候需要用Synchronized
想必大家對synchronized都不陌生,主要作用是在多個執行緒操作共享資料的時候,保證對共享資料訪問的執行緒安全性。
比如在下面這個圖片中,兩個執行緒對於i這個共享變數同時做i++遞增操作,那麼這個時候對於i這個值來說就存在一個不確定性,也就是說理論上i的值應該是2,但是也可能是1。而導致這個問題的原因是執行緒並行執行i++操作並不是原子的,存線上程安全問題。所以通常來說解決辦法是通過加鎖來實現執行緒的序列執行,而synchronized就是java中鎖的實現的關鍵字。
synchronized在併發程式設計中是一個非常重要的角色,在JDK1.6之前,它是一個重量級鎖的角色,但是在JDK1.6之後對synchronized做了優化,優化以後效能有了較大的提升(這塊會在後面做詳細的分析)。
先來看一下synchronized的使用
Synchronized的使用
synchronized有三種使用方法,這三種使用方法分別對應三種不同的作用域,程式碼如下
1. 修飾普通同步方法
將synchronized修飾在普通同步方法,那麼該鎖的作用域是在當前例項物件範圍內,也就是說對於 Sync Demosd=new SyncDemo();這一個例項物件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){ // } } }
2. 修飾靜態同步方法
修飾靜態同步方法或者靜態物件、類,那麼這個鎖的作用範圍是類級別。舉個簡單的例子,
SyncDemo sd=new 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位虛擬機器中的定義
鎖標誌位的表示意義
- 鎖標識 lock=00 表示輕量級鎖
- 鎖標識 lock=10 表示重量級鎖
- 偏向鎖標識 biased_lock=1表示偏向鎖
- 偏向鎖標識 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來分析一下偏向鎖的獲取邏輯
- 首先獲取目標物件的Mark Word,根據鎖的標識為和epoch去判斷當前是否處於可偏向的狀態
- 如果為可偏向狀態,則通過CAS操作將自己的執行緒ID寫入到MarkWord,如果CAS操作成功,則表示當前執行緒成功獲取到偏向鎖,繼續執行同步程式碼塊
- 如果是已偏向狀態,先檢測MarkWord中儲存的threadID和當前訪問的執行緒的threadID是否相等,如果相等,表示當前執行緒已經獲得了偏向鎖,則不需要再獲得鎖直接執行同步程式碼;如果不相等,則證明當前鎖偏向於其他執行緒,需要撤銷偏向鎖。
CAS:表示自旋鎖,由於執行緒的阻塞和喚醒需要CPU從使用者態轉為核心態,頻繁的阻塞和喚醒對CPU來說效能開銷很大。同時,很多物件鎖的鎖定狀態指會持續很短的時間,因此引入了自旋鎖,所謂自旋就是一個無意義的死迴圈,在迴圈體內不斷的重行競爭鎖。當然,自旋的次數會有限制,超出指定的限制會升級到阻塞鎖。
偏向鎖的撤銷
當其他執行緒嘗試競爭偏向鎖時,持有偏向鎖的執行緒才會釋放偏向鎖,撤銷偏向鎖的過程需要等待一個全域性安全點(所有工作執行緒都停止位元組碼的執行)。
- 首先,暫停擁有偏向鎖的執行緒,然後檢查偏向鎖的執行緒是否為存活狀態
- 如果執行緒已經死了,直接把物件頭設定為無鎖狀態
- 如果還活著,當達到全域性安全點時獲得偏向鎖的執行緒會被掛起,接著偏向鎖升級為輕量級鎖,然後喚醒被阻塞在全域性安全點的執行緒繼續往下執行同步程式碼
偏向鎖的獲取流程圖
偏向鎖的獲取流程圖
輕量級鎖
前面我們知道,當存在超過一個執行緒在競爭同一個同步程式碼塊時,會發生偏向鎖的撤銷。偏向鎖撤銷以後物件會可能會處於兩種狀態
- 一種是不可偏向的無鎖狀態,簡單來說就是已經獲得偏向鎖的執行緒已經退出了同步程式碼塊,那麼這個時候會撤銷偏向鎖,並升級為輕量級鎖
- 一種是不可偏向的已鎖狀態,簡單來說就是已經獲得偏向鎖的執行緒正在執行同步程式碼塊,那麼這個時候會升級到輕量級鎖並且被原持有鎖的執行緒獲得鎖
那麼升級到輕量級鎖以後的加鎖過程和解鎖過程是怎麼樣的呢?
輕量級鎖加鎖
- JVM會先在當前執行緒的棧幀中建立用於儲存鎖記錄的空間(LockRecord)
- 將物件頭中的Mark Word複製到鎖記錄中,稱為Displaced Mark Word.
- 執行緒嘗試使用CAS將物件頭中的Mark Word替換為指向鎖記錄的指標
- 如果替換成功,表示當前執行緒獲得輕量級鎖,如果失敗,表示存在其他執行緒競爭鎖,那麼當前執行緒會嘗試使用CAS來獲取鎖,當自旋超過指定次數(可以自定義)時仍然無法獲得鎖,此時鎖會膨脹升級為重量級鎖
輕量級鎖加鎖
輕量鎖解鎖
- 嘗試CAS操作將所記錄中的Mark Word替換回到物件頭中
- 如果成功,表示沒有競爭發生
- 如果失敗,表示當前鎖存在競爭,鎖會膨脹成重量級鎖
一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處於重量級鎖狀態,其他執行緒嘗試獲取鎖時,都會被阻塞,也就是 BLOCKED狀態。當持有鎖的執行緒釋放鎖之後會喚醒這些現場,被喚醒之後的執行緒會進行新一輪的競爭
輕量級鎖解鎖
重量級鎖
重量級鎖依賴物件內部的monitor鎖來實現,而monitor又依賴作業系統的MutexLock(互斥鎖)
大家如果對MutexLock有興趣,可以抽時間去了解,假設Mutex變數的值為1,表示互斥鎖空閒,這個時候某個執行緒呼叫lock可以獲得鎖,而Mutex的值為0表示互斥鎖已經被其他執行緒獲得,其他執行緒呼叫lock只能掛起等待
為什麼重量級鎖的開銷比較大呢?
原因是當系統檢查到是重量級鎖之後,會把等待想要獲取鎖的執行緒阻塞,被阻塞的執行緒不會消耗CPU,但是阻塞或者喚醒一個執行緒,都需要通過作業系統來實現,也就是相當於從使用者態轉化到核心態,而轉化狀態是需要消耗時間的
總結
到目前為止,我們分析了synchronized的使用方法、以及鎖的儲存、物件頭、鎖升級的原理。如果有問題,可以關注我的公眾號:Java架構師學習,或者掃描下方二維碼加群,找我的助理領取視訊資料。群裡有我分享的併發程式設計,分散式,微服務架構,效能優化,原始碼,設計模式,高併發,高可用,Spring,Netty中,Tomcat時,JVM等技術視訊。