多線程詳解(2)——不得不知的幾個概念

多線程系列文章:html

多線程詳解(1)——線程基本概念java

0. 簡介

在多線程中可能會出現不少預想不到的現象,要理解這些現象的產生的緣由,就必定要理解如下講解的幾個概念。編程

1. Java 線程內存模型

Java 內存模型主要定義變量的訪問規則,這裏的變量只是指實例變量,靜態變量,並不包括局部變量,由於局部變量是線程私有的,並不存在共享。在這個模型有如下幾個主要的元素:緩存

  • 線程
  • 共享變量
  • 工做內存
  • 主內存

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

做用處 說明
線程自己 每條線程都有本身的工做內存,工做內存當中會有共享變量的副本。
線程操做共享變量 線程只能對本身工做內存的當中的共享變量副本進行操做,不能直接操做主內存的共享變量。
不一樣線程間操做共享變量 不一樣線程之間沒法直接操做對方的工做內存的變量,只能經過主線程來協助完成。

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

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 的內存模型後,還須要繼續理解如下幾個能夠幫助理解多線程現象的重要概念。ide

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虛擬機

相關文章
相關標籤/搜索