解讀 Java 內存模型

偉人之因此偉大,是由於他與別人共處逆境時,別人失去了信心,他卻下決心實現本身的目標。java

Java內存模型(Java Memory Model)定義了Java的線程在訪問內存時會發生什麼。這裏針對如下幾個要點進行解析:數組

  • 重排序
  • 可見性
  • synchronized
  • volitile
  • final
  • Double-Checked Locking

首先了解一下與Java內存模型交互時的指南:緩存

* 使用synchronized或volatile來保護在多個線程之間共享的字段
* 將常量字段設置爲final
* 不要從構造函數中泄露this

重排序

什麼是重排序

所謂重排序,英文記做Reorder,是指編譯器和Java虛擬機經過改變程序的處理順序來優化程序。雖然重排序被普遍用於提升程序性能,不過開發人員幾乎不會意識到這一點。實際上,在運行單線程程序時咱們沒法判斷是否進行了重排序。這是由於,雖然處理順序改變了,可是規範上有不少限制能夠避免程序出現運行錯誤。安全

可是,在多線程程序中,有時就會發生明顯是由重排序致使的運行錯誤。多線程

示例程序1

下面代碼展現了一段幫助咱們理解重排序的示例程序。在Something類中,有x、y兩個字段,以及write、read這兩個方法。x和y會在最開始被初始化爲0。write方法會將x賦值爲100,y賦值爲50。而read方法則會比較x和y的值,若是x比y小,則顯示x<y。函數

Main類的main方法會建立一個Something的實例,並啓動兩個線程。寫數據的線程A會調用write方法,而讀數據的線程B則會調用read方法。性能

class Something {
    private int x = 0;
    private int y = 0;
    public void write() {
        x = 100;
        y = 50;
    }
    public void read() {
        if(x < y) {
            System.out.println("x < y");
        }
    }
}
public class Main {
    public static void main(String[] args) {
        final Something obj = new Something();
        // 寫數據的線程A
       new Thread() {
           public void run() {
               obj.write();
           }
       }.start();
       // 讀數據的線程B
       new Thread() {
           public void run() {
               obj.read();
           }
       }.start();
    } 
}

問題是,在運行這段程序後會顯示出"x < y"嗎?
因爲write方法在給x賦值後會接着給y賦值,因此x會先從0變爲100,而以後y則會從0變爲50。所以,你們可能會作出絕對不可能顯示"x < y"的判斷。可是,這麼判斷是錯誤的。優化

你們應該會很吃驚,由於在Java內存模型中,是有可能顯示出x < y 的。緣由就在於重排序。
在write方法中,因爲對x的賦值和對y的賦值之間不存在任何依賴關係,編譯器可能會改變賦值順序。並且,在線程A已經爲y賦值,但還沒有爲x賦值以前,線程B也可能會去查詢x和y的值並執行if語句進行判斷,這時x < y的關係成立。ui

假設如示例程序1所示,對於一個字段,有「寫數據的線程」和「讀數據的線程」,可是咱們並無使用synchronized關鍵字和volatile關鍵字修飾該字段來正確的同步數據時,咱們稱這種沒有同步的狀態爲「存在數據競爭」。此外,咱們稱這樣存在數據競爭的程序爲未正確同步(incorrectly synchronized)的程序。因爲未正確同步的程序缺少安全性,因此必須使用synchronized或volatile來正確地進行同步。this

雖然示例程序1是未正確同步的程序,可是講write和read都聲明爲synchronized方法,就能夠實現正確同步的程序。

可見性

什麼是可見性

假設線程A將某個值寫入到字段x中,而線程B讀取到了該值。咱們稱其爲「線程A向x的寫值對線程B是可見(visible)的」。「是不是可見的」這個性質就稱爲可見性,英文記做visibiliy。

在單線程程序中,無須在乎可見性。這是由於,線程老是能夠看見本身寫入到字段中的值。

可是,在多線程程序中必須注意可見性。這是由於,若是沒有使用synchronized或volatile正確地進行同步,線程A寫入到字段中的值可能並不會當即對線程B課可見。開發人員必須很是清楚地知道在什麼狀況下一個線程的寫值對其餘線程是可見的。

示例程序2

下面代碼展現了一段因沒有注意到可見性而致使程序失去生存性的示例程序。

class Runner extends Thread {
    private boolean quit = false;
    public void run() {
        while(!quit) {
            // ...
        }
        System.out.println("Done");
    }
    public void shutdown() {
        quit = true;
    }
}
public class Main {
    public static void main(String[] args) {
        Runner runner = new Runner();
        // 啓動線程
        runner.start();
        // 終止線程
        runner.shutdown();
    }
}

Runner類的run方法會在字段quit變爲true以前一直執行while循環。當quit變爲true,while循環結束後,會顯示字段Done。

shutdown方法會將字段quit設置爲true。

Main類的main方法會先調用start方法啓動Runner線程,而後調用shutdown方法將quit的值設置爲true。咱們本來覺得在運行這段程序時,Runner線程會當即顯示出Done,而後退出。可是Java內存模型可能會致使Runner線程永遠在while循環中不停地循環也就是說,示例程序2可能會失去生存性。

緣由是,向字段quit寫值的線程(主線程)與讀取字段quit的線程(Runner)是不一樣的線程。主線程向quit寫入的true這個值可能對Runner線程永遠不可見(非visible)。

若是以「緩存」的思路來理解不可見的緣由可能會有助於你們理解。主線程向quit寫入的true這個值可能只是被保存在主線程的緩存中。而Runner線程從quit讀取到的值,仍然是在Runner線程的緩存中保存者的值false,並無任何變化。不過若是將quit聲明爲volatile字段,就能夠實現正確同步的代碼。

共享內存與操做

在Java內存模型中,線程A寫入的值並不必定會當即對線程B可見。下圖展現了線程A和線程B經過字段進行數據交互的情形。
image.png
共享內存(shared memeory)是全部線程共享的存儲空間,也被稱爲堆內存(heap memory)。由於實例會被所有保存在共享內存中,因此實例中的字段也存在與共享內存中。此外,數組的元素也被保存在共享內存中。也就是說,可使用new在共享內存中分配存儲空間。

局部變量不會被保存在共享內存中。一般,除局部變量外,方法的形參、catch語句塊中編寫的異常處理器的參數等也不會被保存在共享內存中,而是被保存在各個線程持有的棧中。正是因爲它們沒有被保存在共享內存中,因此其餘線程不會訪問它們。

在Java內存模型中,只有能夠被多個線程訪問的共享內存纔會發生問題。

下圖展現了6種操做(action)。這些操做是咱們把定義內存模型時使用到的處理分類而成的。
image.png
這裏,(3)~(6)的操做是進行同步(synchronization)的同步操做(synchronization action)。進行同步的操做具備防治重排序,控制可見性的效果。

normal read/normal write操做表示的是對普通字段(volatile之外的字段)的讀寫。如上圖所示,這些操做是經過緩存來執行的。所以,經過normal read讀取到的值並不必定是最新的值,經過normal write寫入的值也不必定會當即對其餘線程可見。

volatile read/volatile write操做表示的是對volatile字段的讀寫。因爲這些操做並非經過緩存來執行的,因此經過volatile read讀取到的值必定是最新的值,經過volatile write寫入的值也會當即對其餘線程可見。

lock/unlock操做是當程序中使用了synchronized關鍵字時進行虎池處理的操做。lock操做能夠獲取實例的鎖,unlock操做能夠釋放實例的鎖。

之因此在normal read/normal write操做中使用緩存,是爲了提升性能。

若是這裏徹底不考慮緩存的存在,定義規範是「某個線程執行的寫操做的結果都必須當即對其餘線程可見」。那麼,因爲這項限制太過嚴格,Java編譯器以及Java虛擬機的開發人員進行優化的餘地就會變的很是少。

在Java內存模型中,某個線程寫操做的結果對其餘線程可見是有條件的。所以,Java編譯器和Java虛擬機的開發人員能夠在知足條件的範圍內自由地進行優化。前面講解的重排序就是一種優化。

那麼,線程的寫操做對其餘線程可見的條件到底是什麼,應該怎樣編寫程序纔好呢?

爲了便於你們理解這些內容,下面將按照順序講解synchronized、volatile以及final這些關鍵字。

synchronized

synchronized具備「線程的互斥處理」和「同步處理」兩種功能。

線程的互斥處理

若是程序中有synchronized關鍵字,線程就會進行lock/unlock操做。線程會在synchronized開始時獲取鎖,在synchronized終止時釋放鎖。

進行lock/unlock的部分並不只僅是程序中寫有synchronized的部分。當線程wait方法內部等待的時候也會釋放鎖。此外,當線程從wait方法中出來的時候還必須從新獲取鎖後才能繼續進行。

只有一個線程可以獲取某個實例的鎖。所以,當線程A正準備獲取鎖時,若是其餘線程已經獲取了鎖,那麼線程A就會進入等待隊列。這樣就實現了線程的互斥(mutal exclusion)。

synchronized的互斥處理以下圖所示,當線程A執行了unlock操做可是尚未從中出來時,線程B就沒法執行lock操做。圖中的unlock M和lock M中都寫了一個M,這表示unlock操做和lock操做是對同一個實例的監視器進行的操做。
image.png

同步處理

synchronized(lock/unlock操做)並不只僅進行線程的互斥處理。Java內存模型確保了某個線程在進行unlock M操做前進行的全部寫入操做對進行lock M操做的線程都是可見的。

下面,咱們使用示例程序3進行說明,將示例程序1中的write和read修改成synchronized方法,這是一段可以正確地進行同步的程序,絕對不可能顯示出x < y。

class Something {
    private int x = 0;
    private int y = 0;
    public synchronized void write() {
        x = 100;
        y = 50;
    }
    public synchronized void read() {
        if(x < y) {
            System.out.println("x < y");
        }
    }
}
public class Main {
    public static void main(String[] args) {
        final Something obj = new Something();
        // 寫數據的線程A
       new Thread() {
           public void run() {
               obj.write();
           }
       }.start();
       // 讀數據的線程B
       new Thread() {
           public void run() {
               obj.read();
           }
       }.start();
    } 
}

經過synchronized進行同步的情形以下圖所示:
image.png
在進行以下操做時,線程A的寫操做對線程B是可見的。

  • 線程A對字段x和y寫值(normal write操做)
  • 線程A進行unlock操做
  • 線程B對同一個監視器M進行lock操做
  • 線程B讀取字段x和y的值(normal read)

大致來講就是:

  • 進行unlock操做後,寫入緩存的內容會被強制的寫入到共享內存中
  • 進行lock操做後,緩存中的內容會先失效,而後共享內存中的最新內容會被強制從新讀取到緩存中

在示例程序3中不可能顯示出x < y的緣由有如下兩個:

  1. 互斥處理能夠防止read方法中斷write方法的處理。雖然在write方法內部會發生重排序,可是該重排序不會對read方法產生任何影響。
  2. 同步處理能夠確保write方法向字段x、y寫入的值對運行read方法的線程B是可見的。

上圖中的release和acquire表示進行同步處理的兩端(synchronized-with edge)。unlock操做是一種release,lock操做是一種acquire。Java內存模型能夠確保處理是按照「release終止後對應的acquire纔開始」的順序進行的。

總結起來就是。只要用synchronized保護會被多個線程讀寫的共享字段,就能夠避免這些共享字段受到重排序和可見性的影響。

volatile

volatile具備「同步處理」和「對long和double的原子操做」這兩種功能。

同步處理

某個線程對volatile字段進行的寫操做的結果對其餘線程當即可見。換言之,對volatile字段的寫入處理並不會被緩存起來。

示例程序4是將示例程序2中的quit修改成volatile字段後的程序。

class Runner extends Thread {
    private volatile boolean quit = false;
    public void run() {
        while(!quit) {
            // ...
        }
        System.out.println("Done");
    }
    public void shutdown() {
        quit = true;
    }
}
public class Main {
    public static void main(String[] args) {
        Runner runner = new Runner();
        // 啓動線程
        runner.start();
        // 終止線程
        runner.shutdown();
    }
}

volatile字段並不是只是不緩存讀取和寫入。若是線程A向volatile字段寫入的值對線程B可見,那麼以前向其餘字段寫入的全部值都對線程B是可見的。此外,在向volatile字段讀取和寫入後不會發生重排序。

示例程序5
class Something {
    private int x = 0;
    private volatile boolean valid = false;
    public void write() {
        x = 123;
        valid = true;
    }
    public void read() {
        if(valid) {
            System.out.println(x);
        }
    }
}
public class Main {
    public static void main(String[] args) {
        final Something obj = new Something();
        // 寫數據的線程A
        new Thread() {
            public void run() {
                obj.write();
            }
        }.start();
        // 讀數據的線程B
        new Thread() {
            public void run() {
                obj.read();
            }
        }.start();
    }
}

如示例程序5所示,Something類的write發方法在將非volatile字段x賦值爲123後,接着又將volatile字段valid賦值爲了true。

在read方法中,當valid的值爲true時,顯示x。
Main類的main方法會啓動兩個線程,寫數據的線程A會調用write方法,該數據的線程B會調用read方法。示例程序5是一段能夠正確地進行同步處理的程序。

因爲valid是volatile字段,因此如下兩條賦值語句不會被重排序。

x = 123;        // [normal write]
valid = true;   // [normal write]

另外,下面兩條語句也不會被重排序。

if(valid) {                 // [volatile read]
    System.out.println(x);  // [normal write]
}

從volatile的使用目的來看,volatile阻止重排序是理所固然的。如示例程序5所示,volatile字段多被用做判斷實例是否變爲了特定狀態的標誌。所以,當要確認volatile字段的值是否發生了變化時,必須確保非volatile的其餘字段的值已經被更新了。
image.png
如上圖所示,在進行以下處理時,線程A向x以及valid寫入的值對線程B是可見的。

  • 線程A向字段x寫值(normal write)
  • 線程A向volatile字段valid寫值(volatile write)
  • 線程B讀取volatile字段valid的值(volatile read)
  • 線程B讀取字段x的值(normal read)
對long和double的原子操做

Java規範沒法確保對long和double的賦值操做的原子性。可是,即便是long和double的字段,只要它是volatile字段,就能夠確保賦值操做的原子性。

指南:使用synchronized或volatile來保護在多個線程之間共享的字段

final

final字段與構建線程安全的實例

使用final關鍵字聲明的字段只能被初始化一次。final字段在建立不容許被改變的對象時起到了很是重要的做用。

final字段的初始化只能在「字段聲明時」或是「構造函數中」進行。那麼,當final字段的初始化結束後,不管在任什麼時候候,它的值對其餘線程都是可見的。Java內存模型能夠確保被初始化後的final字段在構造函數的處理結束後是可見的。也就是說,能夠確保一下事情:

  • 若是構造函數的處理結束了

    • final字段初始化後的值對全部線程都是可見的
    • 在final字段能夠追溯到的全部範圍內均可以看到正確的值
  • 在構造函數的處理結束前

    • 可能會看到final字段的值是默認的初始值(0、false或是null)
指南:將常量字段設置爲final

Java內存模型能夠確保final字段在構造函數執行結束後能夠正確的被看到。這樣就再也不須要經過synchronized和volatile進行同步了。所以,請將不但願被改變的字段設爲final。

指南:不要從構造函數中泄露this

在構造函數中執行結束前,咱們可能會看到final字段的值發生變化。也就是說,存在首先看到「默認初始值」,而後看到「顯式地初始化的值」的可能性。

下面來看示例程序6,有一個final字段x的一個靜態字段last。

在構造函數中,final字段x被顯式地初始化爲了123,而靜態字段last中保存的則是this,咱們能夠理解爲將最後建立的實例保存在了last中。

在靜態方法print中,若是靜態字段last部位null(即如今實例已經建立完成了),這個實例的final字段的值就會顯示出來。

Main類的main方法會啓動兩個線程。線程A會建立Something類的實例,而線程B則會調用Something.print方法來顯示final字段的值。

這裏的問題是,運行程序後會顯示出0嗎?

class Something {
    // final的實例字段 
    private final int x;
    // 靜態字段
    private static Something last = null;
    // 構造函數
    public Something() {
        // 顯式的初始化final字段
        x = 123;
        // 在靜態字段中保存正在建立中的實例(this)
        last = this;
    }
    // 經過last顯式final字段的值
    public static void print() {
        if(last !=null) {
            System.out.println(last.x);
        }
    }
}
public class Main {
    public static void main(String[] args) {
        final Something obj = new Something();
        // 寫數據的線程A
       new Thread() {
           public void run() {
               new Something();
           }
       }.start();
       // 讀數據的線程B
       new Thread() {
           public void run() {
             Something.print();
           }
       }.start();
    } 
}

咱們並無使用synchronized和volatile對線程Ahead線程B進行同步,所以不知道它們會按照怎樣的順序執行。因此,咱們必須考慮各類狀況。

若是線程B在執行print方法時,看到last的值爲null,那麼if語句中的條件就會變成false,該程序什麼都不會顯示。

那麼若是線程B在執行print方法時,看到last的值不是null會怎樣呢?last.x的值必定是123嗎?答案是否認的。根據Java內存模型,這時看到的last.x的值也可能會是0.由於線程B在print方法中看到的last的值,是在構造函數處理結束前獲取的this。

Java內存模型能夠確保構造函數處理結束時final字段的值被正確的初始化,對其餘線程是可見的。總而言之,若是使用經過new Something()獲取的實例,final字段是不會發生可見性問題的。可是,若是在構造函數的處理過程當中this尚未建立完畢,就沒法確保final字段的正確的值對其餘線程是可見的。

以下面實例代碼7這樣修改後,就不可能會顯示出0了。修改以下:

  • 將構造函數修改成private,讓外部沒法調用
  • 編寫一個名爲create的靜態方法,在其中使用new關鍵字建立實例
  • 將靜態字段last賦值爲上面使用new關鍵字建立的實例

這樣修改後,只有當那個構造函數處理結束後靜態字段last纔會被賦值,所以能夠確保final字段被正確的初始化。

class Something {
    // final的實例字段 
    private final int x;
    // 靜態字段
    private static Something last = null;
    // 構造函數
    public Something() {
        // 顯式的初始化final字段
        x = 123;
    }
    // 將使用new關鍵字建立的實例賦值給this
    publicstatic Something create() {
        last = new Something();
        return last;
    }
     // 經過last顯式final字段的值
    public static void print() {
        if(last !=null) {
            System.out.println(last.x);
        }
    }
}
public class Main {
    public static void main(String[] args) {
        final Something obj = new Something();
        // 寫數據的線程A
       new Thread() {
           public void run() {
               Something.create();
           }
       }.start();
       // 讀數據的線程B
       new Thread() {
           public void run() {
             Something.print();
           }
       }.start();
    } 
}

經過上面能夠知道,在構造函數中將靜態字段賦值爲this是很是危險的。由於其餘線程可能會經過這個靜態字段訪問正在建立中的實例。一樣的,向靜態字段保存的數組和集合中保存的this也是很是危險的。

另外,在構造函數中進行方法調用時,以this爲參數的方法調用也是很是危險的。由於該方法可能會將this放在其餘線程能夠訪問到的地方。

Double-Checked Locking模式的危險性

Double-Checked Locking模式本來適用於改善Single Threaded Execution模式的性能的方法之一,也被稱爲test-and-test-and-set。

不過,在Java中使用Double-Checked Locking模式是很危險的。

示例程序

咱們實現一個具備如下特性的MySystem類。

  • MySystem類的實例是惟一的
  • 能夠經過靜態方法getInstance獲取MySystem類的實例
  • MySystem類的實例中有一個字段(date)是java.util.Date類的實例。它的值是建立MySystem類的實例的時間
  • 能夠經過MySystem類的實例方法getDate獲取date字段的值

咱們會採起三種方式來實現上述MySystem類:

  1. Single Threaded Execution模式
  2. Double-Checked Locking模式
  3. Initialization On Demand Holder模式
實現方式1:Single Threaded Execution模式

考慮到可能會有多個線程訪問getInstance方法,咱們將getInstance方法定義爲了synchronized方法。因爲instance字段被synchronized保護着,因此即便多個線程調用getInstance方法,也能夠確保MySystem類的實例是惟一的。
程序雖然與咱們的要求一致,可是getInstance是synchronized的,所以性能並很差。

import java.util.Date;
public class MySystem {
    private static MySystem instance = null;
    private Date date = new Date();
    private MySystem() {
    }
    public Date gteDate() {
        return date;
    } 
    public static synchronized MySystem getInstance() {
        if(instance == null) {
            instance = new MySystem();
        }
        return instance;
    }
}
實現方式2:Double-Checked Locking模式

Double-Checked Locking模式是用於改善實現方式1中的性能問題的模式。

getInstance方法再也不是synchronized方法。取而代之的時if語句中編寫的一段synchronized代碼塊。

// X沒法確保可以正確地運行
import java.util.Date;
public class MySystem {
    private static MySystem instance = null;
    private Date date = new Date();
    private MySystem() {
    }
    public Date gteDate() {
        return date;
    } 
    public static synchronized MySystem getInstance() {
        if(instance == null) {              // (a)第一次test 
            synchronized(MySystem.class) {  // (b)進入synchronized代碼塊
                if(instance == null) {      // (c)第二次test
                    instance = new MySystem();// (d) set
                }
            }                               // (e)退出synchronized代碼塊
        }
        return instance;                    // (f)
    }
}

下圖解釋了爲何上述代碼可能會沒法正確的運行。
image.png
注意上圖中的(A-4),這裏寫着「在(d)處建立MySystem的實例並將其賦值給instance字段」,即代碼中的如下部分:

instance = new MySystem();

這裏建立了一個MySystem的實例。在建立MySystem的實例時,new Date()的值會被賦給實例字段date。若是線程A從synchronized代碼塊退出後,線程B才進入synchronized代碼塊,那麼線程B也能夠看見date的值。可是在(A-4)這個階段,咱們沒法確保線程B能夠看見線程A寫入date字段的值。
接下來,咱們再假設線程B在(B-1)這個階段的判斷結果是instance != null。這樣的話,線程B將不進入synchronized代碼塊,而是當即將instance的值做爲返回值return出來。這以後,線程B會在(B-3)這個階段調用getInstance的返回值的getDate方法。getDate方法的返回值就是date字段的值,因線程B會引用date字段的值。可是,線程A尚未從synchronized代碼塊中退出,線程B也沒有進入synchronized代碼塊。所以,咱們沒法確保date字段的值對線程B可見。

實現方式3:Initialization On Demand Holder模式

Initialization On Demand Holder模式既不會像Single Threaded Execution模式那樣下降性能,也不會帶來像Double-Checked Locking模式那樣的危險性。

Holder類是MySystem的嵌套類,有一個靜態字段instance,並使用new MySystem()來初始化該字段。
MySystem類的靜態方法getInstance的返回值是Holder.instance。
這段程序會使用Holder的「類的初始化」來建立惟一的實例,並確保線程安全。

咱們使用了嵌套類的延遲初始化(lazy initialization)。Holder類的初始化在線程剛剛要使用該類時纔會開始進行。也就是說,在調用MySystem.getInsta方法前,Holder類不會被初始化,甚至連MySystem的實例都不會建立。所以,使用該模式能夠避免內存浪費。

import java.util.Date;
public class MySystem {
    private static class Holder {
        public static MySystem instance = new MySystem();
    }
    private Date date = new Date();
    private MySystem() {
    }
    public Date getDate() {
        return date;
    }
    public static MySystem getInstance() {
        return Holder.instance;
    }
}
相關文章
相關標籤/搜索