很多程序員對一個共享變量初始化要注意可見性和安全發佈(安全地構建一個對象,並其他線程能正確訪問)等問題不是很理解,認爲Java是一個屏蔽內存細節的平臺,連對象回收都不需要關心,因此談到可見性和安全發佈大多不知所云。其實關鍵在於對Java存儲模型,可見性和安全發佈的問題是起源於Java的存儲結構。
Java存儲模型原理
有很多書和文章都講解過Java存儲模型,其中一個圖很清晰地說明了其存儲結構:
由上圖可知, jvm系統中存在一個主內存(Main Memory或Java Heap Memory),Java中所有變量都儲存在主存中,對於所有線程都是共享的。 每條線程都有自己的工作內存(Working Memory),工作內存中保存的是主存中某些變量的拷貝,線程對所有變量的操作都是在工作內存中進行,線程之間無法相互直接訪問,變量傳遞均需要通過主存完成。
這個存儲模型很像我們常用的緩存與數據庫的關係,因此由此可以推斷JVM如此設計應該是爲了提升性能,提高多線程的併發能力,並減少線程之間的影響。
Java存儲模型潛在的問題
一談到緩存, 我們立馬想到會有緩存不一致性問題,就是說當有緩存與數據庫不一致的時候,就需要有相應的機制去同步數據。同理,Java存儲模型也有這個問題,當一個線程在自己工作內存裏初始化一個變量,當還沒來得及同步到主存裏時,如果有其他線程來訪問它,就會出現不可預知的問題。另外,JVM在底層設計上,對與那些沒有同步到主存裏的變量,可能會以不一樣的操作順序來執行指令,舉個實際的例子:
- 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看來也是可行的。
因此,總結起來,會有以下兩種潛在問題:
- 緩存不一致性
- 重排序執行
解決Java存儲模型潛在的問題
爲了能讓開發人員安全正確地在Java存儲模型上編程,JVM提供了一個happens-before原則,有人整理得非常好,我摘抄如下:
- 在程序順序中, 線程中的每一個操作, 發生在當前操作後面將要出現的每一個操作之前.
- 對象監視器的解鎖發生在等待獲取對象鎖的線程之前.
- 對volitile關鍵字修飾的變量寫入操作, 發生在對該變量的讀取之前.
- 對一個線程的 Thread.start() 調用 發生在啓動的線程中的所有操作之前.
- 線程中的所有操作 發生在從這個線程的 Thread.join()成功返回的所有其他線程之前.
有了原則還不夠,Java提供了以下工具和方法來保證變量的可見性和安全發佈:
- 使用 synchronized來同步變量初始化。此方式會立馬把工作內存中的變量同步到主內存中
- 使用 volatile關鍵字來標示變量。此方式會直接把變量存在主存中而不是工作內存中
- final變量。常量內也是存於主存中
另外,一定要明確只有共享變量纔會有以上那些問題,如果變量只是這個線程自己使用,就不用擔心那麼多問題了
搞清楚Java存儲模型後,再來看共享對象可見性和安全發佈的問題就較爲容易了
共享對象的可見性
當對象在從工作內存同步到主內存之前,那麼它就是不可見的。若有其他線程在存取不可見對象就會引發可見性問題,看下面一個例子:
- 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來設計領域模型,如下所示:
- @NotThreadSafe
- public class MutableInteger {
- private int value;
- public int get() { return value; }
- public void set(int value) { this.value = value; }
- }
但是,當有多個線程同時來存取某一個對象時,可能就會有類似的可見性問題。
爲了保證變量的可見性,一般可以用鎖、 synchronized關鍵字、 volatile關鍵字或直接設置爲final
共享變量發佈
共享變量發佈和我們常說的發佈程序類似,就是說讓本屬於內部的一個變量變爲一個可以被外部訪問的變量。發佈方式分爲以下幾種:
- 將對象引用存儲到公共靜態域
- 初始化一個可以被外部訪問的對象
- 將對象引用存儲到一個集合裏
安全發佈和保證可見性的方法類似,就是要同步發佈動作,並使發佈後的對象可見。
線程安全
其實當我們把這些變量封閉在本線程內訪問,就可以從根本上避免以上問題,現實中存在很多例子通過線程封閉來安全使用本不是線程安全的對象,比如:
- swing的可視化組件和數據模型對象並不是線程安全的,它通過將它們限制到swing的事件分發線程中,實現線程安全
- JDBC Connection對象沒有要求爲線程安全,但JDBC的存取模式決定了一個Connection只會同時被一個線程使用
- ThreadLocal把變量限制在本線程中共享