JAVA多線程之並發編程三大核心問題


概述

並發編程是Java語言的重要特性之一,它能使復雜的代碼變得更簡單,從而極大的簡化復雜系統的開發。並發編程可以充分發揮多處理器系統的強大計算能力,隨着處理器數量的持續增長,如何高效的並發變得越來越重要。但是開發難,並發更難,因為並發程序極易出現bug,這些bug是比較詭異的,跟蹤難,且難以復現。如果要解決這些問題就要正確的發現這些問題,這就需要弄清並發編程的本質,以及並發編程要解決什么問題。本文主要講解並發要解決的三大問題:原子性、可見性、有序性。

基本概念

硬件的發展

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

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

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

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

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

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

 優化之后,速度和性能的提升也伴隨着開發所帶來的各種新問題,比如多線程利用多個cpu問題。

 

並發和並行

〔美〕布雷謝斯的書籍並發的藝術一書中的引述是:

如果某個系統支持兩個或者多個動作(Action)同時存在,那么這個系統就是一個並發系統。如果某個系統支持兩個或者多個動作同時執行,那么這個系統就是一個並行系統。並發系統與並行系統這兩個定義之間的關鍵差異在於“存在”這個詞。

在並發程序中可以同時擁有兩個或者多個線程。這意味着,如果程序在單核處理器上運行,那么這兩個線程將交替地換入或者換出內存。這些線程是同時“存在”的——每個線程都處於執行過程中的某個狀態。如果程序能夠並行執行,那么就一定是運行在多核處理器上。此時,程序中的每個線程都將分配到一個獨立的處理器核上,因此可以同時運行。
我相信你已經能夠得出結論——“並行”概念是“並發”概念的一個子集。也就是說,你可以編寫一個擁有多個線程或者進程的並發程序,但如果沒有多核處理器來執行這個程序,那么就不能以並行方式來運行代碼。因此,凡是在求解單個問題時涉及多個執行流程的編程模式或者執行行為,都屬於並發編程的范疇。
 

重排序概念

在執行程序時為了提高性能,編譯器和處理器常常會對指令做重排序。
從java源代碼到最終實際執行的指令序列,會分別經歷下面三種重排序。
 

 

 1,編譯器優化的重排序:編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。

2,指令級並行的重排序:處理器將多條指令重疊執行,如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。

3,內存系統的重排序:處理器使用緩存和讀/寫緩沖區,使得加載和存儲操作看上去可能實在亂序執行。

重排序需要遵守一定的規則:

重排序遵守數據依賴性:如果兩個操作訪問同一個變量,且這兩個操作中有一個為寫操作,此時這2個操作就存在數據依賴性。存在數據依賴性的操作,不可以重排序。數據依賴性只是針對單個處理器中執行的指令序列和單個線程中執行的操作。

重排序遵守as-if-serial操作:就是說不管怎么重排序,單線程程序的執行結果都不會改變。

但是重排序也會帶來一些問題,導致多線程程序出現可見性和有序性的問題。下面我在一一描述。

JAVA內存模型(JMM)

Java內存模型(Java Memory Model)描述了Java程序中各種變量(線程共享變量)的訪問規則,以及在JVM中將變量存儲到內存和從內存中讀取出變量這樣的底層細節。

這里的變量指的是:共享變量

1、所有的變量都存儲在主內存中

2、每個線程都有自己獨立的工作內存,里面保存該線程使用到的變量的副本(主內存中該變量的一份拷貝)

X 變量就是共享變量:

 

happens-before原則

一提到happens-before原則,就讓人有點“丈二和尚摸不着頭腦”。這個涵蓋了整個JMM中可見性原則的規則,究竟如何理解,把我個人一些理解記錄下來。兩個操作間具有happens-before關系,並不意味着前一個操作必須要在后一個操作之前執行。happens-before僅僅要求前一個操作對后一個操作可見。

happens-before部分規則如下:

1、程序順序規則:一個線程中的每個操作happens-before於該線程中的任意后續操作

2、監視器鎖(同步)規則:對於一個監視器的解鎖,happens-before於隨后對這個監視器的加鎖

注1:為什么是部分happens-before原則,因為這篇文章是讓你理解happens-before原則,我會盡量讓你專注在這件事情上不被其他的所影響

注2:程序順序規則中所說的每個操作happens-before於該線程中的任意后續操作並不是說前一個操作必須要在后一個操作之前執行,而是指前一個操作的執行結果必須對后一個操作可見,如果不滿足這個要求那就不允許這兩個操作進行重排序。

舉例:

public double rectangleArea(double length , double width){
double leng;
double wid;
leng=length;//A
wid=width;//B
double area=leng*wid;//C
return area;
}

在程序中

A  happens-before  B

B  happens-before C

A  happens-before C //happens-before具有傳遞規則

根據happens-before規則我們來分析重排序后可能產生的結果

因為A  happens-before  B,所以A操作產生的結果leng一定要對B操作可見,但是現在B操作並沒有用到length,所以這兩個操作可以重排序,那A操作是否可以和C操作重排序呢,如果A操作和C操作進行了重排序,因為leng沒有被賦值,所以leng=0,area=0*wid也就是area=0;這個結果顯然是錯誤的,所以A操作是不能和C操作進行重排序的(這就是注2中說的前一個操作的執行結果必須對后羿操作可見,如果不滿足這個要求就不允許這兩個操作進行重排序)。

as-if-serial語義

as-if-serial語義的意思指:管怎么重排序(編譯器和處理器為了提高並行度),(單線程)程序的執行結果不能被改變。編譯器,runtime 和處理器都必須遵守as-if-serial語義。

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

可見性

簡而言之:一個線程對共享變量的修改,另一個線程能夠立刻看到,我們稱之為可見性。

為什么會用可見性?

對於如今的多核處理器,每個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 的。這就是緩存的可見性問題。

 要實現共享變量的可見性,必須保證兩點:

1、線程修改后的共享變量值能夠及時從工作內存刷新到主內存中
2、其他線程能夠及時把共享變量的最新值從主內存更新到自己的工作內存中

 

原子性

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

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

1,原子操作是對於多線程而言的,對於單一線程,無所謂原子性。有點多線程常識的朋友這個都應該知道,但也要時刻牢記

2,原子操作是針對共享變量的。因此,涉及局部變量(如方法中的變量)我們是沒必要要求它具有原子性的。

3,原子操作是不可分割的。(我們要站在多線程的角度)指訪問某個共享變量的操作從其執行線程之外的線程來看,該操作要么已經執行完畢,要么尚未發生,其他線程不會看到執行操作的中間結果。學過數據庫的朋友應該很熟悉這種原子性。那么,站在訪問變量的角度,我們可以這樣看,如果要改變一個對象,而該對象包含一組需要同時改變的共享變量,那么,在一個線程開始改變一個變量之后,在其它線程看來,這個對象的所有屬性要么都被修改,要么都沒有被修改,不會看到部分修改的中間結果。

並且記住,在Java語言中,long型和double型以外的任何類型的變量的寫操作都是原子操作。(不提讀操作的原因是如果所有線程都是讀操作的話,那么沒必要保持原子性。

為什么會有原子性問題?

線程是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。

舉例:

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的成員變量就可能觸發空指針異常。

執行時序圖:

 

VOLATILE關鍵字使用

volatile原理

volatile保證有序性原理

前文介紹過,JMM通過插入內存屏障指令來禁止特定類型的重排序。

java編譯器在生成字節碼時,在volatile變量操作前后的指令序列中插入內存屏障來禁止特定類型的重排序。

volatile內存屏障插入策略:

在每個volatile寫操作的前面插入一個StoreStore屏障。
在每個volatile寫操作的后面插入一個StoreLoad屏障。
在每個volatile讀操作的后面插入一個LoadLoad屏障。
在每個volatile讀操作的后面插入一個LoadStore屏障。

 

 Store:數據對其他處理器可見(即:刷新到內存中)
Load:讓緩存中的數據失效,重新從主內存加載數據。

volatile保證可見性原理

volatile內存屏障插入策略中有一條,“在每個volatile寫操作的后面插入一個StoreLoad屏障”。

StoreLoad屏障會生成一個Lock前綴的指令,Lock前綴的指令在多核處理器下會引發了兩件事:

1. 將當前處理器緩存行的數據寫回到系統內存。
2. 這個寫回內存的操作會使在其他CPU里緩存了該內存地址的。

volatile內存可見的寫-讀過程

 

volatile修飾的變量進行寫操作。

由於編譯期間JMM插入一個StoreLoad內存屏障,JVM就會向處理器發送一條Lock前綴的指令。

Lock前綴的指令將該變量所在緩存行的數據寫回到主內存中,並使其他處理器中緩存了該變量內存地址的數據失效。

當其他線程讀取volatile修飾的變量時,本地內存中的緩存失效,就會到到主內存中讀取最新的數據。

 

保證可見性

volatile保證了不同線程對volatile修飾變量進行操作時的可見性。

對一個volatile變量的讀,(任意線程)總是能看到對這個volatile變量最后的寫入。

一個線程修改volatile變量的值時,該變量的新值會立即刷新到主內存中,這個新值對其他線程來說是立即可見的。

一個線程讀取volatile變量的值時,該變量在本地內存中緩存無效,需要到主內存中讀取。

 

舉例:

 

boolean stop = false;// 是否中斷線程1標志
//Tread1
new Thread() {
    public void run() {
        while(!stop) {
          doSomething();
        }
    };
}.start();
//Tread2
new Thread() {
    public void run() {
        stop = true;
    };
}.start();

 

目的: Tread2設置stop=true時,Tread1讀取到stop=true,Tread1中斷執行。

問題: 雖然大多數時候可以達到中斷線程1的目的,但是有可能發生Tread2設置stop=true后,Thread1未被中斷的情況,而且這種情況引發的都是比較嚴重的線上問題,排查難度很大。

問題分析: Tread2設置stop=true時,並未將stop=true刷到主內存,導致Tread1到主內存中讀取到的仍然是stop=false,Tread1就會繼續執行。也就是有內存可見性問題。

解決: stop變量用volatile修飾。
Tread2設置stop=true時,立即將volatile修飾的變量stop=true刷到主內存;
Tread1讀取stop的值時,會到主內存中讀取最新的stop值。

 

保證有序性

volatile關鍵字能禁止指令重排序,保證了程序會嚴格按照代碼的先后順序執行,即保證了有序性。

volatile的禁止重排序規則:

1)當第二個操作是volatile寫時,不管第一個操作是什么,都不能重排序。這個規則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之后。
2)當第一個操作是volatile讀時,不管第二個操作是什么,都不能重排序。這個規則確保volatile讀之后的操作不會被編譯器重排序到volatile讀之前。
3)當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。

舉例

boolean inited = false;// 初始化完成標志
//線程1:初始化完成,設置inited=true
new Thread() {
    public void run() {
        context = loadContext();   //語句1
        inited = true;             //語句2
    };
}.start();
//線程2:每隔1s檢查是否完成初始化,初始化完成之后執行doSomething方法
new Thread() {
    public void run() {
        while(!inited){
          Thread.sleep(1000);
        }
        doSomething(context);
    };
}.start();

 

目的: 線程1初始化配置,初始化完成,設置inited=true。線程2每隔1s檢查是否完成初始化,初始化完成之后執行doSomething方法。

問題: 線程1中,語句1和語句2之間不存在數據依賴關系,JMM允許這種重排序。如果在程序執行過程中發生重排序,先執行語句2后執行語句1,會發生什么情況?

當線程1先執行語句2時,配置並未加載,而inited=true設置初始化完成了。線程2執行時,讀取到inited=true,直接執行doSomething方法,而此時配置未加載,程序執行就會有問題。

解決: volatile修飾inited變量。
volatile修飾inited,“當第二個操作是volatile寫時,不管第一個操作是什么,都不能重排序。”,保證線程1中語句1與語句2不能重排序。

 

不保證原子性

volatile是不能保證原子性的。

原子性是指一個操作是不可中斷的,要全部執行完成,要不就都不執行。

舉例:

public class VolatileTest {
    public volatile int a = 0;

    public void increase() {
        a++;
    }

    public static void main(String[] args) {
        final VolatileTest test = new VolatileTest();
        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=0,所以線程2的計算結果也是a=1。

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

解決:

synchronized保證原子性,用synchronized修飾increase()方法。

CAS來實現原子性操作,AtomicInteger修飾變量a。

 

Synchronized的使用

在java代碼中使用synchronized可是使用在代碼塊和方法中,根據Synchronized用的位置可以有這些使用場景:

synchronized其實就是鎖,先來介紹一下鎖的機制。

鎖的內存語義

synchronized的底層是使用操作系統的mutex lock實現的。

內存可見性:同步快的可見性是由“如果對一個變量執行lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前需要重新執行load或assign操作初始化變量的值”、“對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store和write操作)”這兩條規則獲得的。

操作原子性:持有同一個鎖的兩個同步塊只能串行地進入

鎖的內存語義:

當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中

當線程獲取鎖時,JMM會把該線程對應的本地內存置為無效。從而使得被監視器保護的臨界區代碼必須從主內存中讀取共享變量

鎖釋放和鎖獲取的內存語義:

線程A釋放一個鎖,實質上是線程A向接下來將要獲取這個鎖的某個線程發出了(線程A對共享變量所做修改的)消息。

線程B獲取一個鎖,實質上是線程B接收了之前某個線程發出的(在釋放這個鎖之前對共享變量所做修改的)消息。

線程A釋放鎖,隨后線程B獲取這個鎖,這個過程實質上是線程A通過主內存向線程B發送消息。

對象鎖(monitor)機制

現在我們來看看synchronized的具體底層實現。先寫一個簡單的demo:

public class SynchronizedDemo {
    public static void main(String[] args) {
        synchronized (SynchronizedDemo.class) {
        }
        method();
    }

    private static void method() {
    }
}

 

上面的代碼中有一個同步代碼塊,鎖住的是類對象,並且還有一個同步靜態方法,鎖住的依然是該類的類對象。編譯之后,切換到SynchronizedDemo.class的同級目錄之后,然后用javap -v SynchronizedDemo.class查看字節碼文件:

如圖,上面用黃色高亮的部分就是需要注意的部分了,這也是添Synchronized關鍵字之后獨有的。執行同步代碼塊后首先要先執行monitorenter指令,退出的時候monitorexit指令。通過分析之后可以看出,使用Synchronized進行同步,其關鍵就是必須要對對象的監視器monitor進行獲取,當線程獲取monitor后才能繼續往下執行,否則就只能等待。而這個獲取的過程是互斥的,即同一時刻只有一個線程能夠獲取到monitor。上面的demo中在執行完同步代碼塊之后緊接着再會去執行一個靜態同步方法,而這個方法鎖的對象依然就這個類對象,那么這個正在執行的線程還需要獲取該鎖嗎?答案是不必的,從上圖中就可以看出來,執行靜態同步方法的時候就只有一條monitorexit指令,並沒有monitorenter獲取鎖的指令。這就是鎖的重入性,即在同一鎖程中,線程不需要再次獲取同一把鎖。Synchronized先天具有重入性。每個對象擁有一個計數器,當線程獲取該對象鎖后,計數器就會加一,釋放鎖后就會將計數器減一。

 總結

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

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

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

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

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

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 
 
 
 
 
 
 
 
 
 
 
 
 
 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM