1. 程式人生 > >Java多執行緒深度理解

Java多執行緒深度理解

深入理解多執行緒

                                          -----作者華

(一)首先了解一下Java的虛擬機器是如何執行執行緒同步的:

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

執行緒和共享資料

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

的Java的虛擬機器中,每個執行緒獨享一塊棧記憶體,其中包括區域性變數,執行緒呼叫的每個方法的引數和返回值。其他執行緒無法讀取到該棧記憶體塊的資料。棧中的資料僅限於基本型別和物件引用。所以,JVM中,棧上是無法儲存真實的物件的,只能儲存物件的引用。真正的物件要儲存在堆中。

在JVM中,堆記憶體是所有執行緒共享的。堆中只包含物件,沒有其他東西。所以,堆上也無法儲存基本型別和物件引用。堆和棧分工明確。但是,物件的引用其實也是物件的一部分。這裡值得一提的是,陣列是儲存在堆上面的,即使是基本型別的資料,也是儲存在堆中的。因為在Java的的中,陣列是物件。 

除了棧和堆,還有一部分資料可能儲存在JVM中的方法區中,比如類的靜態變數。方法區和棧類似,其中只包含基本型別和物件應用。和棧不同的是,方法區中的靜態變數可以被所有執行緒訪問到。

物件和類的鎖

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

 那麼,如果有多個執行緒想要同時訪問同一個物件或者靜態變數,就需要被管控,否則可能出現不可預期的結果。

為了協調多個執行緒之間的共享資料訪問,虛擬機器給每個物件和類都分配了一個鎖。這個鎖就像一個特權,在同一時刻,只有一個執行緒可以“擁有”這個類或者物件。如果一個執行緒想要獲得某個類或者物件的鎖,需要詢問虛擬機器。當一個執行緒向虛擬機器申請某個類或者物件的鎖之後,也許很快或者也許很慢虛擬機器可以把鎖分配給這個執行緒,同時這個執行緒也許永遠也無法獲得鎖。當執行緒不再需要鎖的時候,他再把鎖還給虛擬機器。這時虛擬機器就可以再把鎖分配給其他申請鎖的執行緒。

類鎖其實通過物件鎖實現的。因為當虛擬機器載入一個類的時候,會會為這個類例項化一個java.lang.Class中物件,當你鎖住一個類的時候,其實鎖住的是其對應的類物件。

顯示器

JVM與監視器一起使用鎖。監視器基本上是一個監視器,它監視一系列程式碼,確保一次只有一個執行緒執行程式碼。 

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

當執行緒離開塊時,不管它如何離開塊,它釋放關聯物件上的鎖。

多個鎖

單個執行緒被允許多次鎖定相同的物件。對於每個物件,JVM都會維護物件被鎖定的次數。解鎖物件的計數為零。當一個執行緒第一次獲得鎖定時,計數遞增到1每次執行緒獲取對同一物件的鎖定時,計數都會遞增。每次執行緒釋放鎖定時,計數都會遞減。當計數達到零時,鎖被釋放並可供其他執行緒使用。

同步塊

在的的Java語言術語中,必須訪問共享資料的多個執行緒的協調稱為同步該語言提供了兩種內建的方式來同步對資料的訪問:同步語句或同步方法。

同步語句

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

在上面的例子中,包含在synchronizedblock中的語句將不會被執行,直到在當前物件(this)上獲取一個鎖。如果不是這個引用,則表示式產生另一個物件的引用,執行緒繼續之前將獲取與該物件關聯的鎖。 

兩個操作碼monitorentermonitorexit,用於方法中的同步塊,如下表所示。

表1.監視器

monitorenter Java虛擬機器遇到它時,它會獲取堆疊上由objectref引用的物件的鎖。如果執行緒已經擁有該物件的鎖,則計數會遞增。每次monitorexit為物件上的執行緒執行時,計數都會解減。當計數達到零時,顯示器被釋放。

 

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

請注意,即使非同步從同步塊中丟擲,抓子句也可以確保鎖定的物件將被解鎖。無論如何退出同步塊,當執行緒進入該塊時獲取的物件鎖定將被釋放。 

同步方法

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

JVM不使用任何特殊的操作碼來呼叫或從同步方法返回。當JVM解析對方法的符號引用時,它將確定該方法是否同步。如果是,則在呼叫方法之前,JVM獲取一個鎖。對於例項方法,JVM獲取與呼叫該方法的物件關聯的鎖。對於類方法,它獲取與該方法所屬的類關聯的鎖。在同步方法完成後,無論是通過返回還是通過丟擲異常完成,都會釋放該鎖。 

(二)同步的實現原理

同步,是的Java中的解決用於併發情況下資料同步訪問的一個很重要的關鍵字。當我們想要保證一個共享資源在同一時間只會被一個執行緒訪問到時,我們可以在程式碼中使用同步關鍵字對類或者物件加鎖。 

反編譯

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

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

編譯期後,我們可以看到的Java的編譯器為我們生成的位元組碼。doSth狀語從句:doSth1。的處理上稍有不同也就是說.JVM對於同步方法和同步程式碼塊的。

對於同步方法,JVM採用ACC_SYNCHRONIZED標記符來實現同步。對於同步程式碼塊.JVM採用monitorentermonitorexit兩個指令來實現同步。

方法級的同步是隱式的。同步方法的常量池中會有一個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中了。 

執行緒狀態

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

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

 

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

對於執行緒來說,一共有五種狀態,分別為:初始狀態(新),就緒狀態(可執行),執行狀態(執行),阻塞狀態(阻塞)和死亡狀態(死)。

 

 

自旋鎖

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

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

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

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

 

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

而,在程式中,爪哇的虛擬機器的開發工程師們在分析過大量資料後發現:共享資料的鎖定狀態一般只會持續很短的一段時間,為了這段時間去掛起和恢復執行緒其實並不值得。

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

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

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

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

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

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

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

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

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

鎖消除

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

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

 

 

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

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

在動態編譯同步塊的時候,JIT編譯器可以藉助一種被稱為逃逸分析(逃逸分析)的技術來判斷同步塊所使用的鎖物件是否只能夠被一個執行緒訪問而沒有被髮布到其他執行緒。

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

如以下程式碼:

 

程式碼中對霍利斯這個物件進行加鎖,但是霍利斯物件的生命週期只在f()的的方法中,並不會被其他執行緒所訪問到,所以在JIT編譯階段就會被優化掉優化成:

 

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

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

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

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

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

鎖粗化

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

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

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

 

 

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

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

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

如果在一段程式碼中連續的對同一個物件反覆加鎖解鎖,其實是相對耗費資源的,這種情況可以適當放寬加鎖的範圍,減少效能消耗。 

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

如以下程式碼:

會被粗化成:

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

總結

自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.建立一個執行緒:三種方式

⑴繼承執行緒類建立執行緒類

①定義執行緒類的子類,並重寫該類的執行方法,該執行方法的方法體就代表了執行緒要完成的任務。因此把執行()的方法稱為執行體。

②建立執行緒子類的例項,即建立了執行緒物件。

③呼叫執行緒物件的開始()方法來啟動執行緒。

(二)通過可執行介面建立執行緒類

①定義可執行的介面的實現類,並重寫該介面的執行()的方法,該執行()方法的方法體同樣是該執行緒的執行緒執行體。

②建立可執行實現類的例項,並依此例項作為執行緒的目標來建立主題物件,該螺紋物件才是真正的執行緒物件。

③呼叫執行緒物件的開始()方法來啟動該執行緒。

(三)通過可贖回和未來建立執行緒

①建立可贖回介面的實現類,並實現呼叫()方法,該呼叫()方法將作為執行緒執行體,並且有返回值。

②建立可贖回實現類的例項,使用FutureTask類來包裝可贖回物件,該FutureTask物件封裝了該可贖回物件的呼叫()方法的返回值。

④使用FutureTask物件作為執行緒物件的目標建立並啟動新執行緒

呼叫FutureTask物件的get()方法方法方法來獲得子執行緒執行結束後的返回值

3.建立執行緒的三種方式的對比

(1)採用實現Runnable,Callable介面的方式建立多執行緒時,執行緒類只是實現了Runnable介面或Callable介面,還可以繼承其他類。

(2)使用繼承Thread.currentThread()方法,直接使用這個即可獲得當前執行緒,如果需要訪問當前執行緒,則需要訪問當前執行緒。

(五)資料

1.40 個的Java的多執行緒總結

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

2. 多執行緒在什麼情況下使用

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