search
尋找貓咪~QQ 地點 桃園市桃園區 Taoyuan , Taoyuan

多線程的目的

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

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

其實多線程根本的問題只有一個:線程間變數的共享

java里的變數可以分3類:

  • 類變數(類裡面static修飾的變數)
  • 實例變數(類裡面的普通變數)
  • 局部變數(方法里聲明的變數)

下圖是jvm的內存區域劃分圖:

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

  • 類變數 保存在「方法區」
  • 實例變數 保存在「堆」
  • 局部變數 保存在 「虛擬機棧」

「方法區」和「堆」都屬於線程共享數據區,「虛擬機棧」屬於線程私有數據區。

因此,局部變數是不能多個線程共享的,而類變數和實例變數是可以多個線程共享的。事實上,在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步操作:

  • 把主內存里的a複製到線程的工作內存
  • 線程對工作內存里的a執行a=a+1
  • 把線程工作內存里的a同步回主內存

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

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

同步(synchronized)就是:在多個線程併發訪問共享數據的時候,保證共享數據在同一個時刻只被一個線程使用。

同步基本思想

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

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

  • 偏向鎖
  • 輕量級鎖
  • 重量級鎖

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

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

MarkWord:java中的每個對象在存儲的時候,都有統一的數據結構。每個對象都包含一個對象頭,稱為MarkWord,裡面會保存關於這個對象的加鎖信息。

Lock Record: 即鎖記錄,每個線程在執行的時候,會有自己的虛擬機棧,當個方法的調用相當於虛擬機棧里的一個棧幀,而Lock Record就位於棧幀上,是用來保存關於這個線程的加鎖信息。

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

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

  • 在共享數據里保存一個鎖//java同步是通過synchronized關鍵字實現的,synchronized有三種用法:一種是同步塊,這種用法需要指明一個鎖定對象;一種是修飾靜態方法,這種用法相當於鎖定Class對象;一種是修飾普通方法,這種用法相當於鎖定方法所在的實例對象。因此,在java里能夠被synchronized關鍵字鎖定的一定是對象,因此就要在對象里保存一個鎖,而對象內存結構里的MarkWord就可以認為是這個鎖。三種鎖雖然實現細節不同,但是都是使用MarkWord保存鎖的。
  • 在鎖里保存這個線程的標識//偏向鎖是在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操作,分情況討論:

  • 判斷持有偏向鎖的線程是否還活著,如果線程不處於活動狀態,則偏向鎖被重置為無鎖狀態。
  • 如果持有偏向鎖的線程還活著而且當前線程實際沒有持有著鎖,則偏向鎖被重置為無鎖狀態。
  • 如果持有偏向鎖的線程還活著而且當前線程實際持有著鎖(在同步代碼塊中),那麼試圖獲得偏向鎖的線程將等待一個全局安全點(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下的所有對象的偏向鎖:

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

總結

其實拋開實現的細節,java的多線程很簡單:

java多線程主要面臨的問題就是線程安全問題 --》

線程安全問題是由線程間的通信造成的,多個線程間不通信就沒有線程安全問題--》

java中線程通信只能通過類變數和實例變數,因此解決線程安全問題就是解決對變數的安全訪問問題--》

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

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



熱門推薦

本文由 yidianzixun 提供 原文連結

寵物協尋 相信 終究能找到回家的路
寫了7763篇文章,獲得2次喜歡
留言回覆
回覆
精彩推薦