併發編程(二)——內存模型

併發編程之多線程線程安全

什麼是線程安全?
爲什麼有線程安全問題?

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

      案例:需求現在有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();
	}

}

運行結果:

      一號窗口和二號窗口同時出售火車第九九張,部分火車票會重複出售。

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

線程安全解決辦法:

問:如何解決多線程之間線程安全問題
答:使用多線程之間同步synchronized或使用鎖(lock)。

問:爲什麼使用線程同步或使用鎖能解決線程安全問題呢?
答:將可能會發生數據衝突問題(線程不安全問題),只能讓當前一個線程進行執行。代碼執行完成後釋放鎖,讓後才能讓其他線程進行執行。這樣的話就可以解決線程不安全問題。

問:什麼是多線程之間同步
答:當多個線程共享同一個資源,不會受到其他線程的干擾。

問:什麼是多線程同步
答:當多個線程共享同一個資源,不會受到其他線程的干擾。

內置的鎖

Java提供了一種內置的鎖機制來支持原子性

      每一個Java對象都可以用作一個實現同步的鎖,稱爲內置鎖,線程進入同步代碼塊之前自動獲取到鎖,代碼塊執行完成正常退出或代碼塊中拋出異常退出時會釋放掉鎖
內置鎖爲互斥鎖,即線程A獲取到鎖後,線程B阻塞直到線程A釋放鎖,線程B才能獲取到同一個鎖

      內置鎖使用synchronized關鍵字實現,synchronized關鍵字有兩種用法:

1.修飾需要進行同步的方法(所有訪問狀態變量的方法都必須進行同步),此時充當鎖的對象爲調用同步方法的對象
2.同步代碼塊和直接使用synchronized修飾需要同步的方法是一樣的,但是鎖的粒度可以更細,並且充當鎖的對象不一定是this,也可以是其它對象,所以使用起來更加靈活

同步代碼塊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 稱爲同步方法

代碼樣例

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

      同學們思考問題?同步方法使用的是什麼鎖?

答:同步函數使用this鎖。
證明方式: 一個線程使用同步代碼塊(this明鎖),另一個線程使用同步函數。如果兩個線程搶票不能實現同步,那麼會出現數據錯誤。

代碼:

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 (this) {
					try {
						Thread.sleep(10);
					} catch (Exception e) {
						// TODO: handle exception
					}
					if (trainCount > 0) {
						System.out
								.println(Thread.currentThread().getName() + "," + "出售第" + (100 - trainCount + 1) + "票");
						trainCount--;
					}
				}

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

		}

	}

	public synchronized void sale() {

		try {
			Thread.sleep(10);
		} catch (Exception e) {
			// TODO: handle exception
		}
		if (trainCount > 0) {
			System.out.println(Thread.currentThread().getName() + "," + "出售第" + (100 - trainCount + 1) + "票");
			trainCount--;
		}

	}
}

public class Test009 {
	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();

	}
}

靜態同步函數

答:什麼是靜態同步函數?
      方法上加上static關鍵字,使用synchronized 關鍵字修飾 或者使用類.class文件。靜態的同步函數使用的鎖是 該函數所屬字節碼文件對象 ,可以用 getClass方法獲取,也可以用當前 類名.class 表示。

代碼樣例:

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

總結:

synchronized 修飾方法使用鎖是當前this鎖。
synchronized 修飾靜態方法使用鎖是當前類的字節碼文件

多線程死鎖

什麼是多線程死鎖?
答:同步中嵌套同步,導致鎖無法釋放

代碼:

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 Test009 {
	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();

	}
}}

Threadlocal

什麼是Threadlocal
      ThreadLocal提高一個線程的局部變量,訪問某個線程擁有自己局部變量。
      當使用ThreadLocal維護變量時,ThreadLocal爲每個使用該變量的線程提供獨立的變量副本,所以每一個線程都可以獨立地改變自己的副本,而不會影響其它線程所對應的副本。

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 ThreadLocaDemo2 extends Thread {
	private Res res;

	public ThreadLocaDemo2(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();
		ThreadLocaDemo2 threadLocaDemo1 = new ThreadLocaDemo2(res);
		ThreadLocaDemo2 threadLocaDemo2 = new ThreadLocaDemo2(res);
		ThreadLocaDemo2 threadLocaDemo3 = new ThreadLocaDemo2(res);
		threadLocaDemo1.start();
		threadLocaDemo2.start();
		threadLocaDemo3.start();
	}

}

ThreadLoca實現原理
ThreadLoca通過map集合
Map.put(“當前線程”,值);

多線程有三大特性

原子性、可見性、有序性

什麼是原子性

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

      一個很經典的例子就是銀行賬戶轉賬問題:

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

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

什麼是可見性

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

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

什麼是有序性

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

      一般來說處理器爲了提高程序運行效率,可能會對輸入代碼進行優化,它不保證程序中各個語句的執行先後順序同代碼中的順序一致,但是它會保證程序最終執行結果和代碼順序執行的結果是一致的。如下:
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,因爲這打破了依賴關係。

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

Java內存模型

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

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

  1. 首先,線程A把本地內存A中更新過的共享變量刷新到主內存中去。
  2. 然後,線程B到主內存中去讀取線程A之前已更新過的共享變量。
    下面通過示意圖來說明這兩個步驟:

      如上圖所示,本地內存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,定義了一個線程對另一個線程可見。共享變量存放在主內存中,每個線程都有自己的本地內存,當多個線程同時訪問一個數據的時候,可能本地內存沒有及時刷新到主內存,所以就會發生線程安全問題。

Volatile

什麼是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);

	}
}

運行結果:

已經將結果設置爲fasle爲什麼?還一直在運行呢。
原因:線程之間是不可見的,讀取的是副本,沒有及時讀取到主內存結果。
解決辦法 使用Volatile關鍵字將解決線程之間可見性, 強制線程每次讀取該值的時候都去“主內存”中取值

Volatile特性

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

      2.禁止指令重排序優化。有volatile修飾的變量,賦值後多執行了一個“load addl $0x0, (%esp)”操作,這個操作相當於一個內存屏障(指令重排序時不能把後面的指令重排序到內存屏障之前的位置),只有一個CPU訪問內存時,並不需要內存屏障;(什麼是指令重排序:是指CPU採用了允許將多條指令不按程序規定的順序分開發送給各相應電路單元處理)。

volatile 性能:

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

Volatile與Synchronized區別

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

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

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

重排序
數據依賴性

      如果兩個操作訪問同一個變量,且這兩個操作中有一個爲寫操作,此時這兩個操作之間就存在數據依賴性。數據依賴分下列三種類型:
名稱 代碼示例 說明
寫後讀 a = 1;b = a; 寫一個變量之後,再讀這個位置。
寫後寫 a = 1;a = 2; 寫一個變量之後,再寫這個變量。
讀後寫 a = b;b = 1; 讀一個變量之後,再寫這個變量。

      上面三種情況,只要重排序兩個操作的執行順序,程序的執行結果將會被改變。
      前面提到過,編譯器和處理器可能會對操作做重排序。編譯器和處理器在重排序時,會遵守數據依賴性,編譯器和處理器不會改變存在數據依賴關係的兩個操作的執行順序。
      注意,這裏所說的數據依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操作,不同處理器之間和不同線程之間的數據依賴性不被編譯器和處理器考慮。
as-if-serial語義
s-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
上面三個操作的數據依賴關係如下圖所示:

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

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

程序順序規則

      根據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同樣遵從這一目標。

重排序對多線程的影響

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

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重排序時,可能會產生什麼效果?請看下面的程序執行時序圖:

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

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

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

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

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

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章