1. 程式人生 > >從jvm角度看java多執行緒

從jvm角度看java多執行緒

最近在學習jvm,發現隨著對虛擬機器底層的瞭解,對java的多執行緒也有了全新的認識,原來一個小小的synchronized關鍵字裡別有洞天。決定把自己關於java多執行緒的所學整理成一篇文章,從最基礎的為什麼使用多執行緒,一直深入講解到jvm底層的鎖實現。

多執行緒的目的

為什麼要使用多執行緒?可以簡單的分兩個方面來說:

  • 在多個cpu核心下,多執行緒的好處是顯而易見的,不然多個cpu核心只跑一個執行緒其他的核心就都浪費了;
  • 即便不考慮多核心,在單核下,多執行緒也是有意義的,因為在一些操作,比如IO操作阻塞的時候,是不需要cpu參與的,這時候cpu就可以另開一個執行緒去做別的事情,等待IO操作完成再回到之前的執行緒繼續執行即可。

多執行緒帶來的問題

其實多執行緒根本的問題只有一個:執行緒間變數的共享

java裡的變數可以分3類:

  1. 類變數(類裡面static修飾的變數)
  2. 例項變數(類裡面的普通變數)
  3. 區域性變數(方法裡宣告的變數)

下圖是jvm的記憶體區域劃分圖:

根據各個區域的定義,我們可以知道:

  1. 類變數 儲存在“方法區”
  2. 例項變數 儲存在“堆”
  3. 區域性變數 儲存在 “虛擬機器棧”

“方法區”和“堆”都屬於執行緒共享資料區,“虛擬機器棧”屬於執行緒私有資料區。

因此,區域性變數是不能多個執行緒共享的,而類變數和例項變數是可以多個執行緒共享的。事實上,在java中,多執行緒間進行通訊的唯一途徑就是通過類變數和例項變數。

也就是說,如果一段多執行緒程式中如果沒有類變數和例項變數,那麼這段多執行緒程式就一定是執行緒安全的。

以Web開發的Servlet為例,一般我們開發的時候,自己的類繼承HttpServlet之後,重寫doPost()、doGet()處理請求,不管我們在這兩個方法裡寫什麼程式碼,只要沒有操作類變數或例項變數,最後寫出來的程式碼就是執行緒安全的。如果在Servlet類裡面加了例項變數,就很可能出現執行緒安全性問題,解決方法就是把例項變數改為ThreadLocal變數,而ThreadLocal實現的含義就是讓例項變數變成了“執行緒私有”的,即給每一個執行緒分配一個自己的值。

 

 現在我們知道:其實多執行緒根本的問題只有一個:執行緒間變數的共享,這裡的變數,指的就是類變數和例項變數,後續的一切,都是為了解決類變數和例項變數共享的安全問題。

如何安全的共享變數

現在唯一的問題就是要讓多個執行緒安全的共享變數(下文中的變數一般特指類變數和例項變數),上文提到了一種ThreadLocal的方式,其實這種方式並不是真正的共享,而是為每個執行緒分配一個自己的值。

比如現在有一個特別簡單的需求,有一個類變數a=0,現在啟動5個執行緒,每個執行緒執行a++;如果用ThreadLocal的方式,最後的結果就是5個執行緒都擁有一份自己的a值,最終結果都是1,這顯然不符合我們的預期。

那麼如果不使用ThreadLocal呢?直接宣告一個類變數a=0,然後讓5個執行緒分別去執行a++;這樣結果依舊不對,而且結果是不確定的,可能是1,2,3,4,5中的任一個。這種情況叫做競態條件(Race Condition),要理解競態條件先要理解Java記憶體模型:

要理解java的記憶體模型,可以類比計算機硬體訪問記憶體的模型。由於計算機的cpu運算速度和記憶體io速度有幾個數量級的差距,因此現代計算機都不得不加入一層儘可能接近處理器運算速度的快取記憶體來做緩衝:將記憶體中運算需要使用的資料先複製到快取中,當運算結束後再同步回記憶體。如下圖:

因為jvm要實現跨硬體平臺,因此jvm定義了自己的記憶體模型,但是因為jvm的記憶體模型最終還是要對映到硬體上,因此jvm記憶體模型幾乎與硬體的模型一樣:

每個java執行緒都有一份自己的工作記憶體,執行緒訪問變數的時候,不能直接訪問主記憶體中的變數,而是先把主記憶體的變數複製到自己的工作記憶體,然後操作自己工作記憶體裡的變數,最後再同步給主記憶體。

現在就可以解釋為什麼5個執行緒執行a++最後結果不一定是5了,因為a++可以分解為3步操作:

  1. 把主記憶體裡的a複製到執行緒的工作記憶體
  2. 執行緒對工作記憶體裡的a執行a=a+1
  3. 把執行緒工作記憶體裡的a同步回主記憶體

而5個執行緒併發執行的時候完全有可能5個執行緒都先執行了第一步,這樣5個執行緒的工作記憶體裡a的初始值都是0,然後執行a=a+1後在工作記憶體裡的運算結果都是1,最後同步回主記憶體的值肯定也是1。

而避免這種情況的方法就是:在多個執行緒併發訪問a的時候,保證a在同一個時刻只被一個執行緒使用。

同步(synchronized)就是:在多個執行緒併發訪問共享資料的時候,保證共享資料在同一個時刻只被一個執行緒使用。

同步基本思想

為了保證共享資料在同一時刻只被一個執行緒使用,我們有一種很簡單的實現思想,就是在共享資料裡儲存一個鎖,當沒有執行緒訪問時,鎖是空的,當有第一個執行緒訪問時,就在鎖裡儲存這個執行緒的標識並允許這個執行緒訪問共享資料。在當前執行緒釋放共享資料之前,如果再有其他執行緒想要訪問共享資料,就要等待鎖釋放

我們把這種思想的三個關鍵點抽出來:

  1. 在共享資料裡儲存一個鎖
  2. 在鎖裡儲存這個執行緒的標識
  3. 其他執行緒訪問已加鎖共享資料要等待鎖釋放

Jvm的同步實現  

可以說jvm中的三種鎖都是以上述思想為基礎的,只是實現的“重量級”不同,jvm中有以下三種鎖(由上到下越來越“重量級”):

  1. 偏向鎖
  2. 輕量級鎖
  3. 重量級鎖

其中重量級鎖是最初的鎖機制,偏向鎖和輕量級鎖是在jdk1.6加入的,可以選擇開啟或關閉。如果把偏向鎖和輕量級鎖都開啟,那麼在java程式碼中使用synchronized關鍵字的時候,jvm底層會嘗試先使用偏向鎖,如果偏向鎖不可用,則轉換為輕量級鎖,如果輕量級鎖不可用,則轉換為重量級鎖。具體轉換過程下面會講。

要想深入瞭解這3種鎖需要了解物件的記憶體結構(MarkWord頭),會涉及到位元組碼的內部儲存格式,但是其實我覺得脫離細節的實現,單從原理上理解這三個鎖是很容易的,只需要瞭解兩個大體的概念:

MarkWord:java中的每個物件在儲存的時候,都有統一的資料結構。每個物件都包含一個物件頭,稱為MarkWord,裡面會儲存關於這個物件的加鎖資訊。

Lock Record: 即鎖記錄,每個執行緒在執行的時候,會有自己的虛擬機器棧,當個方法的呼叫相當於虛擬機器棧裡的一個棧幀,而Lock Record就位於棧幀上,是用來儲存關於這個執行緒的加鎖資訊。

最初jvm沒有前兩種鎖(前兩種都是jdk1.6才引入的),只有重量級鎖。

我們之前給出了同步基本思想的三個點,我們也說了jvm的三種鎖都是以基本思想為基礎的,而這三種鎖在第1、2點的實現上本質上是一樣的:

  1. 在共享資料裡儲存一個鎖  //java同步是通過synchronized關鍵字實現的,synchronized有三種用法:一種是同步塊,這種用法需要指明一個鎖定物件;一種是修飾靜態方法,這種用法相當於鎖定Class物件;一種是修飾普通方法,這種用法相當於鎖定方法所在的例項物件。因此,在java裡能夠被synchronized關鍵字鎖定的一定是物件,因此就要在物件裡儲存一個鎖,而物件記憶體結構裡的MarkWord就可以認為是這個鎖。三種鎖雖然實現細節不同,但是都是使用MarkWord儲存鎖的。
  2. 在鎖裡儲存這個執行緒的標識  //偏向鎖是在MarkWord裡儲存執行緒id,輕量級鎖是在MarkWord裡儲存指向擁有鎖的執行緒棧中鎖記錄的指標,重量級鎖是在MarkWord中儲存指向互斥量的指標(互斥量只向一個執行緒授予對共享資源的獨佔訪問權,可以認為是記錄了執行緒的標識)

 

而區分這三種鎖的關鍵,就是同步基本思想的第三點:

   3.其他執行緒訪問已加鎖共享資料要等待鎖釋放

這裡的等待鎖釋放是一個抽象的說法,並沒有嚴格要求怎麼等待。而重量級鎖因為使用了互斥量,這裡的等待就是執行緒阻塞。使用互斥量可以保證所有情況下的併發安全,但是使用互斥量會帶來較大的效能消耗。而且在實際的專案程式碼中,很可能一段本來不會有併發情況的程式碼被加了鎖,這樣每次使用互斥量就白白消耗了效能。能不能先假設被加鎖的程式碼不會有併發的情況,等到發現有併發的時候再使用互斥量呢?答案是可以的,輕量級鎖和偏向鎖都是基於這種假設來實現的。

輕量級鎖

輕量級鎖的核心思想就是“被加鎖的程式碼不會發生併發,如果發生併發,那就膨脹成重量級鎖(膨脹指的鎖的重量級上升,一旦升級,就不會降級了)”。

輕量級鎖依賴了一種叫做CAS(compare and swap)的操作,這個操作是由底層硬體提供相關指令實現的:

CAS操作需要3個引數,分別是記憶體位置V,舊的期望值A和新值B。CAS指令執行時,當且僅當V當前值符合舊值A時,處理器用新值B更新V的值,否則不執行更新。上述過程是一個原子操作。

輕量級鎖加鎖

假設現在開啟了輕量級鎖,當第一個執行緒要鎖定物件時,該執行緒首先會在棧幀中建立Lock Record(鎖記錄)的空間,用於儲存物件目前MarkWord的拷貝,然後虛擬機器將使用CAS操作嘗試將物件的MarkWord更新為指向執行緒鎖記錄的指標。如果操作成功,則該執行緒獲得物件鎖。如果失敗,說明在該執行緒拷貝物件當前MarkWord之後,執行CAS操作之前,有其他執行緒獲取了物件鎖,我們最開始的假設“被加鎖的程式碼不會發生併發”失效了。此時輕量級鎖還不會直接膨脹為重量級鎖,執行緒會自旋不停地重試CAS操作寄希望於鎖的持有執行緒主動釋放鎖,在自旋一定次數後如果還是沒有成功獲得鎖,那麼輕量級鎖要膨脹為重量級鎖:之前成功獲取了輕量級鎖的那個執行緒現在依舊持有鎖,只是換成了重量級鎖,其他嘗試獲取鎖的執行緒進入等待狀態。

輕量級鎖解鎖

輕量級鎖的解鎖也是用CAS來操作,如果物件的MarkWord中依然是持有鎖執行緒的鎖記錄指標,則CAS成功,把鎖記錄中的原MarkWord的拷貝複製回去,解鎖完成;如果物件的MarkWord中儲存的不再是持有鎖執行緒的鎖記錄指標,說明在持有鎖執行緒持有鎖期間,這個輕量級鎖已經因為其它執行緒併發獲取膨脹為了重量級鎖,因此執行緒在釋放鎖的同時,還要喚醒(notify)等待的執行緒。

偏向鎖

根據輕量級鎖的實現,我們知道雖然輕量級鎖不支援“併發”,遇到“併發”就要膨脹為重量級鎖,但是輕量級鎖可以支援多個執行緒以序列的方式訪問同一個加鎖物件。比如A執行緒可以先獲取物件o的輕量鎖,然後A釋放了輕量鎖,這個時候B執行緒來獲取o的輕量鎖,是可以成功獲取得,以這種方式可以一直序列下去。之所以能實現這種序列,是因為有一個釋放鎖的動作。那麼假設有一個加鎖的java方法,這個方法在執行的時候其實從始至終只有一個執行緒在呼叫,但是每次呼叫完卻也要釋放鎖,下次呼叫還要重新獲得鎖。

那麼我們能不能做一個假設:“假設加鎖的程式碼從始至終就只有一個執行緒在呼叫,如果發現有多於一個執行緒呼叫,再膨脹成輕量級鎖也不遲”。這個假設,就是偏向鎖的核心思想。

核心實現

偏向鎖的核心實現很簡單:假設開啟了偏向鎖,當第一個執行緒嘗試獲得物件鎖的時候,也會在棧幀中建立Lock Record鎖記錄,但是這個Lock Record空間不需要初始化(後面會用到它),然後直接用CAS將自己的執行緒ID寫到物件的MarkWord裡,如果CAS操作成功,就獲取了偏向鎖。執行緒獲取偏向鎖後即便是執行完加鎖的程式碼塊,也會一直持有鎖不會主動釋放。因此這個執行緒以後每次進入這個鎖相關的程式碼塊的時候,都不需要執行任何額外的同步操作。

當有另外一個執行緒嘗試獲得鎖的時候,需要進行revoke操作,分情況討論:

  1. 判斷持有偏向鎖的執行緒是否還活著,如果執行緒不處於活動狀態,則偏向鎖被重置為無鎖狀態。
  2. 如果持有偏向鎖的執行緒還活著而且當前執行緒實際沒有持有著鎖,則偏向鎖被重置為無鎖狀態。
  3. 如果持有偏向鎖的執行緒還活著而且當前執行緒實際持有著鎖(在同步程式碼塊中),那麼試圖獲得偏向鎖的執行緒將等待一個全域性安全點(global safepoint),在全域性安全點,【試圖獲得偏向鎖的執行緒】操作【持有偏向鎖的執行緒的執行緒棧】,遍歷裡面的所有棧幀裡的所有與當前鎖物件相關聯的LockRecord,修改LockRecord裡的內容為輕量級鎖的LockRecord應該有的內容,然後把“最老的”(oldest)一個LockRecord的指標寫到物件的MarkWord裡,至此,就好像是原來從沒有使用過偏向鎖,使用的一直是輕量級鎖。

上面的第3點基本是照著官方文件翻譯的,看了一些書、部落格,對這塊都說的不明白。

以下是我自己的理解:

一個已經持有偏向鎖的執行緒,再次進入這個鎖相關的程式碼塊的時候,雖然不需要執行額外的同步操作,但是依舊會在棧上生成一個空的LockRecord,因此對於一個重入了幾次物件鎖的執行緒來說,棧中就有了關聯同一個物件的多個LockRecord。

而且jvm執行時裡,會記錄著加鎖的次數,每重入一次,就+1;當每次要解鎖的時候,首先會把加鎖次數-1,只有當加鎖次數減到0的時候,才真正的去執行加鎖操作。這個是參考了monitorexit位元組碼的解釋來的:

Note that a single thread can lock an object several times - the runtime system maintains a count of the number of times that the object was locked by the current thread, and only unlocks the object when the counter reaches zero .

而加鎖次數減到0的時候,此時對應的鎖記錄肯定是第一次加鎖的鎖記錄,也就是“最老的”,因此需要把“最老的”鎖記錄的指標寫到物件的MarkWord裡,這樣當執行輕量級鎖解鎖的CAS操作的時候就能夠成功解鎖了。)

偏向鎖優化手段

從上述偏向鎖核心實現我們可以看出來,當訪問一個物件鎖的只有一個執行緒時,偏向鎖確實很快,但是一旦有第二個執行緒來訪問,就可能要膨脹為輕量級鎖,膨脹的開銷是很大的。

所以我們會有一個想法:如果在要給一個物件加偏向鎖的時候,能提前知道這個物件會是由單個執行緒訪問還是多個執行緒訪問就好了。那麼怎麼知道一個沒有被訪問過的物件是不是僅會被單執行緒訪問呢?我們知道每個物件都有對應的類,我們可以通過和這個物件同屬一個類(data type)的其他物件被訪問的情況來推測這個物件將要被訪問的情況。

因此我們可以從data type的維度來批量操作這個data type下的所有物件的偏向鎖:

  1. 當某個data type下的所有物件的偏向鎖發生revoke次數到達一定閾值的時候,將觸發bulk rebias:對該data type下所有物件,將偏向鎖重置為初始狀態(即可以讓下一個訪問的執行緒獲得鎖的狀態),如果物件正在持有鎖(當前在synchronized塊中),則對該物件執行revoke操作使膨脹為輕量級鎖。
  2. 當某個data type下執行的bulk rebias次數達到一定閾值時,會觸發bulk revocation,該data type下所有物件的偏向鎖被膨脹為輕量級鎖,而且未來產生的這個data type的例項物件預設就被禁用了偏向鎖。

總結

其實拋開實現的細節,java的多執行緒很簡單:

java多執行緒主要面臨的問題就是執行緒安全問題 --》

執行緒安全問題是由執行緒間的通訊造成的,多個執行緒間不通訊就沒有執行緒安全問題--》

java中執行緒通訊只能通過類變數和例項變數,因此解決執行緒安全問題就是解決對變數的安全訪問問題--》

java中解決變數的安全訪問採用的是同步的手段,同步是通過鎖實現的--》

有三種鎖能保證變數只有一個執行緒訪問,偏向鎖最快但是隻能用於從始至終只有一個執行緒獲得鎖,輕量級鎖較快但是隻能用於執行緒序列獲得鎖,重量級鎖最慢但是可以用於執行緒併發獲得鎖,先用最快的偏向鎖,每次假設不成立就升級一個重量。