1、前言
1.1、多線程程式設計的問題
多線程程式設計中,有可能會出現多個線程同時通路同一個共享、可變資源的情況,這個資源我們稱之其為臨界資源;這種資源可能是:對象、變量、檔案等。
共享:資源可以由多個線程同時通路
可變:資源可以在其生命周期内被修改
引出的問題:
由于線程執行的過程是不可控的,是以需要采用同步機制來協同對對象可變狀态的通路!
1.2、怎麼解決線程安全問題
實際上,所有的工具在解決線程并發安全問題時,采用的方案都是序列化通路臨界資源。就是同一時刻,隻能有一個線程通路臨界資源,也稱作同步互斥通路。
Java 中,提供了兩種方式來實作同步互斥通路:synchronized 和 Lock(下篇講解)
不過有一點需要注意的是:當多個線程執行一個方法時,該方法内部的局部變量并不是臨界資源,因為這些局部變量是在每個線程的私有棧中,是以不具有共享性,不會導緻線程安全問題。
2、synchronized原理詳解
synchronized内置鎖是一種對象鎖(鎖的是對象而非引用),作用粒度是對象,可以用來實作對臨界資源的同步互斥通路,是可重入的。
加鎖的方式:
- 同步執行個體方法,鎖是目前執行個體對象
- 同步類方法,鎖是目前類對象
- 同步代碼塊,鎖是括号裡面的對象
2.1、synchronized底層原理
synchronized是基于JVM内置鎖實作,通過内部對象Monitor(螢幕鎖)實作,基于進入與退出Monitor對象實作方法與代碼塊同步,螢幕鎖的實作依賴底層作業系統的Mutex lock(互斥鎖)實作,它是一個重量級鎖性能較低。
當然,JVM内置鎖在1.5之後版本做了重大的優化,如鎖粗化(Lock Coarsening)、鎖消除(Lock Elimination)、輕量級鎖(Lightweight Locking)、偏向鎖(Biased Locking)、适應性自旋(Adaptive Spinning)等技術來減少鎖操作的開銷,,内置鎖的并發性能已經基本與Lock持平。
synchronized關鍵字被編譯成位元組碼後會被翻譯成monitorenter 和 monitorexit 兩條指令,分别在同步塊邏輯代碼的起始位置與結束位置。
每個同步對象都有一個自己的Monitor(螢幕鎖),加鎖過程如下圖所示:
2.2、monitor螢幕鎖
任何一個對象都有一個Monitor與之關聯,當且一個Monitor被持有後,它将處于鎖定狀态。Synchronized在JVM裡的實作都是 基于進入和退出Monitor對象來實作方法同步和代碼塊同步,雖然具體實作細節不一樣,但是都可以通過成對的MonitorEnter和MonitorExit指令來實作。
monitorenter:每個對象都是一個螢幕鎖(monitor)。當monitor被占用時就會處于鎖定狀态,線程執行monitorenter指令時嘗試擷取monitor的所有權,過程如下:
- 如果monitor的進入數為0,則該線程進入monitor,然後将進入數設定為1,該線程即為monitor的所有者;
- 如果線程已經占有該monitor,隻是重新進入,則進入monitor的進入數加1;
- 如果其他線程已經占用了monitor,則該線程進入阻塞狀态,直到monitor的進入數為0,再重新嘗試擷取monitor的所有權;
monitorexit:執行monitorexit的線程必須是object所對應的monitor的所有者。指令執行時,monitor的進入數減1,如果減1後進入數為0,那線程退出monitor,不再是這個monitor的所有者。其他被這個monitor阻塞的線程可以嘗試去擷取這個 monitor 的所有權
通過上面的解釋,我們應該就能清楚的看出Synchronized的實作原理,Synchronized的語義底層是通過一個monitor的對象來完成,其實wait/notify等方法也依賴于monitor對象,這就是為什麼隻有在同步的塊或者方法中才能調用wait/notify等方法,否則會抛出java.lang.IllegalMonitorStateException的異常的原因。
2.3、什麼是monitor
可以把它了解為 一個同步工具,也可以描述為 一種同步機制,它通常被 描述為一個對象。與一切皆對象一樣,所有的Java對象是天生的Monitor,每一個Java對象都有成為Monitor的潛質,因為在Java的設計中 ,每一個Java對象從建立了就帶了一把看不見的鎖,它叫做内部鎖或者Monitor鎖。也就是通常說Synchronized的對象鎖,MarkWord鎖辨別位為10,其中指針指向的是Monitor對象的起始位址。在Java虛拟機(HotSpot)中,Monitor是由ObjectMonitor實作的,
其主要資料結構如下(位于HotSpot虛拟機源碼ObjectMonitor.hpp檔案,C++實作的):
ObjectMonitor() {
_header = NULL;
_count = 0; // 記錄個數
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //處于wait狀态的線程,會被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //處于等待鎖block狀态的線程,會被加入到該清單
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor中有兩個隊列,_WaitSet 和 _EntryList,用來儲存ObjectWaiter對象清單( 每個等待鎖的線程都會被封裝成ObjectWaiter對象 ),_owner指向持有ObjectMonitor對象的線程
當多個線程同時通路一段同步代碼時:
- 首先會進入 _EntryList 集合,當線程擷取到對象的monitor後,進入 _Owner區域并把monitor中的owner變量設定為目前線程,同時monitor中的計數器count加1;
- 若線程調用 wait() 方法,将釋放目前持有的monitor,owner變量恢複為null,count自減1,同時該線程進入 WaitSet集合中等待被喚醒;
- 若目前線程執行完畢,也将釋放monitor(鎖)并複位count的值,以便其他線程進入擷取monitor(鎖);
同時,Monitor對象存在于每個Java對象的對象頭Mark Word中(存儲的指針的指向),Synchronized鎖便是通過這種方式擷取鎖的,也是為什麼Java中任意對象可以作為鎖的原因,同時notify/notifyAll/wait等方法會使用到Monitor鎖對象,是以必須在同步代碼塊中使用。
螢幕Monitor有兩種同步方式:互斥與協作。多線程環境下線程之間如果需要共享資料,需要解決互斥通路資料的問題,螢幕可以確定螢幕上的資料在同一時刻隻會有一個線程在通路。
我們知道synchronized加鎖加在對象上,對象是如何記錄鎖狀态的呢? 答案是鎖狀态是被記錄在每個對象的對象頭(Mark Word)中,下面我們一起認識一下對象的記憶體布局
2.3、對象的記憶體布局
HotSpot虛拟機中,對象在記憶體中存儲的布局可以分為三塊區域:對象頭(Header)、執行個體資料(Instance Data)和對齊填充(Padding)。
- 對象頭:比如 hash碼,對象所屬的年代,對象鎖,鎖狀态标志,偏向鎖(線程)ID,偏向時間,數組長度(數組對象)等。Java對象頭一般占有2個機器碼(在32位虛拟機中,1個機器碼等于4位元組,也就是32bit,在64位虛拟機中,1個機器碼是8個位元組,也就是64bit),但是 如果對象是數組類型,則需要3個機器碼,因為JVM虛拟機可以通過Java對象的中繼資料資訊确定Java對象的大小,但是無法從數組的中繼資料來确認數組的大小,是以用一塊來記錄數組長度。
- 執行個體資料:存放類的屬性資料資訊,包括父類的屬性資訊;
- 對齊填充:由于虛拟機要求 對象起始位址必須是8位元組的整數倍。填充資料不是必須存在的,僅僅是為了位元組對齊;
對象頭
HotSpot虛拟機的對象頭包括兩部分資訊,第一部分是“Mark Word”,用于存儲對象自身的運作時資料, 如哈希碼(HashCode)、GC分代年齡、鎖狀态标志、線程持有的鎖、偏向線程ID、偏向時間戳等等,它是實作輕量級鎖和偏向鎖的關鍵。
但是如果對象是數組類型,則需要三個機器碼,因為JVM虛拟機可以通過Java對象的中繼資料資訊确定Java對象的大小,但是無法從數組的中繼資料來确認數組的大小,是以用一塊來記錄數組長度。
對象頭資訊是與對象自身定義的資料無關的額外存儲成本,但是考慮到虛拟機的空間效率,Mark Word被設計成一個非固定的資料結構以便在極小的空間記憶體存儲盡量多的資料,它會根據對象的狀态複用自己的存儲空間,也就是說,Mark Word會随着程式的運作發生變化。
32位虛拟機:
64位虛拟機:
現在我們虛拟機基本是64位的,而64位的對象頭有點浪費空間,JVM預設會開啟指針壓縮,是以基本上也是按32位的形式記錄對象頭的。
哪些資訊會被壓縮?
- 對象的全局靜态變量(即類屬性)
- 對象頭資訊:64位平台下,原生對象頭大小為16位元組,壓縮後為12位元組
- 對象的引用類型:64位平台下,引用類型本身大小為8位元組,壓縮後為4位元組
- 對象數組類型:64位平台下,數組類型本身大小為24位元組,壓縮後16位元組
對象頭分析工具
運作時對象頭鎖狀态分析工具JOL,他是OpenJDK開源工具包,引入下方maven依賴
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
列印markword
System.out.println(ClassLayout.parseInstance(object).toPrintable());
輸出
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
2.3、鎖的膨脹更新過程
鎖的狀态總共有四種,無鎖狀态、偏向鎖、輕量級鎖和重量級鎖。随着鎖的競争,鎖可以從偏向鎖更新到輕量級鎖,再更新的重量級鎖,但是鎖的更新是單向的,也就是說隻能從低到高更新,不會出現鎖的降級。從JDK 1.6 中預設是開啟偏向鎖和輕量級鎖的,可以通過-XX:-UseBiasedLocking來禁用偏向鎖。
偏向鎖
偏向鎖是Java 6之後加入的新鎖,它是一種針對加鎖操作的優化手段,經過研究發現,在大多數情況下,鎖不僅不存在多線程競争,而且總是由同一線程多次獲得,是以為了減少同一線程擷取鎖(會涉及到一些CAS操作,耗時)的代價而引入偏向鎖。偏向鎖的核心思想是,如果一個線程獲得了鎖,那麼鎖就進入偏向模式,此時Mark Word 的結構也變為偏向鎖結構,當這個線程再次請求鎖時,無需再做任何同步操作,即擷取鎖的過程,這樣就省去了大量有關鎖申請的操作,進而也就提供程式的性能。
是以,對于沒有鎖競争的場合,偏向鎖有很好的優化效果,畢竟極有可能連續多次是同一個線程申請相同的鎖。但是對于鎖競争比較激烈的場合,偏向鎖就失效了,因為這樣場合極有可能每次申請鎖的線程都是不相同的,是以這種場合下不應該使用偏向鎖,否則會得不償失,需要注意的是,偏向鎖失敗後,并不會立即膨脹為重量級鎖,而是先更新為輕量級鎖。下面我們接着了解輕量級鎖。
預設開啟偏向鎖
開啟偏向鎖:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
關閉偏向鎖:-XX:-UseBiasedLocking
輕量級鎖
倘若偏向鎖失敗,虛拟機并不會立即更新為重量級鎖,它還會嘗試使用一種稱為輕量級鎖的優化手段(1.6之後加入的),此時Mark Word 的結構也變為輕量級鎖的結構。輕量級鎖能夠提升程式性能的依據是“對絕大部分的鎖,在整個同步周期内都不存在競争”,注意這是經驗資料。需要了解的是,輕量級鎖所适應的場景是線程交替執行同步塊的場合,如果存在同一時間同一線程通路同一鎖的場合,就會導緻輕量級鎖膨脹為重量級鎖。
自旋鎖
輕量級鎖失敗後,虛拟機為了避免線程真實地在作業系統層面挂起,還會進行一項稱為自旋鎖的優化手段。這是基于在大多數情況下,線程持有鎖的時間都不會太長,如果直接挂起作業系統層面的線程可能會得不償失,畢竟作業系統實作線程之間的切換時需要從使用者态轉換到核心态,這個狀态之間的轉換需要相對比較長的時間,時間成本相對較高,是以自旋鎖會假設在不久将來,目前的線程可以獲得鎖,是以虛拟機會讓目前想要擷取鎖的線程做幾個空循環(這也是稱為自旋的原因),一般不會太久,可能是50個循環或100循環,在經過若幹次循環後,如果得到鎖,就順利進入臨界區。如果還不能獲得鎖,那就會将線程在作業系統層面挂起,這就是自旋鎖的優化方式,這種方式确實也是可以提升效率的。最後沒辦法也就隻能更新為重量級鎖了
鎖消除
消除鎖是虛拟機另外一種鎖的優化,這種優化更徹底,Java虛拟機在JIT編譯時(可以簡單了解為當某段代碼即将第一次被執行時進行編譯,又稱即時編譯),通過對運作上下文的掃描,去除不可能存在共享資源競争的鎖,通過這種方式消除沒有必要的鎖,可以節省毫無意義的請求鎖時間,如下StringBuffer的append是一個同步方法,但是在add方法中的StringBuffer屬于一個局部變量,并且不會被其他線程所使用,是以StringBuffer不可能存在共享資源競争的情景,JVM會自動将其鎖消除。
最後分享一道筆者遇到的面試題:
我們都知道jdk1.6之後,java對鎖進行了優化,例如偏向鎖,自旋鎖,鎖消除等,具體優化的點是什麼?
答案就是:
- 偏向鎖:在無鎖競争的情況下,隻在Mark Word裡存儲目前線程指針,CAS操作都不作
- 輕量級鎖:在沒有多線程競争時,相對重量級鎖來說,減少作業系統互斥量帶來的性能開銷。如果存在鎖競争,出了互斥量本身開銷,還額外有CAS操作的開銷。
- 自旋鎖:減少不必要的上下文切換,在輕量級鎖更新為重量級鎖的過程中,使用了自旋加鎖
- 鎖粗化:将多個連續的加鎖,解鎖操作擠在一起,擴充成一個範圍更大的鎖,減少性能開銷
- 鎖消除:虛拟機編譯器運作時,對一些代碼上要求同步,但是不會存在共享資料競争的資源進行鎖消除,減少性能開銷