天道不一定酬所有勤
但是,天道只酬勤

深入理解多線程(五)—— Java虛擬機的鎖優化技術

本文是《深入理解多線程》的第五篇文章,前面幾篇文章中我們從synchronized的實現原理開始,一直介紹到了Monitor的實現原理。

前情提要

通過前面幾篇文章,我們已經知道:

1、同步方法通過ACC_SYNCHRONIZED關鍵字隱式的對方法進行加鎖。當線程要執行的方法被標注上ACC_SYNCHRONIZED時,需要先獲得鎖才能執行該方法?!?a href="http://www.9604040.live/archives/1883">深入理解多線程(一)——Synchronized的實現原理》

2、同步代碼塊通過monitorentermonitorexit執行來進行加鎖。當線程執行到monitorenter的時候要先獲得所鎖,才能執行后面的方法。當線程執行到monitorexit的時候則要釋放鎖?!?a href="http://www.9604040.live/archives/2030">深入理解多線程(四)—— Moniter的實現原理》

3、在HotSpot虛擬機中,使用oop-klass模型來表示對象。每一個Java類,在被JVM加載的時候,JVM會給這個類創建一個instanceKlass,保存在方法區,用來在JVM層表示該Java類。當我們在Java代碼中,使用new創建一個對象的時候,JVM會創建一個instanceOopDesc對象,這個對象中包含了對象頭以及實例數據?!?a href="http://www.9604040.live/archives/1910">深入理解多線程(二)—— Java的對象模型》

4、對象頭中主要包含了GC分代年齡、鎖狀態標記、哈希碼、epoch等信息。對象的狀態一共有五種,分別是無鎖態、輕量級鎖、重量級鎖、GC標記和偏向鎖?!?a href="http://www.9604040.live/archives/1953">深入理解多線程(三)—— Java的對象頭》

在上一篇文章的最后,我們說過,事實上,只有在JDK1.6之前,synchronized的實現才會直接調用ObjectMonitorenterexit,這種鎖被稱之為重量級鎖。

高效并發是從JDK 1.5 到 JDK 1.6的一個重要改進,HotSpot虛擬機開發團隊在這個版本中花費了很大的精力去對Java中的鎖進行優化,如適應性自旋、鎖消除、鎖粗化、輕量級鎖和偏向鎖等。這些技術都是為了在線程之間更高效的共享數據,以及解決競爭問題。

本文,主要先來介紹一下自旋、鎖消除以及鎖粗化等技術。

這里簡單說明一下,本文要介紹的這幾個概念,以及后面要介紹的輕量級鎖和偏向鎖,其實對于使用他的開發者來說是屏蔽掉了的,也就是說,作為一個Java開發,你只需要知道你想在加鎖的時候使用synchronized就可以了,具體的鎖的優化是虛擬機根據競爭情況自行決定的。

也就是說,在JDK 1.5 以后,我們即將介紹的這些概念,都被封裝在synchronized中了。

線程狀態

要想把鎖說清楚,一個重要的概念不得不提,那就是線程和線程的狀態。鎖和線程的關系是怎樣的呢,舉個簡單的例子你就明白了。

比如,你今天要去銀行辦業務,你到了銀行之后,要先取一個號,然后你坐在休息區等待叫號,過段時間,廣播叫到你的號碼之后,會告訴你去哪個柜臺辦理業務,這時,你拿著你手里的號碼,去到對應的柜臺,找相應的柜員開始辦理業務。當你辦理業務的時候,這個柜臺和柜臺后面的柜員只能為你自己服務。當你辦完業務離開之后,廣播再喊其他的顧客前來辦理業務。

Pic1

這個例子中,每個顧客是一個線程。 柜臺前面的那把椅子,就是。 柜臺后面的柜員,就是共享資源。 你發現無法直接辦理業務,要取號等待的過程叫做阻塞。 當你聽到叫你的號碼的時候,你起身去辦業務,這就是喚醒。 當你坐在椅子上開始辦理業務的時候,你就獲得鎖。 當你辦完業務離開的時候,你就釋放鎖。

對于線程來說,一共有五種狀態,分別為:初始狀態(New) 、就緒狀態(Runnable) 、運行狀態(Running) 、阻塞狀態(Blocked) 和死亡狀態(Dead) 。

thread

自旋鎖

前一篇文章中,我們介紹的synchronized的實現方式中使用Monitor進行加鎖,這是一種互斥鎖,為了表示他對性能的影響我們稱之為重量級鎖。

這種互斥鎖在互斥同步上對性能的影響很大,Java的線程是映射到操作系統原生線程之上的,如果要阻塞或喚醒一個線程就需要操作系統的幫忙,這就要從用戶態轉換到內核態,因此狀態轉換需要花費很多的處理器時間。

就像去銀行辦業務的例子,當你來到銀行,發現柜臺前面都有人的時候,你需要取一個號,然后再去等待區等待,一直等待被叫號。這個過程是比較浪費時間的,那么有沒有什么辦法改進呢?

有一種比較好的設計,那就是銀行提供自動取款機,當你去銀行取款的時候,你不需要取號,不需要去休息區等待叫號,你只需要找到一臺取款機,排在其他人后面等待取款就行了。

Pic2

之所以能這樣做,是因為取款的這個過程相比較之下是比較節省時間的。如果所有人去銀行都只取款,或者辦理業務的時間都很短的話,那也就可以不需要取號,不需要去單獨的休息區,不需要聽叫號,也不需要再跑到對應的柜臺了。

而,在程序中,Java虛擬機的開發工程師們在分析過大量數據后發現:共享數據的鎖定狀態一般只會持續很短的一段時間,為了這段時間去掛起和恢復線程其實并不值得。

如果物理機上有多個處理器,可以讓多個線程同時執行的話。我們就可以讓后面來的線程“稍微等一下”,但是并不放棄處理器的執行時間,看看持有鎖的線程會不會很快釋放鎖。這個“稍微等一下”的過程就是自旋。

自旋鎖在JDK 1.4中已經引入,在JDK 1.6中默認開啟。

很多人在對于自旋鎖的概念不清楚的時候可能會有以下疑問:這么聽上去,自旋鎖好像和阻塞鎖沒啥區別,反正都是等著嘛。

  • 對于去銀行取錢的你來說,站在取款機面前等待和去休息區等待叫號有一個很大的區別:

    • 那就是如果你在休息區等待,這段時間你什么都不需要管,隨意做自己的事情,等著被喚醒就行了。

    • 如果你在取款機面前等待,那么你需要時刻關注自己前面還有沒有人,因為沒人會喚醒你。

    • 很明顯,這種直接去取款機前面排隊取款的效率是比較高。

所以呢,自旋鎖和阻塞鎖最大的區別就是,到底要不要放棄處理器的執行時間。對于阻塞鎖和自旋鎖來說,都是要等待獲得共享資源。但是阻塞鎖是放棄了CPU時間,進入了等待區,等待被喚醒。而自旋鎖是一直“自旋”在那里,時刻的檢查共享資源是否可以被訪問。

由于自旋鎖只是將當前線程不停地執行循環體,不進行線程狀態的改變,所以響應速度更快。但當線程數不停增加時,性能下降明顯,因為每個線程都需要執行,占用CPU時間。如果線程競爭不激烈,并且保持鎖的時間段。適合使用自旋鎖。

鎖消除

除了自旋鎖之后,JDK中還有一種鎖的優化被稱之為鎖消除。還拿去銀行取錢的例子說。

你去銀行取錢,所有情況下都需要取號,并且等待嗎?其實是不用的,當銀行辦理業務的人不多的時候,可能根本不需要取號,直接走到柜臺前面辦理業務就好了。

Pic3

能這么做的前提是,沒有人和你搶著辦業務。

上面的這種例子,在鎖優化中被稱作“鎖消除”,是JIT編譯器對內部鎖的具體實現所做的一種優化。

在動態編譯同步塊的時候,JIT編譯器可以借助一種被稱為逃逸分析(Escape Analysis)的技術來判斷同步塊所使用的鎖對象是否只能夠被一個線程訪問而沒有被發布到其他線程。

如果同步塊所使用的鎖對象通過這種分析被證實只能夠被一個線程訪問,那么JIT編譯器在編譯這個同步塊的時候就會取消對這部分代碼的同步。

如以下代碼:

public void f() {
    Object hollis = new Object();
    synchronized(hollis) {
        System.out.println(hollis);
    }
}

代碼中對hollis這個對象進行加鎖,但是hollis對象的生命周期只在f()方法中,并不會被其他線程所訪問到,所以在JIT編譯階段就會被優化掉。優化成:

public void f() {
    Object hollis = new Object();
    System.out.println(hollis);
}

這里,可能有讀者會質疑了,代碼是程序員自己寫的,程序員難道沒有能力判斷要不要加鎖嗎?就像以上代碼,完全沒必要加鎖,有經驗的開發者一眼就能看的出來的。其實道理是這樣,但是還是有可能有疏忽,比如我們經常在代碼中使用StringBuffer作為局部變量,而StringBuffer中的append是線程安全的,有synchronized修飾的,這種情況開發者可能會忽略。這時候,JIT就可以幫忙優化,進行鎖消除。

了解我的朋友都知道,一般到這個時候,我就會開始反編譯,然后拿出反編譯之后的代碼來證明鎖優化確實存在。

但是,之前很多例子之所以可以用反編譯工具,是因為那些“優化”,如語法糖等,是在javac編譯階段發生的,并不是在JIT編譯階段發生的。而鎖優化,是JIT編譯器的功能,所以,無法使用現有的反編譯工具查看具體的優化結果。(關于javac編譯和JIT編譯的關系和區別,我在我的知識星球中單獨發了一篇文章介紹。)

但是,如果讀者感興趣,還是可以看的,只是會復雜一點,首先你要自己build一個fasttest版本的jdk,然后在使用java命令對.class文件進行執行的時候加上-XX:+PrintEliminateLocks參數。而且jdk的模式還必須是server模式。

總之,讀者只需要知道,在使用synchronized的時候,如果JIT經過逃逸分析之后發現并無線程安全問題的話,就會做鎖消除。

鎖粗化

很多人都知道,在代碼中,需要加鎖的時候,我們提倡盡量減小鎖的粒度,這樣可以避免不必要的阻塞。

這也是很多人原因是用同步代碼塊來代替同步方法的原因,因為往往他的粒度會更小一些,這其實是很有道理的。

還是我們去銀行柜臺辦業務,最高效的方式是你坐在柜臺前面的時候,只辦和銀行相關的事情。如果這個時候,你拿出手機,接打幾個電話,問朋友要往哪個賬戶里面打錢,這就很浪費時間了。最好的做法肯定是提前準備好相關資料,在辦理業務時直接辦理就好了。

Pic4

加鎖也一樣,把無關的準備工作放到鎖外面,鎖內部只處理和并發相關的內容。這樣有助于提高效率。

那么,這和鎖粗化有什么關系呢?可以說,大部分情況下,減小鎖的粒度是很正確的做法,只有一種特殊的情況下,會發生一種叫做鎖粗化的優化。

就像你去銀行辦業務,你為了減少每次辦理業務的時間,你把要辦的五個業務分成五次去辦理,這反而適得其反了。因為這平白的增加了很多你重新取號、排隊、被喚醒的時間。

如果在一段代碼中連續的對同一個對象反復加鎖解鎖,其實是相對耗費資源的,這種情況可以適當放寬加鎖的范圍,減少性能消耗。

當JIT發現一系列連續的操作都對同一個對象反復加鎖和解鎖,甚至加鎖操作出現在循環體中的時候,會將加鎖同步的范圍擴散(粗化)到整個操作序列的外部。

如以下代碼:

for(int i=0;i<100000;i++){  
    synchronized(this){  
        do();  
}  

會被粗化成:

synchronized(this){  
    for(int i=0;i<100000;i++){  
        do();  
}  

這其實和我們要求的減小鎖粒度并不沖突。減小鎖粒度強調的是不要在銀行柜臺前做準備工作以及和辦理業務無關的事情。而鎖粗化建議的是,同一個人,要辦理多個業務的時候,可以在同一個窗口一次性辦完,而不是多次取號多次辦理。

總結

自Java 6/Java 7開始,Java虛擬機對內部鎖的實現進行了一些優化。這些優化主要包括鎖消除(Lock Elision)、鎖粗化(Lock Coarsening)、偏向鎖(Biased Locking)以及適應性自旋鎖(Adaptive Locking)。這些優化僅在Java虛擬機server模式下起作用(即運行Java程序時我們可能需要在命令行中指定Java虛擬機參數“-server”以開啟這些優化)。

本文主要介紹了自旋鎖、鎖粗化和鎖消除的概念。在JIT編譯過程中,虛擬機會根據情況使用這三種技術對鎖進行優化,目的是減少鎖的競爭,提升性能。

(全文完) 歡迎關注『Java之道』微信公眾號
贊(21)
如未加特殊說明,此網站文章均為原創,轉載必須注明出處。HollisChuang's Blog » 深入理解多線程(五)—— Java虛擬機的鎖優化技術
分享到: 更多 (0)

評論 3

  • 昵稱 (必填)
  • 郵箱 (必填)
  • 網址
  1. #1

    看了您好多篇文章,感覺這一篇講的最通俗易懂

    風中追風1年前 (2018-12-06)回復
  2. #2

    寫的太好了 贊

    啦啦啦啦11個月前 (04-30)回復
  3. #3

    gzy8個月前 (08-13)回復

HollisChuang's Blog

聯系我關于我
网上设计赚钱的网站有哪些