讀古今文學網 > 編寫高質量代碼:改善Java程序的151個建議 > 建議123:volatile不能保證數據同步 >

建議123:volatile不能保證數據同步

volatile關鍵字比較少用,原因無外乎兩點,一是在Java 1.5之前該關鍵字在不同的操作系統上有不同的表現,所帶來的問題就是移植性較差;二是比較難設計,而且誤用較多,這也導致它的「名譽」受損。

我們知道,每個線程都運行在棧內存中,每個線程都有自己的工作內存(Working Memory,比如寄存器Register、高速緩衝存儲器Cache等),線程的計算一般是通過工作內存進行交互的,其示意圖如圖9-1所示。

圖 9-1 線程讀取變量的示意圖

從示意圖上我們可以看到,線程在初始化時從主內存中加載所需的變量值到工作內存中,然後在線程運行時,如果是讀取,則直接從工作內存中讀取,若是寫入則先寫到工作內存中,之後再刷新到主存中,這是JVM的一個簡單的內存模型,但是這樣的結構在多線程的情況下有可能會出現問題,比如:A線程修改變量的值,也刷新到了主存中,但B、C線程在此時間內讀取的還是本線程的工作內存,也就是說它們讀取的不是最「新鮮」的值,此時就出現了不同線程持有的公共資源不同步的情況。

對於此類問題有很多解決辦法,比如使用synchronized同步代碼塊,或者使用Lock鎖來解決該問題,不過,Java可以使用volatile更簡單地解決此類問題,比如在一個變量前加上volatile關鍵字,可以確保每個線程對本地變量的訪問和修改都是直接與主內存交互的,而不是與本線程的工作內存交互的,保證每個線程都能獲得最「新鮮」的變量值,其示意圖如圖9-2所示。

圖 9-2 volatile變量操作示意圖

明白了volatile變量的原理,那我們思考一下:volatile變量是否能夠保證數據的同步性呢?兩個線程同時修改一個volatile是否會產生髒數據呢?我們來看下面的代碼:


class UnsafeThread implements Runnable{

//共享資源

private volatile int count=0;

@Override

public void run(){

//增加CPU的繁忙程度,不用關心其邏輯含義

for(int i=0;i<1000;i++){

Math.hypot(Math.pow(92456789,i),Math.cos(i));

}

//自增運算

count++;

}

public int getCount(){

return count;

}

}


上面的代碼定義了一個多線程類,run方法的主要邏輯是共享資源count的自加運算,而且我們還為count變量加上了volatile關鍵字,確保是從主內存中讀取和寫入的,如果有多個線程運行,也就是多個線程執行count變量的自加動作,count變量會產生髒數據嗎?想想看,我們已經為count加上了volatile關鍵字呀!模擬多線程的代碼如下:


public static void main(Stringargs)throws Exception{

//理想值,並作為最大循環次數

int value=1000;

//循環次數,防止出現無限循環造成死機情況

int loops=0;

//主線程組,用於估計活動線程數

ThreadGroup tg=Thread.currentThread().getThreadGroup();

while(loops++<value){

//共享資源清零

UnsafeThread ut=new UnsafeThread();

for(int i=0;i<value;i++){

new Thread(ut).start();

}

//先等15毫秒,等待活動線程數量成為1

do{

Thread.sleep(15);

}while(tg.activeCount()!=1);

//檢查實際值與理論值是否一致

if(ut.getCount()!=value){

//出現線程不安全的情況

System.out.println(\"循環到第\"+loops+\"遍,出現線程不安全情況\");

System.out.println(\"此時,count=\"+ut.getCount());

System.exit(0);

}

}

}


這是一段設計很巧妙的程序,要讓volatile變量「出點丑」還是需要花點功夫的。此段程序的運行邏輯如下:

啟動100個線程,修改共享資源count的值。

暫停15毫秒,觀察活動線程數是否為1(即只剩下主線程在運行),若不為1,則再等待15毫秒。

判斷共享資源是否是不安全的,即實際值與理想值是否相同,若不相同,則發現目標,此時count的值為髒數據。

如果沒有找到,繼續循環,直到達到最大循環次數為止。運行結果如下:


循環到第247遍,出現線程不安全情況

此時,count=999


這只是一種可能的結果,每次執行都有可能產生不同的結果。這也說明我們的count變量沒有實現數據同步,在多個線程修改的情況下,count的實際值與理論值產生了偏差,直接說明了volatile關鍵字並不能保證線程安全。

在解釋原因之前,我們先說一下自加操作。count++表示的是先取出count的值然後再加1,也就是count=count+1,所以,在某兩個緊鄰的時間片段內會發生如下神奇的事情:

(1)第一個時間片段

A線程獲得執行機會,因為有關鍵字volatile修飾,所以它從主內存中獲得count的最新值998,接下來的事情又分為兩種類型:

如果是單CPU,此時調度器暫停A線程執行,出讓執行機會給B線程,於是B線程也獲得了count的最新值998。

如果是多CPU,此時線程A繼續執行,而線程B也同時獲得count的最新值998。

(2)第二個時間片段

如果是單CPU, B線程執行完加1動作(這是一個原子處理),count的值為999,由於是volatile類型的變量,所以直接寫入主內存,然後A線程繼續執行,計算的結果也是999,重新寫入主內存中。

如果是多CPU, A線程執行完加1動作後修改主內存的變量count為999,線程B執行完畢後也修改主內存中的變量為999。

這兩個時間片段執行完畢後,原本期望的結果為1000,但運行後的值卻為999,這表示出現了線程不安全的情況。這也是我們要說明的:volatile關鍵字並不能保證線程安全,它只能保證當線程需要該變量的值時能夠獲得最新的值,而不能保證多個線程修改的安全性。

順便說一下,在上面的代碼中,UnsafeThread類的消耗CPU計算是必須的,其目的是加重線程的負荷,以便出現單個線程搶佔整個CPU資源的情景,否則很難模擬出volatile線程不安全的情況,讀者可以自行模擬測試。

注意 volatile不能保證數據是同步的,只能保證線程能夠獲得最新值。