1. 程式人生 > >Java 儲存模型和共享物件詳解

Java 儲存模型和共享物件詳解

Java 儲存模型和共享物件詳解

很多程式設計師對一個共享變數初始化要注意可見性和安全釋出(安全地構建一個物件,並其他執行緒能正確訪問)等問題不是很理解,認為Java是一個遮蔽記憶體細節的平臺,連物件回收都不需要關心,因此談到可見性和安全釋出大多不知所云。其實關鍵在於對Java儲存模型,可見性和安全釋出的問題是起源於Java的儲存結構。

Java儲存模型原理

有很多書和文章都講解過Java儲存模型,其中一個圖很清晰地說明了其儲存結構:

由上圖可知, jvm系統中存在一個主記憶體(Main Memory或Java Heap Memory)Java中所有變數都儲存在主存中,對於所有執行緒都是共享的

每條執行緒都有自己的工作記憶體(Working Memory)工作記憶體中儲存的是主存中某些變數的拷貝,執行緒對所有變數的操作都是在工作記憶體中進行,執行緒之間無法相互直接訪問,變數傳遞均需要通過主存完成

這個儲存模型很像我們常用的快取與資料庫的關係,因此由此可以推斷JVM如此設計應該是為了提升效能,提高多執行緒的併發能力,並減少執行緒之間的影響。

Java儲存模型潛在的問題

一談到快取, 我們立馬想到會有快取不一致性問題,就是說當有快取與資料庫不一致的時候,就需要有相應的機制去同步資料。同理,Java儲存模型也有這個問題,當一個執行緒在自己工作記憶體裡初始化一個變數,當還沒來得及同步到主存裡時,如果有其他執行緒來訪問它,就會出現不可預知的問題。另外,JVM在底層設計上,對與那些沒有同步到主存裡的變數,可能會以不一樣的操作順序來執行指令,舉個實際的例子:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

public class PossibleReordering {

  static int x = 0, y = 0;

  static int a = 0, b = 0;

  public static void main(String[] args)

      throws InterruptedException {

    Thread one = new Thread(new Runnable() {

      public void run() {

        a = 1;

        x = b;

      }

    });

    Thread other = new Thread(new Runnable() {

      public void run() {

        b = 1;

        y = a;

      }

    });

    one.start(); other.start();

    one.join();  other.join();

    System.out.println("( "+ x + "," + y + ")");

  }

}

由於,變數x,y,a,b沒有安全釋出,導致會不以規定的操作順序來執行這次四次賦值操作,有可能出現以下順序:

出現這個問題也可以理解,因為既然這些物件不可見,也就是說本應該隔離在各個執行緒的工作區內,那麼對於有些無關順序的指令,打亂順序執行在JVM看來也是可行的。

因此,總結起來,會有以下兩種潛在問題:

  1. 快取不一致性
  2. 重排序執行

解決Java儲存模型潛在的問題

為了能讓開發人員安全正確地在Java儲存模型上程式設計,JVM提供了一個happens-before原則,有人整理得非常好,我摘抄如下:

  1. 在程式順序中, 執行緒中的每一個操作, 發生在當前操作後面將要出現的每一個操作之前.
  2. 物件監視器的解鎖發生在等待獲取物件鎖的執行緒之前.
  3. 對volitile關鍵字修飾的變數寫入操作, 發生在對該變數的讀取之前.
  4. 對一個執行緒的 Thread.start() 呼叫 發生在啟動的執行緒中的所有操作之前.
  5. 執行緒中的所有操作 發生在從這個執行緒的 Thread.join()成功返回的所有其他執行緒之前.

有了原則還不夠,Java提供了以下工具和方法來保證變數的可見性和安全釋出:

  1. 使用 synchronized來同步變數初始化。此方式會立馬把工作記憶體中的變數同步到主記憶體中
  2. 使用 volatile關鍵字來標示變數。此方式會直接把變數存在主存中而不是工作記憶體中
  3. final變數。常量內也是存於主存中

另外,一定要明確只有共享變數才會有以上那些問題,如果變數只是這個執行緒自己使用,就不用擔心那麼多問題了
搞清楚Java儲存模型後,再來看共享物件可見性和安全釋出的問題就較為容易了

共享物件的可見性

當物件在從工作記憶體同步到主記憶體之前,那麼它就是不可見的。若有其他執行緒在存取不可見物件就會引發可見性問題,看下面一個例子:

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

public class NoVisibility {

  private static boolean ready;

  private static int number;

  private static class ReaderThread extends Thread {

    public void run() {

      while (!ready)

        Thread.yield();

      System.out.println(number);

    }

  }

  public static void main(String[] args) {

    new ReaderThread().start();

    number = 42;

    ready = true;

  }

}

按照正常邏輯,應該會輸出42,但其實際結果會非常奇怪,可能會永遠沒有輸出(因為ready為false),可能會輸出0(因為重排序問題導致ready=true先執行)。再舉一個更為常見的例子,大家都喜歡用只有set和get方法的pojo來設計領域模型,如下所示:

1

2

3

4

5

6

@NotThreadSafe

public class MutableInteger {

  private int value;

  public int get() { return value; }

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

}

但是,當有多個執行緒同時來存取某一個物件時,可能就會有類似的可見性問題。
為了保證變數的可見性,一般可以用鎖、 synchronized關鍵字、 volatile關鍵字或直接設定為final

共享變數釋出

共享變數釋出和我們常說的釋出程式類似,就是說讓本屬於內部的一個變數變為一個可以被外部訪問的變數。釋出方式分為以下幾種:

  • 將物件引用儲存到公共靜態域
  • 初始化一個可以被外部訪問的物件
  • 將物件引用儲存到一個集合裡

安全釋出和保證可見性的方法類似,就是要同步釋出動作,並使釋出後的物件可見。

執行緒安全

其實當我們把這些變數封閉在本執行緒內訪問,就可以從根本上避免以上問題,現實中存在很多例子通過執行緒封閉來安全使用本不是執行緒安全的物件,比如:

  1. swing的視覺化元件和資料模型物件並不是執行緒安全的,它通過將它們限制到swing的事件分發執行緒中,實現執行緒安全
  2. JDBC Connection物件沒有要求為執行緒安全,但JDBC的存取模式決定了一個Connection只會同時被一個執行緒使用
  3. ThreadLocal把變數限制在本執行緒中共享