Java併發編程之併發編程三大核心問題

個人博客請訪問 http://www.x0100.top     

寫在前面

編寫併發程序是比較困難的,因爲併發程序極易出現Bug,這些Bug有都是比較詭異的,很多都是沒辦法追蹤,而且難以復現。

要快速準確的發現並解決這些問題,首先就是要弄清併發編程的本質,併發編程要解決的是什麼問題。

本文將帶你深入理解併發編程要解決的三大問題:原子性、可見性、有序性。

補充知識

硬件的發展中,一直存在一個矛盾,CPU、內存、I/O設備的速度差異。

速度排序:CPU >> 內存 >> I/O設備

爲了平衡這三者的速度差異,做了如下優化:

  1. CPU 增加了緩存,以均衡內存與CPU的速度差異;

  2. 操作系統增加了進程、線程,以分時複用CPU,進而均衡I/O設備與CPU的速度差異;

  3. 編譯程序優化指令執行次序,使得緩存能夠得到更加合理地利用。

可見性

可見性是什麼?

一個線程對共享變量的修改,另外一個線程能夠立刻看到,我們稱爲可見性。

爲什麼會有可見性問題?

對於如今的多核處理器,每顆CPU都有自己的緩存,而緩存僅僅對它所在的處理器可見,CPU緩存與內存的數據不容易保證一致。

爲了避免處理器停頓下來等待向內存寫入數據而產生的延遲,處理器使用寫緩衝區來臨時保存向內存寫入的數據。寫緩衝區合併對同一內存地址的多次寫,並以批處理的方式刷新,也就是說寫緩衝區不會即時將數據刷新到主內存中

緩存不能及時刷新導致了可見性問題。

可見性問題舉例

public class Test {
public int a = 0;

public void increase() {
		a++;
	}

public static void main(String[] args) {
final Test test = new Test();
for (int i = 0; i < 10; i++) {
new Thread() {
public void run() {
for (int j = 0; j < 1000; j++)
						test.increase();
				};
			}.start();
		}

while (Thread.activeCount() > 1) {
// 保證前面的線程都執行完
			Thread.yield();
		}
		System.out.println(test.a);
	}
}

目的:10個線程將inc加到10000。

結果:每次運行,得到的結果都小於10000。

原因分析:

假設線程1和線程2同時開始執行,那麼第一次都會將a=0 讀到各自的CPU緩存裏,線程1執行a++之後a=1,但是此時線程2是看不到線程1中a的值的,所以線程2裏a=0,執行a++後a=1。

線程1和線程2各自CPU緩存裏的值都是1,之後線程1和線程2都會將自己緩存中的a=1寫入內存,導致內存中a=1,而不是我們期望的2。所以導致最終 a 的值都是小於 10000 的。這就是緩存的可見性問題。

原子性

原子性是什麼?

把一個或者多個操作在 CPU 執行的過程中不被中斷的特性稱爲原子性。

在併發編程中,原子性的定義不應該和事務中的原子性(一旦代碼運行異常可以回滾)一樣。應該理解爲:一段代碼,或者一個變量的操作,在一個線程沒有執行完之前,不能被其他線程執行。

爲什麼會有原子性問題?

線程是CPU調度的基本單位。CPU會根據不同的調度算法進行線程調度,將時間片分派給線程。當一個線程獲得時間片之後開始執行,在時間片耗盡之後,就會失去CPU使用權。多線程場景下,由於時間片在線程間輪換,就會發生原子性問題

如:對於一段代碼,一個線程還沒執行完這段代碼但是時間片耗盡,在等待CPU分配時間片,此時其他線程可以獲取執行這段代碼的時間片來執行這段代碼,導致多個線程同時執行同一段代碼,也就是原子性問題。

線程切換帶來原子性問題。

在Java中,對基本數據類型的變量的讀取和賦值操作是原子性操作,即這些操作是不可被中斷的,要麼執行,要麼不執行。

i = 0;		// 原子性操作
j = i;		// 不是原子性操作,包含了兩個操作:讀取i,將i值賦值給j
i++; 			// 不是原子性操作,包含了三個操作:讀取i值、i + 1 、將+1結果賦值給i
i = j + 1;		// 不是原子性操作,包含了三個操作:讀取j值、j + 1 、將+1結果賦值給i

原子性問題舉例

還是上文中的代碼,10個線程將inc加到10000。假設在保證可見性的情況下,仍然會因爲原子性問題導致執行結果達不到預期。爲方便看,把代碼貼到這裏:

public class Test {
public int a = 0;

public void increase() {
		a++;
	}

public static void main(String[] args) {
final Test test = new Test();
for (int i = 0; i < 10; i++) {
new Thread() {
public void run() {
for (int j = 0; j < 1000; j++)
						test.increase();
				};
			}.start();
		}

while (Thread.activeCount() > 1) {
// 保證前面的線程都執行完
			Thread.yield();
		}
		System.out.println(test.a);
	}
}

目的:10個線程將inc加到10000。
結果:每次運行,得到的結果都小於10000。

原因分析:

首先來看a++操作,其實包括三個操作: 

①讀取a=0; 

②計算0+1=1; 

③將1賦值給a; 

保證a++的原子性,就是保證這三個操作在一個線程沒有執行完之前,不能被其他線程執行。

實際執行時序圖如下:

關鍵一步:線程2在讀取a的值時,線程1還沒有完成a=1的賦值操作,導致線程2的計算結果也是a=1。

問題在於沒有保證a++操作的原子性。如果保證a++的原子性,線程1在執行完三個操作之前,線程2不能執行a++,那麼就可以保證在線程2執行a++時,讀取到a=1,從而得到正確的結果。

有序性

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

編譯器爲了優化性能,有時候會改變程序中語句的先後順序。例如程序中:“a=6;b=7;”編譯器優化後可能變成“b=7;a=6;”,在這個例子中,編譯器調整了語句的順序,但是不影響程序的最終結果。不過有時候編譯器及解釋器的優化可能導致意想不到的Bug。

有序性問題舉例

Java中的一個經典的案例:利用雙重檢查創建單例對象

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

在獲取實例getInstance()的方法中,我們首先判斷 instance是否爲空,如果爲空,則鎖定 Singleton.class並再次檢查instance是否爲空,如果還爲空則創建Singleton的一個實例。
看似很完美,既保證了線程完全的初始化單例,又經過判斷instance爲null時再用synchronized同步加鎖。但是還有問題!

instance = new Singleton(); 創建對象的代碼,分爲三步:
①分配內存空間
②初始化對象Singleton
③將內存空間的地址賦值給instance

但是這三步經過重排之後:
①分配內存空間
②將內存空間的地址賦值給instance
③初始化對象Singleton

會導致什麼結果呢?

線程A先執行getInstance()方法,當執行完指令②時恰好發生了線程切換,切換到了線程B上;如果此時線程B也執行getInstance()方法,那麼線程B在執行第一個判斷時會發現instance!=null,所以直接返回instance,而此時的instance是沒有初始化過的,如果我們這個時候訪問instance的成員變量就可能觸發空指針異常。

執行時序圖:

總結

併發編程的本質就是解決三大問題:原子性、可見性、有序性。

原子性:一個或者多個操作在 CPU 執行的過程中不被中斷的特性。由於線程的切換,導致多個線程同時執行同一段代碼,帶來的原子性問題。

可見性:一個線程對共享變量的修改,另外一個線程能夠立刻看到。緩存不能及時刷新導致了可見性問題。

有序性:程序執行的順序按照代碼的先後順序執行。編譯器爲了優化性能而改變程序中語句的先後順序,導致有序性問題。

啓發:線程的切換、緩存及編譯優化都是爲了提高性能,但是引發了併發編程的問題。這也告訴我們技術在解決一個問題時,必然會帶來另一個問題,需要我們提前考慮新技術帶來的問題以規避風險。

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