多執行緒詳解(2)——不得不知的幾個概念

Deziko發表於2018-02-04

多執行緒系列文章:

多執行緒詳解(1)——執行緒基本概念

0. 簡介

在多執行緒中可能會出現很多預想不到的現象,要理解這些現象的產生的原因,就一定要理解以下講解的幾個概念。

1. Java 執行緒記憶體模型

Java 記憶體模型主要定義變數的訪問規則,這裡的變數只是指例項變數,靜態變數,並不包括區域性變數,因為區域性變數是執行緒私有的,並不存在共享。在這個模型有以下幾個主要的元素:

  • 執行緒
  • 共享變數
  • 工作記憶體
  • 主記憶體

這幾個元素之間還有幾個要注意的地方:

作用處 說明
執行緒本身 每條執行緒都有自己的工作記憶體,工作記憶體當中會有共享變數的副本。
執行緒操作共享變數 執行緒只能對自己工作記憶體的當中的共享變數副本進行操作,不能直接操作主記憶體的共享變數。
不同執行緒間操作共享變數 不同執行緒之間無法直接操作對方的工作記憶體的變數,只能通過主執行緒來協助完成。

以下就是這幾個元素之間的關係圖:

Java 記憶體模型

1.1 記憶體間的操作

Java 定義了 8 種操作來操作變數,這 8 種操作定義如下:

操作 作用處 說明
lock(鎖定) 主記憶體變數 把一個變數標識成一條執行緒獨佔的狀態
unlock(解鎖) 主記憶體變數 把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定
read(讀取) 主記憶體變數 把一個變數的值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的 load 動作使用
load(載入) 工作記憶體變數 把 read 操作得到的變數放入到工作記憶體的變數副本中
use(使用) 工作記憶體變數 將工作記憶體中的一個變數的值傳遞給執行引擎
assign(賦值) 工作記憶體變數 將執行引擎接收到的值賦給工作記憶體的變數
store(儲存) 工作記憶體變數 把工作記憶體中一個變數的值傳給主記憶體中,以便給隨後的 write 操作使用
write(寫入) 主記憶體變數 把 store 操作從工作記憶體中得到的變數的值放入主記憶體變數中

1.1.1 記憶體操作的規則

Java 記憶體模型操作還必須滿足如下規則:

操作方法 規則
read 和 load 這兩個方法必須以組合的方式出現,不允許一個變數從主記憶體讀取了但工作記憶體不接受情況出現
store 和 write 這兩個方法必須以組合的方式出現,不允許從工作記憶體發起了儲存操作但主記憶體不接受的情況出現
assign 工作記憶體的變數如果沒有經過 assign 操作,不允許將此變數同步到主記憶體中
load 和 use 在 use 操作之前,必須經過 load 操作
assign 和 store 在 store 操作之前,必須經過 assign 操作
lock 和 unlock 1. unlock 操作只能作用於被 lock 操作鎖定的變數
2. 一個變數被執行了多少次 lock 操作就要執行多少次 unlock 才能解鎖
lock 1. 一個變數只能在同一時刻被一條執行緒進行 lock 操作
2. 執行 lock 操作後,工作記憶體的變數的值會被清空,需要重新執行 load 或 assign 操作初始化變數的值
unlock 對一個變數執行 unlock 操作之前,必須先把此變數同步回主記憶體中

這些操作不用記下來,只要用到的時候再回來檢視一下就好。

2. 多執行緒中幾個重要的概念

瞭解完 Java 的記憶體模型後,還需要繼續理解以下幾個可以幫助理解多執行緒現象的重要概念。

2.1 同步和非同步

同步和非同步的都是形容一次方法的呼叫。它們的概念如下:

  • 同步:呼叫者必須要等到呼叫的方法返回後才會繼續後續的行為。

  • 非同步:呼叫者呼叫後,不必等呼叫方法返回就可以繼續後續的行為。

下面兩個圖就可以清晰表明同步和非同步的區別:

同步

非同步

2.2 併發和並行

併發和並行是形容多個任務時的狀態,它們的概念如下:

  • 併發:多個任務交替執行。

  • 並行:多個任務同時執行。

其實這兩個概念的的區別就是一個是交替,另一個是同時。其實如果只有一個 CPU 的話,系統是不可能並行執行任務,只能併發,因為 CPU 每次只能執行一條指令。所以如果要實現並行,就需要多個 CPU。為了加深這兩個概念的理解,可以看下面兩個圖:

併發

並行

2.3 原子性

原子就是指化學反應當中不可分割的微粒。所以原子性概念如下:

原子性:在 Java 中就是指一些不可分割的操作。

比如剛剛介紹的記憶體操作全部都屬於原子性操作。以下再舉個例子幫助大家理解:

x = 1;
y = x;
複製程式碼

以上兩句程式碼哪個是原子性操作哪個不是? x = 1 是,因為執行緒中是直接將數值 1 寫入到工作記憶體中。 y = x 不是,因為這裡包含了兩個操作:

  1. 讀取了 x 的值(因為 x 是變數)
  2. 將 x 的值寫入到工作記憶體中

2.4 可見性

可見性:指一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改。

這裡舉個例子來講解這個可見性的重要性,程式碼如下:

public class ThreadTest {
	
	
	private static boolean plus = true;
	private static int a;
	
	static class VisibilityThread1 extends Thread {
			
		
		public VisibilityThread1(String name) {
			setName(name);
		}
		
		@Override
		public void run() {
			while(true) {
				if(plus) {
					a++;
					plus = false;
					System.out.println(getName() + " a = " + a + " plus = " + plus);
				}
			}
		}
		
	}

	static class VisibilityThread2 extends Thread {
		
		public VisibilityThread2(String name) {
			setName(name);
		}
		
		@Override
		public void run() {
			while(true) {
				if(!plus) {
					a--;
					plus = true;
					System.out.println(getName() + " a = " + a + " plus = " + plus);
				}
			}

		}
		
	}
	
	
	public static void main(String[] args) {
		
		VisibilityThread1 visibilityThread1 = new VisibilityThread1("執行緒1");
		VisibilityThread2 visibilityThread2 = new VisibilityThread2("執行緒2");
		
		visibilityThread1.start();
		visibilityThread2.start();
		
	}
	
	

}

複製程式碼

這段程式碼的期待輸出的結果應該是以下這兩句迴圈輸出:

執行緒1 a = 1 plus = false
執行緒2 a = 0 plus = true
複製程式碼

但是你會發現會出現如下的結果:

執行緒1 a = 0 plus = true
執行緒2 a = 1 plus = false
複製程式碼

出現這個錯誤的結果是因為兩條執行緒同時都在修改共享變數 a 和 plus。一個執行緒在修改共享變數時,其他執行緒並不知道這個共享變數被修改了,所以多執行緒開發中一定要關注可見性。

2.5 重排序

重排序:編譯器和處理器為了優化程式效能而對指令重新排序的一種手段。 在講解這個概念之前要先鋪墊一個概念:資料依賴性。

2.5.1 資料依賴性

如果兩個操作同時操作一個變數,其中一個操作還包括寫的操作,那麼這兩個操作之間就存在資料依賴性了。這些組合操作看下錶:

名稱 說明 程式碼示例
寫後讀 寫一個變數後,再讀取這個變數 a = 1;
b = a;
寫後寫 寫一個變數後,再寫入這個變數 a = 1;
a = 2;
讀後寫 讀取一個變數後,再寫入這個變數 b = a;
a = 2;

上表這三種情況如果重排序的話就會改變程式的結果了。所以編譯器和處理器並不會對這些有資料依賴性的操作進行重排序的。 注意,這裡所說的資料依賴性只是在單執行緒的才會出現,如果多執行緒的話,編譯器和處理器並不會有資料依賴性。

2.5.2 多執行緒中的重排序

這裡使用簡化的程式碼來講解,程式碼如下:

int a = 0;
boolean flag = false;

// 執行緒1
VisibilityThread1 {
  a = 3; // 1
  flag = true; // 2
}

// 執行緒2
VisibilityThread2 {
  if(flag) { // 3
    a= a * 3; // 4
  }
}
複製程式碼

這裡操作 1,2 和 操作 3,4 並不存在資料依賴性,所以編譯器和處理器有可能會對這些操作組合進行重排序。程式的執行的其中一種情況如下圖:

重排序

因為執行緒 2 中的操作 5 和 6 存在控制依賴的關係,這會影響程式執行的速度,所以編譯器和處理器就會猜測執行的方式來提升速度,以上的情況就是採用了這種方式,執行緒 2 提前讀取了 a 的值,並計算出 a * 3 的值並把這個值臨時儲存到重排序緩衝的硬體快取中,等待 flag 的值變為 true 後,再把儲存後的值寫入 a 中。但是這就會出現我們並不想要的結果了,這種情況下,a 可能還是為 1。

2.6 有序性

如果理解了重排序後,有序性這個概念其實也是很容易理解的。 有序性:是指程式的執行順序與編寫程式碼的順序一致。

3. 執行緒安全

理解了上述的概念之後,再來講解執行緒安全的概念可能會更容易理解。

3.1 定義

執行緒安全就是指某個方法在多執行緒環境被呼叫的時候,能夠正確處理多個執行緒之間的共享變數,使程式功能能夠正確執行。 這裡舉個經典的執行緒安全的案例——多視窗賣票。假設有 30 張票,現在有兩個視窗同時賣這 30 張票。這裡的票就是共享變數,而視窗就是執行緒。這裡的程式碼邏輯大概可以分為這幾步:

  1. 兩條執行緒不停迴圈賣票,每次賣出一張,總票數就減去一張。
  2. 如果發現總票數為 0,停止迴圈。

程式碼如下:

public class SellTicketDemo implements Runnable {

	private int ticketNum = 30;
	
	@Override
	public void run() {
		while(true) {
			
			if(ticketNum <= 0) {
				break;
			}
			
			System.out.println(Thread.currentThread().getName() +" 賣出第  " + ticketNum + " 張票,剩餘的票數:" + --ticketNum);
		}
	}
	
	public static void main(String[] args) {
		
		SellTicketDemo sellTicketDemo = new SellTicketDemo();
		
		Thread thread1 = new Thread(sellTicketDemo,"視窗1");
		Thread thread2 = new Thread(sellTicketDemo,"視窗2");
		
		thread1.start();
		thread2.start();
		
	}

}
複製程式碼

程式碼列印結果如下:

視窗1 賣出第  30 張票,剩餘的票數:28
視窗2 賣出第  30 張票,剩餘的票數:29
視窗1 賣出第  28 張票,剩餘的票數:27
視窗2 賣出第  27 張票,剩餘的票數:26
視窗1 賣出第  26 張票,剩餘的票數:25
視窗2 賣出第  25 張票,剩餘的票數:24
視窗1 賣出第  24 張票,剩餘的票數:23
視窗2 賣出第  23 張票,剩餘的票數:22
視窗2 賣出第  21 張票,剩餘的票數:20
視窗1 賣出第  22 張票,剩餘的票數:21
視窗2 賣出第  20 張票,剩餘的票數:19
視窗1 賣出第  19 張票,剩餘的票數:18
視窗1 賣出第  17 張票,剩餘的票數:16
視窗1 賣出第  16 張票,剩餘的票數:15
視窗1 賣出第  15 張票,剩餘的票數:14
視窗1 賣出第  14 張票,剩餘的票數:13
視窗1 賣出第  13 張票,剩餘的票數:12
視窗1 賣出第  12 張票,剩餘的票數:11
視窗1 賣出第  11 張票,剩餘的票數:10
視窗1 賣出第  10 張票,剩餘的票數:9
視窗1 賣出第  9 張票,剩餘的票數:8
視窗1 賣出第  8 張票,剩餘的票數:7
視窗1 賣出第  7 張票,剩餘的票數:6
視窗1 賣出第  6 張票,剩餘的票數:5
視窗1 賣出第  5 張票,剩餘的票數:4
視窗1 賣出第  4 張票,剩餘的票數:3
視窗1 賣出第  3 張票,剩餘的票數:2
視窗1 賣出第  2 張票,剩餘的票數:1
視窗1 賣出第  1 張票,剩餘的票數:0
視窗2 賣出第  18 張票,剩餘的票數:17
複製程式碼

從以上的列印結果就可以看到,視窗1和視窗2同時都賣出第 30 張票,這和我們所期待的並不相符,這個就是執行緒不安全了。

4. synchronized 修飾符

那上述賣票的案例怎麼才可以有執行緒安全性呢?其中一個辦法就是用synchronized 來解決。

4.1 synchronized 程式碼塊

4.1.1 語法格式

synchronized(obj) {
	// 同步程式碼塊
}
複製程式碼

4.1.2 使用 synchronized 程式碼塊

synchronized 括號的 obj 是同步監視器,Java 允許任何物件作為同步監視器,這裡使用 SellTicketDemo 例項來作為同步監視器。程式碼如下:

public class SellTicketDemo implements Runnable {

	private int ticketNum = 30;
	
	@Override
	public void run() {
		while(true) {
			synchronized(this) {
				if(ticketNum <= 0) {
					break;
				}
				
				System.out.println(Thread.currentThread().getName() +" 賣出第  " + ticketNum + " 張票,剩餘的票數:" + --ticketNum);
			}
		}
	}
	
	public static void main(String[] args) {
		
		SellTicketDemo sellTicketDemo = new SellTicketDemo();
		
		Thread thread1 = new Thread(sellTicketDemo,"視窗1");
		Thread thread2 = new Thread(sellTicketDemo,"視窗2");
		
		thread1.start();
		thread2.start();
		
	}

}

複製程式碼

列印結果如下:

視窗1 賣出第  30 張票,剩餘的票數:29
視窗1 賣出第  29 張票,剩餘的票數:28
視窗1 賣出第  28 張票,剩餘的票數:27
視窗1 賣出第  27 張票,剩餘的票數:26
視窗1 賣出第  26 張票,剩餘的票數:25
視窗1 賣出第  25 張票,剩餘的票數:24
視窗1 賣出第  24 張票,剩餘的票數:23
視窗1 賣出第  23 張票,剩餘的票數:22
視窗1 賣出第  22 張票,剩餘的票數:21
視窗1 賣出第  21 張票,剩餘的票數:20
視窗2 賣出第  20 張票,剩餘的票數:19
視窗2 賣出第  19 張票,剩餘的票數:18
視窗2 賣出第  18 張票,剩餘的票數:17
視窗2 賣出第  17 張票,剩餘的票數:16
視窗2 賣出第  16 張票,剩餘的票數:15
視窗2 賣出第  15 張票,剩餘的票數:14
視窗2 賣出第  14 張票,剩餘的票數:13
視窗2 賣出第  13 張票,剩餘的票數:12
視窗2 賣出第  12 張票,剩餘的票數:11
視窗2 賣出第  11 張票,剩餘的票數:10
視窗2 賣出第  10 張票,剩餘的票數:9
視窗2 賣出第  9 張票,剩餘的票數:8
視窗2 賣出第  8 張票,剩餘的票數:7
視窗2 賣出第  7 張票,剩餘的票數:6
視窗2 賣出第  6 張票,剩餘的票數:5
視窗2 賣出第  5 張票,剩餘的票數:4
視窗2 賣出第  4 張票,剩餘的票數:3
視窗2 賣出第  3 張票,剩餘的票數:2
視窗2 賣出第  2 張票,剩餘的票數:1
視窗2 賣出第  1 張票,剩餘的票數:0
複製程式碼

可以看到現在的結果就是正確的了。

4.2 synchronized 方法

4.2.1 語法格式

[修飾符] synchronized [返回值] [方法名](形參...) {
		
}
複製程式碼

4.2.2 使用 synchronized 方法

使用同步方法非常簡單,直接用 synchronized 修飾多執行緒操作的方法即可,程式碼如下:

public class SellTicketDemo implements Runnable {

	private int ticketNum = 30;
	
	@Override
	public void run() {
		while(true) {

			sellTicket();
			
		}
	}
	
	public synchronized void sellTicket() {
		if(ticketNum <= 0) {
			return;
		}
		
		System.out.println(Thread.currentThread().getName() +" 賣出第  " + ticketNum + " 張票,剩餘的票數:" + --ticketNum);
	}
	
	public static void main(String[] args) {
		
		SellTicketDemo sellTicketDemo = new SellTicketDemo();
		
		Thread thread1 = new Thread(sellTicketDemo,"視窗1");
		Thread thread2 = new Thread(sellTicketDemo,"視窗2");
		
		thread1.start();
		thread2.start();
		
	}

}
複製程式碼

列印如下:

視窗1 賣出第  30 張票,剩餘的票數:29
視窗1 賣出第  29 張票,剩餘的票數:28
視窗1 賣出第  28 張票,剩餘的票數:27
視窗1 賣出第  27 張票,剩餘的票數:26
視窗1 賣出第  26 張票,剩餘的票數:25
視窗1 賣出第  25 張票,剩餘的票數:24
視窗1 賣出第  24 張票,剩餘的票數:23
視窗1 賣出第  23 張票,剩餘的票數:22
視窗1 賣出第  22 張票,剩餘的票數:21
視窗1 賣出第  21 張票,剩餘的票數:20
視窗1 賣出第  20 張票,剩餘的票數:19
視窗2 賣出第  19 張票,剩餘的票數:18
視窗2 賣出第  18 張票,剩餘的票數:17
視窗2 賣出第  17 張票,剩餘的票數:16
視窗2 賣出第  16 張票,剩餘的票數:15
視窗2 賣出第  15 張票,剩餘的票數:14
視窗2 賣出第  14 張票,剩餘的票數:13
視窗2 賣出第  13 張票,剩餘的票數:12
視窗2 賣出第  12 張票,剩餘的票數:11
視窗2 賣出第  11 張票,剩餘的票數:10
視窗2 賣出第  10 張票,剩餘的票數:9
視窗2 賣出第  9 張票,剩餘的票數:8
視窗2 賣出第  8 張票,剩餘的票數:7
視窗2 賣出第  7 張票,剩餘的票數:6
視窗2 賣出第  6 張票,剩餘的票數:5
視窗2 賣出第  5 張票,剩餘的票數:4
視窗2 賣出第  4 張票,剩餘的票數:3
視窗2 賣出第  3 張票,剩餘的票數:2
視窗2 賣出第  2 張票,剩餘的票數:1
視窗2 賣出第  1 張票,剩餘的票數:0
複製程式碼

參考文章和書籍:

java併發之原子性、可見性、有序性

Java記憶體訪問重排序的研究

Java併發程式設計的藝術

Java併發程式設計實戰

實戰Java高併發程式設計

深入理解Java虛擬機器

相關文章