最近在學習jvm,發現隨着對虛擬機底層的瞭解,對java的多線程也有了全新的認識,原來一個小小的synchronized關鍵字裏別有洞天。決定把本身關於java多線程的所學整理成一篇文章,從最基礎的爲何使用多線程,一直深刻講解到jvm底層的鎖實現。java
爲何要使用多線程?能夠簡單的分兩個方面來講:緩存
其實多線程根本的問題只有一個:線程間變量的共享安全
java裏的變量能夠分3類:數據結構
下圖是jvm的內存區域劃分圖:多線程
根據各個區域的定義,咱們能夠知道:併發
「方法區」和「堆」都屬於線程共享數據區,「虛擬機棧」屬於線程私有數據區。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步操做:
而5個線程併發執行的時候徹底有可能5個線程都先執行了第一步,這樣5個線程的工做內存裏a的初始值都是0,而後執行a=a+1後在工做內存裏的運算結果都是1,最後同步回主內存的值確定也是1。
而避免這種狀況的方法就是:在多個線程併發訪問a的時候,保證a在同一個時刻只被一個線程使用。
同步(synchronized)就是:在多個線程併發訪問共享數據的時候,保證共享數據在同一個時刻只被一個線程使用。
爲了保證共享數據在同一時刻只被一個線程使用,咱們有一種很簡單的實現思想,就是在共享數據裏保存一個鎖,當沒有線程訪問時,鎖是空的,當有第一個線程訪問時,就在鎖裏保存這個線程的標識並容許這個線程訪問共享數據。在當前線程釋放共享數據以前,若是再有其餘線程想要訪問共享數據,就要等待鎖釋放。
咱們把這種思想的三個關鍵點抽出來:
能夠說jvm中的三種鎖都是以上述思想爲基礎的,只是實現的「重量級」不一樣,jvm中有如下三種鎖(由上到下愈來愈「重量級」):
其中重量級鎖是最初的鎖機制,偏向鎖和輕量級鎖是在jdk1.6加入的,能夠選擇打開或關閉。若是把偏向鎖和輕量級鎖都打開,那麼在java代碼中使用synchronized關鍵字的時候,jvm底層會嘗試先使用偏向鎖,若是偏向鎖不可用,則轉換爲輕量級鎖,若是輕量級鎖不可用,則轉換爲重量級鎖。具體轉換過程下面會講。
要想深刻了解這3種鎖須要瞭解對象的內存結構(MarkWord頭),會涉及到字節碼的內部存儲格式,可是其實我以爲脫離細節的實現,單從原理上理解這三個鎖是很容易的,只須要了解兩個大致的概念:
MarkWord:java中的每一個對象在存儲的時候,都有統一的數據結構。每一個對象都包含一個對象頭,稱爲MarkWord,裏面會保存關於這個對象的加鎖信息。
Lock Record: 即鎖記錄,每一個線程在執行的時候,會有本身的虛擬機棧,當個方法的調用至關於虛擬機棧裏的一個棧幀,而Lock Record就位於棧幀上,是用來保存關於這個線程的加鎖信息。
最初jvm沒有前兩種鎖(前兩種都是jdk1.6才引入的),只有重量級鎖。
咱們以前給出了同步基本思想的三個點,咱們也說了jvm的三種鎖都是以基本思想爲基礎的,而這三種鎖在第一、2點的實現上本質上是同樣的:
而區分這三種鎖的關鍵,就是同步基本思想的第三點:
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操做,分狀況討論:
上面的第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下的全部對象的偏向鎖:
其實拋開實現的細節,java的多線程很簡單:
java多線程主要面臨的問題就是線程安全問題 --》
線程安全問題是由線程間的通訊形成的,多個線程間不通訊就沒有線程安全問題--》
java中線程通訊只能經過類變量和實例變量,所以解決線程安全問題就是解決對變量的安全訪問問題--》
java中解決變量的安全訪問採用的是同步的手段,同步是經過鎖實現的--》
有三種鎖能保證變量只有一個線程訪問,偏向鎖最快可是隻能用於從始至終只有一個線程得到鎖,輕量級鎖較快可是隻能用於線程串行得到鎖,重量級鎖最慢可是能夠用於線程併發得到鎖,先用最快的偏向鎖,每次假設不成立就升級一個重量。