1. 程式人生 > >Java多執行緒安全原理

Java多執行緒安全原理

從[深入理解Java虛擬機器],[Java併發程式設計的藝術]這兩本書裡學到了很多知識。
在學習的過程中,總結下對多執行緒的理解。多執行緒的底層原理非常複雜,個人也在不斷學習當中,這篇文章也只是管中窺豹,難免有錯誤的地方。

這篇文章希望能對以下幾個問題有疑惑的人有所啟發:

  1. 物件為什麼會不安全?
  2. Java多執行緒之間怎麼相互通訊?
  3. 開發人員怎樣判斷物件在多執行緒環境下是否安全?
  4. 開發人員怎麼編寫正確的高併發程式碼?

JVM的記憶體區域

首先看下JVM(java虛擬機器)執行時的資料區域的劃分:
這裡寫圖片描述

圖中的這些區域都有各自的用途以及生命週期,簡單瞭解下:

  • 方法區:Method Area是各個執行緒共享的記憶體區域,它用來儲存被jvm載入的類資訊,常量,靜態變數等資料。我們經常提到的常量池就存放在這裡。

  • 堆:Java Heap用來存放所有物件的例項,由所有執行緒共享。JVM規範中指出所有的物件例項以及對應的陣列例項都在堆中分配。堆是jvm記憶體中最大的一塊區域,也是GC(Garbage Collected)垃圾回收機制作用的主要區域。

  • 程式計數器:每一個執行緒都各自擁用有獨立的計數器,用來儲存所執行的位元組碼的行號。當多執行緒相互交替執行的時候,各自執行緒依靠它來選取下一條需要執行的位元組碼指令。(你可以把它當作一個標記,當執行緒被其他執行緒搶佔了cpu,也就是阻塞的時候,會記錄下當前執行到的位元組碼行號。當該執行緒重新可執行的時候,它就知道從哪開始執行了)

  • 棧:虛擬機器棧是用來儲存Java方法執行的區域。解釋下這句話:每個方法在執行的時候都會建立一個棧幀(一種資料結構),棧幀裡儲存了局部變量表,物件引用,運算元棧,方法出口等資訊。每一個方法從開始到執行結束的過程,就對應著一個棧幀在虛擬機器棧中入棧到出棧的過程。

再看下面的程式碼:

public class Demo {

    public Demo() {
    }

}
/**
 * Created by xieyuhui on 2017/9/5.
 */
public class TestDemo {

    public static void main(String[] args) {
        String s = new String();
        int i = 1;
        Demo d = new Demo();
    }
}

當我們執行main方法的時候,對應的JVM記憶體區域是這樣的:
jvm例項化物件


main方法沒有邏輯,只例項化了兩個變數,賦值了一個基本型別int。main方法執行的時候會被轉換成一個棧幀push到虛擬機器棧的棧頂(棧裡面可能會有很多棧幀,只有位於棧頂的棧幀才會被執行緒執行)
當執行緒執行到String s = new String()的時候,生成一個引用變數’s’,該變數儲存了指向堆記憶體中String物件的指標,同理引用變數’d’儲存了指向Demo物件的指標。JVM規範規定所有的基本型別直接在棧上分配記憶體空間,稱之為區域性變數(下面再說到變數的時候,私有的區域性變數不算在內,因為討論它們沒有意義)
如果該方法是第一次執行,因為’s’指向的String物件在構造的時候char[]被final修飾(這也是string物件不可變的原因之一),jvm會把’s’放到方法區的常量池裡已備後續使用。

JVM執行原理非常複雜,也不是本篇要討論的,知道概念就行了。

JAVA記憶體模型

執行緒之間通訊主要通過兩種方式(通訊是指執行緒之間以何種機制來交換資訊)

  • 共享記憶體:通過讀-寫記憶體中的公共區域進行隱式通訊。
  • 訊息傳遞:執行緒之間傳送訊息進行顯式通訊。

JAVA執行緒之間通訊是通過共享記憶體方式完成的。

JMM概念圖

上圖是JMM(Java Memory Model)JAVA記憶體模型的概念結構圖,雖然和上面的JVM記憶體區域是不同層次的記憶體劃分,但其實可以對應上。工作記憶體可以對應棧,主記憶體可以對應堆。

工作記憶體與主記憶體的互動

執行緒不能自己建立變數,執行緒在工作記憶體中操作的變數全都是從主記憶體裡copy的變數副本。JMM中規定了8種操作來完成工作記憶體和主記憶體的互動:

  • 加鎖 lock:把主記憶體中的一個變數標識為一條執行緒獨佔的狀態。

  • 解鎖 unlock:把主記憶體中處於加鎖狀態的變數釋放出來。

  • 讀取 read:把主記憶體的變數的值傳輸到工作記憶體中。

  • 載入load:把主記憶體傳輸過來的變數值放進工作記憶體的變數副本中。

  • 使用 use:把變數副本的值傳遞給執行引擎進行運算操作。

  • 賦值 assign:工作記憶體接受執行引擎傳遞過來的值放進變數副本里。

  • 儲存 store:把工作記憶體的變數副本的值傳輸給主記憶體。

  • 寫入 write :把工作記憶體接收到的值放進主記憶體的變數中。

Java執行緒之間的通訊由JMM控制,JMM決定一個執行緒對主記憶體中的共享變數的寫入何時對另外的執行緒可見。
理想狀態下的執行緒執行緒執行

  1. JMM通過read和load操作把主記憶體裡的共享變數i的值複製到各個工作記憶體裡。

  2. 執行緒A通過use和assign操作改變了i的值。

  3. 執行緒A通過store和write操作把工作記憶體中i的值同步回主記憶體。

  4. 執行緒B需要操作i的時候,會通過read和load操作重新讀取主記憶體裡的變數i,發現此時i的值已經改變了,重新整理自己工作記憶體i的值。

這就是JAVA執行緒之間的通訊手段,通過共享主記憶體隱式通訊

然而上圖是理想化的執行緒執行模型,我們認為執行緒B會線上程A執行完成後執行,並且認為執行緒A在本地記憶體改變了i的值後會重新整理回主記憶體。然而現實並不是這樣,Java多執行緒是通過執行緒輪流切換並分配處理器執行時間的方式來實現的,在不做任何處理的情況下,我們無法保證執行緒之間的執行順序。jvm也沒有保證上面的8種操作是有序的。

也就是說執行緒A在工作記憶體中改變了i的值,可能還沒有重新整理回主記憶體,這時執行緒B搶到了執行時間,去讀取主記憶體i變數的值。這時候程式設計師認為此時i的值是1,對於執行緒A來說是的,但對於其他執行緒來說,它們沒有和執行緒A通訊過,因此它們並不知道i的值已經改變了。

所以我們認為:在多執行緒環境下,主記憶體裡的變數i是一個執行緒不安全的物件

happens-before原則

在主記憶體中,像i這樣的共享變數都是執行緒不安全的,因為它們無法保證一個執行緒對它們的操作對於另外的執行緒可見。在我們日常開發中,諸如i這樣的共享變數非常常見,我們也沒有對它們做過任何的同步處理(比如鎖),那麼是不是說在多執行緒環境下,這些程式碼都有執行緒安全問題呢?答案是不一定~

在JMM中,存在一個”先行發生(happens-before)原則”,JMM就是依靠這個原則判斷執行緒是否安全,資料是否存在競爭。我們也可以通過這些原則發現物件在併發環境下是否可能存線上程安全的問題。

定義:”先行併發”指定是操作之間的順序,A”先行併發”B,那麼A操作產生的影響能被B察覺到,影響指的是:修改了共享變數的值,呼叫了某些方法,傳送了訊息等。

下面是JMM中”天然存在”的先行發生原則,如果你的程式碼裡有兩個操作之間的關係從下列原則裡推導不出來,那麼它們就不受JMM控制,JVM會對它們隨意的進行重排序,這時候就需要程式設計師介入,用編碼的方式解決了。

  • 程式次序規則:在一個執行緒中的每個操作,happen-before於該執行緒中的任意後續操作。

  • 監視器鎖定規則:對一個鎖的unlock,happen-before於隨後對這個鎖的lock。

  • volatile變數規則:對一個volatile變數的寫操作,happen-before於任意後續對這個物件的讀操作。

  • 執行緒啟動規則:Thread物件的start()方法,happen-before於此執行緒的所有操作。

  • 執行緒終止規則:執行緒所有操作,happen-before於Thread物件的join()方法。

  • 執行緒中斷規則:對執行緒interrupt()方法的呼叫,happen-before於被中斷執行緒的程式碼檢測到中斷事件的發生。

  • 物件終結規則:一個物件的初始化完成,happen-before於它的finalized()方法。

  • 傳遞性:如果A happen-before B,且B happen-before C,那麼A happen-before C。

上面就是Java語言無須任何同步手段保障就能成立的先行發生規則。這些規則隨便拿出一條來介紹都能獨立出一篇部落格文章。你可能看的很糊塗,我們來舉個例子:

/**
 * Created by xieyuhui on 2017/7/17.
 */
public class TestDemo {

    private int value = 0;

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }
}

TestDemo類非常簡單,有一組getter/setter方法。假設執行緒A先執行,呼叫了”setValue(1)”方法,然後執行緒B呼叫”getValue()”,這時候執行緒B返回值是1嗎?

就這個例子我們套用上面的各個先行發生原則分析一下:

  • 程式次序規則:set()和get()方法分別由兩個執行緒執行,不在一個執行緒中,這條規則不適用。

  • 監視器鎖定規則:TestDemo類中,我們沒有用synchronized關鍵字修飾過,沒有同步塊,不會發生lock和unlock,這條規則不適用。

  • volatile變數規則:TestDemo類的value變數沒有被volatile關鍵字修飾,這條規則不適用。

  • 執行緒啟動規則:沒有使用Thread類控制,這條規則不適用。

  • 執行緒終止規則:沒有使用Thread類控制,這條規則不適用。

  • 執行緒中斷規則:沒有使用Thread類控制,這條規則不適用。

  • 傳遞性:TestDemo類沒有找到先行發生關係,這條規則不適用。

結論:TestDemo類是一個執行緒不安全的類。即使執行緒A在時間上先於執行緒B執行,也無法確定執行緒B返回的結果。時間先後順序於先行發生原則之間沒有關係,所以我們碰到併發安全問題的時候不要受到時間順序的干擾,一切都按照先行發生原則為準。

那麼怎麼讓TestDemo變成一個執行緒安全的類呢?只要滿足上面的先行發生原則中的某一條,JMM就能保證變數在併發過程中的原子性可見性有序性
就這個例子中,至少有兩種簡單的解決方法。

  1. 把set()和get()方法定義為synchronized方法,這樣就有了監視器鎖定規則。

  2. 把value變數用volatile修飾,這樣就有了 volatile變數規則。

結語

程式不會按照你看到的順序執行,即使是單執行緒環境。在多執行緒環境下,所有的事情都會變得更復雜,甚至是不可預測。

除非你能保證你的程式碼是執行在單執行緒環境下的,否則,你在程式設計的時候必須要考慮你現在正在操作的這個物件,在多執行緒環境下是否是執行緒安全的,是否會按照你頭腦中想象的那樣去執行。

正確使用JAVA API處理執行緒安全問題是困難的,理解多執行緒底層原理則更加困難,但是,理解了底層原理後再使用API處理執行緒安全問題會變的非常簡單。