線程同步volatile與synchronized詳解

在之前的文章java面試 synchronized關鍵字中,已經詳細的介紹了synchronized關鍵字的用法和使用規則,synchronized主要有如下規則

1) 當兩個或者多個并發線程同時訪問一個object中的synchronized(this)同步代碼塊時,一個時間內只能有一個線程得到執行,其他線程必須要等到當前線程執行完這個代碼塊之后才能繼續執行該代碼塊。
2) 然而,當一個線程訪問object中的synchronized(this)同步代碼塊時,其他線程仍然可以調用object中的其它非synchronized(this)同步代碼塊。
3) 尤其關鍵的是,當一個線程訪問object中的一個synchronized(this)同步代碼塊時,其他線程對于object中的其它synchronized(this)同步代碼塊的訪問,都將被阻塞。
4) 第3條中的說明同樣適用于其它同步代碼塊,也就是說,當一個線程訪問object中的一個synchronized(this)同步代碼塊時,它就獲得了整個object的對象鎖,因此,其他所有線程對于object的所有同步代碼塊的訪問都將被暫時阻塞,直至當前線程執行完這個代碼塊。
5) 上述的規則對于其他對象鎖也同樣適用。

因此,在這邊文章中,我們將著重介紹volatile關鍵字,以及volatile與synchronized的區別

一、 使用volatile修飾的變量具有可見性

可見性就是說一旦某個線程修改了volatile關鍵字修飾的變量,則該變量將會立即保存修改后的值到物理內存中,其他線程讀取該變量時,也可以立即獲取修改后的值。
在java運行中,為了提高程序的運行效率,對于一些變量的操作通常是在寄存器或者cpu緩存上進行的,之后才會保存到物理內存中,而使用volatile關鍵字修飾的變量則是直接讀取物理內存。
如下代碼:

class MyThread extends Thread {             
    private volatile boolean isStop = false;          
    public void run() {      
        while (!isStop) {      
            System.out.println("do something");      
        }      
    }      
    public void setStop() {      
        isStop = true;      
    }            
} 

線程在啟動后,一直運行中,如果我們想要跳出循環,只需要調用setStop()即可,因為在代碼中,isStop是使用volatile關鍵字修飾的,我們可以通過調用方法修改該變量的值,修改后,程序會即刻讀取到變更后的值,從而跳出while循環。

Java為了保證其平臺性,使Java應用程序與操作系統內存模型隔離開,需要定義自己的內存模型。
在Java內存模型中,內存分為主內存和工作內存兩個部分,其中主內存是所有線程所共享的,而工作內存則是每個線程分配一份,各線程的工作內存間彼此獨立、互不可見,在線程啟動的時候,虛擬機為每個內存分配一塊工作內存,不僅包含了線程內部定義的局部變量,也包含了線程所需要使用的共享變量(非線程內構造的對象)的副本,即為了提高執行效率,讀取副本比直接讀取主內存更快(這里可以簡單地將主內存理解為虛擬機中的堆,而工作內存理解為棧(或稱為虛擬機棧),棧是連續的小空間、順序入棧出棧,而堆是不連續的大空間,所以在棧中尋址的速度比堆要快很多)。
工作內存與主內存之間的數據交換通過主內存來進行,如下圖:


同時,Java內存模型還定義了一系列工作內存和主內存之間交互的操作及操作之間的順序的規則(這規則比較多也比較復雜,參見《深入理解Java虛擬機-JVM高級特性與最佳實踐》第12章12.3.2部分),這里只談和volatile有關的部分。對于共享普通變量來說,約定了變量在工作內存中發生變化了之后,必須要回寫到工作內存(遲早要回寫但并非馬上回寫),但對于volatile變量則要求工作內存中發生變化之后,必須馬上回寫到工作內存,而線程讀取volatile變量的時候,必須馬上到工作內存中去取最新值而不是讀取本地工作內存的副本,此規則保證了前面所說的“當線程A對變量X進行了修改后,在線程A后面執行的其他線程能看到變量X的變動”。

*工作內存可以說是主內存的一份緩存,為了避免緩存的不一致性,所以volatile需要廢棄此緩存。但除了內存緩存之外,在CPU硬件級別也是有緩存的,即寄存器。假如線程A將變量X由0修改為1的時候,CPU是在其緩存內操作,沒有及時回寫到內存,那么JVM是無法確保X=1能及時被之后執行的線程B看到的,所以我覺得JVM在處理volatile變量的時候,也同樣用了硬件級別的緩存一致性原則(CPU的緩存一致性原則參見《
*Java的多線程機制系列:(二)緩存一致性和CAS
*
》。
*

二、 volatile禁止指令重排

我們可以先看一個代碼示例

/**
 * 一個簡單的展示Happen-Before的例子.
 * 這里有兩個共享變量:a和flag,初始值分別為0和false.在ThreadA中先給a=1,然后flag=true.
 * 如果按照有序的話,那么在ThreadB中如果if(flag)成功的話,則應該a=1,而a=a*1之后a仍然為1,下方的if(a==0)應該永遠不會為真,永遠不會打印.
 * 但實際情況是:在試驗100次的情況下會出現0次或幾次的打印結果,而試驗1000次結果更明顯,有十幾次打印.
 */
public class SimpleHappenBefore {
    /** 這是一個驗證結果的變量 */
    private static int a=0;
    /** 這是一個標志位 */
    private static boolean flag=false;
    
    public static void main(String[] args) throws InterruptedException {
        //由于多線程情況下未必會試出重排序的結論,所以多試一些次
        for(int i=0;i<1000;i++){
            ThreadA threadA=new ThreadA();
            ThreadB threadB=new ThreadB();
            threadA.start();
            threadB.start();
            
            //這里等待線程結束后,重置共享變量,以使驗證結果的工作變得簡單些.
            threadA.join();
            threadB.join();
            a=0;
            flag=false;
        }
    }
    
    static class ThreadA extends Thread{
        public void run(){
            a=1;
            flag=true;
        }
    }
    
    static class ThreadB extends Thread{
        public void run(){
            if(flag){
                a=a*1;
            }
            if(a==0){
                System.out.println("ha,a==0");
            }
        }
    }
}

多次運行,可能得到多次不同的結果,每次打印出來的內容數量是不一的。

什么是指令重排序?有兩個層面:

在虛擬機層面,為了盡可能減少內存操作速度遠慢于CPU運行速度所帶來的CPU空置的影響,虛擬機會按照自己的一些規則(這規則后面再敘述)將程序編寫順序打亂——即寫在后面的代碼在時間順序上可能會先執行,而寫在前面的代碼會后執行——以盡可能充分地利用CPU。
拿上面的例子來說:假如不是a=1的操作,而是a=new byte1024*1024,那么它會運行地很慢,此時CPU是等待其執行結束呢,還是先執行下面那句flag=true呢?顯然,先執行flag=true可以提前使用CPU,加快整體效率,當然這樣的前提是不會產生錯誤(什么樣的錯誤后面再說)。
雖然這里有兩種情況:

  • 后面的代碼先于前面的代碼開始執行
  • 前面的代碼先開始執行,但當效率較慢的時候,后面的代碼開始執行并先于前面的代碼執行結束。

不管誰先開始,總之后面的代碼在一些情況下存在先結束的可能。

在硬件層面,CPU會將接收到的一批指令按照其規則重排序,同樣是基于CPU速度比緩存速度快的原因,和上一點的目的類似,只是硬件處理的話,每次只能在接收到的有限指令范圍內重排序,而虛擬機可以在更大層面、更多指令范圍內重排序。硬件的重排序機制參見《從JVM并發看CPU內存指令重排序(Memory Reordering)

重排序很不好理解,上面只是簡單地提了下其場景,要想較好地理解這個概念,需要構造一些例子和圖表,在這里介紹兩篇介紹比較詳細、生動的文章《happens-before俗解》和《深入理解Java內存模型(二)——重排序》。其中的“as-if-serial”是應該掌握的,即:不管怎么重排序,單線程程序的執行結果不能被改變。編譯器、運行時和處理器都必須遵守“as-if-serial”語義。拿個簡單例子來說,

public void execute(){
    int a=0;
    int b=1;
    int c=a+b;
}

這里a=0,b=1兩句可以隨便排序,不影響程序邏輯結果,但c=a+b這句必須在前兩句的后面執行。

從前面那個例子可以看到,重排序在多線程環境下出現的概率還是挺高的,在關鍵字上有volatile和synchronized可以禁用重排序,除此之外還有一些規則,也正是這些規則,使得我們在平時的編程工作中沒有感受到重排序的壞處。

  1. 程序次序規則(Program Order Rule)
    在一個線程內,按照代碼順序,書寫在前面的操作先行發生于書寫在后面的操作。準確地說應該是控制流順序而不是代碼順序,因為要考慮分支、循環等結構。
  • 監視器鎖定規則(Monitor Lock Rule)
    一個unlock操作先行發生于后面對同一個對象鎖的lock操作。這里強調的是同一個鎖,而“后面”指的是時間上的先后順序,如發生在其他線程中的lock操作。
  • volatile變量規則(Volatile Variable Rule)
    對一個volatile變量的寫操作發生于后面對這個變量的讀操作,這里的“后面”也指的是時間上的先后順序。
  • 線程啟動規則(Thread Start Rule)
    Thread獨享的start()方法先行于此線程的每一個動作。
  • 線程終止規則(Thread Termination Rule)
    線程中的每個操作都先行發生于對此線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值檢測到線程已經終止執行。
  • 線程中斷規則(Thread Interruption Rule)
    對線程interrupte()方法的調用優先于被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測線程是否已中斷。
  • 對象終結原則(Finalizer Rule)
    一個對象的初始化完成(構造函數執行結束)先行發生于它的finalize()方法的開始。
  • 傳遞性(Transitivity)
    如果操作A先行發生于操作B,操作B先行發生于操作C,那就可以得出操作A先行發生于操作C的結論。

正是以上這些規則保障了happen-before的順序,如果不符合以上規則,那么在多線程環境下就不能保證執行順序等同于代碼順序,也就是“如果在本線程中觀察,所有的操作都是有序的;如果在一個線程中觀察另外一個線程,則不符合以上規則的都是無序的”,因此,如果我們的多線程程序依賴于代碼書寫順序,那么就要考慮是否符合以上規則,如果不符合就要通過一些機制使其符合,最常用的就是synchronized、Lock以及volatile修飾符。

請注意,volatile不能保證操作原子性

下面通過一個代碼案例來說明,我們通過啟用1000個線程,來對一個變量執行自增加1的操作

public class Counter {
    public static int count = 0;

    public static void inc() {
        //這里延遲1毫秒,使得結果明顯
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
        }
        count++;
    }

    public static void main(String[] args) {
        //同時啟動1000個線程,去進行i++計算,看看實際結果
        for (int i = 0; i < 1000; i++) {
            new Thread(new Runnable() {
                public void run() {
                    Counter.inc();
                }
            }).start();
        }

        //這里每次運行的值都有可能不同,可能為1000
        System.out.println("運行結果:Counter.count=" + Counter.count);
    }
}

執行結果為:運行結果:Counter.count=983,并不是預期的1000,且每次執行的結果都可能不一致。
那么如果使用volatile關鍵字修飾的變量是具有可見性的,那么使用關鍵字修飾之后的程序執行結果是否就是我們預期的1000呢?
代碼修改為:

public class Counter {
    public static volatile int count = 0;

    public static void inc() {
        //這里延遲1毫秒,使得結果明顯
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
        }
        count++;
    }

    public static void main(String[] args) {
        //同時啟動1000個線程,去進行i++計算,看看實際結果
        for (int i = 0; i < 1000; i++) {
            new Thread(new Runnable() {
                public void run() {
                    Counter.inc();
                }
            }).start();
        }

        //這里每次運行的值都有可能不同,可能為1000
        System.out.println("運行結果:Counter.count=" + Counter.count);
    }
}

執行結果依然不是預期的1000,下面我們具體分析一下問題原因:
JVM結構、GC工作機制詳解一文中,描述了jvm運行時刻內存的分配。其中有一個內存區域是jvm虛擬機棧,每一個線程運行時都有一個線程棧。
線程棧用于存儲局部變量表、操作棧、方法返回值等。
當線程訪問某一個對象時候值的時候,首先通過對象的引用找到對應在堆內存的變量的值,然后把堆內存變量的具體值load到線程本地內存中,建立一個變量副本,之后線程就不再和對象在堆內存變量值有任何關系,而是直接修改副本變量的值,在修改完之后的某一個時刻(線程退出之前),自動把線程變量副本的值回寫到對象在堆中變量。
這樣在堆中的對象的值就產生變化了。下面我們使用圖片來描述這些交互

Java線程運行時內存交互

  • read and load 從主存復制變量到當前工作內存
  • use and assign 執行代碼,改變共享變量值
  • store and write 用工作內存數據刷新主內存相關內容

其中use and assign在線程執行過程中可以多次出現。
但是這一些操作并不是原子性,也就是說,在read and load之后,線程使用的變量值就是自己棧內存中的變量值備份副本了,這時如果主內存count變量發生修改之后,線程工作內存中的值由于已經加載,不會產生對應的變化,所以計算出來的結果會和預期不一樣。

對于volatile修飾的變量,jvm虛擬機只是保證從主內存加載到線程工作內存的值是最新的。

例如線程1,線程2 在進行read and load操作中,發現主內存中count的值都是5,那么都會加載這個最新的值。
在線程1對count進行修改之后,會write到主內存中,主內存中的count變量就會變為6
線程2由于已經進行read and load操作,在后續的運算中,使用的均是自己棧內存中的副本,也就是使用5進行的運算,因此進行運算之后,更新主內存count的變量值也為6

這也就是導致兩個線程及時用volatile關鍵字修改之后,仍會存在并發的情況的原因。

synchronized和volatile均能實現對象的可見性,而synchronized更是能保證操作的原子性。因此如果想要實現多線程時的數據準確,還是要使用synchronized關鍵字。

參考:
Java并發——線程同步volatile與synchronized詳解
java中volatile關鍵字的含義
Java的多線程機制系列:(四)不得不提的volatile及指令重排序(happen-before)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容