1. 程式人生 > 實用技巧 >Java併發程式設計實戰(1)- 併發程式的bug源頭

Java併發程式設計實戰(1)- 併發程式的bug源頭

概述

併發程式設計一般屬於程式設計進階部分的知識,它會涉及到很多底層知識,包括作業系統。
編寫正確的併發程式是一件很困難的事情,由併發導致的bug,有時很難排查或者重現,這需要我們理解併發的本質,深入分析Bug的源頭。

併發程式問題的源頭

為了提升系統性能,在過去幾十年中,我們一直在不斷的提升硬體的設計,包括CPU、記憶體以及I/O裝置,但存在一個主要矛盾:三者之間速度有很大差異,CPU最快,記憶體其次,I/O裝置最慢。

我們編寫的程式,在執行過程中,上述三者都會使用到,在這種情況下,速度慢的記憶體和I/O裝置就會成為瓶頸,為了解決這個問題,計算機體系結構、作業系統和編譯程式做了如下改進:

  • CPU增加了快取,以均衡與記憶體的速度差異。
  • 作業系統增加了程序、執行緒以及分時複用CPU,從而均衡CPU與I/O裝置的速度差異。
  • 編譯程式優化指令執行次序,使得快取能夠得到更加合理的利用。

併發程式的問題根源也基本來源於上述改進:

  • 快取引發的可見性問題
  • 執行緒切換引發的原子性問題
  • 編譯優化引發的有序性問題

接下來我們分別展開描述。

快取引發的可見性問題

什麼是可見性?

可見性是說一個執行緒對共享變數的修改,另外一個執行緒能夠立刻看到。

可見性問題是由CPU快取引起的,它是在CPU變為多核後才出現的,單核CPU並不會存在可見性問題。

我們可以參考下面的示意圖。

如圖所示,當有2個執行緒同時訪問記憶體中的變數x時,2個執行緒執行在不同的CPU上,每個CPU快取都會儲存變數x,執行緒執行時,會通過CPU快取來操作x,那麼當執行緒1進行操作後,執行緒2並不會立刻得到更新後的x,從而引發了問題。

我們來看下面的程式碼示例,它顯示了對同一個變數使用多個執行緒進行加操作,最後判斷變數值是否符合預期。

public class ConcurrencyAddDemo {

	private long count = 0;

	private void add() {
		int index = 0;
		while (index < 10000) {
			count = count + 1;
			index++;
		}
	}

	private void reset() {
		this.count = 0;
	}

	private void addTest() throws InterruptedException {

		List<Thread> threads = new ArrayList<Thread>();

		for (int i = 0; i < 6; i++) {
			threads.add(new Thread(() -> {
				this.add();
			}));
		}
		
		for (Thread thread : threads) {
			thread.start();
		}
		
		for (Thread thread : threads) {
			thread.join();
		}
		
		threads.clear();

		System.out.println(String.format("Count is %s", count));
	}

	public static void main(String[] args) throws InterruptedException {
		ConcurrencyAddDemo demoObj = new ConcurrencyAddDemo();
		for (int i = 0; i < 10; i++) {
			demoObj.addTest();
			demoObj.reset();
		}
	}
}

程式執行的結果如下。

Count is 18020
Count is 18857
Count is 16902
Count is 16295
Count is 54453
Count is 59475
Count is 56772
Count is 37376
Count is 60000
Count is 60000

我們可以看到,並不是每次返回的結果都是60000。

執行緒切換引發的原子性問題

什麼是原子性?

一個或者多個操作在CPU執行的過程中不被中斷的特性,被稱為原子性。原子性可以保證操作執行的中間狀態,對外是不可見的。

CPU可以保證的原子操作是在CPU指令級別的,並不是高階語言的操作符,而高階語言中的一個操作,可能會包含多個CPU指令。

以上述程式碼中的count = count + 1為例,它至少包含了三條CPU指令:

  • 指令1:首先需要把變數count從記憶體載入到CPU暫存器。
  • 指令2:在暫存器中執行+1操作。
  • 指令3:將結果進行儲存,這裡可能會儲存在CPU快取,也可能儲存在記憶體中。

上述指令執行過程中,可能會產生”執行緒切換“,如果多個執行緒同時執行相同的語句,那麼因為執行緒切換,就會導致結果不是我們期望的。

原子性問題並不只在多核CPU中存在,在單核CPU中也是存在的。

編譯優化引發的有序性問題

什麼是有序性?

有序性是指程式按照程式碼的先後順序執行。

編譯器為了優化效能,有時候會改變程式中語句的先後順序,一般情況下,這並不會影響程式的最終結果,但有時也會引發意想不到的問題。

我們以典型的單例模式為例進行說明,示例程式碼如下。

public class SingletonDemo {

	private static SingletonDemo instance;
	
	public static SingletonDemo getInstance() {
		if (instance == null) {
			synchronized(SingletonDemo.class) {
				if (instance == null) {
					instance = new SingletonDemo();
				}
			}
		}
		
		return instance;
	}
}

一般情況下,假設有兩個執行緒 A、B 同時呼叫 getInstance() 方法,他們會同時發現 instance == null ,於是同時對 Singleton.class 加鎖,此時 JVM 保證只有一個執行緒能夠加鎖成功(假設是執行緒 A),另外一個執行緒則會處於等待狀態(假設是執行緒 B);執行緒 A 會建立一個 Singleton 例項,之後釋放鎖,鎖釋放後,執行緒 B 被喚醒,執行緒 B 再次嘗試加鎖,此時是可以加鎖成功的,加鎖成功後,執行緒 B 檢查 instance == null 時會發現,已經建立過 Singleton 例項了,所以執行緒 B 不會再建立一個 Singleton 例項。

但是,如果我們仔細分析getInstance()方法中的new操作,會發現它包含以下幾步:

  • 分配一塊記憶體M。
  • 在記憶體M上初始化SingletonDemo物件。
  • 將M的地址賦值給instance變數。

但編譯器可能會做一些優化,變成下面的樣子:

  • 分配一塊記憶體M。
  • 將M的地址賦值給instance變數。
  • 在記憶體M上初始化SingletonDemo物件。

這樣很可能導致執行緒 B獲取instance之後,在instance初始化沒有完全結束的情況下,呼叫它的方法,從而引發空指標異常。

上述是我們常見的併發程式的bug源頭,只要我們能夠深刻理解可見性、原子性和有序性在併發場景下的原理,很多併發bug就很容易理解了。

參考資料: