歡迎來到Linux教程網
Linux教程網
Linux教程網
Linux教程網
Linux教程網 >> Linux編程 >> Linux編程 >> 從JVM的角度來看Java的多線程

從JVM的角度來看Java的多線程

日期:2017/3/1 9:05:46   编辑:Linux編程

最近在學習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。

而且在對象的MarkWord裡,會記錄著加鎖的次數,每重入一次,就+1;當每次要解鎖的時候,首先會把對象MarkWord裡的加鎖次數-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中解決變量的安全訪問采用的是同步的手段,同步是通過鎖實現的--》

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

Copyright © Linux教程網 All Rights Reserved