面試題:再談Synchronized實(shí)現(xiàn)原理!
前言
線程安全是并發(fā)編程中的重要關(guān)注點(diǎn)。
造成線程安全問題的主要誘因有兩點(diǎn),一是存在共享數(shù)據(jù)(也稱臨界資源),二是存在多條線程共同操作共享數(shù)據(jù)。
為了解決這個(gè)問題,我們可能需要這樣一個(gè)方案,當(dāng)存在多個(gè)線程操作共享數(shù)據(jù)時(shí),需要保證同一時(shí)刻有且只有一個(gè)線程在操作共享數(shù)據(jù),其他線程必須等到該線程處理完數(shù)據(jù)后再進(jìn)行。
在 Java 中,關(guān)鍵字 Synchronized可以保證在同一個(gè)時(shí)刻,只有一個(gè)線程可以執(zhí)行某個(gè)方法或者某個(gè)代碼塊(主要是對方法或者代碼塊中存在共享數(shù)據(jù)的操作)。
下面來一起探索Synchronized的基本使用、實(shí)現(xiàn)機(jī)制。
文章首發(fā)在公眾號(月伴飛魚),之后同步到掘金和個(gè)人網(wǎng)站:xiaoflyfish.cn/
面試題來自:社招一年半面經(jīng)分享(含阿里美團(tuán)頭條京東滴滴)
喜歡的話,之后會分享更多系列文章!
覺得有收獲,希望幫忙點(diǎn)贊,轉(zhuǎn)發(fā)下哈,謝謝,謝謝
用法
兩類鎖:
對象鎖:包括方法鎖(默認(rèn)鎖對象為this當(dāng)前實(shí)例對象)和同步代碼塊鎖(自己指定鎖對象)。
類鎖:指Synchronized修飾靜態(tài)的方法或指定鎖為Class對象。
當(dāng)一個(gè)線程試圖訪問同步代碼塊時(shí),它首先必須得到鎖,而退出或拋出異常時(shí)必須釋放鎖。
給普通方法加鎖時(shí),上鎖的對象是 this;
給靜態(tài)方法加鎖時(shí),鎖的是 class 對象;
給代碼塊加鎖,可以指定一個(gè)具體的對象作為鎖。
代碼示例如下:
public class SynchronizedTest {
/**
* 修飾靜態(tài)方法, 等同于下面注釋的方法
*/
public synchronized static void test1() {
System.out.println("月伴飛魚");
}
// public static void test1() {
// synchronized (SynchronizedTest.class){
// System.out.println("月伴飛魚");
// }
// }
/**
* 修飾實(shí)例方法, 等同于下面注釋的方法
*/
public synchronized void test2(){
System.out.println("月伴飛魚");
}
// public void test2(){
// synchronized (this){
// System.out.println("月伴飛魚");
// }
// }
/**
* 修飾代碼塊
*/
public void test3(){
synchronized (this){
System.out.println("月伴飛魚");
}
}
}
多線程訪問同步方法的幾種情況:
兩個(gè)線程同時(shí)訪問一個(gè)對象的同步方法。
由于同步方法鎖使用的是this對象鎖,同一個(gè)對象的this鎖只有一把,兩個(gè)線程同一時(shí)間只能有一個(gè)線程持有該鎖,所以該方法將會串行運(yùn)行。
兩個(gè)線程訪問的是兩個(gè)對象的同步方法。
由于兩個(gè)對象的this鎖互不影響,Synchronized將不會起作用,所以該方法將會并行運(yùn)行。
兩個(gè)線程訪問的是Synchronized的靜態(tài)方法。
Synchronized修飾的靜態(tài)方法獲取的是當(dāng)前類模板對象的鎖,該鎖只有一把,無論訪問多少個(gè)該類對象的方法,都將串行執(zhí)行。
同時(shí)訪問同步方法與非同步方法
非同步方法不受影響。
訪問同一個(gè)對象的不同的普通同步方法。
由于this對象鎖只有一個(gè),不同線程訪問多個(gè)普通同步方法將串行運(yùn)行。
同時(shí)訪問靜態(tài)Synchronized和非靜態(tài)Synchronized方法
靜態(tài)Synchronized方法的鎖為class對象的鎖,非靜態(tài)Synchronized方法鎖為this的鎖,它們不是同一個(gè)鎖,所以它們將并行運(yùn)行。
使用優(yōu)化
大家在使用synchronized關(guān)鍵字的時(shí)候,可能經(jīng)常會這么寫:
synchronized (this) {
...
}
它的作用域是當(dāng)前對象,鎖的就是當(dāng)前對象,誰拿到這個(gè)鎖誰就可以運(yùn)行它所控制的代碼。
當(dāng)有一個(gè)明確的對象作為鎖時(shí),就可以這么寫,但是當(dāng)沒有一個(gè)明確的對象作為鎖,只想讓一段代碼同步時(shí),可以創(chuàng)建一個(gè)特殊的變量(對象)來充當(dāng)鎖:
public class Demo {
private final Object lock = new Object();
public void methonA() {
synchronized (lock) {
...
}
}
}
這樣寫沒問題。但是用new Object()作為鎖對象是否是一個(gè)最佳選擇呢?
我在StackOverFlow看到這么一篇文章:object-vs-byte0-as-lock
大意就是用new byte[0]作為鎖對象更好,會減少字節(jié)碼操作的次數(shù)。
public class Demo {
private final byte[] lock = new byte[0];
}
具體細(xì)節(jié)大家,可以看看這篇文章,算提供一種思路吧!
實(shí)現(xiàn)原理
因?yàn)镾ynchronized鎖的是對象,在講解原理之前先介紹下對象結(jié)構(gòu)相關(guān)知識。
HotSpot虛擬機(jī)中,對象在內(nèi)存中存儲的布局可以分為三塊區(qū)域:對象頭、實(shí)例數(shù)據(jù)和對齊填充。
對象頭
對象頭包括兩部分信息:運(yùn)行時(shí)數(shù)據(jù)Mark Word和類型指針
如果對象是數(shù)組對象,那么對象頭占用3個(gè)字寬(Word)(需要記錄數(shù)組長度),如果對象是非數(shù)組對象,那么對象頭占用2個(gè)字寬(1word = 2Byte = 16bit)
對象頭的類型指針指向該對象的類元數(shù)據(jù),虛擬機(jī)通過這個(gè)指針可以確定該對象是哪個(gè)類的實(shí)例
Mark Word
用于存儲對象自身的運(yùn)行時(shí)數(shù)據(jù), 如哈希碼(HashCode)、GC分代年齡、鎖狀態(tài)標(biāo)志、線程持有的鎖、偏向線程ID、偏向時(shí)間戳等等,它是實(shí)現(xiàn)輕量級鎖和偏向鎖的關(guān)鍵。
這部分?jǐn)?shù)據(jù)的長度在32位和64位的虛擬機(jī)(暫不考慮開啟壓縮指針的場景)中分別為32個(gè)和64個(gè)Bits。
Synchronized鎖對象就存儲在MarkWord中,下面是MarkWord的布局:
32位虛擬機(jī)

實(shí)例數(shù)據(jù)
實(shí)例數(shù)據(jù)就是在程序代碼中所定義的各種類型的字段,包括從父類繼承的
對齊填充
由于HotSpot的自動(dòng)內(nèi)存管理要求對象的起始地址必須是8字節(jié)的整數(shù)倍,即對象的大小必須是8字節(jié)的整數(shù)倍,對象頭的數(shù)據(jù)正好是8的整數(shù)倍,所以當(dāng)實(shí)例數(shù)據(jù)不夠8字節(jié)整數(shù)倍時(shí),需要通過對齊填充進(jìn)行補(bǔ)全
意思是每次分配的內(nèi)存大小一定是8的倍數(shù),如果對象頭+實(shí)例數(shù)據(jù)的值不是8的倍數(shù),那么會重新計(jì)算一個(gè)較大值,進(jìn)行分配
底層實(shí)現(xiàn)
下面的代碼,在命令行執(zhí)行 javac,然后再執(zhí)行javap -v -p,就可以看到它具體的字節(jié)碼。
可以看到,在字節(jié)碼的體現(xiàn)上,它只給方法加了一個(gè) flag:ACC_SYNCHRONIZED。
synchronized void syncMethod() {
System.out.println("syncMethod");
}
synchronized void syncMethod();
descriptor: ()V
flags: ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #4
3: ldc #5
5: invokevirtual #6
8: return
我們再來看下同步代碼塊的字節(jié)碼。可以看到,字節(jié)碼是通過 monitorenter 和monitorexit 兩個(gè)指令進(jìn)行控制的。
void syncBlock(){
synchronized (Test.class){
}
}
void syncBlock();
descriptor: ()V
flags:
Code:
stack=2, locals=3, args_size=1
0: ldc #2
2: dup
3: astore_1
4: monitorenter
5: aload_1
6: monitorexit // 兩個(gè)monitorexit是表示正常退出和異常退出的場景
7: goto 15
10: astore_2
11: aload_1
12: monitorexit
13: aload_2
14: athrow
15: return
Exception table:
from to target type
5 7 10 any
10 13 10 any
這兩者雖然顯示效果不同,但他們都是通過 monitor 來實(shí)現(xiàn)同步的。
其中在Java虛擬機(jī)(HotSpot)中,Monitor是由ObjectMonitor實(shí)現(xiàn)的,其主要數(shù)據(jù)結(jié)構(gòu)如下(位于HotSpot虛擬機(jī)源碼ObjectMonitor.hpp文件,C++實(shí)現(xiàn)的):
ObjectMonitor() {
_header = NULL;
_count = 0; // 用來記錄該對象被線程獲取鎖的次數(shù),這也說明了synchronized是可重入的
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL; // 指向持有ObjectMonitor對象的線程
_WaitSet = NULL; // 處于wait狀態(tài)的線程,會被加入到_WaitSet,調(diào)用了wait方法之后會進(jìn)入這里
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 處于等待鎖block狀態(tài)的線程,會被加入到該列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
每個(gè) Java 對象在 JVM 的對等對象的頭中保存鎖狀態(tài),指向 ObjectMonitor。
ObjectMonitor 保存了當(dāng)前持有鎖的線程引用,EntryList 中保存目前等待獲取鎖的線程,WaitSet 保存 wait 的線程。
還有一個(gè)計(jì)數(shù)器count,每當(dāng)線程獲得 monitor 鎖,計(jì)數(shù)器 +1,當(dāng)線程重入此鎖時(shí),計(jì)數(shù)器還會 +1。當(dāng)計(jì)數(shù)器不為 0 時(shí),其它嘗試獲取 monitor 鎖的線程將會被保存到EntryList中,并被阻塞。
當(dāng)持有鎖的線程釋放了monitor 鎖后,計(jì)數(shù)器 -1。當(dāng)計(jì)數(shù)器歸位為 0 時(shí),所有 EntryList 中的線程會嘗試去獲取鎖,但只會有一個(gè)線程會成功,沒有成功的線程仍舊保存在 EntryList 中。


詳細(xì)流程:
加鎖時(shí),即遇到Synchronized關(guān)鍵字時(shí),線程會先進(jìn)入monitor的 _EntryList隊(duì)列阻塞等待。如果monitor的 _owner為空,則從隊(duì)列中移出并賦值與_owner。如果在程序里調(diào)用了wait()方法,則該線程進(jìn)入 _WaitSet隊(duì)列。我們都知道wait方法會釋放monitor鎖,即將_owner賦值為null并進(jìn)入_WaitSet隊(duì)列阻塞等待。這時(shí)其他在_EntryList中的線程就可以獲取鎖了。當(dāng)程序里其他線程調(diào)用了notify/notifyAll方法時(shí),就會喚醒 _WaitSet中的某個(gè)線程,這個(gè)線程就會再次嘗試獲取monitor鎖。如果成功,則就會成為monitor的owner。當(dāng)程序里遇到Synchronized關(guān)鍵字的作用范圍結(jié)束時(shí),就會將monitor的owner設(shè)為null,退出。
Java對象如何與Monitor關(guān)聯(lián)

鎖優(yōu)化
相比于 JDK 1.5,在 JDK 1.6 中 HotSopt 虛擬機(jī)對 Synchronized 內(nèi)置鎖的性能進(jìn)行了很多優(yōu)化,包括自適應(yīng)的自旋、鎖消除、鎖粗化、偏向鎖、輕量級鎖等。
自適應(yīng)的自旋鎖
在 JDK 1.6 中引入了自適應(yīng)的自旋鎖來解決長時(shí)間自旋的問題。
比如,如果最近嘗試自旋獲取某一把鎖成功了,那么下一次可能還會繼續(xù)使用自旋,并且允許自旋更長的時(shí)間;但是如果最近自旋獲取某一把鎖失敗了,那么可能會省略掉自旋的過程,以便減少無用的自旋,提高效率。
鎖消除
經(jīng)過逃逸分析之后,如果發(fā)現(xiàn)某些對象不可能被其他線程訪問到,那么就可以把它們當(dāng)成棧上數(shù)據(jù),棧上數(shù)據(jù)由于只有本線程可以訪問,自然是線程安全的,也就無需加鎖,所以會把這樣的鎖給自動(dòng)去除掉。
鎖粗化
按理來說,同步塊的作用范圍應(yīng)該盡可能小,僅在共享數(shù)據(jù)的實(shí)際作用域中才進(jìn)行同步,這樣做的目的是為了使需要同步的操作數(shù)量盡可能縮小,縮短阻塞時(shí)間,如果存在鎖競爭,那么等待鎖的線程也能盡快拿到鎖。
但是加鎖解鎖也需要消耗資源,如果存在一系列的連續(xù)加鎖解鎖操作,可能會導(dǎo)致不必要的性能損耗。
鎖粗化就是將多個(gè)連續(xù)的加鎖、解鎖操作連接在一起,擴(kuò)展成一個(gè)范圍更大的鎖,避免頻繁的加鎖解鎖操作。
偏向鎖/輕量級鎖/重量級鎖
JVM 默認(rèn)會優(yōu)先使用偏向鎖,如果有必要的話才逐步升級,這大幅提高了鎖的性能。
鎖升級
鎖的狀態(tài)總共有四種,無鎖狀態(tài)、偏向鎖、輕量級鎖和重量級鎖
隨著鎖的競爭,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖,但是鎖的升級是單向的,也就是說只能從低到高升級,不會出現(xiàn)鎖的降級
從JDK 1.6中默認(rèn)是開啟偏向鎖,可以通過-XX:-UseBiasedLocking來禁用偏向鎖
偏向鎖
在只有一個(gè)線程使用了鎖的情況下,偏向鎖能夠保證更高的效率。
具體過程是這樣的:當(dāng)?shù)谝粋€(gè)線程第一次訪問同步塊時(shí),會先檢測對象頭 Mark Word 中的標(biāo)志位 Tag 是否為 01,以此判斷此時(shí)對象鎖是否處于無鎖狀態(tài)或者偏向鎖狀態(tài)。
線程一旦獲取了這把鎖,就會把自己的線程 ID 寫到 MarkWord 中,在其他線程來獲取這把鎖之前,鎖都處于偏向鎖狀態(tài)。
當(dāng)下一個(gè)線程參與到偏向鎖競爭時(shí),會先判斷 MarkWord 中保存的線程 ID 是否與這個(gè)線程 ID 相等,如果不相等,會立即撤銷偏向鎖,升級為輕量級鎖。
輕量級鎖
當(dāng)鎖處于輕量級鎖的狀態(tài)時(shí),就不能夠再通過簡單地對比標(biāo)志位 Tag 的值進(jìn)行判斷,每次對鎖的獲取,都需要通過自旋。
當(dāng)然,自旋也是面向不存在鎖競爭的場景,比如一個(gè)線程運(yùn)行完了,另外一個(gè)線程去獲取這把鎖;但如果自旋失敗達(dá)到一定的次數(shù),鎖就會膨脹為重量級鎖。
重量級鎖
重量級鎖,這種情況下,線程會掛起,進(jìn)入到操作系統(tǒng)內(nèi)核態(tài),等待操作系統(tǒng)的調(diào)度,然后再映射回用戶態(tài)。系統(tǒng)調(diào)用是昂貴的,所以重量級鎖的名稱由此而來。
如果系統(tǒng)的共享變量競爭非常激烈,鎖會迅速膨脹到重量級鎖,這些優(yōu)化就名存實(shí)亡。
如果并發(fā)非常嚴(yán)重,可以通過參數(shù)-XX:-UseBiasedLocking 禁用偏向鎖,理論上會有一些性能提升,但實(shí)際上并不確定。
| 鎖 | 優(yōu)點(diǎn) | 缺點(diǎn) | 適用場景 |
|---|---|---|---|
| 偏向鎖 | 加鎖和解鎖不需要額外的消耗,和執(zhí)行非同步方法比僅存在納秒級的差距 | 如果線程間存在鎖競爭,會帶來額外的鎖撤銷的消耗 | 基本沒有線程競爭鎖的場景 |
| 輕量級鎖 | 競爭的線程不會阻塞,提高了程序的響應(yīng)速度 | 如果始終得不到鎖競爭的線程使用自旋會消耗CPU | 適用于少量線程競爭鎖對象,且線程持有鎖時(shí)間不長,追求響應(yīng)時(shí)間的場景。 |
| 重量級鎖 | 線程競爭不使用自旋,不會消耗CPU | 線程阻塞,響應(yīng)時(shí)間緩慢 | 追求吞吐量。競爭比較激烈,鎖持有時(shí)間較長的場景 |
常見面試題
Synchronized和Lock的區(qū)別:
Synchronized屬于JVM層面,底層通過 monitorenter和 monitorexit 兩個(gè)指令實(shí)現(xiàn),Lock是API層面的東西,JUC提供的具體類Synchronized不需要用戶手動(dòng)釋放鎖,當(dāng)Synchronized代碼執(zhí)行完畢之后會自動(dòng)讓線程釋放持有的鎖,Lock需要一般使用try-finally模式去手動(dòng)釋放鎖 Synchronized是不可中斷的,除非拋出異常或者程序正常退出,Lock可中斷,使用 lockInterruptibly,調(diào)用interrupt方法可中斷Synchronized是非公平鎖,Lock默認(rèn)是非公平鎖,但是可以通過構(gòu)造函數(shù)傳入boolean類型值更改是否為公平鎖 鎖是否能綁定多個(gè)條件,Synchronized沒有condition的說法,要么喚醒所有線程,要么隨機(jī)喚醒一個(gè)線程,Lock可以使用condition實(shí)現(xiàn)分組喚醒需要喚醒的線程,實(shí)現(xiàn)精準(zhǔn)喚醒 Synchronized 鎖只能同時(shí)被一個(gè)線程擁有,但是 Lock 鎖沒有這個(gè)限制,例如在讀寫鎖中的讀鎖,是可以同時(shí)被多個(gè)線程持有的,可是 Synchronized做不到 性能區(qū)別:在 Java 5 以及之前Synchronized的性能比較低,但是到了 Java 6 以后 JDK 對 Synchronized 進(jìn)行了很多優(yōu)化,比如自適應(yīng)自旋、鎖消除、鎖粗化、輕量級鎖、偏向鎖等
最后
覺得有收獲,希望幫忙點(diǎn)贊,轉(zhuǎn)發(fā)下哈,謝謝,謝謝
微信搜索:月伴飛魚,交個(gè)朋友,進(jìn)面試交流群
公眾號后臺回復(fù)666,可以獲得免費(fèi)電子書籍

面試題:在日常工作中怎么做MySQL優(yōu)化的?

美團(tuán)面試題:緩存一致性,我是這么回答的!

