面試官最想要的synchronized,你值得擁有

synchronized簡介

synchronizedJava語言的一個關鍵字,它本身的意思爲同步,是用來保證線程安全的,可用來給對象和方法或者代碼塊加鎖,當它鎖定一個方法或者一個代碼塊的時候,同一時刻最多隻有一個線程執行這段代碼。

synchronized一句話來解釋其作用就是:能夠保證同一時刻最多隻有一個線程執行該段代碼,以達到併發安全的效果synchronized就猶如一把鎖,當一個線程獲取到該鎖,別的線程只能等待其執行完才能執行。

synchronized可以說是Java中元老級的關鍵字了,也是面試的高頻的問點,在jdk1.6之前它是一把重量級鎖,性能不被大家看好,在次之後對它做了很多優化,性能也大大提升。

那麼synchronized的實現的底層原理是什麼,jdk1.6之後又對它做了哪些優化呢?接下來我們一步一步的分析。

synchronized的特性

synchronized能夠保證在多線程的情況下線程安全,直接可以它的特性進行總結原因,synchronized有以下四個特性

  1. 原子性:保證被synchronized修飾的一個或者多個操作,在執行的過程中不會被任何的因素打斷,即所謂的原子操作,直到鎖被釋放。

  2. 可見性:保證持有鎖的當前線程在釋放鎖之前,對共享變量的修改會刷新到主存中,並對其它線程可見。

  3. 有序性:保證多線程時刻中只有一個線程執行,線程執行的順序都是有序的。

  4. 可重入性:保證在多線程中,有其他的線程試圖競爭持有鎖的臨界資源時,其它的線程會處於等待狀態,而當前持有鎖的線程可以重複的申請自己持有鎖的臨界資源。

上面的也是粗略的進行概括,接下來就一步一步的進行深入的分析synchronized的這四個特性的底層原理。

原子性

上面介紹了原子性就是一個或者多個操作,在執行的過程中不會被任何的因素打斷,這裏的任何因素打斷具體一點主要是指cpu的線程調度

在Java語言中對基本數據類型讀取和賦值纔是原子操作,這些操作在執行的過程不會被中斷。而像a++或者a+=1類似的操作,都並非是原子性操作。

因爲這些操作底層執行的流程分爲這三步:讀取值計算值賦值。纔算完成上面的操作,在多線程的時候就會存在線程安全的問題,產生髒數據,導致最後的結果並非預期的結果。

在面試的過程中也會有很多面試官常常拿volatilesynchronized做比較,在原子性方面區別就是volatile沒有辦法保證原子性,而synchronized可以實現原子性。

這裏簡單的只對volatile做一個簡介volatile的具體作用主要有兩個:保證可見性禁止指令重排,這裏畫了一個圖給大家,可以參考:

具體的volatile爲什麼沒辦法保證原子操作,我之前寫過一篇關於volatile詳細的文章,可以參考這一篇文章[]。

那麼synchronized的底層又是怎麼實現原子性的呢?這裏又要從synchronized的字節碼說起,在idea中寫了一段簡單的代碼如下所示:

public class TestSynchronized implements Runnable {

    @Override
    public void run() {
        synchronized (this) {
            System.out.println("同步代碼塊");
        }
    }

    public static void main(String[] args) {
        TestSynchronized sync = new TestSynchronized();
        Thread t = new Thread(sync);
        t.start();
    }
}

代碼很簡單,通過字節碼進行分析,執行的字節碼如下圖所示,在字節碼中可以看出在執行代碼塊中的代碼之前有一個monitorenter,後面的是離開monitorexit

不難猜測執行同步代碼塊中的代碼時,首先要獲取對象鎖,對應使用monitorenter指令 ,在執行完代碼塊之後,就要釋放鎖,所對應的指令就是monitorexit

在這裏又會有一個面試考點就是:什麼會出現兩次的monitorexit呢? 這是因爲一個線程對一個對象上鎖了,後續就一定要解鎖,第二個monitorexit是爲了保證在線程異常時,也能正常解鎖,避免造成死鎖

可見性

synchronized實現可見性就是在解鎖之前,必須將工作內存中的數據同步到主內存,其它線程操作該變量時每次都可以看到被修改後的值。

說到工作內存和主內存這個要從JMM說起,主存是放共享變量的地方,而工作內存線程私有的,存放的是主存的變量的副本,線程不會對主存的變量直接操作。這裏畫了一張圖給大家理解:

具體講解JMM的文章我之前寫過一篇詳細的文章,這裏只做上面的概述,詳細瞭解JMM的可以看這一篇[]。

有序性

synchronized在實現有序性時,多線程併發訪問只有一個線程執行,從而保證線程執行的順序都是有序的。

synchronized爲了實現有序性,通過阻塞其它線程的方式,來達到線程的有序執行,接下來看一個簡單的代碼:

public class TestSynchronized implements Runnable {
    Object o= new Object();
    public static void main(String[] args) throws InterruptedException {
        TestSynchronized sync = new TestSynchronized ();
        Thread t1 = new Thread(sync);
        Thread t2 = new Thread(sync);
        t1.start();
        t2.start();
    }
    @Override
    public void run() {
        synchronized (o) {
            try {
                System.out.println(Thread.currentThread().getName() + "線程開始執行");
                Thread.sleep(5000);
                System.out.println(Thread.currentThread().getName() + "線程等待5秒後執行完畢");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

這個毋庸置疑,當你加了synchronized代碼塊的時候,這兩個線程執行必須是有序的,同一個線程前後的輸出一定會在一起,執行的結果如圖所示:

假如註釋掉synchronized的代碼塊,兩個線程的執行就不再是有序的執行,就會出現如圖所示的情況:

可重入性

synchronized的可重入性就是當一個線程已經持有鎖對象的臨界資源,當該線程再次請求對象的臨界資源,可以請求成功,這種情況屬於重入鎖。

實現的底層原理就是synchronized底層維護一個計數器,當線程獲取該鎖時,計數器+1,再次獲取鎖時繼續+1,釋放鎖時,計數器-1,當計數器值爲0時,表明該鎖未被任何線程所持有,其它線程可以競爭獲取鎖。

synchronized基本用法

前面詳細的介紹了synchronized的基本特性,接下來詳細的介紹synchronized的基本用法,我們基本都知道大部分是時候只會用到同步方法上,但是它的用法有下面三種:

  1. 同步普通方法:在方法上添加synchronized關鍵字。

  2. 同步靜態方法:在方法上添加synchronized關鍵字,並且方法被static修飾。

  3. 同步代碼塊:執行的代碼操作被synchronized修飾。

  • 鎖定this實例或者實例對象

  • 鎖定類字節碼

在同步方法中這個相信大家都是知道,代碼如下圖所示:

private synchronized void syncMethod() {
       // 邏輯代碼
}

這裏有一個問題就是對於synchronized的鎖無非就是兩種,對於同步方法中的鎖對象又是什麼呢? ,這裏畫了一張圖給大家,如下如圖所示:

在同步普通方法中鎖對象就是this,也就是當前對象,哪個對象調用的同步方法,鎖對象就是就是它。

當然同步普通方法只能作用在單例上,若不是單例,同步方法就會失效,原因很簡單,多例中鎖對象不一樣,沒辦法生效

同步靜態方法中的鎖對象是當前類的class對象,這個相信大家都能想到。

在同步代碼塊中,可以有很多的玩法,因爲鎖對象是任意的,由程序員自己操作指定,主要這幾種方式獲得鎖對象:thisObjectthis.getClass()className.getClass()

具體用哪種就要看你的具體的業務場景了,這裏只是做了總結和歸納。

synchronized的優化

JVM的書籍中介紹到,synchronizedjdk6之前一直使用的是重量級鎖,在jdk6之後便對其進行了優化,新增了偏向鎖輕量級鎖(自旋鎖),並通過鎖消除鎖粗化自旋鎖自適應自旋等方法使用於各種場景,大大提升了synchronized的性能。

下面就來詳細的介紹synchronized被優化的過程以及原理,對synchronized優化的實現的具體的原理圖如下所示:

在synchronized優化的最重要的就是鎖升級的優化過程,也是大廠面試的必問的鎖知識點,接下來我們就詳細的瞭解這個過程。

鎖升級

在講解鎖升級的過程,先了解對象的在內存中的佈局情況,爲什麼呢?因爲鎖的信息是存儲在對象的markword中,只有瞭解了對象的佈局,對深入的瞭解鎖升級會更有幫助。

在我們創建一個對象後,大部分時候,對象都是分配在堆中,因爲還有可能對象在棧上分配,所以這裏用大部分情況。

對於一個對象創建完之後,在內存中的佈局情況,我之前也寫過一篇文章,詳細可以參考這一篇[],這裏做一個大概的回顧,一個對象在內存中的佈局圖如下所示。

對象在內存佈局中主要分爲以下三個部分:對象頭(markword、class pointer)、示例數據(instance data)、對齊(可有可無)

其中對象頭中,若是對象爲數組則還包含數據的長度,其中markword中主要包含信息有:GC年齡信息鎖對象信息hashCode信息

class pointer是類型指針,指向當前對象class文件,實例數據若是一個對象有屬性private int n=1,這是n=1即使存儲在示例數據中。

最後的填充可有可無,這個取決於對象的大小,所示對象大小能被8字節整除,則該部分沒有,不能被整除,就會填充對象大小到能夠被8字節整除

在對象的內存佈局中,最值得我們關注的就是markword,因爲markword是存儲鎖信息的,接下來的實驗中,就是要觀察markword包含的位裏面的大小的變化。

要在實際中觀察到對象的內存佈局情況,可以藉助JOL依賴庫,全程是JAVA Objct Layout,即是Java對象佈局,只需要在你的maven工程裏面引入如下maven座標:

<dependency>
	<groupId>org.openjdk.jol</groupId>
	<artifactId>jol-core</artifactId>
	<version>0.9</version>
</dependency>

然後創建一個SpringBoot項目,加入上面Maven依賴,接着創建Java類JaveObjectLayout,代碼如下:

public class JaveObjectLayout {
	
	public static void main(String[] args) {
		Object o = new Object();
        String s = ClassLayout.parseInstance(o).toPrintable();
        System.out.println(s);
	}

}

執行代碼後輸出的結果如下圖所示:

有人問這是啥?不慌,且聽我慢慢道來,這個就是Java在內存中的佈局數據,前八個字節表示的是markword,其中OFFSET表示起始位,SIZE表示偏移位。

比如第一行0 4,表示第0個字節開始算4個字節,然後第二行4 4表示第4個字節開始算4個字節,這樣就一共8個字節,表示完整的markword信息

其中後面的VAlUE數據表示的是對應的這4個字節上的具體位的數據,1字節=8位,這個也剛好對應。

在能看懂這個之前必須要了解各種鎖對應的位數上的是0還是1,才能夠知道上面輸出的表示是什麼信息,看一張各種鎖表示的信息圖:

其中無鎖狀態位001,偏向鎖爲101,輕量級鎖爲00,而重量級鎖爲10,最後11表示GC信息。這個怎麼對應呢?我們再來看上面的那種圖:

從代碼中可以看出,是沒有加鎖的,所有對應的最低三位爲001爲無鎖狀態,當代碼改成如下圖所示:

Object o = new Object();
		synchronized (o) {
			String s = ClassLayout.parseInstance(o).toPrintable();
			System.out.println(s);
		}

再次輸出,這時候便表示輕量級鎖,前四個字節的數據明顯變大,後面字節的數據都沒有變化,說明鎖信息是存儲在markword中的,所謂的加鎖,就是在對象的markword中儲存鎖信息(包括線程的ThreadID),並且對象的鎖狀態由0改爲了1,表示該對象已經被哪個線程所持有。

接下來我們來聊聊詳細的鎖升級的過程,當初始化完對象後,對象處於無鎖狀態,在只有一個線程第一次使用該對象,不存在鎖競爭時,我們便會認爲該線程偏向於它。

偏向鎖的實質就是將線程的ThreadID存儲於markword中,表明該線程偏向於它

若是某一時刻又來了線程二、線程三也想競爭這把鎖,此時是輕度的競爭,便升級爲輕量級鎖,於是這三個線程就開始競爭了,他們就會去判斷鎖是否由釋放,若是沒有釋放,沒有獲得鎖的線程就會自旋,這就是自旋鎖

在自旋的過程,也會嘗試的去獲取鎖,直到獲取鎖成功。在jdk1.6之後又出現了自適應自旋,就是jdk根據運行的情況和每個線程運行的情況決定要不要升級

自適應自旋是對自旋鎖優化方式的進一步優化,它的自旋的次數不再固定,其自旋的次數由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定,這就解決了自旋鎖帶來的缺點。

這個競爭的過程的實質就是看誰能把自己的ThreadID貼在對象的markword中,而這個過程就是CAS操作,原子操作。

倘若此時又來了線程四、線程5.....線程n,都想獲取該鎖,競爭越來越激烈了,此時就會升級爲重量級鎖

所謂的重量級鎖,爲什麼叫做重量級呢?因爲重量級鎖要通過操作系統,由用戶態切換到內核態的過程,這個切換的過程是非常消耗資源的,並且經過系統調用

那麼爲啥重量級鎖那麼消耗資源?還要它,要它有何用?是這樣的,假如沒有重量級鎖,不管有多少個線程都是自旋,那麼當線程是大了,等待的線程永遠在自旋。

自旋是要消耗cpu資源的,這樣cpu就撐不住了,反而性能會大大下降,在經過反覆的測試後,肯定是有一個臨界值,當超過這個臨界值時,反而使用重量級鎖性能更加高效

因爲重量級鎖不需要消耗cpu的資源,都把等待的線程放在了一個等待的隊列中,需要的時候在喚醒他們。

jdk1.6之前當線程的自選次數超過10次或者等待的自旋的線程數超過了CPU核數的二分之一,就會升級爲重量級鎖。

當然也有情況就是偏向鎖一開始就重度競爭,這是就直接升級爲重量級鎖,這個在互聯網項目中也是很常見的。

經過上面的詳細講解於是就出現了下面的鎖升級圖,在不同的條件就會升級爲不同的鎖:

鎖消除、鎖粗化

鎖消除是另一種鎖的優化措施,在編譯期間會對上下文進行掃描,去除掉不可能存在競爭的鎖,這樣就不必執行沒有必要的上鎖和解鎖操作消耗性能。

鎖粗化就是擴大所得範圍,避免反覆執行加鎖和釋放鎖,避免不必要的性能消耗。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章