天天看點

Java多線程深度了解

深入了解多線程

                                          -----作者華

(一)首先了解一下Java的虛拟機是如何執行線程同步的:

的Java的語言要想被JVM執行,需要被轉換成由位元組碼組成的類檔案。首先就來分析一下的Java的虛拟機是如何在位元組碼層面上執行線程同步的。

線程和共享資料

的Java的程式設計語言的優點之一是它在語言層面上對多線程的支援這種支援大部分集中在協調多個線程對共享資料的通路上.JVM的記憶體結構主要包含以下幾個重要的區域:棧,堆,方法區等。

的Java的虛拟機中,每個線程獨享一塊棧記憶體,其中包括局部變量,線程調用的每個方法的參數和傳回值。其他線程無法讀取到該棧記憶體塊的資料。棧中的資料僅限于基本類型和對象引用。是以,JVM中,棧上是無法儲存真實的對象的,隻能儲存對象的引用。真正的對象要儲存在堆中。

在JVM中,堆記憶體是所有線程共享的。堆中隻包含對象,沒有其他東西。是以,堆上也無法儲存基本類型和對象引用。堆和棧分工明确。但是,對象的引用其實也是對象的一部分。這裡值得一提的是,數組是儲存在堆上面的,即使是基本類型的資料,也是儲存在堆中的。因為在Java的的中,數組是對象。 

除了棧和堆,還有一部分資料可能儲存在JVM中的方法區中,比如類的靜态變量。方法區和棧類似,其中隻包含基本類型和對象應用。和棧不同的是,方法區中的靜态變量可以被所有線程通路到。

對象和類的鎖

如前文提到,JVM中的兩塊記憶體區域可以被所有線程共享:

 那麼,如果有多個線程想要同時通路同一個對象或者靜态變量,就需要被管控,否則可能出現不可預期的結果。

為了協調多個線程之間的共享資料通路,虛拟機給每個對象和類都配置設定了一個鎖。這個鎖就像一個特權,在同一時刻,隻有一個線程可以“擁有”這個類或者對象。如果一個線程想要獲得某個類或者對象的鎖,需要詢問虛拟機。當一個線程向虛拟機申請某個類或者對象的鎖之後,也許很快或者也許很慢虛拟機可以把鎖配置設定給這個線程,同時這個線程也許永遠也無法獲得鎖。當線程不再需要鎖的時候,他再把鎖還給虛拟機。這時虛拟機就可以再把鎖配置設定給其他申請鎖的線程。

類鎖其實通過對象鎖實作的。因為當虛拟機加載一個類的時候,會會為這個類執行個體化一個java.lang.Class中對象,當你鎖住一個類的時候,其實鎖住的是其對應的類對象。

顯示器

JVM與螢幕一起使用鎖。螢幕基本上是一個螢幕,它監視一系列代碼,確定一次隻有一個線程執行代碼。 

每個螢幕都與一個對象引用關聯。當一個線程到達螢幕監視下的一段代碼中的第一條指令時,該線程必須獲得對該引用對象的鎖定。直到它獲得鎖定,線程才被允許執行代碼。一旦獲得鎖定,線程将進入受保護代碼塊。 

當線程離開塊時,不管它如何離開塊,它釋放關聯對象上的鎖。

多個鎖

單個線程被允許多次鎖定相同的對象。對于每個對象,JVM都會維護對象被鎖定的次數。解鎖對象的計數為零。當一個線程第一次獲得鎖定時,計數遞增到1每次線程擷取對同一對象的鎖定時,計數都會遞增。每次線程釋放鎖定時,計數都會遞減。當計數達到零時,鎖被釋放并可供其他線程使用。

同步塊

在的的Java語言術語中,必須通路共享資料的多個線程的協調稱為同步該語言提供了兩種内置的方式來同步對資料的通路:同步語句或同步方法。

同步語句

要建立一個同步語句,您可以使用同步的關鍵字和一個表達式來評估對象引用,如reverseOrder()下面的方法:

Java多線程深度了解

在上面的例子中,包含在synchronizedblock中的語句将不會被執行,直到在目前對象(this)上擷取一個鎖。如果不是這個引用,則表達式産生另一個對象的引用,線程繼續之前将擷取與該對象關聯的鎖。 

兩個操作碼monitorenter和monitorexit,用于方法中的同步塊,如下表所示。

表1.螢幕

Java多線程深度了解

當monitorenter Java虛拟機遇到它時,它會擷取堆棧上由objectref引用的對象的鎖。如果線程已經擁有該對象的鎖,則計數會遞增。每次monitorexit為對象上的線程執行時,計數都會解減。當計數達到零時,顯示器被釋放。

看看這個類的方法産生的位元組碼序列.reverseOrder()KitchenSync 

請注意,即使異步從同步塊中抛出,抓子句也可以確定鎖定的對象将被解鎖。無論如何退出同步塊,當線程進入該塊時擷取的對象鎖定将被釋放。 

同步方法

要同步整個方法,隻需将該同步關鍵字包含為方法限定符之一,如下所示:

Java多線程深度了解

JVM不使用任何特殊的操作碼來調用或從同步方法傳回。當JVM解析對方法的符号引用時,它将确定該方法是否同步。如果是,則在調用方法之前,JVM擷取一個鎖。對于執行個體方法,JVM擷取與調用該方法的對象關聯的鎖。對于類方法,它擷取與該方法所屬的類關聯的鎖。在同步方法完成後,無論是通過傳回還是通過抛出異常完成,都會釋放該鎖。 

(二)同步的實作原理

同步,是的Java中的解決用于并發情況下資料同步通路的一個很重要的關鍵字。當我們想要保證一個共享資源在同一時間隻會被一個線程通路到時,我們可以在代碼中使用同步關鍵字對類或者對象加鎖。 

反編譯

衆所周知,在Java的的中,同步有兩種使用形式,同步方法和同步代碼塊代碼如下:

Java多線程深度了解

先用的Java的-P來反編譯以上代碼,結果如下

Java多線程深度了解

編譯期反後,我們可以看到的Java的編譯器為我們生成的位元組碼。

doSth

狀語從句:

doSth1

。的處理上稍有不同也就是說.JVM對于同步方法和同步代碼塊的。

對于同步方法,JVM采用

ACC_SYNCHRONIZED

标記符來實作同步。對于同步代碼塊.JVM采用

monitorenter

monitorexit

兩個指令來實作同步。

方法級的同步是隐式的。同步方法的常量池中會有一個ACC_SYNCHRONIZED标志。當某個線程要通路某個方法的時候,會檢查是否有ACC_SYNCHRONIZED,如果有設定,則需要先獲得螢幕鎖,然後開始執行方法,方法執行之後再釋放螢幕鎖。這時如果其他線程來請求執行方法,會因為無法獲得螢幕鎖而被阻斷住。值得注意的是,如果在方法執行過程中,發生了異常,并且方法内部并沒有處理該異常,那麼在異常被抛到方法外面之前螢幕鎖會被自動釋放。

同步代碼塊

同步代碼塊使用

monitorenter

狀語從句:

monitorexit

兩個指令實作。 

可以把執行monitorenter指令了解為加鎖,執行monitorexit了解為釋放鎖。每個對象維護着一個記錄着被被鎖次數的計數器。未被鎖定的對象的該計數器為0,當一個線程獲得鎖執行monitorenter)後,該計數器自增變為1,當同一個線程再次獲得該對象的鎖的時候,計數器再次自增。當同一個線程釋放鎖(執行monitorexit指令)的時候,計數器再自減。為0的時候,鎖将被釋放,其他線程便可以獲得鎖。

(三)      Java 虛拟機的鎖化技術

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

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

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

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

線程狀态

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

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

Java多線程深度了解

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

對于線程來說,一共有五種狀态,分别為:初始狀态(新),就緒狀态(可運作),運作狀态(運作),阻塞狀态(阻塞)和死亡狀态(死)。

Java多線程深度了解

自旋鎖

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

這種互斥鎖在互斥同步上對性能的影響很大,爪哇的線程是映射到作業系統原生線程之上的,如果要阻塞或喚醒一個線程就需要作業系統的幫忙,這就要從使用者态轉換到核心态,是以狀态轉換需要花費很多的處理器時間。 

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

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

Java多線程深度了解

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

而,在程式中,爪哇的虛拟機的開發工程師們在分析過大量資料後發現:共享資料的鎖定狀态一般隻會持續很短的一段時間,為了這段時間去挂起和恢複線程其實并不值得。

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

自旋鎖在JDK 1.4中已經引入,在JDK1.6中預設開啟。

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

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

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

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

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

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

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

鎖消除

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

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

Java多線程深度了解

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

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

在動态編譯同步塊的時候,JIT編譯器可以借助一種被稱為逃逸分析(逃逸分析)的技術來判斷同步塊所使用的鎖對象是否隻能夠被一個線程通路而沒有被釋出到其他線程。

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

如以下代碼:

Java多線程深度了解

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

Java多線程深度了解

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

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

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

但是,如果讀者感興趣,還是可以看的,隻是會複雜一點,首先你要自己建構一個的的FastTesT版本的JDK,然後在使用的Java的指令對的的.class檔案進行執行的時候加上-XX:+ PrintEliminateLocks參數。而且JDK的模式還必須是伺服器模式。

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

鎖粗化

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

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

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

Java多線程深度了解

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

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

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

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

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

如以下代碼:

Java多線程深度了解

會被粗化成:

Java多線程深度了解

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

總結

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

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

(四)      代碼階段

1. 線程的優先級:

每一個Java的線程都有一個優先級,這樣有助于作業系統确定線程的排程順序。

Java線程的優先級是一個整數,其取值範圍是1(Thread.MIN_PRIORITY) - 10(Thread.MAX_PRIORITY)。 

預設情況下,每一個線程都會配置設定一個優先級NORM_PRIORITY(5)。 

具有較高優先級的線程對程式更重要,并且應該在低優先級的線程之前配置設定處理器資源。但是,線程優先級不能保證線程執行的順序,而且非常依賴于平台。 

2.建立一個線程:三種方式

⑴繼承線程類建立線程類

①定義線程類的子類,并重寫該類的運作方法,該運作方法的方法體就代表了線程要完成的任務。是以把運作()的方法稱為執行體。

②建立線程子類的執行個體,即建立了線程對象。

③調用線程對象的開始()方法來啟動線程。

Java多線程深度了解
Java多線程深度了解

(二)通過可運作接口建立線程類

①定義可運作的接口的實作類,并重寫該接口的運作()的方法,該運作()方法的方法體同樣是該線程的線程執行體。

②建立可運作實作類的執行個體,并依此執行個體作為線程的目标來建立主題對象,該螺紋對象才是真正的線程對象。

③調用線程對象的開始()方法來啟動該線程。

Java多線程深度了解
Java多線程深度了解

(三)通過可贖回和未來建立線程

①建立可贖回接口的實作類,并實作呼叫()方法,該呼叫()方法将作為線程執行體,并且有傳回值。

②建立可贖回實作類的執行個體,使用FutureTask類來包裝可贖回對象,該FutureTask對象封裝了該可贖回對象的呼叫()方法的傳回值。

④使用FutureTask對象作為線程對象的目标建立并啟動新線程。

調用FutureTask對象的get()方法方法方法來獲得子線程執行結束後的傳回值

Java多線程深度了解

3.建立線程的三種方式的對比

(1)采用實作Runnable,Callable接口的方式建立多線程時,線程類隻是實作了Runnable接口或Callable接口,還可以繼承其他類。

(2)使用繼承Thread.currentThread()方法,直接使用這個即可獲得目前線程,如果需要通路目前線程,則需要通路目前線程。

(五)資料

1.40 個的Java的多線程總結

https://juejin.im/entry/58f1d35744d904006cf14b17

2. 多線程在什麼情況下使用

https://www.zhihu.com/question/65200684

繼續閱讀