天天看點

java架構師 并發程式設計之Java記憶體模型

1、線程安全

(1)什麼是線程安全問題?

         當多個線程同時共享,同一個全局變量或靜态變量,做寫的操作時,可能會發生資料沖突問題,也就是線程安全問題。但是做讀操作是不會發生資料沖突問題。

案例:需求現在有100張火車票,有兩個視窗同時搶火車票,請使用多線程模拟搶票效果。

public class ThreadTrain implements Runnable {
	private int trainCount = 100;

	@Override
	public void run() {
		while (trainCount > 0) {
			try {
				Thread.sleep(50);
			} catch (Exception e) {

			}
			sale();
		}
	}

	public void sale() {
		if (trainCount > 0) {
			System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "張票");
			trainCount--;
		}
	}

	public static void main(String[] args) {
		ThreadTrain threadTrain = new ThreadTrain();
		Thread t1 = new Thread(threadTrain, "①号");
		Thread t2 = new Thread(threadTrain, "②号");
		t1.start();
		t2.start();
	}

}
           
java架構師 并發程式設計之Java記憶體模型
java架構師 并發程式設計之Java記憶體模型

結論發現,多個線程共享同一個全局成員變量時,做寫的操作可能會發生資料沖突問題。

(2)線程安全解決辦法

問:如何解決多線程之間線程安全問題?

答:使用多線程之間同步synchronized或使用鎖(lock)。

問:為什麼使用線程同步或使用鎖能解決線程安全問題呢?

答:将可能會發生資料沖突問題(線程不安全問題),隻能讓目前一個線程進行執行。代碼執行完成後釋放鎖,讓後才能讓其他線程進行執行。這樣的話就可以解決線程不安全問題。

問:什麼是多線程之間同步?

答:當多個線程共享同一個資源,不會受到其他線程的幹擾。

2、内置鎖

Java提供了一種内置的鎖機制來支援原子性。

每一個Java對象都可以用作一個實作同步的鎖,稱為内置鎖,線程進入同步代碼塊之前自動擷取到鎖,代碼塊執行完成正常退出或代碼塊中抛出異常退出時會釋放掉鎖。

内置鎖為互斥鎖,即線程A擷取到鎖後,線程B阻塞直到線程A釋放鎖,線程B才能擷取到同一個鎖。

内置鎖使用synchronized關鍵字實作,synchronized關鍵字有兩種用法:

(1)、修飾需要進行同步的方法(所有通路狀态變量的方法都必須進行同步),此時充當鎖的對象為調用同步方法的對象。

(2)、同步代碼塊和直接使用synchronized修飾需要同步的方法是一樣的,但是鎖的粒度可以更細,并且充當鎖的對象不一定是this,也可以是其它對象,是以使用起來更加靈活。

3、同步代碼塊synchronized

就是将可能會發生線程安全問題的代碼,給包括起來。
synchronized(同一個資料){
 可能會發生線程沖突問題
}
就是同步代碼塊 
synchronized(對象)//這個對象可以為任意對象 
{ 
    需要被同步的代碼 
} 
           

對象如同鎖,持有鎖的線程可以在同步中執行 

沒持有鎖的線程即使擷取CPU的執行權,也進不去 

同步的前提: 

(1)、必須要有兩個或者兩個以上的線程 

(2)、必須是多個線程使用同一個鎖 

同步的作用:保證同步中隻能有一個線程在運作 

好處:解決了多線程的安全問題 

 弊端:多個線程需要判斷鎖,較為消耗資源、搶鎖的資源。 

案例:

public void sale() {
		synchronized (this) {
			if (trainCount > 0) {
				System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "張票");
				trainCount--;
			}
		}
	}
           

總結:

synchronized 修飾方法使用鎖是目前this鎖。

synchronized 修飾靜态方法使用鎖是目前類的位元組碼檔案。

4、多線程死鎖

多線程死鎖指:至少兩個以上的線程,同時執行,線程A持有鎖a的同時,想要擷取鎖b,線程B持有鎖b的同時,想要擷取鎖a,此時就會導緻線程A、線程B同時持有對方需要的鎖,而發生阻塞的現象,稱為死鎖。

(1)案例:同步中嵌套同步,導緻鎖無法釋放

class Thread009 implements Runnable {
	private int trainCount = 100;
	private Object oj = new Object();
	public boolean flag = true;

	public void run() {
		if (flag) {
			while (trainCount > 0) {
				synchronized (oj) {
					try {
						Thread.sleep(10);
					} catch (Exception e) {
						// TODO: handle exception
					}
					sale();
				}

			}
		} else {
			while (trainCount > 0) {
				sale();
			}

		}

	}

	public synchronized void sale() {
		synchronized (oj) {
			try {
				Thread.sleep(10);
			} catch (Exception e) {

			}
			if (trainCount > 0) {
				System.out.println(Thread.currentThread().getName() + "," + "出售第" + (100 - trainCount + 1) + "票");
				trainCount--;
			}
		}
	}
}

public class test {
	public static void main(String[] args) throws InterruptedException {
		Thread009 threadTrain = new Thread009();
		Thread t1 = new Thread(threadTrain, "視窗1");
		Thread t2 = new Thread(threadTrain, "視窗2");
		t1.start();
		Thread.sleep(40);
		threadTrain.flag = false;
		t2.start();

	}
}
           

(2)如何避免死鎖

  • 盡量使用tryLock,設定逾時時間,逾時可以退出防止死鎖。
  • 盡量使用Java.util.concurrent并發類,代替自己手寫鎖。
  • 盡量降低鎖的使用粒度,盡量不要幾個功能使用同一把鎖。
  • 盡量減少同步代碼塊的使用。

5、ThreadLocal

(1)ThreadLocal概念及作用

ThreadLocal為每個線程提供局部變量,使線程擁有自己局部變量,進而解決線程安全問題。

當使用ThreadLocal維護變量時,ThreadLocal為每個使用該變量的線程提供獨立的變量副本,是以每一個線程都可以獨立地改變自己的副本,而不會影響其它線程所對應的副本。

(2)ThreadLocal的接口方法

ThreadLocal類接口有4個方法,如下:

  • void set(Object value)設定目前線程的線程局部變量的值。
  • public Object get()該方法傳回目前線程所對應的線程局部變量。
  • public void remove()将目前線程局部變量的值删除,目的是為了減少記憶體的占用,該方法是JDK 5.0新增的方法。需要指出的是,當線程結束後,對應該線程的局部變量将自動被垃圾回收,是以顯式調用該方法清除線程的局部變量并不是必須的操作,但它可以加快記憶體回收的速度。
  • protected Object initialValue()傳回該線程局部變量的初始值,該方法是一個protected的方法,顯然是為了讓子類覆寫而設計的。這個方法是一個延遲調用方法,線上程第1次調用get()或set(Object)時才執行,并且僅執行1次。ThreadLocal中的預設實作直接傳回一個null。

案例:

class Res {
	// 生成序列号共享變量
	public static Integer count = 0;
	public static ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>() {
		protected Integer initialValue() {
			return 0;
		};

	};

	public Integer getNum() {
		int count = threadLocal.get() + 1;
		threadLocal.set(count);
		return count;
	}
}

public class test extends Thread {
	private Res res;

	public test(Res res) {
		this.res = res;
	}

	@Override
	public void run() {
		for (int i = 0; i < 3; i++) {
			System.out.println(Thread.currentThread().getName() + "---" + "i---" + i + "--num:" + res.getNum());
		}

	}

	public static void main(String[] args) {
		Res res = new Res();
		test threadLocaDemo1 = new test(res);
		test threadLocaDemo2 = new test(res);
		test threadLocaDemo3 = new test(res);
		threadLocaDemo1.start();
		threadLocaDemo2.start();
		threadLocaDemo3.start();
	}

}
           

6、多線程有三大特性

        多線程的三大特性:原子性、可見性、有序性

(1)原子性

        即一個操作或者多個操作,要麼全部執行并且執行的過程不會被任何因素打斷,要麼就都不執行。

案例:銀行賬戶轉賬問題:

        比如從賬戶A向賬戶B轉1000元,那麼必然包括2個操作:從賬戶A減去1000元,往賬戶B加上1000元。這2個操作必須要具備原子性才能保證不出現一些意外的問題。

        我們操作資料也是如此,比如i = i+1;其中就包括,讀取i的值,計算i,寫入i。這行代碼在Java中是不具備原子性的,則多線程運作肯定會出問題,是以也需要我們使用同步和lock這些東西來確定這個特性了。

        原子性其實就是保證資料一緻、線程安全一部分,

(2)可見性

        當多個線程通路同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。

        若兩個線程在不同的cpu,那麼線程1改變了i的值還沒重新整理到主存,線程2又使用了i,那麼這個i值肯定還是之前的,線程1對變量的修改線程沒看到這就是可見性問題。

(3)有序性

        程式執行的順序按照代碼的先後順序執行。

        一般來說處理器為了提高程式運作效率,可能會對輸入代碼進行優化,它不保證程式中各個語句的執行先後順序同代碼中的順序一緻,但是它會保證程式最終執行結果和代碼順序執行的結果是一緻的。如下:

int a = 10;    //語句1

int r = 2;    //語句2

a = a + 3;    //語句3

r = a*a;     //語句4
           

        則因為重排序,他還可能執行順序為 2-1-3-4,1-3-2-4

        但絕不可能 2-1-4-3,因為這打破了依賴關系。

        顯然重排序對單線程運作是不會有任何問題,而多線程就不一定了,是以我們在多線程程式設計時就得考慮這個問題了。

7、Java記憶體模型

        共享記憶體模型指的就是Java記憶體模型(簡稱JMM),JMM決定一個線程對共享變量的寫入時,能對另一個線程可見。從抽象的角度來看,JMM定義了線程和主記憶體之間的抽象關系:線程之間的共享變量存儲在主記憶體(main memory)中,每個線程都有一個私有的本地記憶體(local memory),本地記憶體中存儲了該線程以讀/寫共享變量的副本。本地記憶體是JMM的一個抽象概念,并不真實存在。它涵蓋了緩存,寫緩沖區,寄存器以及其他的硬體和編譯器優化。

java架構師 并發程式設計之Java記憶體模型

從上圖來看,線程A與線程B之間如要通信的話,必須要經曆下面2個步驟:

(1) 首先,線程A把本地記憶體A中更新過的共享變量重新整理到主記憶體中去。

(2) 然後,線程B到主記憶體中去讀取線程A之前已更新過的共享變量。

如下圖所示:

java架構師 并發程式設計之Java記憶體模型

         如上圖所示,本地記憶體A和B有主記憶體中共享變量x的副本。假設初始時,這三個記憶體中的x值都為0。線程A在執行時,把更新後的x值(假設值為1)臨時存放在自己的本地記憶體A中。當線程A和線程B需要通信時,線程A首先會把自己本地記憶體中修改後的x值重新整理到主記憶體中,此時主記憶體中的x值變為了1。随後,線程B到主記憶體中去讀取線程A更新後的x值,此時線程B的本地記憶體的x值也變為了1。

        從整體來看,這兩個步驟實質上是線程A在向線程B發送消息,而且這個通信過程必須要經過主記憶體。JMM通過控制主記憶體與每個線程的本地記憶體之間的互動,來為java程式員提供記憶體可見性保證。

        總結:什麼是Java記憶體模型:java記憶體模型簡稱jmm,定義了一個線程對另一個線程可見。共享變量存放在主記憶體中,每個線程都有自己的本地記憶體,當多個線程同時通路一個資料的時候,可能本地記憶體沒有及時重新整理到主記憶體,是以就會發生線程安全問題。

8、Volatile

8.1 volatile簡介        

可見性也就是說一旦某個線程修改了該被volatile修飾的變量,它會保證修改的值會立即被更新到主存,當有其他線程需要讀取時,可以立即擷取修改之後的值。

        在Java中為了加快程式的運作效率,對一些變量的操作通常是在該線程的寄存器或是CPU緩存上進行的,之後才會同步到主存中,而加了volatile修飾符的變量則是直接讀寫主存。

        Volatile 保證了線程間共享變量的及時可見性,但不能保證原子性。

案例:

class ThreadVolatileDemo extends Thread {
	public    boolean flag = true;
	@Override
	public void run() {
		System.out.println("開始執行子線程....");
		while (flag) {
		}
		System.out.println("線程停止");
	}
	public void setRuning(boolean flag) {
		this.flag = flag;
	}

}

public class ThreadVolatile {
	public static void main(String[] args) throws InterruptedException {
		ThreadVolatileDemo threadVolatileDemo = new ThreadVolatileDemo();
		threadVolatileDemo.start();
		Thread.sleep(3000);
		threadVolatileDemo.setRuning(false);
		System.out.println("flag 已經設定成false");
		Thread.sleep(1000);
		System.out.println(threadVolatileDemo.flag);

	}
}
           

運作結果:

java架構師 并發程式設計之Java記憶體模型

問題:已經将結果設定為fasle為什麼?還一直在運作呢。

原因:線程之間是不可見的,讀取的是副本,沒有及時讀取到主記憶體結果。

解決辦法使用Volatile關鍵字将解決線程之間可見性, 強制線程每次讀取該值的時候都去“主記憶體”中取值

8.2 Volatile特性

        (1) 保證此變量對所有的線程的可見性,這裡的“可見性”,如本文開頭所述,當一個線程修改了這個變量的值,volatile 保證了新值能立即同步到主記憶體,以及每次使用前立即從主記憶體重新整理。但普通變量做不到這點,普通變量的值線上程間傳遞均需要通過主記憶體(詳見:Java記憶體模型)來完成。

        (2) 禁止指令重排序優化。有volatile修飾的變量,指派後多執行了一個“load addl $0x0, (%esp)”操作,這個操作相當于一個記憶體屏障(指令重排序時不能把後面的指令重排序到記憶體屏障之前的位置),隻有一個CPU通路記憶體時,并不需要記憶體屏障;(什麼是指令重排序:是指CPU采用了允許将多條指令不按程式規定的順序分開發送給各相應電路單元處理)。

        (3)volatile 性能:volatile 的讀性能消耗與普通變量幾乎相同,但是寫操作稍慢,因為它需要在本地代碼中插入許多記憶體屏障指令來保證處理器不發生亂序執行。

8.3 Volatile與Synchronized差別

       (1)進而我們可以看出volatile雖然具有可見性但是并不能保證原子性。

       (2)性能方面,synchronized關鍵字是防止多個線程同時執行一段代碼,就會影響程式執行效率,而volatile關鍵字在某些情況下性能要優于synchronized。

          但是要注意volatile關鍵字是無法替代synchronized關鍵字的,因為volatile關鍵字無法保證操作的原子性。

9、重排序

9.1 資料依賴性

        如果兩個操作通路同一個變量,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在資料依賴性。資料依賴分下列三種類型:

名稱 代碼示例 說明
寫後讀 a = 1;b = a; 寫一個變量之後,再讀這個位置。
寫後寫 a = 1;a = 2; 寫一個變量之後,再寫這個變量。
讀後寫 a = b;b = 1; 讀一個變量之後,再寫這個變量。

        上面三種情況,隻要重排序兩個操作的執行順序,程式的執行結果将會被改變。

        前面提到過,編譯器和處理器可能會對操作做重排序。編譯器和處理器在重排序時,會遵守資料依賴性,編譯器和處理器不會改變存在資料依賴關系的兩個操作的執行順序。

        注意,這裡所說的資料依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操作,不同處理器之間和不同線程之間的資料依賴性不被編譯器和處理器考慮。

9.2 as-if-serial語義

        as-if-serial的語義:不管怎麼重排序(編譯器和處理器為了提高并行度),(單線程)程式的執行結果不能被改變。編譯器,runtime 和處理器都必須遵守as-if-serial語義。

        為了遵守as-if-serial語義,編譯器和處理器不會對存在資料依賴關系的操作做重排序,因為這種重排序會改變執行結果。但是,如果操作之間不存在資料依賴關系,這些操作可能被編譯器和處理器重排序。為了具體說明,請看下面計算圓面積的代碼示例:

double pi  = 3.14;    //A
double r   = 1.0;     //B
double area = pi * r * r; //C
           

        上面三個操作的資料依賴關系如下圖所示:

java架構師 并發程式設計之Java記憶體模型

        如上圖所示,A和C之間存在資料依賴關系,同時B和C之間也存在資料依賴關系。是以在最終執行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程式的結果将會被改變)。但A和B之間沒有資料依賴關系,編譯器和處理器可以重排序A和B之間的執行順序。下圖是該程式的兩種執行順序:

java架構師 并發程式設計之Java記憶體模型

        as-if-serial語義把單線程程式保護了起來,遵守as-if-serial語義的編譯器,runtime 和處理器共同為編寫單線程程式的程式員建立了一個幻覺:單線程程式是按程式的順序來執行的。as-if-serial語義使單線程程式員無需擔心重排序會幹擾他們,也無需擔心記憶體可見性問題。

9.3 程式順序規則

        根據happens- before的程式順序規則,上面計算圓的面積的示例代碼存在三個happens- before關系:

        (1)A happens- before B;

        (2)B happens- before C;

        (3)A happens- before C;
           

        這裡的第3個happens- before關系,是根據happens- before的傳遞性推導出來的。

        這裡A happens- before B,但實際執行時B卻可以排在A之前執行(看上面的重排序後的執行順序)。在第一章提到過,如果A happens- before B,JMM并不要求A一定要在B之前執行。JMM僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前。這裡操作A的執行結果不需要對操作B可見;而且重排序操作A和操作B後的執行結果,與操作A和操作B按happens- before順序執行的結果一緻。在這種情況下,JMM會認為這種重排序并不非法(not illegal),JMM允許這種重排序。

        在計算機中,軟體技術和硬體技術有一個共同的目标:在不改變程式執行結果的前提下,盡可能的開發并行度。編譯器和處理器遵從這一目标,從happens- before的定義我們可以看出,JMM同樣遵從這一目标。

9.4 重排序對多線程的影響

        現在讓我們來看看,重排序是否會改變多線程程式的執行結果。請看下面的示例代碼:

class ReorderExample {
int a = 0;
boolean flag = false;

public void writer() {
    a = 1;                   //1
    flag = true;             //2
}

Public void reader() {
    if (flag) {                //3
        int i =  a * a;        //4
        ……
    }
}
}
           

         flag變量是個标記,用來辨別變量a是否已被寫入。這裡假設有兩個線程A和B,A首先執行writer()方法,随後B線程接着執行reader()方法。線程B在執行操作4時,能否看到線程A在操作1對共享變量a的寫入?

        答案是:不一定能看到。

        由于操作1和操作2沒有資料依賴關系,編譯器和處理器可以對這兩個操作重排序;同樣,操作3和操作4沒有資料依賴關系,編譯器和處理器也可以對這兩個操作重排序。讓我們先來看看,當操作1和操作2重排序時,可能會産生什麼效果?請看下面的程式執行時序圖:

java架構師 并發程式設計之Java記憶體模型

         如上圖所示,操作1和操作2做了重排序。程式執行時,線程A首先寫标記變量flag,随後線程B讀這個變量。由于條件判斷為真,線程B将讀取變量a。此時,變量a還根本沒有被線程A寫入,在這裡多線程程式的語義被重排序破壞了!

        注:本文統一用紅色的虛箭線表示錯誤的讀操作,用綠色的虛箭線表示正确的讀操作。

        下面再讓我們看看,當操作3和操作4重排序時會産生什麼效果(借助這個重排序,可以順便說明控制依賴性)。下面是操作3和操作4重排序後,程式的執行時序圖:

java架構師 并發程式設計之Java記憶體模型

         在程式中,操作3和操作4存在控制依賴關系。當代碼中存在控制依賴性時,會影響指令序列執行的并行度。為此,編譯器和處理器會采用猜測(Speculation)執行來克服控制相關性對并行度的影響。以處理器的猜測執行為例,執行線程B的處理器可以提前讀取并計算a*a,然後把計算結果臨時儲存到一個名為重排序緩沖(reorder buffer ROB)的硬體緩存中。當接下來操作3的條件判斷為真時,就把該計算結果寫入變量i中。

      從圖中我們可以看出,猜測執行實質上對操作3和4做了重排序。重排序在這裡破壞了多線程程式的語義!

      在單線程程式中,對存在控制依賴的操作重排序,不會改變執行結果(這也是as-if-serial語義允許對存在控制依賴的操作做重排序的原因);但在多線程程式中,對存在控制依賴的操作重排序,可能會改變程式的執行結果。

繼續閱讀