你真的會用volatile嗎

volatile的概念

或者說,volatile解決什麼問題?

我自己的總結:volatile解決多線程下變量訪問的內存可見性問題,用於線程間通信。

通信怎能理解呢,線程A寫一個volatile變量,隨後線程B讀這個volatile變量,這個過程實質上是線程A通過
主內存向線程B發送消息。

java語言標準規範對volatile的描述是這樣的:

The Java programming language allows threads to access shared variables (§17.1). As a rule, to ensure that shared variables are consistently and reliably updated, a thread should ensure that it has exclusive use of such variables by obtaining a lock that, conventionally, enforces mutual exclusion for those shared variables.

The Java programming language provides a second mechanism, volatile fields, that is more convenient than locking for some purposes.

A field may be declared volatile, in which case the Java Memory Model ensures that all threads see a consistent value for the variable (§17.4).

上面這段話摘自這個鏈接,有興趣的可以自己點開看。

https://docs.oracle.com/javase/specs/jls/se8/html/jls-8.html#jls-8.3.1.4

大概意思是,java語言允許多個線程訪問共享變量。爲了保證共享變量能準確一致的更新,線程要保證通過鎖的機制單獨獲得這個變量。java提供了一種機制,允許你把變量定義成volatile,在某些情況下比直接用鎖更加方便。

如果一個變量被定義成volatile,java內存模型確保所有線程看到的這個共享變量是一致的。

這個一致怎麼理解呢?繼續往下看。

volatile詳解

先來看一幅圖,

在這裏插入圖片描述

這是一幅計算的內存架構圖。

現在的CPU大部分都是多核的,在計算機內部,變量讀寫的流程是這樣的:

  • 當一個處理器需要讀取變量的時候,首先會把變量從主內存讀到緩存,也有可能是寄存器,然後再做各種計算。
  • 計算的結果由寄存器刷新到緩存,然後再由緩存刷新到主內存。
  • 一個處理器的緩存回寫到內存會導致其他處理器的緩存無效,這樣其它處理器

這裏的一個關鍵點是,什麼時候刷新?答案是不知道。我們不能假設CPU什麼時候會刷新。這樣就會帶來一些問題,比如一個線程寫完一個共享變量,還沒有刷新到主內存。然後另一個線程讀這個變量還是舊的值,在很多場景下,這個結果和程序員期望的並不一致。

幸運的是,我們雖然不知道CPU什麼時候刷新,但是我們可以強制CPU執行刷新。

再來看一個圖,這是JAVA的內存模型圖。

在這裏插入圖片描述

本地內存是JVM裏一個抽象的概念,它可以涵蓋寄存器,緩存等。

我們把這兩幅圖對應起來,可以這樣解釋。

在JAVA中,當一個線程寫變量時,會先把這個變量從主內存拷貝一份線程的本地內存,然後在本地內存操作。操作完成之後,再刷新到主內存。只有刷新後,另一個線程才能讀取新的值。

來看個例子:

public class VolatileTest implements Runnable {
    private boolean running = true;

    @Override
    public void run() {
        if (running) {
            System.out.println("I am running");
        }
    }

    public void stop() {
        running = false;
    }
}

這段代碼在多線程環境下執行的時候,假設A線程正在執行run方法,B線程執行了stop方法,我們的程序沒法保證A線程什麼時候會馬上停止。因爲這取決於CPU什麼時候進行刷新,把最新變量的值同步到主內存。

解決方法是,把running這個共享變量用volatile修飾即可,這樣可以保證B線程的修改會立刻刷新到主內存,對其它線程可見。

public class VolatileTest implements Runnable {
    private volatile boolean running = true;

再來看個稍微複雜一點的例子。

public class VolatileTest {
    public volatile int a = 0;
    volatile boolean flag = false;

    public void write() {
        a = 1; // 位置1
        flag = true; //// 位置2
    }
    public void read() {
        if (flag) { // 位置3
            int i = a; // 位置4
        }
    }
}

Java規範對於volatile變量規則是:對一個volatile域的寫,happens-before於任意後續對這個volatile域的
讀。

假設線程A執行writer()方法之後,線程B執行reader()方法。根據volatile變量的happens-before規則,位置2必然先於位置3執行。同時我們知道在同一個線程中,所有操作必須按照程序的順序來執行,所以位置1肯定早於位置2,位置3早於位置4。然後我們能推出位置1早於位置4。

這樣的順序是符合我們預期的。

這裏A線程寫一個volatile變量後,B線程讀同一個volatile變量。A線程在寫volatile變量之
前所有可見的共享變量,在B線程讀同一個volatile變量後,將立即變得對B線程可見。

什麼時候需要使用volatile

通過上面的例子,我們可以總結下volatile的使用場景。

通常是,存在一個或者多個共享變量,會有線程對他們寫操作,也會有其它線程對他們讀操作。這樣的變量都應該使用volatile修飾。

volatile在標準庫裏的應用

ConcurrentHashMap裏用到了一些volatile的操作,比如:

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val;
        volatile Node<K,V> next;

        Node(int hash, K key, V val, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.val = val;
            this.next = next;
        }
        ...

可以看到,用於存儲值的value變量就是volatile類型,這樣可以保證在多線程讀取的時候,不會讀到過期的值。之所以不會讀到過期的值,是因爲根據Java內存模型的happen before原則,對volatile字段的寫入操作先於讀操作,即使兩個線程同時修改和獲取volatile變量,get操作也能拿到最新的值,這是用volatile替換鎖的經典應用場景。

volatile會降低程序執行的效率

不要過度使用volatile,不必要的場景沒有必要用volatile修飾變量,儘管這樣做程序也不會出什麼錯。

根據前面的描述,volatile相當於給變量的操作加了“鎖”,每次操作都有加鎖和釋放鎖的動作,效率自然會受影響。

volatile不是萬能的

對volatile經常有一中誤解就是,它可以保證原子操作。

通過上面的例子,我們知道,volatile關鍵字可以保證內存可見性,指令執行的有序性。但是請一定記住,它沒法保證原子性。舉個例子你可能比較容易明白。

public class VolatileTest {
    public volatile int inc = 0;

    public void increase() {
        inc++;
    }
    public static void main(String[] args) {
        final VolatileTest test = new VolatileTest();

        for(int i=0;i<10;i++){
            new Thread(() -> {
                for(int j=0;j<1000;j++)
                    test.increase();
            }).start();
        }
        while(Thread.activeCount()>2)  //保證前面的線程都執行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

執行這段代碼,會發現結果每次一般都不同,但是肯定都小於10*1000。這就是volatile不保證原子性的最好證據。那麼深層次的原因是什麼呢?

事實上,自增操作包括三個步驟:

  1. 讀取變量的原始值
  2. 進行加1操作
  3. 寫入線程工作內存

既然分了三個步驟,就有可能出現下面這種情況:

假如某個時刻變量inc的值爲10。

第一步,線程1對變量進行自增操作,線程1先讀取了變量inc的原始值,然後線程1被阻塞了;

第二步, 然後線程2對變量進行自增操作,線程2也去讀取變量inc的原始值,由於線程1只是對變量inc進行讀取操作,而沒有對變量進行修改操作,所以不會導致線程2會直接去主存讀取inc的值,此時inc的值時10;

第三步, 線程2進行加1操作,並把11寫入工作內存,最後寫入主存。

第四步,線程1接着進行加1操作,由於已經讀取了inc的值,此時在線程1的工作內存中inc的值仍然爲10,所以線程1對inc進行加1操作後inc的值爲11,然後將11寫入工作內存,最後寫入主存。

最後,兩個線程分別進行了一次自增操作後,但是inc只增加了1。

有很多人會在第三步和第四步那裏有疑問,線程2更新inc的值以後,不是會導致線程1工作內存中的值失效嗎?

答案是不會,因爲在一個操作中,值只會讀取一次。這個是原子性和可見性區分的核心。

解決方案是使用increase方法使用synchronized同步鎖修飾。具體不展開了。


參考:

  • 《java併發編程的藝術》
  • https://www.cnblogs.com/dolphin0520/p/3920373.html
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章