Java併發編程系列(一)——Volatile

LZ水平有限,如果發現有錯誤之處,歡迎大家指出,或者覺得那塊說的不好,歡迎建議。希望和大家一塊討論學習
LZ QQ:1310368322


在討論Volatile關鍵字之前,我們先來聊聊併發
什麼是併發?爲什麼需要併發?併發會產生什麼問題、是如何解決的?接下來我們就看看這些問題
什麼是併發?
併發簡單來說就是在一個CPU上(也可以是多個CPU),在一段時間之內,同時啓動了多個進程或線程,在宏觀上看好像多個進程或線程在同時執行,其實在一個確定的時刻,一個CPU上只有一個線程或者進程在運行。
爲什麼需要併發?
①提高資源利用率:如果說進程之間串行執行的話,如果一個進程在進行磁盤I/O操作的話,因爲CPU的執行速度遠大於磁盤I/O的讀寫速度,所以這時候如果是串行的話,當前進程就會佔着CPU等待磁盤的讀取結果,然後交給CPU進行運算。有的人可能會問,如果終止當前進程,那這個磁盤讀取還會進行嗎?答案是肯定的。當進程進入IO操作,OS會啓動DMA從硬盤copy數據到內存,這個時候線程就可以讓出CPU,讓別的線程來運行了。注:DMA傳輸不需要CPU的介入,只需要CPU“通知”一下DMA就可以了
②劃分模塊:實現目標和時機的解耦,它把我們要做什麼(目標)和是什麼時候做(時機)分離開來
併發會產生什麼問題?
當多個線程對同一共享資源在進行操作的時候,就會出現數據的“誤讀”或“誤寫”,比如共享變量 i = 0; 線程 A 和 線程 B都是給 變量 i 做加 1 操作,線程在給 變量加一的時候,先把變量 i 從內存中讀到 寄存器中,然後加 1, 此時 寄存器的值爲1,這時候線程 A 時間片到期,切換線程 B,線程 B從內存中讀取共享變量 i 的值【此時變量 i 的值還是 0】,在對變量 i 進行讀取後,在寄存器中加 1 ,賦值給內存後,共享變量 i 變爲 1,這個時候 線程 A 來把它之前保存的寄存器中的值給了寄存器,然後又賦給了內存,這個時候,內存的值還是 1,這個結果顯然是錯誤的。 大概過程如下
這裏寫圖片描述
關於進程/線程的併發的詳細問題,請參考操作系統之進程與線程
操作系統在處理這類問題時,會有一些方案,比如加鎖、信號量等等。
接下來我們聊聊Java中的Volatile
可見性
首先我們看看Java的內存模型
這裏寫圖片描述
這裏的工作內存其實相當於CPU中的緩存,上面的寄存器也是一種緩存。
每一個線程在對主內存中的變量值做更改的時候,都要拷貝一份到自己的工作內存中去,然後纔去運算,這就導致了一個很嚴重的問題—對於每一個線程而言,變量的修改對另一個線程“不可見”,什麼是不可見呢?其實這個和前面的例子非常像,這裏的線程私有工作內存就相當於線程中維護的寄存器,還是剛纔的那個例子, 假如在 主內存中有 一個 共享變量 i = 0, 線程 1 和 線程 2 都要對共享變量做加一操作,首先線程1從主內存中copy變量 i 的值到工作內存1中,然後對其進行 加一 操作,假設該計算機爲雙核,於此同時,線程2也從主內存中copy共享變量 i, 線程一對其進行加一操作完成之後,將主內存的i值進行更新,但是這個時候線程2完全不知道,因爲線程1對線程2不可見,這就導致了線程2對它之前從主內存中獲得的變量 i = 0 進行加一,而不是對線程1對變量 i 的具體操作後的變量加一,這就出現了錯誤。
我們看一段代碼來驗證一些我們的這個理論:

public class TestThread extends Thread{

    boolean stop = false;// 共享變量
    int value = 0;  
    public void run(){
        while(!stop){
            value++;
        }
    }

    public static void main(String[] args) throws 
InterruptedException {

        TestThread thread = new TestThread();
        thread.start(); // 讓在主線程中的 新建的線程就緒
        Thread.sleep(2000);// 讓正在執行的線程休眠
        thread.stop = true;// 主線程在自己的工作內存中設置共享變量 stop 爲 true,然後寫回主內存,然而子線程“這個時候不知道”,子線程一直在自己的工作內存中對變量進行操作(value++)
        System.out.println("value= " + thread.value);
        Thread.sleep(2000);
        System.out.println("value= " + thread.value);

    }
}

這裏寫圖片描述
大家可以看到執行的結果是兩個value的值不相同,這是因爲main線程創建的子線程(thread),他有一份變量的拷貝[創建時stop的值爲false],然後就一直在自己的工作內存中工作,不再去主內存讀值了,後來main線程在執行的時候,他也要從主內存中copy一份到自己的工作內存中來。然後進行操作[thread.stop],然後把stop的值寫回主內存,但是子線程不會在從主內存存讀取數據了,這個數據對子線程不可見。
最後值得一提的是,這個程序是死循環,這是因爲子線程一旦創建,就和main線程沒有關係了,main線程執行完,但是子線程裏的while死循環使得子線程一直循環着
如果給共享變量stop加上volatile關鍵字,那麼,任何一個線程修改了它,其他線程都是立即得知的,即:volatile變量對所有線程是立即可見的,當讀寫一個volatile變量的時候,每次都從主內存讀寫,而不是工作內存
原子性
volatile修飾的變量雖然能保證變量的可見性,但不保證對變量的操作時原子性的,這意味着對於併發情況下加了volatile關鍵字有可能出錯。這是因爲有的操作並不是原子性的,下來我們來看一段代碼

package com.thread.Volatile;

public class TestAtomicity {

    public static volatile int value = 0;

    public static void increase(){
        value++;
    }

    public static void main(String[] args) {

        final TestAtomicity test = new TestAtomicity();

        for (int i = 0; i < 10; i++) {// 啓動十個線程
            new Thread(){
                public void run(){
                    for (int j = 0; j < 1000; j++) {
                        increase();
                    }
                }
            }.start();// 讓每個線程對value累加1000次
        }

        // 如果還有子線程在運行,主線程就讓出CUP,直到所有的子線程都運行完了,主線程再繼續往下執行
        while(Thread.activeCount()>1){// 線程的活動數
            Thread.yield();// 使當前線程從運行狀態變爲就緒狀態
        }

        System.out.println("number: " + test.value);
    }
}

這個程序的某一次運行結果:number: 9219
本來的結果應該是10000,爲什麼會小於10000呢?這裏就是因爲value++這一句話在字節碼層次(更準確地說是在機器碼層次)不是單個的指令,而是多個指令,也就是說這個操作不是原子性的。
我們用javap 工具反編譯這個類,得到字節碼,其中increase函數的執行的字節碼如下:
這裏寫圖片描述
其中我們可以看到,原本在源碼中寫的一句value++,在編譯成字節碼後並不是一個指令,而是多條指令,簡單地介紹一下:
①getstatic: 指令是獲取指定類的靜態域,並將其壓入棧頂,這裏就是獲取value的值,然後壓入操作數棧
②iconst_1: 將int型1推送至棧頂,就是把 1 壓入操作數棧
③iadd: 將操作數棧中的元素彈出,相加,並將計算結果壓入棧中
④putstatic: 爲指定的類的靜態域賦值,就是給把棧頂的值賦給value
我們從字節碼的層次去分析它,很容易就知道這樣的操作在併發的情況下是不安全的,當一個線程去執行increase++的時候,它先得到value的值(比如10)[volatile關鍵字保證了此時的value值是正確的],壓入自己的操作數棧,然後另一個線程對value操作之後把值寫回了主內存,雖然value對所有變量是可見的,但是這個時候,第一個線程沒有去讀value的值,後面的iconst_1 iadd putstatic都是執行引擎在操作,根本不知道value的值變了,所以後面的putstatic就有可能把錯誤的值放到value中
過程如下:
這裏寫圖片描述
我們看看這個錯誤的本質是什麼?其根本原因就是value++不是原子操作,導致線程從主內存中讀取值之後,後面還有幾個指令,就在執行引擎執行後面的指令的時候,其他線程會改變主內存的值
這裏我們在字節碼層次對value++做了分析,其實跟嚴謹的做法是在彙編指令層次進行分析,但是在字節碼層次足以說明問題,在此就不詳細討論了
有序性
volatile的第二個語義是禁止指令重排序優化
什麼是指令重排序?其實很簡單,就是說我們的代碼被翻譯成指令的時候,CPU會將這些指令在不影響其最終結果的前提下,對這些指令進行重新排序,舉個很簡單的例子,假設內存地址爲0x001地址處的值爲1,指令A對地址0x001處的內存加1,指令B對地址爲0x001處的內存加2,指令A和B的執行順序不影響最終的結果(4),所以CPU就可以對其進行重新排列。而一個變量如果加上volatile關鍵字的時候,就禁止了這種重排,更準確地說,不允許對一個volatile變量的賦值操作 與其之前的任何讀寫操作 重新排序(之前的指令如果不互相依賴就可以進行重排序),也不允許將 讀取一個volatile 變量的操作與其之後的任何讀寫操作重新排序,這句話有點拗口,其實是說,對volatile變量的賦值操作和 讀取一個volatile變量的操作這兩個指令相當於一個屏障,不管前面的操作如何,我都要保證我賦值操作和讀取操作的位置不能變
下面我們看一個實例來分析一下

package com.thread.Volatile;

public class Singleton {

    private static  volatile Singleton instance;

    private Singleton(){    
    }

    public static Singleton getInstance(){
        if( instance == null){
            synchronized(Singleton.class){
                if( instance == null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

這段代碼看起來沒有問題,其實是有問題的,原因就在於 new Singleton();這句話,這句話其實真正執行的時候是要進行以下步驟的:
1. 分配內存
2. 調用Singleton的構造函數
3. 給instance賦值,指向新創建的對象
如果給 instance 引用不加 volatile 關鍵字,那麼上面的幾步就有可能不按順序執行
如下圖:
這裏寫圖片描述
這樣的話,就會出現線程1執行了給instance賦值但沒有調用Singleton構造函數,如果線程2執行的話就會出錯(intance不爲null,直接使用instance),如果加上 volatile 關鍵字就確保了在給 instance 引用賦值的時候,前面的任何操作已經執行完畢,這裏的給 instance 引用賦值就相當一個屏障,任何之前的操作不能越過這個屏障,必須在之前執行完

總結: Volatile關鍵字有兩個語義:
① 保證 所修飾的變量對所有線程可見
② 保證 修飾的變量的賦值以及讀取操作的指令不可重排,也就是禁止指令重排序

注:本博客部分選自《深入理解Java虛擬機 》和 劉欣老師(碼農翻身公衆號的作者)的講課

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章