深刻學習Java多線程——併發機制底層實現原理

    Java代碼在編譯後會變成Java字節碼,字節碼被類加載器加載到JVM裏,JVM執行字節碼,最終須要轉化爲彙編指令在CPU上執行,Java中所使用的併發機制依賴於JVM的實現和CPU的指令。建議先對Java併發的內存模型進行了解。html

    對於併發編程的底層實現,必需要保證明現三大特性:java

  1. 可見性:即多個線程訪問同一個變量時,一個線程修改了這個變量的值,其餘線程可以當即看獲得修改的值。
  2. 原子性:一個操做或者多個操做要麼所有執行而且執行的過程不會被任何因素打斷,或者一旦中斷就都不執行。
  3. 有序性:程序執行的順序按照代碼的前後順序執行。

1.volatile

    在多線程併發編程中synchronized和volatile都扮演着重要的角色,volatile是輕量級的synchronized,它在多處理器開發中保證了共享變量的「可見性」。可見性的意思是當一個線程修改一個共享變量時,另一個線程能讀到這個修改的值。若是volatile變量修飾符使用恰當的話,它比synchronized的使用和執行成本更低,由於它不會引發線程上下文的切換和調度。編程

推薦博客:緩存

http://www.importnew.com/24082.html安全

 http://www.cnblogs.com/dolphin0520/p/3920373.html多線程

1.1實現原理

    實現可見性的底層原理,可經過觀察Java代碼與彙編代碼查看。併發

Java代碼:ide

instance = new Singleton(); // instance是volatile變量

彙編代碼:性能

0x01a3de1d: movb $0×0,0×1104800(%esi);
0x01a3de24: lock addl $0×0,(%esp);

    有volatile變量修飾的共享變量進行寫操做的時候會多出第二行彙編代碼,Lock前綴的指令在多核處理器下會引起了兩件事情:學習

    (1)將當前處理器緩存行的數據寫回到系統內存。

    (2)這個寫回內存的操做會使在其餘CPU裏緩存了該內存地址的數據無效。

    本來爲了提升處理速度,處理器不直接和內存進行通訊,而是先將系統內存的數據讀到內部緩存後再進行操做,但操做完不知道什麼時候會寫到內存。

    可是,若是對聲明瞭volatile的變量進行寫操做,JVM就會向處理器發送一條Lock前綴的指令,將這個變量所在緩存行的數據寫回到系統內存。同時還有一個問題,就算寫回到內存,若是其餘處理器緩存的值仍是舊的,再執行計算操做就會有問題。因此,在多處理器下,爲了保證各個處理器的緩存是一致的,就會實現緩存一致性協議,每一個處理器經過嗅探在總線上傳播的數據來檢查本身緩存的值是否是過時了,當處理器發現本身緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器對這個數據進行修改操做的時候,會從新從系統內存中把數據讀處處理器緩存裏。

    我我的理解就是:在多核處理器中,每一個處理器處理計算一個線程的(任務)代碼,好比說一個四核處理器,有一個核正在處理一個包含對共享變量進行更改賦值的操做的線程,另外三個處理器處理一個包含讀取同一個共享變量操做的線程。

    若是該共享變量不是volatile,首先,CPU會從系統內存中獲取數據到CPU緩存中進行相應的處理(關於內存、高速緩存和CPU寄存器,能夠參考計算機中內存、cache和寄存器之間的關係及區別),當處理對共享變量進行更改賦值的操做完成後,並不必定會當即將處理後的數據寫回系統內存,這就可能會致使當某個賦值操做完成(即更改操做的那行代碼執行)後,另外一個讀取共享變量的線程會讀到錯誤數據,或者說未改變的數據。(以下列代碼測試中兩個線程的i值應該至少一個爲2,可是兩個都爲1就說明發生了這種狀況)

    若是該共享變量是volatile的,那麼CPU會從系統內存中獲取數據到CPU緩存中進行相應的處理,當處理對共享變量進行更改賦值的操做(即更改操做的那行代碼執行)完成後,會當即將處理後的數據寫回系統內存,而且其餘三個處理器經過緩存一致性協議檢查本身緩存的數據是否過時,是則會從新從系統內存讀取。

    簡單來講,volatile的兩條實現原則是:

    (1)Lock前綴的彙編指令會引發處理器緩存回寫到內存

    (2)一個處理器的緩存回寫到內存會致使其餘處理器的緩存無效。

//volatile 關鍵字修飾的變量與無該關鍵字修飾的變量在多線程讀改寫時的區別
public class KeyWord_volatile{
	int i=0;
	volatile int x=0;
	class Runner implements Runnable{
		public void run() {
			i++;
			System.out.println(Thread.currentThread().getName()+"計算的i爲:"+i);
			x++;
			System.out.println(Thread.currentThread().getName()+"計算的x爲:"+x);
		}
	}
	Runnable getRun(){
		return new Runner();
	}
	public static void main(String[] args) {
		KeyWord_volatile v=new KeyWord_volatile();
		Runner r1=(Runner) v.getRun();
		Runner r2=(Runner) v.getRun();
		Thread t1=new Thread(r1);
		Thread t2=new Thread(r1);
		t1.start();
		t2.start();
	}
}
//測試結果(隨機,可能會發生)
Thread-1計算的i爲:1
Thread-0計算的i爲:1
Thread-1計算的x爲:1
Thread-0計算的x爲:2

 

2.synchronized

2.1 實現原理

(1)synchronized實現同步的基礎:Java中的每個對象均可以做爲鎖。具體表現爲如下3種形式。

  1. 對於普通同步方法,鎖是當前實例對象。
  2. 對於靜態同步方法,鎖是當前類的Class對象。
  3. 對於同步方法塊,鎖是Synchonized括號裏配置的對象。當一個線程試圖訪問同步代碼塊時,它首先必須獲得鎖,退出或拋出異常時必須釋放鎖。

    當一個線程試圖訪問synchronized同步代碼塊時,它首先必須獲得鎖,退出或拋出異常時必須釋放鎖。那麼這個鎖是什麼? 存儲在那裏?

(2) Synchonized在JVM裏的實現原理:JVM基於進入和退出Monitor對象來實現方法同步和代碼塊同步,但二者的實現細節不同。代碼塊同步是使用monitorenter 和monitorexit指令實現的,而方法同步是使用另一種方式實現的,細節在JVM規範裏並無詳細說明。可是,方法的同步一樣可使用這兩個指令來實現。 monitorenter指令是在編譯後插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束處和異常處,JVM要保證每一個monitorenter必須有對應的monitorexit與之配對。任何對象都有一個monitor與之關聯,當且一個monitor被持有後,它將處於鎖定狀態。線程執行到monitorenter指令時,將會嘗試獲取對象所對應的monitor的全部權,即嘗試得到對象的鎖。synchronized用的鎖是存在Java對象頭裏的

(3)對象頭

   https://blog.csdn.net/yinbucheng/article/details/70037521

2.2 鎖的升級與對比

    爲了減小得到鎖和釋放鎖帶來的性能消耗,引入了「偏向鎖」和「輕量級鎖」。鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨着競爭狀況逐漸升級。鎖能夠升級但不能降級,意味着偏向鎖升級成輕量級鎖後不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是爲了提升得到鎖和釋放鎖的效率。

1.偏向鎖

    大多數狀況下,鎖不只不存在多線程競爭,並且老是由同一線程屢次得到,爲了讓線程得到鎖的代價更低而引入了偏向鎖。當一個線程訪問同步塊並獲取鎖時,會在對象頭和棧幀中的鎖記錄裏存儲鎖偏向的線程ID,之後該線程在進入和退出同步塊時不須要進行CAS操做來加鎖和解鎖,只需簡單地測試一下對象頭的Mark Word裏是否存儲着指向當前線程的偏向鎖。若是測試成功,表示線程已經得到了鎖。若是測試失敗,則須要再測試一下Mark Word中偏向鎖的標識是否設置成1(表示當前是偏向鎖):若是沒有設置,則 使用CAS競爭鎖;若是設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程。

(1)偏向鎖的撤銷

    偏向鎖使用了一種等到競爭出現才釋放鎖的機制,因此當其餘線程嘗試競爭偏向鎖時, 持有偏向鎖的線程纔會釋放鎖。偏向鎖的撤銷,須要等待全局安全點(在這個時間點上沒有正在執行的字節碼)。它會首先暫停擁有偏向鎖的線程,而後檢查持有偏向鎖的線程是否活着, 若是線程不處於活動狀態,則將對象頭設置成無鎖狀態;若是線程仍然活着,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄,棧中的鎖記錄和對象頭的Mark Word要麼從新偏向於其餘線程,要麼恢復到無鎖或者標記對象不適合做爲偏向鎖,最後喚醒暫停的線程。

(2)關閉偏向鎖:偏向鎖在Java 6和Java 7裏是默認啓用的,可是它在應用程序啓動幾秒鐘以後才激活,如 有必要可使用JVM參數來關閉延遲:-XX:BiasedLockingStartupDelay=0。若是你肯定應用程 序裏全部的鎖一般狀況下處於競爭狀態,能夠經過JVM參數關閉偏向鎖:-XX:- UseBiasedLocking=false,那麼程序默認會進入輕量級鎖狀態。

2.輕量級鎖

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

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

    由於自旋會消耗CPU,爲了不無用的自旋(好比得到鎖的線程被阻塞住了),一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處於這個狀態下,其餘線程試圖獲取鎖時, 都會被阻塞住,當持有鎖的線程釋放鎖以後會喚醒這些線程,被喚醒的線程就會進行新一輪的奪鎖之爭。

3.各級別鎖的優缺點對比

還能夠參考學習這篇文章http://www.javashuo.com/article/p-eawupvdg-ba.html

3.原子操做的實現

3.1 處理器實現原子操做

1.相關CPU術語

  1. 緩存行:緩存的最小存儲單位。
  2. CAS(比較並交換,即compare and swap):須要輸入兩個數值,一箇舊值(操做前指望的值),一個新值,在操做期間,先比較舊值是不是指望的舊值,若是是則表示沒有發生變化,則進行交換返回true,不然不進行交換並返回false。
  3. CPU流水線:相似於工業生產時的裝配流水線,在CPU中有多個不一樣功能的電路單元組成一條指令處理流水線,而後將一條處理器指令分紅多個部分,與處理單元一一對應,分別執行提升運算速度。
  4. 內存順序衝突:由假共享引發,假共享是指多個cpu同時修改同一個緩存行的不一樣部分而引發的其中一個CPU的操做無效,當出現內存順序衝突時,CPU必須清空流水線。

2.實現方式

    處理器提供總線鎖定和緩存鎖定兩個機制來保證複雜內存操做的原子性。

    (1)使用總線鎖定:若是多個處理器同時對共享變量進行讀改寫操做 (i++就是經典的讀改寫操做),那麼共享變量就會被多個處理器同時進行操做,這樣讀改寫操做就不是原子的,操做完以後共享變量的值會和指望的不一致。舉個例子,若是i=1,咱們進行兩次i++操做,咱們指望的結果是3,可是有可能結果是2。緣由多是多個處理器同時從各自的緩存中讀取變量i,分別進行加1操做,而後分別寫入系統內存中。那麼,想要保證讀改寫共享變量的操做是原子的,就必須保證CPU1讀改寫共享變量的時候,CPU2不能操做緩存了該共享變量內存地址的緩存。

    處理器使用總線鎖就是來解決這個問題的。所謂總線鎖就是使用處理器提供的一個 LOCK#信號,當一個處理器在總線上輸出此信號時,其餘處理器的請求將被阻塞住,那麼該處理器能夠獨佔共享內存。

//volatile 關鍵字使用的時緩存鎖來實現
public class KeyWord_volatile{
	int i=0;
	volatile int x=0;
	class Runner implements Runnable{
		public void run() {
			i++;
			System.out.println(Thread.currentThread().getName()+"計算的i爲:"+i);
			x++;
			System.out.println(Thread.currentThread().getName()+"計算的x爲:"+x);
		}
	}
	Runnable getRun(){
		return new Runner();
	}
	public static void main(String[] args) {
		KeyWord_volatile v=new KeyWord_volatile();
		Runner r1=(Runner) v.getRun();
		Runner r2=(Runner) v.getRun();
		Thread t1=new Thread(r1);
		Thread t2=new Thread(r1);
		t1.start();
		t2.start();
	}
}
//測試結果(隨機,可能會發生)
Thread-1計算的i爲:1
Thread-0計算的i爲:1
Thread-1計算的x爲:1
Thread-0計算的x爲:2

    (2)使用緩存鎖保證原子性:在同一時刻,咱們只需保證對某個內存地址的操做是原子性便可,但總線鎖定把CPU和內存之間的通訊鎖住了,這使得鎖按期間,其餘處理器不能操做其餘內存地址的數據,因此總線鎖定的開銷比較大,目前處理器在某些場合下使用緩存鎖定代替總線鎖定來進行優化。

    處理器可使用「緩存鎖定」的方式來實現複雜的原子性。所謂「緩存鎖定」是指內存區域若是被緩存在處理器的緩存行中,而且在Lock操做期間被鎖定,那麼當它執行鎖操做回寫到內存時,處理器不在總線上發出LOCK#信號,而是修改內部的內存地址,並容許它的緩存一致性機制來保證操做的原子性,由於緩存一致性機制會阻止同時修改由兩個以上處理器緩存的內存區域數據,當其餘處理器回寫已被鎖定的緩存行的數據時,會使緩存行無效。

    有兩種狀況處理器不能使用緩存鎖定:

    (1)第一種狀況是:當操做的數據不能被緩存在處理器內部,或操做的數據跨多個緩存行 時,則處理器會調用總線鎖定。

    (2)第二種狀況是:有些處理器不支持緩存鎖定。對於Intel 486和Pentium處理器,就算鎖定的內存區域在處理器的緩存行中也會調用總線鎖定。

3.2 Java中實現原子操做

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

1.使用循環CAS實現原子操做

    自旋CAS實現的基本思路就是循環進行CAS操做直到成功爲止,如下代碼實現了一個基於CAS線程安全的計數器方法safeCount和一個非線程安全的計數器count。

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

public class Counter {
	private AtomicInteger atomicI = new AtomicInteger(0);
	private int i = 0;

	public static void main(String[] args) {
		final Counter cas = new Counter();
		List<Thread> ts = new ArrayList<Thread>(600);
		long start = System.currentTimeMillis();
		for (int j = 0; j < 100; j++) {
			Thread t = new Thread(new Runnable() {
				@Override
				public void run() {
					for (int i = 0; i < 10000; i++) {
						cas.count();
						cas.safeCount();
					}
				}
			});
			ts.add(t);
		}
		for (Thread t : ts) {
			t.start();
		}
		// 等待全部線程執行完成
		for (Thread t : ts) {
			try {
				t.join();
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
		System.out.println(cas.i);
		System.out.println(cas.atomicI.get());
		System.out.println(System.currentTimeMillis() - start);
	}

	/** * 使用CAS實現線程安全計數器 */
	private void safeCount() {
		for (;;) {
			int i = atomicI.get();
			boolean suc = atomicI.compareAndSet(i, ++i);
			if (suc) {
				break;
			}
		}
	}

	/**
	 * 非線程安全計數器
	 */
	private void count() {
		i++;
	}

}

    循環CAS的三大問題:ABA問題,循環時間長開銷大,以及只能保證一個共享變量的原子操做。

  1. ABA問題:由於CAS須要在操做值的時候,檢查值有沒有發生變化,若是沒有發生變化則更新,可是若是一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,可是實際上卻變化了。ABA問題的解決思路就是使用版本號,在變量前面追加上版本號,每次變量更新的時候把版本號加1,那麼A→B→A就會變成1A→2B→3A。從 Java 1.5開始,JDK的Atomic包裏提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法的做用是首先檢查當前引用是否等於預期引用,而且檢查當前標誌是否等於預期標誌,若是所有相等,則以原子方式將該引用和該標誌的值設置爲給定的更新值。
  2. 循環時間長開銷大:自旋CAS若是長時間不成功,會給CPU帶來很是大的執行開銷。如 果JVM能支持處理器提供的pause指令,那麼效率會有必定的提高。pause指令有兩個做用:第 一,它能夠延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零;第二,它能夠避免在退出循環的時候因內存順序衝突(Memory Order Violation)而引發CPU流水線被清空(CPU Pipeline Flush),從而 提升CPU的執行效率。
  3. 只能保證一個共享變量的原子操做:當對一個共享變量執行操做時,咱們可使用循環CAS的方式來保證原子操做,可是對多個共享變量操做時,循環CAS就沒法保證操做的原子性,這個時候就能夠用鎖。還有一個取巧的辦法,就是把多個共享變量合併成一個共享變量來操做。好比,有兩個共享變量i=2,j=a,合併一下ij=2a,而後用CAS來操做ij。從Java 1.5開始, JDK提供了AtomicReference類來保證引用對象之間的原子性,就能夠把多個變量放在一個對象裏來進行CAS操做。

2 使用鎖機制來實現原子性操做

    鎖機制保證了只有得到鎖的線程纔可以操做鎖定的內存區域。JVM內部實現了不少種鎖 機制,有偏向鎖、輕量級鎖和互斥鎖。有意思的是除了偏向鎖,JVM實現鎖的方式都用了循環 CAS,即當一個線程想進入同步塊的時候使用循環CAS的方式來獲取鎖,當它退出同步塊的時 候使用循環CAS釋放鎖。

相關文章
相關標籤/搜索