1.為什么要使用synchronized關鍵字
在并發場景下,如果多個執行緒并發修改同一個物件,那么就極有可能會出現執行緒安全問題,換句話說,判斷一段代碼是否會存在執行緒安全問題,主要判斷標準就是,有沒有執行緒共享的變數被并發修改,話不多說,看幾個例子,來加深對上面這句話的理解,
public class SynchronizedTest {
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
i++;
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
i++;
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}

第一個例子很簡單,有一個靜態的變數i,然后兩個執行緒t1和t2分別對i進行1000次++操作,主執行緒等t1和t2執行完成后輸出i的值,我們期望的輸出結果是2000,但是執行后發現,絕大多數情下輸出的結果都是比2000小,有極小的概率是2000,按照上面的標準來判斷,首先i是SynchronizedTest類的靜態變數,這樣無論是t1還是t2訪問的都是同一個變數i,其次,t1和t2并發地對變數i進行修改,因此這種場景是有可能會出現執行緒安全問題的,
至于更底層的原因,我們可以使用位元組碼來分析,一個i++陳述句,在被編譯器編譯完成后,會生成如下圖所示的四條虛擬機指令,其中,getstatic:獲取i的值,iconst_1:準備一個常量1,iadd:計算i+1的值,putstatic:將結果賦值給變數i,假設當前i的值為1000,然后t1去執行i++陳述句,這時候它獲取的值是1000,執行了前三條虛擬機指令,準備將1001賦值給i,這時候發生了執行緒切換,t2去執行i++陳述句,由于t1還沒來得及將i的值改為1001,所以t2獲取到的i值依然為1000,在這個基礎上,假設t2做了10次回圈將i的值改為了1010,再次發生執行緒切換,t1直接將自己上次計算的1001賦值給i,此時i的值又變為了1001,這樣就出現了問題,導致大部分場景下得到的結果都小于2000,

再來看第二個例子,t1執行緒和t2執行緒幾乎同時去呼叫method()方法,method()方法中,對i進行了1000次的++操作,最后輸出i的值,從運行結果來看,每次執行完method()方法,輸出的i都是1000,但這是不是就說明這段代碼是執行緒安全的呢?答案是肯定的,
JVM在程式運行時,會為每一個執行緒開辟一塊執行緒獨占的記憶體空間--虛擬機堆疊,執行緒在運行時,每呼叫一個方法,就會為該方法生成一個堆疊幀,并把這個堆疊幀入堆疊,執行完成后再出堆疊,可以這樣說,執行緒執行方法就是對應的堆疊幀入堆疊和出堆疊,這里的堆疊指的是虛擬機堆疊,每一個堆疊幀都包含了區域變數表、運算元堆疊、動態連接、方法回傳地址和一些額外資訊,這里我們只關注區域變數表,
結合第二個例子,在程式運行時,JVM會為執行緒t1和t2分別開辟一個虛擬機堆疊stack_t1和stack_t2,t1執行method()方法時會生成一個堆疊幀stack_frame_t1,并將其入堆疊至stack_t1,t2執行method()方法時會生成一個堆疊幀stack_frame_t2,將其入堆疊至stack_t2,stack_frame_t1和stack_frame_t2堆疊幀都包含各自的區域變數i,也就是說t1和t2操作的不是同一個變數,因此也就不存在執行緒安全問題了,
public class SynchronizedTest {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
method();
}, "t1");
Thread t2 = new Thread(() -> {
method();
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
}
private static void method() {
int i = 0;
for (int j = 0; j < 1000; j++) {
i++;
}
System.out.println(Thread.currentThread().getName() + " : " + i);
}
}
從上面的代碼和分析可知,在多執行緒場景下,執行緒安全是一個需要特別注意的問題,一段代碼是否會存在執行緒安全問題的判斷標準就是,有沒有執行緒共享的變數被并發修改,那么一段代碼存在執行緒安全問題該如何解決呢?一個重要的方法就是使用synchronized關鍵字(注:解決執行緒安全問題的方法有很多,本文重點介紹synchronize關鍵字),這也就回答了我們的標題,為什么要使用synchronize關鍵字,那就是解決執行緒安全問題,
2.synchronized關鍵字的用法
synchronized關鍵字既可以用來修飾一個方法,也可以用來修飾一個代碼塊,被synchronized修飾的方法或者代碼塊,同一時刻只能由一個執行緒執行,這樣就可以避免執行緒安全問題,具體的用法如下:
-
synchronized修飾代碼塊
synchronized (obj) {
// 代碼
}
- synchronized修飾方法:
/**
* synchronized修飾成員方法
*/
public void synchronized method() {
}
/**
* synchronized修飾靜態方法
*/
public static void synchronized method() {
}
需要注意的是,synchronized修飾方法時雖然沒有明確關聯物件,但其實成員方法關聯的是呼叫這個方法的物件,靜態方法關聯的是這個靜態方法所在類的class物件,
對于上面說到的第一個例子,可以使用synchronized修飾對i進行操作的代碼塊,就可以解決執行緒安全問題,修改后的輸出結果都是2000,
public class SynchronizedTest {
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (SynchronizedTest.class) {
for (int j = 0; j < 1000; j++) {
i++;
}
}
}, "t1");
Thread t2 = new Thread(() -> {
synchronized (SynchronizedTest.class) {
for (int j = 0; j < 1000; j++) {
i++;
}
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
3.synchronized關鍵字的原理
synchronized的用法并不難,但是我們就有點好奇,為什么加了這個關鍵字就能保證執行緒安全?它的底層原理是啥?接下來就是這篇文章的核心,我們來分析下synchronized關鍵字的底層原理,
3.1 重量級鎖
3.1.1 markword
在分析synchronized的原理之前,先來看看Java中物件的結構,java中每一個物件在運行時都會擁有一個物件頭,用于存盤物件的一些附加資訊,其中,Mark Word主要用來存盤物件的運行時資料;Klass用于存盤物件的型別指標,通過該指標可以獲取到該物件所屬的類資訊,這里我們重點關注Mark Word部分,它的組成如下圖,64位虛擬機的Mark Word長度為64bits,

- 當一個物件處于正常狀態時,Mark Word的前25位為0,接下來的31位為物件的hashcode,緊接著一位是0,然后四位是物件的年齡(這也是為什么物件的年齡最多為15的原因),后三位是001
- 當一個物件加了偏向鎖時,Mark Word的前54位為給這個物件加鎖的執行緒id,接下來的2位為偏向時間戳,緊接著一位是0,然后四位是物件的年齡,后三位是101
- 當一個物件加了輕量級鎖時,Mark Word的前62位為輕量級鎖記錄,后兩位是00
- 當一個物件加了重量級鎖時,Mark Word的前62位為重量級鎖記錄,后兩位是10
- 當一個物件被垃圾回收器標記為可回收時,Mark Word的前62位為0,后兩位是11
這里我們可以jol第三方工具來查看以下物件頭,為了方便查看,這里對ClassLayout的輸出結果做了一下轉換,
import org.openjdk.jol.info.ClassLayout;
public class ClassLayoutPlus {
public static String parseInstance(Object obj) {
String printable = ClassLayout.parseInstance(obj).toPrintable();
String[] split = printable.split("\n");
String[] prefix = split[3].substring(76, 111).split(" ");
String[] suffix = split[2].substring(76, 111).split(" ");
String result = "";
for (int i = 3; i >= 0; i--) {
result += prefix[i] + " ";
}
for (int i = 3; i >= 0; i--) {
result += suffix[i] + " ";
}
return result;
}
}
public class SynchronizedTest {
private static final Object LOCK = new Object();
public static void main(String[] args) {
System.out.println(ClassLayoutPlus.parseInstance(LOCK));
}
}
輸出的結果為:

看這結果有點不對勁呀,為什么物件的hashcode為0呢?看完下面的例子可能就明白了,hashcode需要呼叫hashcode()方法進行計算,默認是0,所以在沒有呼叫hashcode的時候,物件頭中的hashcode是0,這里只展示了正常情況下物件頭的內容,關于其他情況,下面會繼續介紹,
public class SynchronizedTest {
private static final Object LOCK = new Object();
public static void main(String[] args) {
int hashCode = LOCK.hashCode();
System.out.println("hashcode:" + Integer.toBinaryString(hashCode));
System.out.println(ClassLayoutPlus.parseInstance(LOCK));
}
}

3.1.2 Monitor原理
等等,不是研究synchronized關鍵字嘛,Monitor又是個什么東西?莫急,我們首先來看一個例子,代碼層面就不多說了,直接使用javap -v SynchronizedTest.class反編譯這段代碼,從位元組碼中可以看到,在執行System.out.println("do something..");這行代碼之前執行了monitorenter指令,之后執行了monitorexit指令,這兩個指令與synchronized關鍵字息息相關,
public class SynchronizedTest {
private static final Object LOCK = new Object();
public static void main(String[] args) {
synchronized (LOCK) {
System.out.println("do something..");
}
}
}

Monitor是一個物件,可以翻譯為監視器或者管程,是作業系統提供的,可以用來進行執行緒調度,它的結構如下:

執行緒Thread0使用 synchronized 給物件加了重量級鎖,就會為該Java物件關聯一個Monitor物件,對應的底層實作是呼叫monitorenter指令,那么這個指令都干了什么事呢?
- 在Thread0中生成一條鎖記錄,這條記錄保存了鎖物件的參考和MarkWord值
- 為鎖物件關聯一個Monitor物件,將鎖物件頭的中Mark Word的前62位設定為Monitor物件的參考地址,后兩位設定為10
- 將Monitor物件的Owner設定為Thread0(需要注意的是,Monitor同一時間只能有一個Owner)
為了看到這個效果,可以看下面這個例子,在執行緒t1獲取到LOCK鎖之后,列印一下LOCK物件的MarkWord,從運行結果來看,和我們的預期是一致的,
public class SynchronizedTest {
private static final Object LOCK = new Object();
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (LOCK) {
for (int j = 0; j < 1000; j++) {
i++;
}
}
}, "t1");
Thread t2 = new Thread(() -> {
synchronized (LOCK) {
for (int j = 0; j < 1000; j++) {
i++;
}
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}

當Thread0執行完synchronized修飾的方法或代碼塊后,會釋放鎖,對應的虛擬機指令就是上面提到的monitorexit,該指令會將物件的MarkWord還原,將Monitor物件的Owner置為null,并且會喚醒EntryList中的執行緒,
上述程序描述的是執行緒對物件加鎖成功的程序,如果Thread0已經拿到鎖了(Thread0已經是鎖物件關聯的Monitor物件的Owner),此時Thread3、Thread4、Thread5來給物件加鎖,就會進入到Monitor的EntryList中,并且的執行緒狀態會被設定為BLOCKED,直至Thread0釋放了鎖,Thread3、Thread4、Thread5才會重新去競爭,還有一種情況,一個執行緒Thread1,已經拿到鎖了,但是這個執行緒呼叫了wait方法,這是Thread1會釋放鎖,并且加入到Monitor物件的WaitSet中,執行緒狀態被設定為waiting,直至有執行緒喚醒它或wait時間耗盡,它才會再次嘗試去獲得鎖,從這里就可以看出,wait方法必須在同步代碼塊中執行(否則都不知道要把這呼叫了wait方法的執行緒加入到哪個Monitor的WaitSet中),并且呼叫wait后,會釋放鎖,
3.2 輕量級鎖
上面介紹了重量級鎖的原理,主要是為鎖物件關聯一個Monitor物件,從名字也可以看出,重量級鎖,肯定是一個比較耗費資源和時間的鎖,因此Java對它進行了優化,輕量級鎖便應運而生,輕量級鎖對于開發人員來說是無感知的,這里的優化主要指的是在運行時由JVM底層去優化,它的使用方式和重量鎖一模一樣,那么什么時候會進行優化?優化都做了什么?
如果一個物件雖然有多執行緒要加鎖,但加鎖的時間是錯開的(也就是沒有競爭),那么可以使用輕量級鎖來優化,還是上面的例子,只不過這一次t1執行緒和t2執行緒是串行執行,并且在t1和t2執行前后,列印了鎖物件的MarkWord,
public class SynchronizedTest {
private static final Object LOCK = new Object();
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (LOCK) {
System.out.println("t1加鎖之后的MarkWord: " + ClassLayoutPlus.parseInstance(LOCK));
for (int j = 0; j < 1000; j++) {
i++;
}
}
}, "t1");
Thread t2 = new Thread(() -> {
synchronized (LOCK) {
System.out.println("t2加鎖之后的MarkWord: " + ClassLayoutPlus.parseInstance(LOCK));
for (int j = 0; j < 1000; j++) {
i++;
}
}
}, "t2");
System.out.println("t1加鎖之前的MarkWord: " + ClassLayoutPlus.parseInstance(LOCK));
t1.start();
t1.join();
System.out.println("t1釋放鎖之后的MarkWord:" + ClassLayoutPlus.parseInstance(LOCK));
t2.start();
t2.join();
System.out.println("t2釋放鎖之后的MarkWord:" + ClassLayoutPlus.parseInstance(LOCK));
}
}
來分析下上面代碼的執行結果,t1加鎖之前,LOCK物件處于正常狀態,所以Mark Word的后三位是001,t1加了鎖之后,由于沒有鎖競爭,此時synchronized優化為輕量級鎖,Mark Word的前62位是鎖記錄地址,后兩位是00,t1釋放了鎖之后,物件又變成正常狀態,所以Mark Word的后三位是001,t2執行緒加鎖之后,此時沒有鎖競爭,synchronized優化為輕量級鎖,Mark Word的前62位是鎖記錄地址,可以明顯地看到和t1的鎖記錄地址不同,后兩位是00,t2釋放了鎖之后,物件又變成正常狀態,所以Mark Word的后三位是001,
那么輕量級鎖底層實作和重量級鎖有什么不一樣呢?重量級鎖之所以重,就是因為重量級鎖關聯了Monitor物件,Monitor物件是作業系統級別的,并且會涉及到執行緒切換,輕量級鎖對此做了優化,由于不存在鎖競爭,輕量級鎖并不會去關聯Monitor物件,它的執行程序如下:
- 創建鎖記錄(Lock Record)物件,該物件可以存盤鎖定物件的Mark Word和參考

- 讓鎖記錄中的物件參考指向鎖物件,采用cas(compare and sweep)的方式,用鎖記錄地址替換鎖物件的Mark Word的前62位,表示由該執行緒給鎖物件加鎖把,Mark Word的最后兩位設定為00,表示加的是輕量級鎖,最后將原先的Mark Word的值存入鎖記錄中,

- 如果第二步cas失敗,此時分為三種情況:
1. 鎖物件的后兩位為10,說明該物件已經被加了重量級鎖,直接進入重量級鎖的加鎖流程;
2. 鎖物件的后兩位為00,說明已經有執行緒給該物件加了輕量級鎖,如果是別的執行緒加的,那么會發生鎖膨脹,輕量級鎖會升級為重量級鎖,這個下一小節再介紹;
3. 鎖物件的后兩位為00,說明已經有執行緒給該物件加了輕量級鎖,如果是自己加的,即發生了鎖重入,此時會再生成一條鎖記錄,不過這一條鎖記錄不再保存鎖物件的原始Mark Word,如下圖所示

- 當退出synchronized所修飾的方法或代碼塊時(解鎖時),使用CAS將Java鎖物件的Mark Word物件還原

- 如果解鎖失敗,說明輕量級鎖進行了鎖膨脹或已經升級為重量級鎖,進入重量級鎖解鎖流程
3.3 鎖膨脹
JVM對synchronized關鍵字做了優化,在沒有執行緒競爭的場景下,synchronized底層加的是輕量級鎖,如果一個執行緒去給物件加鎖,發現這個物件已經被加了輕量級鎖,說明此時發生了執行緒競爭,輕量級鎖已經不能再保證執行緒安全了,JVM會將輕量級鎖升級為重量級鎖,這個程序稱之為鎖膨脹,
- 當Thread1準備給鎖物件加輕量級鎖時,發現鎖物件的Mark Word后兩位是00,說明鎖物件已經被別的執行緒加了輕量級鎖,此時加鎖失敗

- Thread1加鎖失敗,進入鎖膨脹流程
1. 為鎖物件關聯一個Monitor物件,讓鎖物件的前62位指向Monitor物件的地址,后兩位變為10(重量級鎖的標志)
2. 將Monitor的owner設定為Thread0
3. Thread1進入Monitor的EntryList中

- Thread0執行完同步代碼后,會釋放鎖,當時發現此時鎖物件的Mark Word后兩位是10,會進入重量級鎖的解鎖程序,將Monitor的Owner置為null,并喚醒EntryList中的執行緒,
關于鎖膨脹,可以用一段代碼驗證下,在t1和t2執行之前,LOCK物件處于正常狀態,所以Mark Word的后三位是001,t1開始執行,對LOCK加了輕量級鎖,此時輸出的Mark Word后兩位00,t1執行緒加鎖之后睡眠2s(Sleep不會釋放鎖),在睡眠的程序中,t2執行緒準備給LOCK物件加輕量級鎖,但是發現LOCK物件已經被加了輕量級鎖,所以發生鎖膨脹,將鎖升級為重量級鎖,因此,t2獲得鎖之后,輸出的Mark Word后兩位為10,
?
public class SynchronizedTest {
private static final Object LOCK = new Object();
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (LOCK) {
System.out.println("t1加鎖之后的MarkWord: " + ClassLayoutPlus.parseInstance(LOCK));
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int j = 0; j < 1000; j++) {
i++;
}
}
}, "t1");
Thread t2 = new Thread(() -> {
synchronized (LOCK) {
System.out.println("t2加鎖之后的MarkWord: " + ClassLayoutPlus.parseInstance(LOCK));
for (int j = 0; j < 1000; j++) {
i++;
}
}
}, "t2");
System.out.println("t1加鎖之前的MarkWord: " + ClassLayoutPlus.parseInstance(LOCK));
t1.start();
TimeUnit.SECONDS.sleep(1);
t2.start();
t1.join();
t2.join();
}
}
?

3.4 自旋優化
以Thread0和Thread1為例,假設Thread0已經為鎖物件加了重量級鎖,此時Thread1再給鎖物件加鎖,就會進入到鎖物件關聯的Monitor物件的EntryList中,并且會處于BLOCKED狀態,一直等到Thread0釋放鎖,Thread1才會被喚醒,再次去競爭鎖,拿到鎖后才能執行,由于中間涉及到執行緒切換,會比較消耗性能,所以JVM底層做了優化:當Thread1發現鎖物件已經被Thread0加了重量級鎖后,并不會立即進入到EntryList中,而是回圈一定次數,在每次回圈中都去檢查Thread0是否已經釋放了鎖,如果在這期間Thread0釋放了鎖,那么Thread1就可以獲取到鎖去運行,而不需要變為BLOCKED狀態;如果回圈到達一定次數后,Thread0依然沒有釋放鎖,那么Thread1只好進入到EntryList中,這就是自旋優化,
3.5 偏向鎖
如果一個鎖自始至終都只有一個執行緒使用,那么這種場景下輕量級鎖還可以做進一步優化,輕量級鎖每次重入仍然需要執行 CAS 操作,用鎖記錄去替換Mark Word,Java 6 中引入了偏向鎖對此做了進一步優化:只有第一次使用 CAS 將執行緒 ID 設定到物件的 Mark Word 頭,之后重入時不使用 CAS,只判斷Mark Word中的執行緒id是不是自己的,如果是自己的說明沒有競爭,可以繼續使用,
偏向鎖是默認是延遲的,不會在程式啟動時立即生效,如果想避免延遲,可以加 VM 引數 -XX:BiasedLockingStartupDelay=0 來禁用延遲,
public class SynchronizedTest02 {
private static final Object LOCK = new Object();
public static void main(String[] args) {
System.out.println(ClassLayoutPlus.parseInstance(LOCK));
}
}
沒加 -XX:BiasedLockingStartupDelay=0引數,沒加禁止延遲引數,LOCK物件是正常狀態,Mark Word最后三位是001

加了 -XX:BiasedLockingStartupDelay=0引數,沒加禁止延遲引數,LOCK物件是可偏向狀態,Mark Word最后三位是101

下面的這段代碼,運行時加了 -XX:BiasedLockingStartupDelay=0引數,LOCK物件剛開始就處于可偏向狀態,所以main執行緒在給LOCK物件加鎖時,優先加了偏向鎖,加完偏向鎖之后,LOCK物件的Mark Word前54位存盤main執行緒id,表示這個鎖物件只屬于main執行緒,這也是偏向鎖的由來,后三位為101表示偏向鎖,
public class SynchronizedTest02 {
private static final Object LOCK = new Object();
public static void main(String[] args) {
synchronized (LOCK) {
System.out.println(ClassLayoutPlus.parseInstance(LOCK));
}
}
}
當有其他執行緒也要使用偏向鎖物件時(未發生執行緒競爭,兩個執行緒依次執行),會將偏向鎖膨脹為輕量級鎖,當然,如果兩個執行緒發生競爭,那么會直接膨脹為重量級鎖,這里不再演示,
public class SynchronizedTest02 {
private static final Object LOCK = new Object();
public static void main(String[] args) throws InterruptedException {
synchronized (LOCK) {
System.out.println(ClassLayoutPlus.parseInstance(LOCK));
}
TimeUnit.SECONDS.sleep(1);
new Thread(() -> {
synchronized (LOCK) {
System.out.println(ClassLayoutPlus.parseInstance(LOCK));
}
}).start();
}
}
3.6 批量重偏向
如果物件雖然被多個執行緒訪問,但沒有競爭,這時偏向了執行緒 t1的物件仍有機會重新偏向 t2,當撤銷偏向鎖閾值超過20(默認為20次,可以通過-XX:BiasedLockingBulkRebiasThreshold=20 設定閾值)次后,jvm 會這樣覺得,我是不是偏向錯了呢,于是會在給這些物件加鎖時重新偏向至加鎖執行緒,而不是升級為輕量級鎖,
3.6 批量撤銷偏向
當撤銷偏向鎖閾值超過 40 次后,jvm 會這樣覺得,自己確實偏向錯了,根本就不該偏向,于是整個類的所有物件都會變為不可偏向的,新建的物件也是不可偏向的,
4.總結
- 執行緒安全是多執行緒編程中要特別關注的問題,判斷一段代碼是否會存在執行緒安全問題,主要判斷標準就是,有沒有執行緒共享的變數被并發修改
- Java中可以使用synchronized關鍵字保證執行緒同步執行,進而解決執行緒安全問題
- Java中物件頭有一個Mark Word欄位,該欄位在物件不同的加鎖狀態下取值不同
- Java中的重量級鎖,實作原理是為被加鎖物件關聯一個Monitor物件,通過該Monitor物件間接實作執行緒同步
- JVM對重量級鎖做了優化,在沒有執行緒競爭的場景下,JVM默認先使用輕量級鎖
- 如果一個鎖物件自始至終只有一個執行緒對他加鎖,JVM做了進一步優化,即偏向鎖
- 輕量級鎖在使用程序中如果出現了執行緒競爭,會發生鎖膨脹,升級為重量級鎖
- 加鎖程序可以用如下流程圖表示

轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/271255.html
標籤:其他
上一篇:Shiro





