[Java併發編程(三)] Java volatile 關鍵字介紹

[Java併發編程(三)] Java volatile 關鍵字介紹

摘要

Java volatile 關鍵字是用來標記 Java 變量,並表示變量 「存儲於主內存中」 。更準確的說就是對於 volatile 變量的每次讀操做都是從計算機的主內存中讀取,而不是 CPU 緩存,每次寫操做也是將 volatile 變量寫入主內存中,不是 CPU 緩存。html

事實上,由於 Java 5 的 volatile 關鍵字保證的不止是從主內存讀寫。這點稍後會進行解釋。java

正文

Java volatile 可見性的保證

Java volatile 關鍵字保證了變量在跨線程時的變動可見性。這可能聽起來比較抽象,因此下面舉個例子來講明。c++

在多線程應用中,在線程操做 non-volatile 變量時,每一個線程都會將變量從主內存拷貝到 CPU 緩存中而後進行處理,這主要是性能因素所決定的。若是計算機有不止一個 CPU ,每一個線程會在不一樣的 CPU 上運行。也就是說,每一個線程都會將變量拷貝至不一樣的 CPU 緩存中。以下圖:編程

non-volatile 變量並不能保證 Java 虛擬機(JVM)將數據從主內存讀入 CPU 緩存的時間,也沒法確認 CPU 緩存的數據什麼時候會被寫入到主內存。這樣會引起一些問題。緩存

設想若是有兩個或多個線程會訪問一個共享對象以下:多線程

public class SharedObject {
    
        public int counter = 0;
    
    }

若是隻有 線程 1counter 變量進行自增操做,但 線程 1線程 2 都會時刻讀取 counter 變量。併發

若是 counter 變量不是聲明的 volatile ,那麼並不能保證在寫 counter 變量時,會將 CPU 緩存寫會到主內存。也就是說, counter 變量在 CPU 緩存中的值與主內存中的值不同。這種狀況以下圖所示:oracle

由於變量的值尚未被另外一線程寫入主內存,線程沒法就看到變量最新值。這種問題被稱爲 「可見性」 問題。一個線程的更新對其餘線程是不可見的。app

經過爲 counter 變量聲明 volatile 關鍵字,全部對於 counter 變量的寫操做都會當即被寫回到主內存中。一樣,全部 counter 變量的讀操做也會直接從主內存中直接讀取。爲 counter 變量聲明 volatile 關鍵字的方式以下:性能

public class SharedObject {
    
        public volatile int counter = 0;
    
    }

Java volatile Happens-Before 保證

Java 5 的 volatile 關鍵字並不僅是保證從主內存中讀寫變量。事實上, volatile 關鍵字還保證:

  • 若是 線程 A 寫如一個 volatile 變量,線程 B 接着讀取同一個 volatile 變量,那麼在寫 volatile 變量前全部對 線程 A 可見的變量也會在 線程 B 讀取該 volatile 變量後對 線程 B 可見。

  • volatile 變量的讀寫指令不容許被 JVM 重排(JVM 會在不影響程序行爲前提下,爲了提高性能對指令進行重排)。指令前和指令後能夠被重排,可是 volatile 讀寫操做不會與這些指令混合。在讀寫 volatile 變量後不管跟隨什麼指令,也保證以後能夠讀寫。

以上的陳述須要更深的解釋。

Thread A:
    sharedObject.nonVolatile = 123;
    sharedObject.counter     = sharedObject.counter + 1;

Thread B:
    int counter     = sharedObject.counter;
    int nonVolatile = sharedObject.nonVolatile;

因爲 線程 A 在寫 volatile 變量 sharedObject.counter 前,先寫 non-volatile 變量 sharedObject.nonVolatile 變量,那麼當 線程 A 寫 sharedObject.counter( volatile 變量)時,sharedObject.nonVolatile 與 sharedObject.counter 都會寫入主內存。

因爲 線程 B 先讀取 volatile 變量 sharedObject.counter ,那麼 sharedObject.counter 和 sharedObject.nonVolatile 都會從主內存讀入 CPU 緩存中。在 線程 B 讀取 sharedObject.nonVolatile 變量時,線程 A 的寫入值已對其可見。

開發者能夠利用這個擴展的可見性來優化線程間變量的可見性。無須爲每一個變量都聲明 volatile 只須要對少數變量使用 volatile 。下面一個簡單的例子 Exchanger 類就遵循了以上原則:

public class Exchanger {
    
        private Object   object       = null;
        private volatile hasNewObject = false;
    
        public void put(Object newObject) {
            while(hasNewObject) {
                //wait - do not overwrite existing new object
            }
            object = newObject;
            hasNewObject = true; //volatile write
        }
    
        public Object take(){
            while(!hasNewObject){ //volatile read
                //wait - don't take old object (or null)
            }
            Object obj = object;
            hasNewObject = false; //volatile write
            return obj;
        }
    }

線程 A 會時不時經過調用 put() 方法設置對象。線程 B 會時不時經過調用 take() 方法獲取對象。 Exchanger 能夠只使用 volatile 變量(不使用 synchronized 塊)就能保證程序的正確性,只要 線程 A 只調用 put() 而 線程 B 只調用 take() 。

可是,若是 JVM 在不改變語意的狀況下,可能會爲了優化性能對 Java 指令進行重排。若是 JVM 改變了 put() 和 take() 裏的讀寫順序會發生什麼?若是 put() 的執行順序是下面這樣會怎樣?

while(hasNewObject) {
        //wait - do not overwrite existing new object
    }
    hasNewObject = true; //volatile write
    object = newObject;

注意到寫 volatile 變量 hasNewObject 發生在新對象設置前。對 JVM 來講這是徹底有效的。兩個寫指令的值並不相互依賴。

可是,更改指令執行的順序會損壞 object 變量的可見性。首先,線程 B 會在 線程 A 爲 object 變量設置新值以前就看見 hasNewObject 設置成 true 。其次,這裏沒法肯定新值是什麼時候寫回到主內存中的。(有多是下次 線程 Avolatile 變量進行寫操做時)。

爲了防止以上狀況的出現, volatile 關鍵字有 「發生前保證(happens before guarantee)」。 happens before guarantee 保證 volatile 變量的讀寫不能被重排。指令前和指令後能夠被重排,可是 volatile 讀寫指令不能與在它以前或以後的指令重排。

看如下例子:

sharedObject.nonVolatile1 = 123;
    sharedObject.nonVolatile2 = 456;
    sharedObject.nonVolatile3 = 789;
    
    sharedObject.volatile     = true; //a volatile variable
    
    int someValue1 = sharedObject.nonVolatile4;
    int someValue2 = sharedObject.nonVolatile5;
    int someValue3 = sharedObject.nonVolatile6;

JVM 會對前 3 個指令進行重排,由於它們對於 volatile 寫指令都是 happens before (它們都必須在 volatile 寫指令前執行)。

一樣,只要 volatile 寫指令在後 3 條指令前發生( happens before ),JVM 也可能對後 3 條指令進行重排。

以上是 Java volatile 「happens before」 保證的基本含義。

volatile 並不老是有效

儘管 volatile 關鍵字能夠保證全部 volatile 變量的讀都直接訪問主內存,全部 volatile 寫都直接寫入主內存,仍是會有 volatile 失效的場景。

在以前描述的場景中,線程 1 寫入共享變量 counter ,將 counter 變量聲明 volatile 就能夠保證 線程 2 老是能夠看到最新的寫入值。

事實上,多線程也能夠寫入同一 volatile 共享變量,若是新寫入變量並不依賴於前序值,它仍然能夠保證正確的值能夠存入主內存。換句話說,若是線程將值寫入共享 volatile 變量時不須要先讀取它的值來計算下一個值時,就能有此保證。

只要線程須要先讀取 volatile 變量的值,而後基於該值計算 volatile 變量的新值,那麼 volatile 變量就沒法保證它可見性的正確。在讀取 volatile 變量與寫入新值之間短暫的時間間隔會形成 競爭條件(Race Condition) ,這會致使多線程會讀取 volatile 變量相同的值,並生成新值,當將值寫回到主內存時,有可能會將它們生成的新值相互覆蓋。

當多線程對相同的 counter 進行自增操做就是 volatile 變量沒法保證可見性的典型場景。下面對這個例子進行更詳細的解釋。

設想 線程 1 讀取共享變量 counter 的值 0 到 CPU 緩存,自增 1 後並無將更新的值寫回到主內存中。 線程 2 將相同的 counter 值 0 從主內存讀入它本身的緩存,同時也將 counter 值自增到 1 ,並寫回到主內存。這個場景下圖所示:

線程 1線程 2 並不一樣步(out of sync)。這時共享變量 counter 的真實值應該是 2 ,可是每一個線程在它們本身的 CPU 緩存中存放的值都是 1 ,可是在主內存中,該值仍然是 0 。這很混亂!若是線程最終將 counter 值寫回到主內存,那麼該值也是錯誤的。

volatile 什麼時候有效?

正如以前提到的,若是兩個線程同時對一個共享變量進行讀寫操做,那麼使用 volatile 關鍵字並不有效。這時就須要使用 synchronized 關鍵字來保證讀與寫都是原子操做(atomic)。讀寫 volatile 變量不能阻塞線程的讀寫。爲了能實現阻塞,必須使用 synchronized 關鍵字劃定關鍵區(critical section)。

替代 synchronized 塊的方法可使用 java.util.concurrent 包中的許多原子數據類型。好比,AtomicLongAtomicReference 或其餘類型中的一種。

在只有一個線程對 volatile 變量進行讀寫,而其餘線程都僅對變量進行讀取操做,那麼能夠保證 volatile 變量的最新寫入值對讀線程均可見。

volatile 關鍵字對於 32bit 和 64bit 變量都是有效的。

volatile 的性能考慮

volatile 變量的讀寫要求變量都從主內存中進行讀寫。讀寫主內存的代價要比訪問 CPU 緩存高,訪問 volatile 變量一樣能夠防止指令的重排(指令重排是一種經常使用的提升性能的技術)。所以,只有到真的須要保證變量的可見性的時候,才應該使用 volatile 變量。

附言

關於原子訪問的解釋,Oracle 官方有以下解釋:

在程序中,原子操做(atomic action)能夠保證全部的事情一併發生。原子操做不能在過程當中中止:它要麼徹底發生,或要麼徹底不發生。在一個操做完成前,改原子操做產生的影響是不可見的。

在 c++ 中,自增表達式並非原子操做。每一個簡單的表達式均可以是由多個複雜操做定義的,能夠被分紅多個操做。可是,原子操做是能夠被區分的:

  • 引用變量和大多數原始類型變量(除了 long 和 double)的讀寫都是原子操做
  • 全部聲明瞭 volatile 的變量(包括 long 和 double)的讀寫都是原子操做

原子操做不能重疊,這樣它們就能夠不受線程的干擾。不過,這並不能消除全部同步原子操做的需求,由於內存仍是可能出現一致性錯誤。使用 volatile 變量能夠下降內存一致性錯誤的風險,由於任何寫 volatile 變量都會與以後續對相同變量的讀操做創建 happens-before 的關係。這也意味着 volatile 變量對其餘線程老是可見的。不只僅如此,當一個線程對 volatile 變量進行讀操做時,它看到的並不僅是 volatile 變量的最新更改,同時還包括該更改所引發的反作用。

使用簡單原子變量進行訪問要比經過 synchronized 訪問要更高效,但也須要更當心來避免內存一致性錯誤。能夠更加應用的規模和複雜程度來判斷額外的代價是否值得。

java.util.concurrent 包中的類提供了不少原子方法,並不依賴於 synchronization 。

參考

jenkov: Java Volatile Keyword

javamex: The volatile keyword in Java

oracle: Atomic Access

結束

相關文章
相關標籤/搜索