面試官:你先說下你對synchronized的了解。
我:synchronized可以保證方法或者代碼在運行時,同一時刻只有一個方法可以進入到臨界區,同時還可以保證共享變量的內存可見性。
我:Java中每個對象都可以作為鎖,這是synchronized實現同步的基礎:
面試官:當線程訪問同步代碼塊時,它首先要得到鎖才能執行代碼,退出或者拋異常要釋放鎖,這是怎麼實現的呢?
我:同步代碼塊是使用monitorenter和monitorexit指令實現的,同步方法依靠的是方法修飾符上的ACCSYNCHRONIZED實現的。
1、同步代碼塊:monitorenter指令插入到同步代碼快的開始位置,monitorexit指令插入到同步代碼塊的結束位置,jVM保證每一個monitorexist都有一個monitorenter與之相對應。任何對應都有一個monitor與之相關聯,當且一個monitor被持有之後,他將處於鎖定狀態。線程執行到monitorenter指令時,將會嘗試獲取對象對應的monitor所有權,即嘗試獲取對象的鎖。
2、同步方法:synchronized方法是在Class文件的方法表中將該方法的accessflags欄位中的synchronized標誌位置為1,表示該方法是同步方法並使用調用該方法的對象或該方法所屬的Class在JVM的內部對象表示Klass作為鎖對象。面試官:你剛提到了每個對象都有一個monitor與之對應,那什麼是Monitor呢?我:我們可以把它理解為一個同步工具,也可以描述為一種同步機制,它通常被描述為一個對象。與一切皆對象一樣,所有的java對象是天生的Monitor,每一個java對象都有成為Monitor的潛質,因為在Java的設計中,每一個java對象自打娘胎出來就帶了一把看不見的鎖,它被叫做內部鎖或者Monitor鎖。
我:(接著說)Monitor是線程私有的數據結構,每一個線程都有一個可用monitor record列表,同時還有一個全局的可用列表。每一個被鎖住的對象都會和一個monitor關聯(對象頭的MarkWord中的LockWord指向monitor的起始地址),同時monitor中由一個Owner欄位存放擁有該鎖的線程的唯一標識,表示該鎖被這個線程佔用。面試官:很好。我們知道synchronized是悲觀鎖,一直以來被當做重量級鎖。但是jdk1.6對鎖進行了優化,比如**自旋鎖、適應性自旋鎖、鎖消除、偏向鎖以及輕量級鎖**等技術來減少鎖操作的開銷,這些你都了解嗎?我:知道一些。鎖主要存在四種狀態:無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態。他們會隨著競爭的激烈而逐漸升級。注意鎖可以升級不可降級,
這種策略是為了提高獲得鎖和釋放鎖的效率。
面試官:那你先來說下自旋鎖
我:線程的阻塞和喚醒需要CPU從用戶態轉為核心態,頻繁的阻塞和喚醒對CPU來說是一個負擔很重的工作,同時影響系統的並發能力,同時我們發現很多應用上對象鎖的鎖狀態只會持續很短的一段時間,為了這一段很短的時間頻繁的阻塞和喚醒線程是不值得的,所以引入自旋鎖。何謂自旋鎖呢-就是讓線程等待一段時間,不會被立即掛起,看持有鎖的線程是否會很快釋放鎖。那麼問題來了,等多長時間呢?時間短了等不到持有鎖的線程釋放鎖,時間長了佔用了處理器的時間,典型的「佔著茅坑不拉屎」,反而帶來性能上的浪費。所以,自旋等待的時間(自旋)的次數必須有一個限度,如果自旋超過了定義的時間仍沒有獲得鎖則要被掛起。
面試官:我記得有個適應性自旋鎖,更加智能。你能說下麼?
我:所謂自適應就意味著自旋的次數不再是固定的,它是由上一次在同一個鎖上的自旋時間以及鎖的擁有者的狀態來決定。線程如果自旋成功了,那麼下次自旋的次數會更加多,因為虛擬機認為既然上次成功了,那麼此次自旋也可能成功。反之,如果對於某個鎖,很少有自旋能成功的,那麼以後等待這個鎖的時候自選的次數會減少甚至不自旋。有了自適應自旋鎖,虛擬機對程序鎖的狀況預測越來越準確,虛擬機會越來越聰明。面試官:給你看下面一段代碼,你說下會存在加鎖的操作嗎?
public static void main(String [] args) {
Vector<String> vector = new Vector<>();
for (int i=0; i<10; i++) {
vector.add(i+"");
}
System.out.println(vector);
我:不會。這種情況下,JVM檢測到不可能存在共享數據競爭,這時JVM會對這些同步鎖進行鎖消除。鎖消除的基礎是逃逸分析的數據支持。
面試官:再看一段代碼,分析一下是在什麼地方加鎖的?
public static void test() {
List<String> list = new ArrayList<>();
for (int i=0; i<10; i++) {
synchronized (Demo.class) {
list.add(i + "");
}
}
System.out.println(list);
}
面試官:你能說下輕量級鎖嗎?
我:輕量級鎖提升程序同步性能的依據是:對於絕大部分的鎖,在整個同步周期內是不存在競爭的(區別於偏向鎖),這是一個經驗數據。如果沒有競爭,輕量級鎖使用CAS操作避免了使用互斥量的開銷,但如果存在競爭,除了互斥量的開銷,還額外發生了CAS操作,因此在有競爭的情況下,輕量級鎖比傳統的重量級鎖更慢。1、在代碼進入同步塊的時候,如果同步對象鎖狀態為無鎖狀態(鎖標誌位為「01」,是否為偏向鎖為「0」),虛擬機首先將在當前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於存儲對象目前的Mark Word的拷貝,官方稱之為Displaced Mark Word,這時候線程堆棧與對象頭的狀態如圖:
2、拷貝對象頭中的Mark Word複製到鎖記錄(Lock Record)中。 3、拷貝成功後,虛擬機將使用CAS操作嘗試將鎖對象的Mark Word更新為指向Lock Record的指針,並將線程棧幀中的Lock Record裡的owner指針指向Object的Mark Word。如果這個更新動作成功了,那麼這個線程就擁有了該對象的鎖,並且對象Mark Word的鎖標誌位設置為「00」,表示此對象處於輕量級鎖定狀態。4、如果這個更新操作失敗了,虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是就說明當前線程已經擁有了這個對象的鎖,那就可以直接進入同步塊繼續執行。否則說明多個線程競爭鎖,輕量級鎖就要膨脹為重量級鎖,鎖標誌位的狀態值變為「10」,Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,後面等待鎖的線程也要進入阻塞狀態。我:偏向鎖的目的是消除數據在無競爭情況下的同步原語,進一步提高程序的運行性能。偏向鎖會偏向於第一個獲得它的線程,如果在接下來的執行過程中,該鎖沒有被其他線程獲取,那持有偏向鎖的線程將永遠不需要同步。我頓了下,接著說:當鎖第一次被線程獲取的時候,線程使用CAS操作把這個線程的ID記錄在對象Mark Word中,同時置偏向標誌位1.以後該線程在進入和退出代碼塊時不需要進行CAS操作來加鎖和解鎖,只需要簡單測試一下對象頭的Mark Word裡是否存儲著指向當前線程的ID。如果測試成功,表示線程已經獲得了鎖。當有另外一個線程去嘗試獲取這個鎖時,偏向模式就宣告結束。
根據鎖對象目前是否處於被鎖定的狀態,撤銷偏向後恢復到未鎖定或輕量級鎖定狀態。面試官:那偏向鎖、輕量級鎖和重量級鎖有什麼區別呢?
我:偏向鎖、輕量級鎖都是樂觀鎖,重量級鎖是悲觀鎖。一個對象剛開始實例化的時候,沒有任何線程來訪問它時,它是可偏向的,意味著它認為只可能有一個線程來訪問它,所以當第一個線程訪問它的時候,它會偏向這個線程,此時,對象持有偏向鎖。偏向第一個線程,這個線程在修改對象頭成為偏向鎖的時候使用CAS操作,並將對象頭中的ThreadID改成自己的Id,之後再訪問這個對象只需要對比ID。一旦有第二個線程訪問這個對象,因為偏向鎖不會釋放,所以第二個線程看到對象是偏向狀態,表明在這個對象上存在競爭了,檢查原來持有該對象的線程是否依然存活,如果掛了,則可以將對象變為無鎖狀態,然後重新偏向新的線程。如果原來的線程依然存活,則馬上執行那個線程的操作棧,檢查該對象的使用情況,如果仍然需要持有偏向鎖,則偏向鎖升級為輕量級鎖(偏向鎖就是此時升級為輕量級鎖)。如果不存在使用了,則可以將對象恢復成無鎖狀態,然後重新偏向。
我:(接著說)輕量級鎖認為競爭存在,但是競爭的程度很輕,一般兩個線程對於同一個鎖的操作都會錯開,或者說自旋一下,另一個線程就會釋放鎖。但是當自旋超過一定次數,或者一個線程持有鎖,一個線程在自旋,又有第三個來訪,輕量級鎖膨脹為重量級鎖,重量級鎖使除了擁有鎖的線程以外的線程都阻塞,防止CPU空轉。簡單的說就是:有競爭,偏向鎖升級為輕量級鎖,競爭逐漸激烈,輕量級鎖升級為重量級鎖。
面試官:你了解java的內存模型嗎?能說下對JMM的理解嗎?
我:在JSR113標準中有有一段對JMM的簡單介紹:Java虛擬機支持多線程執行。在Java中Thread類代表線程,創建一個線程的唯一方法就是創建一個Thread類的實例對象,當調用了對象的start方法後,相應的線程將會執行。線程的行為有時會與我們的直覺相左,特別是在線程沒有正確同步的情況下。本規範描述了JMM平臺上多線程程序的語義,具體包含一個線程對共享變量的寫入何時能被其他線程看到。這是官方的接單介紹。
我:Java內存模型是內存模型在JVM中的體現。這個模型的主要目標是定義程序中各個共享變量的訪問規則,也就是在虛擬機中將變量存儲到內存以及從內存中取出變量這類的底層細節。通過這些規則來規範對內存的讀寫操作,保證了並發場景下的可見性、原子性和有序性。JMM規定了多有的變量都存儲在主內存中,每條線程都有自己的工作內存,線程的工作內存保存了該線程中用到的主內存副本拷貝,線程對變量的所有操作都必須在工作內存中進行,而不是直接讀寫主內存。不同線程之間也無法直接訪問對方工作內存中的變量,線程間變量的傳遞均需要自己的工作內存和主存之間
進行數據同步。而JMM就作用於工作內存和主存之間數據同步過程。他規定了如何做數據同步以及什麼時候做數據同步。也就是說Java線程之間的通信由Java內存模型控制,JMM決定一個線程對共享變量的寫入何時對另一個線程可見。我:簡單的說:Java的多線程之間是通過共享內存進行通信的,而由於採用共享內存進行通信,在通信過程中會存在一系列如原子性、可見性和有序性的問題。JMM就是為了解決這些問題出現的,這個模型建立了一些規範,可以保證在多核CPU多線程編程的環境下,對共享變量的讀寫的原子性、可見性和有序性。
面試官:那你說下Java內存模型的happens-before規則?我:在JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須存在happens-before關係。happens-before原則是JMM中非常重要的原則,它是判斷數據是否存在競爭、線程是否安全的主要依據,保證了多線程環境下的可見性。下面我說下happens-before的內容:
happens-before的原則定義如下:
1、如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。 2、兩個操作之間存在happens-before關係,並不一定意味著一定要按照happens-before原則制定的順序來執行。如果重排序之後的執行結果與按照happens-before關係來執行的結果一致,那麼這種重排序並不非法。下面是happens-before的原則規則:
1、程序次序規則:一個線程內,按照代碼書寫順序,書寫在前面的操作先行發生於書寫在後面的操作。
2、鎖定規則:一個unLock操作先行發生於後面對同一個鎖的lock操作。
3、volatile變量規則:對一個變量的寫操作先行發生於後面對這個變量的讀操作。
4、傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C。
5、線程啟動規則:Thread對象的start()方法先行發生於此線程的每個動作。
6、線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生。
7、線程終結規則:線程中所有的操作都先行發生於線程的終止檢測。
8、對象終結規則:一個對象的初始化完成先行發生於它的finalize()方法的開始。
面試官:你剛才提到了JVM會對我們的程序進行重排序,那是隨便重排序嗎?
我:不是的,它需要滿足以下兩個條件:
2、存在數據依賴關係的不允許重排序。
其實這兩點可以歸結為一點:無法通過happens-before原則推導出來的,JMM允許任意的排序。
我:這裡有必要提到as-if-serial語義:所有的操作都可以為了優化而被重排序,但是你必須保證重排序後執行的結果不能被改變,編譯器、runtime和處理器都必須遵守as-if-serial語義。注意as-if-serial只保證單線程環境,多線程環境下無效。舉個慄子:int a=1; //A
int b=2; //B
int c=a+b; //C
A,B,C三個操作存在如下關係:A和B不存在數據依賴,A和C,B和C存在數據依賴,因此在重排序的時候:A和B可以隨意排序,但是必須位於C的前面,但無論何種順序,最終結果C都是3.
我接著說:下面舉個重排序對多線程影響的慄子:
public class RecordExample2 {
int a = 0;
boolean flag = false;
/**
* A線程執行
*/
public void writer(){
a = 1; // 1
flag = true; // 2
}
/**
* B線程執行
*/
public void read(){
if(flag){ // 3
int i = a + a; // 4
}
}}
按照這種執行順序線程B肯定讀不到線程A設置的a值,在這裡多線程的語義就已經被重排序破壞了。操作3和操作4之間也可以重排序,這裡就不闡述了。但是他們之間存在一個控制依賴的關係,因為只有操作3成立操作4才會執行。當代碼中存在控制依賴性時,會影響指令序列的執行的並行度,所以編譯器和處理器會採用猜測執行來克服控制依賴對並行度的影響。假如操作3和操作4重排序了,操作4先執行,則先會把計算結果臨時保存到重排序緩衝中,當操作3為真時才會將計算結果寫入變量i中。
面試官:你能給我講下對volatile的理解嗎?
我:講volatile之前,先補充說明下Java內存模型中的三個概念:原子性、可見性和有序性
1、可見性:可見性是指線程之間的可見性,一個線程修改的狀態對另一個線程是可見的。也就是一個線程的修改的結果,另一個線程能夠馬上看到。比如:用volatile修飾的變量,就會具有可見性,volatile修飾的變量不允許線程內部緩存和重排序,即直接修改內存,所以對其他線程是可見的。但這裡要注意一個問題,volatile只能讓被他修飾的內容具有可見性,不能保證它具有原子性。比如volatile int a=0; ++a;這個變量a具有可見性,但是a++是一個非原子操作,也就是這個操作同樣存在線程安全問題。在Java中,volatile/synchronized/final實現了可見性。2、原子性:即一個操作或者多個操作要麼全部執行並且執行的過程不會被任何因素打斷,要麼都不執行。原子就像資料庫裡的事務一樣,他們是一個團隊,同生共死。看下面一個簡單的慄子:
i=0; //1
j=i; //2
i++; //3
i=j+1; //4
我:volatile的原理是volatile可以保證線程可見性且提供了一定的有序性,但是無法保證原子性,在JVM底層volatile是採用「內存屏障」來實現的。總結起來就是:
volatile的內存語義是:
1、當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量值立即刷新到主內存中。 2、當讀一個volatile變量時,JMM會把線程的本地內存置為無效,直接從主內存中讀取共享變量。所以volatile的寫內存語義是直接刷新到主內存中,讀內存語義是直接從主內存中讀取---所以才能實現線程可見性。那麼volatile的內存語義是如何實現的呢?對於一般的變量會被重排序,而對於volatile則不能,這樣會影響其內存語義,所以為了實現volatile的內存語義JMM會限制重排序。 1、如果第一個操作為volatile讀,則不管第二個操作是啥,都不能重排序。這個操作確保volatile讀之後的操作不會被編譯器重排序到volatile讀之前。 2、當第二個操作為volatile寫,則不管第一個操作是啥,都不能重排序。這個操作確保了volatile寫之前的操作不會被編譯器重排序到volatile寫之後。 3、當第一個操作為volatile寫,第二個操作為volatile讀,不能重排序。volatile的底層實現是通過插入內存屏障,但是對於編譯器來說,發現一個最優布置來最小化插入內存屏障的總數幾乎是不可能的,所以JMM採用了保守策略。如下:
1、在每一個volatile寫操作前插入一個StoreStore屏障。 2、在每一個volatile寫操作後插入一個StoreLoad屏障。 3、在每一個volatile讀操作後插入一個LoadLoad屏障。 4、在每一個volatile讀操作後插入一個LoadStore屏障。 總結:StoreStore屏障->寫操作->StoreLoad屏障->讀操作->LoadLoad屏障->LoadStore屏障。下面通過一個例子簡單分析下:
面試官:很好,看來你對volatile理解的挺深入的了。我們換個話題,你知道**CAS**嗎,能跟我講講嗎?
我:CAS(Compare And Swap),比較並交換。整個AQS同步組件,Atomic原子類操作等等都是基於CAS實現的,甚至ConcurrentHashMap在JDK1.8版本中,也調整為CAS+synchronized。可以說,CAS是整個JUC的基石。如下圖:
我:CAS的實現方式其實不難。在CAS中有三個參數:內存值V、舊的預期值A、要更新的值B,若且唯若內存值V的值等於舊的預期值A時,才會將內存值V的值修改為B,否則什麼也不幹,是一種樂觀鎖。其偽代碼如下:
if (this.value == A) {
this.value = B
return true;
} else {
return false;
}
private static final Unsafe unsafe =Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
}catch (Exception ex) { throw new Error(ex); }
}
private volatile int value;
3、value:當前值,使用volatile修飾,保證多線程環境下看見的是同一個。
// AtomicInteger.java
public final int addAndGet(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta) + delta;
}
// Unsafe.java
public final int getAndAddInt(Object var1,long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
}while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
在方法compareAndSwapInt(var1, var2, var5, var5 + var4)中,有四個參數,分別代表:對象,對象的地址,預期值,修改值。
我:CAS可以保證一次的讀-改-寫操作是原子操作,在單處理器上該操作容易實現,但是在多處理器上實現就有點複雜。CPU提供了兩種方法來實現多處理器的原子操作:總線加鎖或者緩存加鎖。
1、總線加鎖:總線加鎖就是使用處理器提供的一個LOCK#信號,當一個處理器在總線上輸出此信號時,其他處理器的請求將被阻塞住,那麼該處理器可以獨佔使用共享內存。但是這種處理方式顯然有點霸道。
2、緩存加鎖:其實針對上面的情況,我們只需要保證在同一時刻,對某個內存地址的操作是原子性的即可。緩存加鎖,就是緩存在內存區域的數據如果在加鎖期間,當它執行鎖操作寫回內存時,處理器不再輸出#LOCK信號,而是修改內部的內存地址,利用緩存一致性協議來保證原子性。緩存一致性機制可以保證同一個內存區域的數據僅能被一個處理器修改,也就是說當CPU1修改緩存行中的i時使用緩存鎖定,那麼CPU2就不能同時緩存了i的緩存行。面試官:那CAS有什麼缺陷嗎?
我:CAS雖然高效的解決了原子問題,但是還是存在一些缺陷的,主要體現在三個方面: 1、循環時間太長:如果自旋CAS長時間不成功,則會給CPU帶來非常大的開銷,在JUC中,有些地方就會限制CAS自旋的次數。 2、只能保證一個共享變量原子操作:看了CAS的實現就知道這只能針對一個共享變量,如果是多個共享變量就只能使用鎖了。或者把多個變量整成一個變量也可以用CAS。 3、ABA問題:CAS需要檢查操作值有沒有發生改變,如果沒有發生改變則更新,但是存在這樣一種情況:如果一個值原來是A,變成了B,然後又變成了A,那麼在CAS檢查的時候會發現沒有改變,但是實質上它已經發生了改變,這就是所謂的ABA問題。對於ABA問題的解決方案是加上版本號,即在每個變量都加上一個版本號,每次改變時加1,即A->B->A,變成1A->2B->3A。例如原子類中AtomicInteger會發生ABA問題,使用AtomicStampedReference可以解決ABA問題。有段時間沒更《今天面試了嗎》系列了。在面試裡,多線程,並發這塊問的還是非常頻繁的,不過JUC這塊的內容實在太多,一篇文章很難理清楚。今天是第一章節,未完待續...
本文作者:堅持就是勝利,歡迎點擊閱讀原文訪問作者主頁,或者移步下方連結:
https://juejin.im/user/5bee7feee51d4536c03fc698