Part 002: Java併發機制的底層實現原理

1.volatile實現原理

Java語言規範第3版中對volatile的定義以下:java

  • Java編程語言容許線程訪問共享變量,爲了確保共享變量能被準確和一致的更新,線程應該確保經過排他鎖單獨得到這個變量。

1.1 volatile特性

具備可見性、有序性,不具有原子性。編程

1.2 volatile適用場景

1.適用於對變量的寫操做不依賴於當前值,對變量的讀取操做不依賴於非volatile變量。 2.適用於讀多寫少的場景。數組

1.3 volatile是如何保證可見性呢?

若是一個變量被聲明爲volatile,Java線程內存模型確保全部線程看到這個變量的值是一致的。緩存

volatile關鍵字修飾的共享變量進行寫操做時在彙編代碼中會多出lock行代碼,經過查IA-32架構軟件開發者手冊可知,lock前綴的指令在多核處理器下會引起兩件事: a.將當前處理器緩存行的數據寫回到系統內存。b.這個寫回內存的操做會使其餘CPU緩存了該內存地址的數據無效。 volatile兩條實現原則:安全

  • a.Lock前綴指令會引發處理器緩存回寫到內存。

LOCK#信號確保在聲言該信號期間,處理器能夠獨佔任何共享內存,也就是「鎖總線」。目前較新的處理器中,LOCK#信號通常是「鎖緩存」(若是訪問的內存區域已經緩存在處理器內部,則不會聲言LOCK#信號。相反,它會鎖定這個內存區域並回寫到內存,並使用緩存一致性機制來確保修改操做的原子性。此操做被稱爲:緩存鎖定),由於鎖總線的開銷比較大(鎖住總線,致使其餘CPU沒法訪問總線,不能訪問總線就意味着不能訪問系統內存)。多線程

  • b.一個處理器的緩存回寫到內存,會致使其餘處理器的緩存失效。

處理器使用嗅探技術保證它的內部緩存、系統內存和其餘處理的緩存數據在總線上保持一致。架構

2.synchronized實現原理

synchronized被不少人稱之爲重量鎖,通過Java SE 1.6優化後,有些狀況下它已經不那麼重了。synchronized實現同步的基礎是:Java中每一個對象均可以做爲鎖。具體表現爲:併發

  • 對於普通同步方法,鎖是當前實例的對象。
  • 對於靜態同步方法,鎖是當前類的Class對象。
  • 對於同步方法塊,鎖是Synchronized括號裏配置的對象。

當一個線程試圖訪問同步代碼塊時,必須首先得到鎖,退出或異常時釋放鎖,那麼鎖到底在哪裏?裏面存儲的信息有哪些呢?app

JVM基於進入和退出Monitor對象來實現方法同步和代碼塊同步,代碼塊同步是使用monitorenter和monitorexit指令來實現的,而方法同步是使用另外一種方式實現,在JVM中並無詳細說明,但方法同步一樣可使用這兩個指令來實現。編程語言

monitorenter指令是在編譯後插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束處和異常處,JVM要保證每一個monitorenter必須有對應的monitorexit與之配對。任何一個對象都有一個monitor與之關聯,當一個monitor被持有後,它便處於鎖定狀態。線程執行到monitorenter指令時,會嘗試獲取該對象對應的monitor的全部權,即嘗試獲取該對象的鎖。

2.1 Java對象頭

synchronized用的鎖是存在Java對象頭裏的。如對象爲數組類型,則虛擬機使用3個字寬(Word)來存儲對象頭(比非數組類型多存儲了一個數組長度),非數組類型使用2個字寬存儲對象頭。 |長度|內容|說明| |----|-----|------| |32/64bit | Mark Word| 存儲對象的hashCode或鎖信息| |32/64bit|Class Metadata Address|存儲到對象類型數據的指針| |32/64bit|Array Length|數組的長度(若是對象爲數組類型)|

Mark Word裏默認存儲對象的HashCode、分代年齡和鎖標記位。

32位虛擬機默認存儲結構 |鎖狀態|25bit|4bit|1bit是否偏向鎖|2bit 鎖標誌位| |-----|-----|------|------|------| |無鎖狀態|對象的hashCode|對象分代年齡|0|01| 運行期間,Mark Word存儲的數據會隨着鎖標誌位的變化而變化。 64位虛擬機默認存儲結構 |鎖狀態|25bit|31bit|1bit cms_free|4bit 分代年齡|1bit 是否偏向鎖|2bit 鎖標誌位| |-----|-----|------|------|------|------|------| |無鎖狀態|unused|hashCode|||0|01| |偏向鎖|ThreadID(54bit)Epoch(2bit)|||1|01|

2.2 鎖的升級和對比

鎖一共四種狀態:無鎖狀態,偏向鎖狀態,輕量級鎖狀態,重量級鎖狀態。 鎖只能升級而不能降級,這麼作的目的是爲了提升得到鎖和釋放鎖的效率。

2.2.1 偏向鎖

HotSpot做者研究發現:鎖不只存在多線程競爭,並且老是有同一線程屢次得到。爲了讓線程得到鎖的代價更低而引入了偏向鎖。

具體步驟:

  • 1.當某一線程訪問同步塊並獲取鎖時,會在對象頭和棧幀中記錄存儲鎖偏向的線程ID
  • 2.若是該線程再次進入和退出同步塊時,不須要進行CAS操做來加鎖和解鎖,而只須要判斷下對象頭中的Mark Word裏是否存儲着當前線程的偏向鎖。若是Mark Word中存在指向該線程的偏向鎖,則表示該線程已經得到了鎖。
  • 3.若是Mark Word不存在指向該線程的偏向鎖,則判斷該Mark Word中偏向鎖標識是否設置爲1(表示當前爲偏向鎖)。若是沒有設置,則使用CAS競爭鎖。若是已經設置,則嘗試使用CAS將對象頭的偏向鎖指向當前線程。

偏向鎖採用一種等到競爭纔會釋放鎖的機制,因此當其餘線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放鎖。偏向鎖的撤銷,須要等到全局安全點(在這個時間點上沒有正在執行的字節碼)

2.2.2 輕量級鎖

a.輕量級鎖加鎖

線程執行同步塊時,JVM會如今當前線程的棧幀中建立用於存儲鎖記錄的空間,並將對象頭中的Mark Word複製到鎖記錄中(官方稱之爲: Displaced Mark Word)。 而後線程嘗試使用CAS將對象頭中的Mark Word替換爲指向鎖記錄的指針。若成功,則線程得到鎖,若失敗,表示其餘線程競爭鎖,當前線程嘗試使用自旋獲取鎖。

b.輕量級鎖解鎖

解鎖時,會使用CAS操做將Displaced Mark Word 替換回到對象頭,若是成功,表示競爭沒有發生。若是失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。

由於自旋會消耗CPU,爲了不無用的自旋,一旦線程升級爲重量級鎖,就不能再恢復到輕量級鎖狀態。當鎖處於該狀態下時,其餘線程試圖獲取鎖時,都會被阻塞,只有當持有該鎖的線程釋放鎖後,纔會喚醒這些線程。被喚醒的線程進行新一輪的「九子奪嫡」奪鎖之爭。

2.2.3 鎖優缺點對比

優勢 缺點 適用場景
偏向鎖 加鎖解鎖不需額外消耗,和執行非同步方法相比僅存在納秒級的差距 線程間存在鎖競爭,會帶來額外鎖撤銷消耗 適用一個線程訪問同步塊場景
輕量級鎖 競爭的線程不會阻塞,提升了程序相應速度 若是始終得不到鎖競爭的線程,使用自旋會消耗CPU 追求響應時間,同步塊執行速度很是快
重量級鎖 線程競爭不使用自旋,不會消耗CPU 線程阻塞,響應時間緩慢 追求吞吐量,同步塊執行速度較長

3.原子操做的實現原理

原子操做意爲「不可中斷的一個或一系列操做」。在多處理器上實現原子操做就變得有點複雜。

3.1 處理器如何實現原子操做

處理器基於對緩存加鎖或總線加鎖的方式來保證操做的原子性,當一個處理器讀取一個字節時,其餘處理器不能訪問這個字節的內存地址。較新的處理器能保證單處理器對同一個緩存行進行16/32/64位操做是原子的,而對於較爲複雜的內存操做,處理器是不能自動保證其操做的原子性的。好比,跨總線寬度,跨多個緩存行和跨頁表訪問。但處理器提供了總線鎖和緩存鎖這兩個機制來保證複雜內存操做的原子性。

3.1.1 使用總線鎖保證原子性

若是多個處理器同事對共享變量就行讀改寫操做(i++),那麼共享變量就會被多個處理器同時操做,這樣讀改寫的操做就不是原子的,操做完後共享變量的值會和指望值不一致。緣由是多個處理器同時出各自的緩存中讀取變量,而後分別進行+1操做,再分別寫入系統內存中。 而總線鎖就是使用處理器提供的LOCK#信號,當一個處理器在總線上輸出該信號時,其餘處理器的請求將被阻塞,那麼該處理器就能夠獨佔內存。

3.1.2 使用緩存鎖保證原子性

緩存鎖定:內存區域若是被緩存在處理器的緩存行中,而且在LOCK操做期間被鎖定,那麼當它執行鎖操做回寫到內存時,處理器不在總線上聲言LOCK#信號,而是修改內部的內存地址,並容許它的緩存一致性機制來保證操做的原子性,由於緩存一致性機制會組織同時修改由兩個以上處理器緩存的內存區域數據。當其餘處理器回寫已被鎖定的緩存行時,會使緩存行無效。

3.1.3 不使用緩存鎖定的場景

1.操做數據不能被緩存或跨多個緩存行。此時會調用總線鎖。 2.部分處理器不支持緩存鎖定。如Pentium處理器和Intel 486。

3.2 Java如何實現原子操做

在Java中能夠經過鎖和循環CAS的方式來實現原子操做。

3.2.1 使用循環CAS實現原子操做

JVM中的CAS操做利用了處理器提供的CMPXCHG指令實現的。自旋CAS實現的基本思路就是循環進行CAS操做直到成功爲止。

從Java1.5開始,JDK的併發包提供了一些類來支持原子操做,如AtomicLong,AtomicBoolean,AtomicInteger。還提供了一些原子的自增1和自減1方法。

CAS原子操做的三大問題:

  • 1.ABA問題

CAS操做值時,要檢查值有沒有發生變化,若是沒有發生變化則更新,但若是變量原始值爲A,變成了B,又變成了A。那麼使用CAS檢查時,會認爲該值沒有發生變化,但實際上卻發生變化了。解決辦法:在變量更新時增長版本號,更改一次版本號加1。Java1.5開始,新增了一個AtomicStampedReference來解決ABA問題。該類中的compareAndSet方法就是首先檢查當前引用是否等於預期引用,而後檢查當前標誌是否等於預期標誌,若相等,則以原子方式將該引用和該標誌的值設置爲給定的更新值。

  • 2.循環時間長開銷大

自旋CAS長時間不成功,會給CPU帶來很是大的執行開銷。若JVM支持處理器的pause指令,則效率會有必定提高。pause指令做用:a.延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源。b.避免退出循環時,因內存順序衝突(Memory Order Violation)而引發CPU流水線被清空(CPU Pipeline Flush),從而提升CPU的執行效率。

  • 3.只能保證一個共享變量的原子操做

解決方法:(1)使用鎖。(2)把多個共享變量合併成一個共享變量來操做,好比i=2,j=a,合併爲ij=2a。(3)從java1.5開始,JDK提供了AtomicReference類,能夠將多個變量放在一個對象中來實現原子操做。

3.2.2 使用鎖實現原子操做

JVM內部的鎖有偏向鎖,輕量級鎖,互斥鎖。但除了偏向鎖,JVM實現鎖的方式都運用了循環CAS,線程進入和退出同步塊時都使用CAS來獲取鎖和釋放鎖。

4.動手操做

本節使用代碼驗證volatile和synchronized關鍵字的使用。 1.驗證volatile具備可見性,不具有原子性。

public volatile int ivl = 0;

	public static void main(String[] args) {
		final App app = new App();
		for (int i = 0; i < 20; i++) {
			new Thread() {
				@Override
				public void run() {
					for (int j = 0; j < 10000; j++) {
						app.doSome();
					}

				}
			}.start();
		}
		while (Thread.activeCount() > 1) { // 保證前面的線程都執行完成
			Thread.yield();
		}
		System.out.println(app.ivl);

	}

	/**
	 * 自增方法
	 * 
	 * @return
	 */
	public int doSome() {
		return ivl++;
	}
}

若是執行這段代碼,會發現,每次輸出的值都是不同的。並且始終都是小於20*10000的值。自增操做是不具有原子性的,具體緣由上面已經分析過。 若是要使獲得的結果等於20*10000,可使用synchronized修飾doSome()方法,這樣在處理器讀取內存時加LOCK#指令,保證內存只能被一個處理器訪問和修改。

相關文章
相關標籤/搜索