線程安全,是Java并發編程中的重要關注點,應該注意到的是,造成線程安全問題的主要原因有兩點:
1,存在共享數據(也稱臨界資源)
2,存在多條線程,共同操作共享數據。
本文由淺入深,逐步整理了synchronized的相關知識,主要包括:
- 應用場景
- 原理概要
- 原理詳解
- 低層實現
- 鎖的優化(JDK1.6引入)
- 鎖的升級(在什么情況下會升級,以及鎖只能單向升級)
應用方式
synchronized 是解決Java并發最常見的一種方法,也是最簡單的一種方法。關鍵字 synchronized 可以保證在同一時刻,只有一個線程可以訪問某個方法或者某個代碼塊。同時 synchronized 也可以保證一個線程的變化,被另一個線程看到(保證了可見性)
這里要注意:synchronized是一個互斥的 重量級鎖 (細節部分后續會講)
synchronized的作用主要有三個:
- 確保線程互斥的訪問代碼
- 保證共享變量的修改能夠及時可見(可見性)
- 可以阻止JVM的指令重排序
在Java中所有對象都可以作為鎖,這是synchronized實現同步的基礎。
synchronized主要有三種應用方式:
- 普通同步方法,鎖的是當前實例的對象
- 靜態同步方法,鎖的是靜態方法所在的類對象
- 同步代碼塊,鎖的是括號里的對象。(此處的可以是實例對象,也可以是類的class對象。)
原理概要
Java虛擬機中的同步(Synchronization)都是基于進入和退出Monitor對象實現,無論是顯示同步(同步代碼塊)還是隱式同步(同步方法)都是如此。
-
同步代碼塊
monitorenter
指令插入到同步代碼塊的開始位置。monitorexit
指令插入到同步代碼塊結束的位置。JVM需要保證每一個monitorenter
都有一個monitorexit
與之對應。
任何對象,都有一個monitor與之相關聯,當monitor被持有以后,它將處于鎖定狀態。線程執行到monitorenter指令時,會嘗試獲得monitor對象的所有權,即嘗試獲取鎖。
虛擬機規范對 monitorenter 和 monitorexit 的行為描述中,有兩點需要注意。首先 synchronized 同步快對于同一條線程來說是可重入的,也就是說,不會出現把自己鎖死的問題。其次,同步快在已進入的線程執行完之前,會阻塞后面其他線程的進入。(摘自《深入理解JAVA虛擬機》)
-
同步方法
synchronized方法則會被翻譯成普通的方法調用和返回指令如:invokevirtual、areturn指令,在VM字節碼層面并沒有任何特別的指令來實現被synchronized修飾的方法,而是在Class文件的方法表中將該方法的access_flags字段中的synchronized標志位置1,表示該方法是同步方法并使用調用該方法的對象或該方法所屬的Class在JVM的內部對象表示Klass做為鎖對象。
原理詳解
要理解低層實現,就需要理解兩個重要的概念 Monitor 和 Mark Word
- Java對象頭
synchronized用到的鎖,是存儲在對象頭中的。(這也是Java所有對象都可以上鎖的根本原因)
HotSpot虛擬機中,對象頭包括兩部分信息:
Mark Word(對象頭)和 Klass Pointer(類型指針)
- 其中類型指針,是對象指向它的類元素的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。
- 對象頭又分為兩部分:第一部分存儲對象自身的運行時數據,例如哈希碼,GC分代年齡,線程持有的鎖,偏向時間戳等。這一部分的長度是不固定的。第二部分是末尾兩位,存儲鎖標志位,表示當前鎖的級別。
對象頭的長度一般占用兩個機器碼(32位JVM中,一個機器碼等于4個字節,也就是32bit),但如果對象是數組類型,則需要三個機器碼(多出的一塊記錄數組長度)。
下圖是對象頭運行時的變化狀態:
鎖標志位 和 是否偏向鎖 確定唯一的鎖狀態
其中 輕量鎖 和 偏向鎖 是JDK1.6之后新加的,用于對synchronized優化。稍后講到
- Monitor
Monitor是 synchronized 重量級 鎖的實現關鍵。鎖的標識位為 10 。當然 synchronized作為一個重量鎖是非常消耗性能的,所以在JDK1.6以后做了部分優化,接下來的部分是講作為重量鎖的實現。
Monitor是線程私有的數據結構,每一個對象都有一個monitor與之關聯。每一個線程都有一個可用monitor record列表(當前線程中所有對象的monitor),同時還有一個全局可用列表(全局對象monitor)。每一個被鎖住的對象,都會和一個monitor關聯。
當一個monitor被某個線程持有后,它便處于鎖定狀態。此時,對象頭中 MarkWord的 指向互斥量的指針,就是指向鎖對象的monitor起始地址。
monitor是由 ObjectMonitor 實現的,其主要數據結構如下:(位于HotSpot虛擬機源碼ObjectMonitor.hpp文件,C++實現的)
ObjectMonitor() {
_header = NULL;
_count = 0; //記錄個數
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //處于wait狀態的線程,會被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //處于等待鎖block狀態的線程,會被加入到該列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
object monitor 有兩個隊列 _EntryList
和 _WaitSet
,用來保存ObjectWaiter對象列表(每個等待鎖的線程都會被封裝成ObjectWaiter對象)_owner
指向持有 objectMonitor的線程。
當多個線程同時訪問一個同步代碼時,首先會進入 _EntryList
集合,當線程獲取到對象的monitor后,會進入_owner 區域,然后把monitor中的 _owner
變量修改為當前線程,同時monitor中的計數器_count
會加1。
根據虛擬機規范的要求,在執行monitorenter指令時,會嘗試獲取對象的鎖。如果對象沒有被鎖定(獲取鎖),獲取對象已經被該線程鎖定(鎖重入)。則把計數器加1(
_count
加1)。相應的,在執行monitorexit指令時,會講計數器減1。當計數器為0時,_owner指向Null,鎖就被釋放。(摘自《深入理解JAVA虛擬機》)
如果線程調用 wait()
方法,將釋放當前持有的monitor,_owner
變量恢復為null,_count
變量減1,同時該線程進入_WaitSet
等待被喚醒。
底層實現
- synchronized 代碼塊低層原理
從Javac編譯成的字節碼可以看出(具體編譯文件看參考鏈接),同步代碼塊使用的是monitorenter
和monitorexit
指令,其中monitorenter
指向同步代碼塊的開始位置,monitorexit
指向同步代碼塊的結束位置。
在線程執行到monitorenter
指令時,當前線程將嘗試獲取鎖,即嘗試獲取鎖對象對應的monitor的持有權。當monitor的count計數器為0,或者monitor的owner已經是該線程,則獲取鎖,count計數器+1。
如果其他線程已經持有該對象的鎖,則該線程被阻塞,直到其他線程執行完畢釋放鎖。
線程執行完畢時,count歸零,owner指向Null,鎖釋放。
值得注意的是,編譯器將會確保,無論通過何種方法完成,方法中的每一條monitorenter
指令,最終都會有monitorexit
指令對應,不論這個方法正常結束還是異常結束,最終都會配對執行。
編譯器會自動產生一個異常處理器,這個處理器聲明可以處理所有的異常,它的目的就是為了確保monitorexit
指令最終執行。
- synchronized 方法低層原理
方法級的同步是隱式,即無需通過字節碼來控制的,它實現在方法調用和返回操作中。
在Class文件方法常量池中的方法表結構(method_info Structure)中, ACC_SYNCHRONIZED 訪問標志區分一個方法是否為同步方法。在方法被調用時,會檢查方法的 ACC_SYNCHRONIZED 標記是否被設置。如果被設置了,則線程將持有該方法對應對象的monitor(調用方法的實例對象or靜態方法的類對象),然后再執行該方法。
最后在方法執行完成時,釋放monitor。
在方法執行期間,執行線程持有了monitor,其他任何線程都無法再獲得同一個monitor。
以下是字節碼實現:
public class SyncMethod {
public int i;
public synchronized void syncTask(){
i++;
}
}
使用javap反編譯后的字節碼如下:
Classfile /Users/zejian/Downloads/Java8_Action/src/main/java/com/zejian/concurrencys/SyncMethod.class
Last modified 2017-6-2; size 308 bytes
MD5 checksum f34075a8c059ea65e4cc2fa610e0cd94
Compiled from "SyncMethod.java"
public class com.zejian.concurrencys.SyncMethod
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool;
//省略沒必要的字節碼
//==================syncTask方法======================
public synchronized void syncTask();
descriptor: ()V
//方法標識ACC_PUBLIC代表public修飾,ACC_SYNCHRONIZED指明該方法為同步方法
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // Field i:I
5: iconst_1
6: iadd
7: putfield #2 // Field i:I
10: return
LineNumberTable:
line 12: 0
line 13: 10
}
SourceFile: "SyncMethod.java"
從字節碼可以看出,synchronized修飾的方法并沒有monitorenter
和monitorexit
指令。而是用ACC_SYNCHRONIZED
的flag標記該方法是否是同步方法,從而執行相應的同步調用。
鎖的狀態和優化
在早期的Java版本中,synchronized屬于重量級鎖,效率低下,因為監視器鎖(Monitor)是依賴于低層的操作系統的Mutex Lock來實現的。
而操作系統實現線程中的切換時,需要用用戶態切換到核心態,這是一個非常重的操作,時間成本較高。這也是早期 synchronized 效率低下的原因。
JDK1.6之后JVM官方對鎖做了較大優化:
引入了:
- 鎖粗化(Lock Coarsening)
- 鎖消除(Lock Elimination)
- 適應性自旋(Adaptive Spinning)
同時增加了兩種鎖的狀態:
- 偏向鎖(Biased Locking)
- 輕量鎖(Lightweight Locking)
先說鎖的狀態:
鎖的狀態共有四種:無鎖,偏向鎖,輕量鎖,重量鎖。隨著鎖的競爭,鎖會從偏向鎖升級為輕量鎖,然后升級為重量鎖。鎖的升級是單向的,JDK1.6中默認開啟偏向鎖和輕量鎖。
- 偏向鎖
引入偏向鎖的目的是:為了在無多線程競爭的情況下,盡量減少不必要的輕量鎖執行路徑。
因為經過研究發現,在大部分情況下,鎖并不存在多線程競爭,而且總是由一個線程多次獲得鎖。因此為了減少同一線程獲取鎖(會涉及到一些耗時的CAS操作)的代價而引入。
如果一個線程獲取到了鎖,那么該鎖就進入偏向鎖模式,當這個線程再次請求鎖時無需做任何同步操作,直接獲取到鎖。這樣就省去了大量有關鎖申請的操作,提升了程序性能。
獲取偏向鎖:
- 檢查Mark Word 是否為可偏向狀態,即是否為偏向鎖=1,鎖標志位=01.
- 若為可偏向狀態,則檢查 線程ID 是否為當前對象頭中的線程ID,如果是,則獲取鎖,執行同步代碼塊。如果不是,進入第3步。
- 如果線程ID不是當前線程ID,則通過CAS操作競爭鎖,如果競爭成功。則將Mark Word中的線程ID替換為當前線程ID,獲取鎖,執行同步代碼塊。如果沒成功,進入第4步。
- 通過CAS競爭失敗,則說明當前存在鎖競爭。當執行到達全局安全點時,獲得偏向鎖的進程會被掛起,偏向鎖膨脹為輕量級鎖(重要),被阻塞在安全點的線程繼續往下執行同步代碼塊。
釋放偏向鎖:
偏向鎖的釋放,采取了一種只有競爭才會釋放鎖的機制,線程不會主動去釋放鎖,需要等待其他線程來競爭。偏向鎖的撤銷需要等到全局安全點(這個時間點沒有正在執行的代碼),步驟如下:
- 暫停擁有偏向鎖的線程,判斷對象是否還處于被鎖定的狀態。
- 撤銷偏向鎖。恢復到無鎖狀態(01)或者 膨脹為輕量級鎖。
偏向鎖的獲取和釋放流程
- 輕量級鎖
輕量鎖能夠提升性能的依據,是基于如下假設:即在真實情況下,程序中的大部分代碼一般都處于一種無鎖競爭的狀態(即單線程環境),而在無鎖競爭下完全可以避免調用操作系統層面的操作來實現重量鎖。如果打破這個依據,除了互斥的開銷外,還有額外的CAS操作,因此在有線程競爭的情況下,輕量鎖比重量鎖更慢。
為了減少傳統重量鎖造成的性能不必要的消耗,才引入了輕量鎖。
當關閉偏向鎖功能 或者 多個線程競爭偏向鎖導致升級為輕量鎖,則會嘗試獲取輕量鎖。
獲取輕量鎖:
- 判斷當前對象是否處于無鎖狀態(偏向鎖標記=0,無鎖狀態=01),如果是,則JVM會首先將當前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用于存儲當前對象的Mark Word拷貝。(官方稱為Displaced Mark Word)。接下來執行第2步。如果對象處于有鎖狀態,則執行第3步
- JVM利用CAS操作,嘗試將對象的Mark Word更新為指向Lock Record的指針。如果成功,則表示競爭到鎖。將鎖標志位變為00(表示此對象處于輕量級鎖的狀態),執行同步代碼塊。如果CAS操作失敗,則執行第3步。
- 判斷當前對象的Mark Word 是否指向當前線程的棧幀,如果是,則表示當前線程已經持有當前對象的鎖,直接執行同步代碼塊。否則,說明該鎖對象已經被其他對象搶占,此后為了不讓線程阻塞,還會進入一個自旋鎖的狀態,如在一定的自旋周期內嘗試重新獲取鎖,如果自旋失敗,則輕量鎖需要膨脹為重量鎖(重點),鎖標志位變為10,后面等待的線程將會進入阻塞狀態。
釋放輕量鎖:
輕量級鎖的釋放操作,也是通過CAS操作來執行的,步驟如下:
- 取出在獲取輕量級鎖時,存儲在棧幀中的 Displaced Mard Word 數據。
- 用CAS操作,將取出的數據替換到對象的Mark Word中,如果成功,則說明釋放鎖成功,如果失敗,則執行第3步。
-
如果CAS操作失敗,說明有其他線程在嘗試獲取該鎖,則要在釋放鎖的同時喚醒被掛起的線程。
輕量鎖的獲取和釋放
- 重量級鎖
重量級鎖通過對象內部的監視器(Monitor)來實現,而其中monitor本質上是依賴于低層操作系統的 Mutex Lock實現。
操作系統實現線程切換,需要從用戶態切換到內核態,切換成本非常高。
- 適應性自旋
在輕量級鎖獲取失敗時,為了避免線程真實的在系統層面被掛起,還會進行一項稱為自旋鎖的優化手段。
這是基于以下假設:
大多數情況下,線程持有鎖的時間不會太長,將線程掛起在系統層面耗費的成本較高。
而“適應性”則表示,該自學的周期更加聰明。自旋的周期是不固定的,它是由上一次在同一個鎖上的自旋時間 以及 鎖擁有者的狀態 共同決定。
具體方式是:如果自旋成功了,那么下次的自旋最大次數會更多,因為JVM認為既然上次成功了,那么這一次也有很大概率會成功,那么允許等待的最大自旋時間也相應增加。反之,如果對于某一個鎖,很少有自旋成功的,那么就會相應的減少下次自旋時間,或者干脆放棄自旋,直接升級為重量鎖,以免浪費系統資源。
有了適應性自旋,隨著程序的運行信息不斷完善,JVM會對鎖的狀態預測更加精準,虛擬機會變得越來越聰明。
再談談鎖的優化:
- 鎖粗化
我們知道,在使用鎖的時候,需要讓同步的作用范圍盡可能的小——僅在共享數據的操作中才進行。這樣做的目的,是為了讓同步操作的數量盡可能小,如果村子鎖競爭,那么也能盡快的拿到鎖。
在大多數的情況下,上面的原則是正確的。
但是如果存在一系列連續的 lock unlock 操作,也會導致性能的不必要消耗.
粗化鎖就是將連續的同步操作連在一起,粗化為一個范圍更大的鎖。
例如,對Vector的循環add操作,每次add都需要加鎖,那么JVM會檢測到這一系列操作,然后將鎖移到循環外。
- 鎖消除
鎖消除是JVM進行的另外一項鎖優化,該優化更徹底。
JVM在進行JIT編譯時,通過對上下文的掃描,JVM檢測到不可能存在共享數據的競爭,如果這些資源有鎖,那么會消除這些資源的鎖。這樣可以節省毫無意義的鎖請求時間。
雖然大部分程序員可以判斷哪些操作是單線程的不必要加鎖,但我們在使用Java的內置 API時,部分操作會隱性的包含鎖操作。例如StringBuffer的操作,HashTable的操作。
鎖消除的依據,是逃逸分析的數據支持。
(如果有什么錯誤或者建議,歡迎留言指出)
(本文內容是對各個知識點的轉載整理,用于個人技術沉淀,以及大家學習交流用)
參考資料:
**【死磕Java并發】深入分析synchronized實現原理
** 深入理解Java并發之synchronized原理
JVM內部細節之synchronized實現細節
Java對象頭解析-不得不了解的對象頭