線程的通訊是指線程之間以何種機制來交換信息。在編程中,線程之間的通訊機制有兩種,共享內存和消息傳遞。java
在共享內存的併發模型裏,線程之間共享程序的公共狀態,線程之間經過寫-讀內存中的公共狀態來隱式進行通訊,典型的共享內存通訊方式就是經過共享對象進行通訊。git
在消息傳遞的併發模型裏,線程之間沒有公共狀態,線程之間必須經過明確的發送消息來顯式進行通訊,在java中典型的消息傳遞方式就是wait()和notify()。程序員
同步是指程序用於控制不一樣線程之間操做發生相對順序的機制。github
在共享內存併發模型裏,同步是顯式進行的。程序員必須顯式指定某個方法或某段代碼須要在線程之間互斥執行。
在消息傳遞的併發模型裏,因爲消息的發送必須在消息的接收以前,所以同步是隱式進行的。面試
注意到,Java的併發採用的是共享內存模型,接下來將會主要進行介紹。算法
Java的內存模型以下圖所示:數據庫
每一個Java線程擁有對應的工做內存,工做內寸經過Save和Load操做和主內存進行數據交互。編程
在JVM內部,Java內存模型把內存分紅了兩部分:線程棧區和堆區後端
JVM中運行的每一個線程都擁有本身的線程棧,線程棧包含了當前線程執行的方法調用相關信息,咱們也把它稱做調用棧。隨着代碼的不斷執行,調用棧會不斷變化。數組
線程棧還包含了當前方法的全部局部變量信息。一個線程只能讀取本身的線程棧,也就是說,線程中的本地變量對其它線程是不可見的。即便兩個線程執行的是同一段代碼,它們也會各自在本身的線程棧中建立局部變量,所以,每一個線程中的局部變量都會有本身的版本。
堆中的對象能夠被多線程共享,若是一個線程得到一個對象的應用,它即可訪問這個對象的成員變量。若是兩個線程同時調用了同一個對象的同一個方法,那麼這兩個線程即可同時訪問這個對象的成員變量,可是對於局部變量,每一個線程都會拷貝一份到本身的線程棧中。
上述介紹的JMM也帶來了一些問題:
1. 共享對象對各個線程的可見性
在一個線程中修改了共享數據後,如何保證對另一個線程可見?
2. 共享對象的競爭現象
對於同一個共享數據,如何保證兩個線程正確的修改?
除了以前提到的JMM中存在的兩個問題以外,指令重排也可能對程序的正確性產生影響。
如上圖所示,JVM中爲了提升指令執行的效率,可能在不改變運行結果的狀況下,重排部分指令的順序達到併發執行的效果。
單線程下,這種指令重排序會聽從如下兩個規則:
1. 數據依賴性
在下面三種狀況,數據存在依賴關係,指令重排必須保證這種狀況下的正確性。
2. 控制依賴性
對於控制依賴性,好比下面的例子,b的值依賴於a的狀態,這種狀況下,指令重排也會保證這種關係的正確性。
if (a == 1){ b = 2; }
as-if-serial語義:無論怎麼重排序(編譯器和處理器爲了提升並行度),(單線程)程序的執行結果不會改變。在as-if-serial語義下,編譯器和處理器不會對存在數據依賴關係的操做作重排序,由於這種重排序會改變執行結果。
可是在指令重排並不保證併發執行的正確性,所以可能帶來比較嚴重的問題,好比下面的例子中,use()經過判斷flag是否爲true,來獲取初始化完成的信息。可是因爲指令重排,可能拿到錯誤的a的值。
在併發狀況下,爲了解決重排序帶來的問題,引入了內存屏障來阻止重排序:
用happens-before的概念來闡述操做之間的內存可見性。在JMM中,若是一個操做執行的結果須要對另外一個操做可見,那麼這兩個操做之間必需要存在happens-before關係 。
兩個操做之間具備happens-before關係,並不意味着前一個操做必需要在後一個操做以前執行!happens-before僅僅要求前一個操做(執行的結果)對後一個操做可見,且前一個操做按順序排在第二個操做以前(the first is visible to and ordered before the second)。
對於happens-before,能夠從下面兩個方面去理解:
對用戶來說:若是一個操做happens-before另外一個操做,那麼第一個操做的執行結果將對第二個操做可見,並且第一個操做的執行順序排在第二個操做以前。
對編譯器和處理器來講:兩個操做之間存在happens-before關係,並不意味着Java平臺的具體實現必需要按照happens-before關係指定的順序來執行。若是重排序以後的執行結果,與按happens-before關係來執行的結果一致,那麼這種重排序是容許的。
下面幾種規則,無需任何同步手段就能夠保證:
1)程序順序規則:一個線程中的每一個操做,happens-before於該線程中的任意後續操做。
2)監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
3)volatile變量規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
4)傳遞性:若是A happens-before B,且B happens-before C,那麼A happens-before C。
5)start()規則:若是線程A執行操做ThreadB.start()(啓動線程B),那麼A線程的ThreadB.start()操做happens-before於線程B中的任意操做。
6)join()規則:若是線程A執行操做ThreadB.join()併成功返回,那麼線程B中的任意操做happens-before於線程A從ThreadB.join()操做成功返回。
7 )線程中斷規則:對線程interrupt方法的調用happens-before於被中斷線程的代碼檢測到中斷事件的發生。
volatile變量自身具備下列特性:
可見性。對一個volatile變量的讀,老是能看到(任意線程)對這個volatile變量最後的寫入。
原子性:對任意單個volatile變量的讀/寫具備原子性,但相似於volatile++這種複合操做不具備原子性。
具體來看,能夠把對volatile變量的單個讀/寫,當作是使用同一個鎖對這些單個讀/寫操做作了同步。以下面的例子所示:
等價於:
JMM經過內存屏障插入策略,來實現volatile的讀寫語義。
volatile的底層實現原理:
有volatile變量修飾的共享變量進行寫操做的時候會使用CPU提供的Lock前綴指令。
- 將當前處理器緩存行的數據寫回到系統內存
- 這個寫回內存的操做會使在其餘CPU裏緩存了該內存地址的數據無效。
volatile語義進一步:
編譯器和處理器要遵照兩個重排序規則:
final域爲引用類型
增長了以下規則:在構造函數內對一個final引用的對象的成員域的寫入,與隨後在構造函數外把這個被構造對象的引用賦值給一個引用變量,這兩個操做之間不能重排序。
final語義在處理器中的實現
這是一個很是常見的面試題,標準回答以下:
synchronized 代碼塊是由一對monitorenter/monitorexit 指令實現的,Monitor 對象是同步的基本實現單元。
在 Java 6 以前,Monitor 的實現徹底是依靠操做系統內部的互斥鎖,由於須要進行用戶態到內核態的切換,因此同步操做是一個無差異的重量級操做。
現代的(Oracle)JDK 中,JVM進行了大量改進,提供了三種不一樣的 Monitor 實現,也就是常說的三種不一樣的鎖:偏斜鎖(Biased Locking)、輕量級鎖和重量級鎖,大大改進了其性能。
所謂鎖的升級、降級,就是 JVM 優化 synchronized 運行的機制,當 JVM 檢測到不一樣的競爭情況時,會自動切換到適合的鎖實現,這種切換就是鎖的升級、降級。
當沒有競爭出現時,默認會使用偏斜鎖。JVM 會利用 CAS 操做(compare and swap),在對象頭上的 Mark Word 部分設置線程 ID,以表示這個對象偏向於當前線程,因此並不涉及真正的互斥鎖。這樣作的假設是基於在不少應用場景中,大部分對象生命週期中最多會被一個線程鎖定,使用偏斜鎖能夠下降無競爭開銷。
若是有另外的線程試圖鎖定某個已經被偏斜過的對象,JVM 就須要撤銷(revoke)偏斜鎖,並切換到輕量級鎖實現。輕量級鎖依賴 CAS 操做 Mark Word 來試圖獲取鎖,若是重試成功,就使用普通的輕量級鎖;不然,進一步升級爲重量級鎖。
1. monitor和對象頭
Java對象頭和monitor是實現synchronized的基礎。
synchronized用的鎖是存在Java對象頭裏的。JVM基於進入和退出Monitor對象來實現方法同步和代碼塊同步,任何對象都有一個monitor與之關聯,當且一個monitor被持有後,它將處於鎖定狀態。在實現時,使用到了monitorenter和monitorexit指令,monitorenter指令是在編譯後插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束處和異常處。
Java對象頭
synchronized用的鎖是存在Java對象頭裏的,Hotspot虛擬機的對象頭主要包括兩部分數據:Mark Word(標記字段)、Klass Pointer(類型指針)。
Java對象頭通常佔有兩個機器碼(在32位虛擬機中,1個機器碼等於4字節,也就是32bit),可是若是對象是數組類型,則須要三個機器碼,由於JVM虛擬機能夠經過Java對象的元數據信息肯定Java對象的大小,可是沒法從數組的元數據來確認數組的大小,因此用一塊來記錄數組長度。下圖是Java對象頭的存儲結構(32位虛擬機):
對象頭信息是與對象自身定義的數據無關的額外存儲成本,可是考慮到虛擬機的空間效率,Mark Word被設計成一個非固定的數據結構以便在極小的空間內存存儲儘可能多的數據,它會根據對象的狀態複用本身的存儲空間,也就是說,Mark Word會隨着程序的運行發生變化,變化狀態以下(32位虛擬機):
Monitor
Monitor能夠把它理解爲一個同步工具,也能夠描述爲一種同步機制,它一般被描述爲一個對象。
與一切皆對象同樣,全部的Java對象是天生的Monitor,每個Java對象都有成爲Monitor的潛質,由於在Java的設計中 ,每個Java對象自生成後就自帶一種看不見的鎖,它叫作內部鎖或者Monitor鎖。
Monitor 是線程私有的數據結構,每個線程都有一個可用monitor record列表,同時還有一個全局的可用列表。每個被鎖住的對象都會和一個monitor關聯(對象頭的MarkWord中的LockWord指向monitor的起始地址),同時monitor中有一個Owner字段存放擁有該鎖的線程的惟一標識,表示該鎖被這個線程佔用。其結構以下:
2. 偏向鎖&輕量級鎖&重量級鎖
Java SE 1.6爲了減小得到鎖和釋放鎖帶來的性能消耗,引入了「偏向鎖」和「輕量級鎖」:鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態。
偏向鎖
HotSpot的做者通過研究發現,大多數狀況下,鎖不只不存在多線程競爭,並且老是由同一線程屢次得到。偏向鎖是爲了在只有一個線程執行同步塊時提升性能。
當一個線程訪問同步塊並獲取鎖時,會在對象頭和棧幀中的鎖記錄裏存儲鎖偏向的線程ID,之後該線程在進入和退出同步塊時不須要進行CAS操做來加鎖和解鎖,只需簡單地測試一下對象頭的Mark Word裏是否存儲着指向當前線程的偏向鎖。
引入偏向鎖是爲了在無多線程競爭的狀況下儘可能減小沒必要要的輕量級鎖執行路徑,由於輕量級鎖的獲取及釋放依賴屢次CAS原子指令,而偏向鎖只須要檢查是否爲偏向鎖、鎖標識爲以及ThreadID便可。
獲取鎖的流程:
釋放鎖
偏向鎖的釋放採用了一種只有競爭纔會釋放鎖的機制,線程是不會主動去釋放偏向鎖,須要等待其餘線程來競爭。偏向鎖的撤銷須要等待全局安全點(這個時間點是上沒有正在執行的代碼)。其步驟以下:
關閉偏向鎖
偏向鎖在Java 6和Java 7裏是默認啓用的。因爲偏向鎖是爲了在只有一個線程執行同步塊時提升性能,若是你肯定應用程序裏全部的鎖一般狀況下處於競爭狀態,能夠經過JVM參數關閉偏向鎖:-XX:-UseBiasedLocking=false,那麼程序默認會進入輕量級鎖狀態。
輕量級鎖
輕量級鎖是爲了在線程近乎交替執行同步塊時提升性能。
加鎖過程
在代碼進入同步塊的時候,若是同步對象鎖狀態爲無鎖狀態(鎖標誌位爲「01」狀態,是否爲偏向鎖爲「0」),虛擬機首先將在當前線程的棧幀中創建一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝,官方稱之爲 Displaced Mark Word。這時候線程堆棧與對象頭的狀態以下圖所示。
若是這個更新動做成功了,那麼這個線程就擁有了該對象的鎖,而且對象Mark Word的鎖標誌位設置爲「00」,即表示此對象處於輕量級鎖定狀態,這時候線程堆棧與對象頭的狀態以下圖所示。
若是這個更新操做失敗了,虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,若是是就說明當前線程已經擁有了這個對象的鎖,那就能夠直接進入同步塊繼續執行。不然說明多個線程競爭鎖,若當前只有一個等待線程,則可經過自旋稍微等待一下,可能另外一個線程很快就會釋放鎖。 可是當自旋超過必定的次數,或者一個線程在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖膨脹爲重量級鎖,重量級鎖使除了擁有鎖的線程之外的線程都阻塞,防止CPU空轉,鎖標誌的狀態值變爲「10」,Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,後面等待鎖的線程也要進入阻塞狀態。
解鎖過程
自旋鎖
1.基於樂觀狀況下推薦使用,即鎖競爭不強,鎖等待時間不長的狀況下推薦使用
2.單cpu無效,由於基於cas的輪詢會佔用cpu,致使沒法作線程切換
3.輪詢不產生上下文切換,若是可估計到睡眠的時間很長,用互斥鎖更好
重量級鎖
如上輕量級鎖的加鎖過程見輕量級鎖的步驟(5),輕量級鎖所適應的場景是線程近乎交替執行同步塊的狀況,若是存在同一時間訪問同一鎖的狀況,就會致使輕量級鎖膨脹爲重量級鎖。Mark Word的鎖標記位更新爲10,Mark Word指向互斥量(重量級鎖)。
Synchronized的重量級鎖是經過對象內部的一個叫作監視器鎖(monitor)來實現的,監視器鎖本質又是依賴於底層的操做系統的Mutex Lock(互斥鎖)來實現的。而操做系統實現線程之間的切換須要從用戶態轉換到核心態,這個成本很是高,狀態之間的轉換須要相對比較長的時間,這就是爲何Synchronized效率低的緣由。
3. 三種鎖的切換
偏向所鎖,輕量級鎖都是樂觀鎖,重量級鎖是悲觀鎖。
悲觀鎖
老是假設最壞的狀況,每次去拿數據的時候都認爲別人會修改,因此每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會阻塞直到它拿到鎖(共享資源每次只給一個線程使用,其它線程阻塞,用完後再把資源轉讓給其它線程)。傳統的關係型數據庫裏邊就用到了不少這種鎖機制,好比行鎖,表鎖等,讀鎖,寫鎖等,都是在作操做以前先上鎖。Java中synchronized和ReentrantLock等獨佔鎖就是悲觀鎖思想的實現。
樂觀鎖
老是假設最好的狀況,每次去拿數據的時候都認爲別人不會修改,因此不會上鎖,可是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,可使用版本號機制和CAS算法實現。樂觀鎖適用於多讀的應用類型,這樣能夠提升吞吐量,像數據庫提供的相似於write_condition機制,其實都是提供的樂觀鎖。在Java中java.util.concurrent.atomic包下面的原子變量類就是使用了樂觀鎖的一種實現方式CAS實現的。
樂觀鎖適用於寫比較少的狀況下(多讀場景),即衝突真的不多發生的時候,這樣能夠省去了鎖的開銷,加大了系統的整個吞吐量。但若是是多寫的狀況,通常會常常產生衝突,這就會致使上層應用會不斷的進行retry,這樣反卻是下降了性能,因此通常多寫的場景下用悲觀鎖就比較合適。
參考連接:
本文由『後端精進之路』原創,首發於博客 http://teckee.github.io/ , 轉載請註明出處
搜索『後端精進之路』關注公衆號,馬上獲取最新文章和價值2000元的BATJ精品面試課程。