您好,登錄后才能下訂單哦!
本篇內容主要講解“如何理解Synchronized ”,感興趣的朋友不妨來看看。本文介紹的方法操作簡單快捷,實用性強。下面就讓小編來帶大家學習“如何理解Synchronized ”吧!
synchronized 這個關鍵字的重要性不言而喻,幾乎可以說是并發、多線程必須會問到的關鍵字了。synchronized 會涉及到鎖、升級降級操作、鎖的撤銷、對象頭等。所以理解 synchronized 非常重要,本篇文章就帶你從 synchronized 的基本用法、再到 synchronized 的深入理解,對象頭等,為你揭開 synchronized 的面紗。
synchronized 是 Java 并發模塊 非常重要的關鍵字,它是 Java 內建的一種同步機制,代表了某種內在鎖定的概念,當一個線程對某個共享資源加鎖后,其他想要獲取共享資源的線程必須進行等待,synchronized 也具有互斥和排他的語義。
什么是互斥?我們想必小時候都玩兒過磁鐵,磁鐵會有正負極的概念,同性相斥異性相吸,相斥相當于就是一種互斥的概念,也就是兩者互不相容。
synchronized 也是一種獨占的關鍵字,但是它這種獨占的語義更多的是為了增加線程安全性,通過獨占某個資源以達到互斥、排他的目的。
在了解了排他和互斥的語義后,我們先來看一下 synchronized 的用法,先來了解用法,再來了解底層實現。
關于 synchronized 想必你應該都大致了解過
synchronized 修飾實例方法,相當于是對類的實例進行加鎖,進入同步代碼前需要獲得當前實例的鎖
synchronized 修飾靜態方法,相當于是對類對象進行加鎖
synchronized 修飾代碼塊,相當于是給對象進行加鎖,在進入代碼塊前需要先獲得對象的鎖
下面我們針對每個用法進行解釋
synchronized 修飾實例方法
synchronized 修飾實例方法,實例方法是屬于類的實例。synchronized 修飾的實例方法相當于是對象鎖。下面是一個 synchronized 修飾實例方法的例子。
public synchronized void method() { // ... }
像如上述 synchronized 修飾的方法就是實例方法,下面我們通過一個完整的例子來認識一下 synchronized 修飾實例方法
public class TSynchronized implements Runnable{ static int i = 0; public synchronized void increase(){ i++; System.out.println(Thread.currentThread().getName()); } @Override public void run() { for(int i = 0;i < 1000;i++) { increase(); } } public static void main(String[] args) throws InterruptedException { TSynchronized tSynchronized = new TSynchronized(); Thread aThread = new Thread(tSynchronized); Thread bThread = new Thread(tSynchronized); aThread.start(); bThread.start(); aThread.join(); bThread.join(); System.out.println("i = " + i); } }
上面輸出的結果 i = 2000 ,并且每次都會打印當前現成的名字
來解釋一下上面代碼,代碼中的 i 是一個靜態變量,靜態變量也是全局變量,靜態變量存儲在方法區中。increase 方法由 synchronized 關鍵字修飾,但是沒有使用 static 關鍵字修飾,表示 increase 方法是一個實例方法,每次創建一個 TSynchronized 類的同時都會創建一個 increase 方法,increase 方法中只是打印出來了當前訪問的線程名稱。Synchronized 類實現了 Runnable 接口,重寫了 run 方法,run 方法里面就是一個 0 - 1000 的計數器,這個沒什么好說的。在 main 方法中,new 出了兩個線程,分別是 aThread 和 bThread,Thread.join 表示等待這個線程處理結束。這段代碼主要的作用就是判斷 synchronized 修飾的方法能夠具有獨占性。
synchronized 修飾靜態方法就是 synchronized 和 static 關鍵字一起使用
public static synchronized void increase(){}
當 synchronized 作用于靜態方法時,表示的就是當前類的鎖,因為靜態方法是屬于類的,它不屬于任何一個實例成員,因此可以通過 class 對象控制并發訪問。
這里需要注意一點,因為 synchronized 修飾的實例方法是屬于實例對象,而 synchronized 修飾的靜態方法是屬于類對象,所以調用 synchronized 的實例方法并不會阻止訪問 synchronized 的靜態方法。
synchronized 除了修飾實例方法和靜態方法外,synchronized 還可用于修飾代碼塊,代碼塊可以嵌套在方法體的內部使用。
public void run() { synchronized(obj){ for(int j = 0;j < 1000;j++){ i++; } } }
上面代碼中將 obj 作為鎖對象對其加鎖,每次當線程進入 synchronized 修飾的代碼塊時就會要求當前線程持有obj 實例對象鎖,如果當前有其他線程正持有該對象鎖,那么新到的線程就必須等待。
synchronized 修飾的代碼塊,除了可以鎖定對象之外,也可以對當前實例對象鎖、class 對象鎖進行鎖定
// 實例對象鎖 synchronized(this){ for(int j = 0;j < 1000;j++){ i++; } } //class對象鎖 synchronized(TSynchronized.class){ for(int j = 0;j < 1000;j++){ i++; } }
在簡單介紹完 synchronized 之后,我們就來聊一下 synchronized 的底層原理了。
我們或許都有所了解(下文會細致分析),synchronized 的代碼塊是由一組 monitorenter/monitorexit 指令實現的。而Monitor 對象是實現同步的基本單元。
啥是 Monitor 對象呢?
任何對象都關聯了一個管程,管程就是控制對象并發訪問的一種機制。管程 是一種同步原語,在 Java 中指的就是 synchronized,可以理解為 synchronized 就是 Java 中對管程的實現。
管程提供了一種排他訪問機制,這種機制也就是 互斥。互斥保證了在每個時間點上,最多只有一個線程會執行同步方法。
所以你理解了 Monitor 對象其實就是使用管程控制同步訪問的一種對象。
在 hotspot 虛擬機中,對象在內存中的布局分為三塊區域:
對象頭(Header)
實例數據(Instance Data)
對齊填充(Padding)
這三塊區域的內存分布如下圖所示
我們來詳細介紹一下上面對象中的內容。
對象頭 Header 主要包含 MarkWord 和對象指針 Klass Pointer,如果是數組的話,還要包含數組的長度。
在 32 位的虛擬機中 MarkWord ,Klass Pointer 和數組長度分別占用 32 位,也就是 4 字節。
如果是 64 位虛擬機的話,MarkWord ,Klass Pointer 和數組長度分別占用 64 位,也就是 8 字節。
在 32 位虛擬機和 64 位虛擬機的 Mark Word 所占用的字節大小不一樣,32 位虛擬機的 Mark Word 和 Klass Pointer 分別占用 32 bits 的字節,而 64 位虛擬機的 Mark Word 和 Klass Pointer 占用了64 bits 的字節,下面我們以 32 位虛擬機為例,來看一下其 Mark Word 的字節具體是如何分配的。
用中文翻譯過來就是
無狀態也就是無鎖的時候,對象頭開辟 25 bit 的空間用來存儲對象的 hashcode ,4 bit 用于存放分代年齡,1 bit 用來存放是否偏向鎖的標識位,2 bit 用來存放鎖標識位為 01。
偏向鎖 中劃分更細,還是開辟 25 bit 的空間,其中 23 bit 用來存放線程ID,2bit 用來存放 epoch,4bit 存放分代年齡,1 bit 存放是否偏向鎖標識, 0 表示無鎖,1 表示偏向鎖,鎖的標識位還是 01。
輕量級鎖中直接開辟 30 bit 的空間存放指向棧中鎖記錄的指針,2bit 存放鎖的標志位,其標志位為 00。
重量級鎖中和輕量級鎖一樣,30 bit 的空間用來存放指向重量級鎖的指針,2 bit 存放鎖的標識位,為 11
GC標記開辟 30 bit 的內存空間卻沒有占用,2 bit 空間存放鎖標志位為 11。
其中無鎖和偏向鎖的鎖標志位都是 01,只是在前面的 1 bit 區分了這是無鎖狀態還是偏向鎖狀態。
關于為什么這么分配的內存,我們可以從 OpenJDK 中的markOop.hpp類中的枚舉窺出端倪
來解釋一下
age_bits 就是我們說的分代回收的標識,占用4字節
lock_bits 是鎖的標志位,占用2個字節
biased_lock_bits 是是否偏向鎖的標識,占用1個字節。
max_hash_bits 是針對無鎖計算的 hashcode 占用字節數量,如果是 32 位虛擬機,就是 32 - 4 - 2 -1 = 25 byte,如果是 64 位虛擬機,64 - 4 - 2 - 1 = 57 byte,但是會有 25 字節未使用,所以 64 位的 hashcode 占用 31 byte。
hash_bits 是針對 64 位虛擬機來說,如果最大字節數大于 31,則取 31,否則取真實的字節數
cms_bits 我覺得應該是不是 64 位虛擬機就占用 0 byte,是 64 位就占用 1byte
epoch_bits 就是 epoch 所占用的字節大小,2 字節。
在上面的虛擬機對象頭分配表中,我們可以看到有幾種鎖的狀態:無鎖(無狀態),偏向鎖,輕量級鎖,重量級鎖,其中輕量級鎖和偏向鎖是 JDK1.6 中對 synchronized 鎖進行優化后新增加的,其目的就是為了大大優化鎖的性能,所以在 JDK 1.6 中,使用 synchronized 的開銷也沒那么大了。其實從鎖有無鎖定來講,還是只有無鎖和重量級鎖,偏向鎖和輕量級鎖的出現就是增加了鎖的獲取性能而已,并沒有出現新的鎖。
所以我們的重點放在對 synchronized 重量級鎖的研究上,當 monitor 被某個線程持有后,它就會處于鎖定狀態。在 HotSpot 虛擬機中,monitor 的底層代碼是由 ObjectMonitor 實現的,其主要數據結構如下(位于 HotSpot 虛擬機源碼 ObjectMonitor.hpp 文件,C++ 實現的)
這段 C++ 中需要注意幾個屬性:_WaitSet 、 _EntryList 和 _Owner,每個等待獲取鎖的線程都會被封裝稱為 ObjectWaiter 對象。
_Owner 是指向了 ObjectMonitor 對象的線程,而 _WaitSet 和 _EntryList 就是用來保存每個線程的列表。
那么這兩個列表有什么區別呢?這個問題我和你聊一下鎖的獲取流程你就清楚了。
鎖的兩個列表
當多個線程同時訪問某段同步代碼時,首先會進入 _EntryList 集合,當線程獲取到對象的 monitor 之后,就會進入 _Owner 區域,并把 ObjectMonitor 對象的 _Owner 指向為當前線程,并使 _count + 1,如果調用了釋放鎖(比如 wait)的操作,就會釋放當前持有的 monitor ,owner = null, _count - 1,同時這個線程會進入到 _WaitSet 列表中等待被喚醒。如果當前線程執行完畢后也會釋放 monitor 鎖,只不過此時不會進入 _WaitSet 列表了,而是直接復位 _count 的值。
Klass Pointer 表示的是類型指針,也就是對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。
你可能不是很理解指針是個什么概念,你可以簡單理解為指針就是指向某個數據的地址。
實例數據部分是對象真正存儲的有效信息,也是代碼中定義的各個字段的字節大小,比如一個 byte 占 1 個字節,一個 int 占用 4 個字節。
對齊 Padding
對齊不是必須存在的,它只起到了占位符(%d, %c 等)的作用。這就是 JVM 的要求了,因為 HotSpot JVM 要求對象的起始地址必須是 8 字節的整數倍,也就是說對象的字節大小是 8 的整數倍,不夠的需要使用 Padding 補全。
先來個大體的流程圖來感受一下這個過程,然后下面我們再分開來說
無鎖狀態,無鎖即沒有對資源進行鎖定,所有的線程都可以對同一個資源進行訪問,但是只有一個線程能夠成功修改資源。
無鎖的特點就是在循環內進行修改操作,線程會不斷的嘗試修改共享資源,直到能夠成功修改資源并退出,在此過程中沒有出現沖突的發生,這很像我們在之前文章中介紹的 CAS 實現,CAS 的原理和應用就是無鎖的實現。無鎖無法全面代替有鎖,但無鎖在某些場合下的性能是非常高的。
HotSpot 的作者經過研究發現,大多數情況下,鎖不僅不存在多線程競爭,還存在鎖由同一線程多次獲得的情況,偏向鎖就是在這種情況下出現的,它的出現是為了解決只有在一個線程執行同步時提高性能。
可以從對象頭的分配中看到,偏向鎖要比無鎖多了線程ID 和 epoch,下面我們就來描述一下偏向鎖的獲取過程
偏向鎖獲取過程
首先線程訪問同步代碼塊,會通過檢查對象頭 Mark Word 的鎖標志位判斷目前鎖的狀態,如果是 01,說明就是無鎖或者偏向鎖,然后再根據是否偏向鎖 的標示判斷是無鎖還是偏向鎖,如果是無鎖情況下,執行下一步
線程使用 CAS 操作來嘗試對對象加鎖,如果使用 CAS 替換 ThreadID 成功,就說明是第一次上鎖,那么當前線程就會獲得對象的偏向鎖,此時會在對象頭的 Mark Word 中記錄當前線程 ID 和獲取鎖的時間 epoch 等信息,然后執行同步代碼塊。
全局安全點(Safe Point):全局安全點的理解會涉及到 C 語言底層的一些知識,這里簡單理解 SafePoint 是 Java 代碼中的一個線程可能暫停執行的位置。
等到下一次線程在進入和退出同步代碼塊時就不需要進行 CAS 操作進行加鎖和解鎖,只需要簡單判斷一下對象頭的 Mark Word 中是否存儲著指向當前線程的線程ID,判斷的標志當然是根據鎖的標志位來判斷的。如果用流程圖來表示的話就是下面這樣
關閉偏向鎖
偏向鎖在Java 6 和Java 7 里是默認啟用的。由于偏向鎖是為了在只有一個線程執行同步塊時提高性能,如果你確定應用程序里所有的鎖通常情況下處于競爭狀態,可以通過JVM參數關閉偏向鎖:-XX:-UseBiasedLocking=false,那么程序默認會進入輕量級鎖狀態。
關于 epoch
偏向鎖的對象頭中有一個被稱為 epoch 的值,它作為偏差有效性的時間戳。
輕量級鎖是指當前鎖是偏向鎖的時候,資源被另外的線程所訪問,那么偏向鎖就會升級為輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,不會阻塞,從而提高性能,下面是詳細的獲取過程。
輕量級鎖加鎖過程
鴻蒙官方戰略合作共建——HarmonyOS技術社區
緊接著上一步,如果 CAS 操作替換 ThreadID 沒有獲取成功,執行下一步
如果使用 CAS 操作替換 ThreadID 失敗(這時候就切換到另外一個線程的角度)說明該資源已被同步訪問過,這時候就會執行鎖的撤銷操作,撤銷偏向鎖,然后等原持有偏向鎖的線程到達全局安全點(SafePoint)時,會暫停原持有偏向鎖的線程,然后會檢查原持有偏向鎖的狀態,如果已經退出同步,就會喚醒持有偏向鎖的線程,執行下一步
檢查對象頭中的 Mark Word 記錄的是否是當前線程 ID,如果是,執行同步代碼,如果不是,執行偏向鎖獲取流程 的第2步。
如果用流程表示的話就是下面這樣(已經包含偏向鎖的獲取)
重量級鎖其實就是 synchronized 最終加鎖的過程,在 JDK 1.6 之前,就是由無鎖 -> 加鎖的這個過程。
重量級鎖的獲取流程
鴻蒙官方戰略合作共建——HarmonyOS技術社區
接著上面偏向鎖的獲取過程,由偏向鎖升級為輕量級鎖,執行下一步
會在原持有偏向鎖的線程的棧中分配鎖記錄,將對象頭中的 Mark Word 拷貝到原持有偏向鎖線程的記錄中,原持有偏向鎖的線程獲得輕量級鎖,然后喚醒原持有偏向鎖的線程,從安全點處繼續執行,執行完畢后,執行下一步,當前線程執行第 4 步
執行完畢后,開始輕量級解鎖操作,解鎖需要判斷兩個條件
判斷對象頭中的 Mark Word 中鎖記錄指針是否指向當前棧中記錄的指針
拷貝在當前線程鎖記錄的 Mark Word 信息是否與對象頭中的 Mark Word 一致。
如果上面兩個判斷條件都符合的話,就進行鎖釋放,如果其中一個條件不符合,就會釋放鎖,并喚起等待的線程,進行新一輪的鎖競爭。
鴻蒙官方戰略合作共建——HarmonyOS技術社區
在當前線程的棧中分配鎖記錄,拷貝對象頭中的 MarkWord 到當前線程的鎖記錄中,執行 CAS 加鎖操作,會把對象頭 Mark Word 中鎖記錄指針指向當前線程鎖記錄,如果成功,獲取輕量級鎖,執行同步代碼,然后執行第3步,如果不成功,執行下一步
當前線程沒有使用 CAS 成功獲取鎖,就會自旋一會兒,再次嘗試獲取,如果在多次自旋到達上限后還沒有獲取到鎖,那么輕量級鎖就會升級為 重量級鎖
如果用流程圖表示是這樣的
根據上面對于鎖升級細致的描述,我們可以總結一下不同鎖的適用范圍和場景。
為了便于方便研究,我們把 synchronized 修飾代碼塊的示例簡單化,如下代碼所示
public class SynchronizedTest { private int i; public void syncTask(){ synchronized (this){ i++; } } }
我們主要關注一下 synchronized 的字節碼,如下所示
從這段字節碼中我們可以知道,同步語句塊使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代碼塊的開始位置,monitorexit 指令指向同步代碼塊的結束位置。
那么為什么會有兩個 monitorexit 呢?
不知道你注意到下面的異常表了嗎?如果你不知道什么是異常表,那么我建議你讀一下這篇文章
看完這篇Exception 和 Error,和面試官扯皮就沒問題了
方法的同步是隱式的,也就是說 synchronized 修飾方法的底層無需使用字節碼來控制,真的是這樣嗎?我們來反編譯一波看看結果
public class SynchronizedTest { private int i; public synchronized void syncTask(){ i++; } }
這次我們使用 javap -verbose 來輸出詳細的結果
從字節碼上可以看出,synchronized 修飾的方法并沒有使用 monitorenter 和 monitorexit 指令,取得代之是ACC_SYNCHRONIZED 標識,該標識指明了此方法是一個同步方法,JVM 通過該 ACC_SYNCHRONIZED 訪問標志來辨別一個方法是否聲明為同步方法,從而執行相應的同步調用。這就是 synchronized 鎖在同步代碼塊上和同步方法上的實現差別。
到此,相信大家對“如何理解Synchronized ”有了更深的了解,不妨來實際操作一番吧!這里是億速云網站,更多相關內容可以進入相關頻道進行查詢,關注我們,繼續學習!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。