Java 多線程:volatile 變量蝙茶、happens-before 關(guān)系及內(nèi)存一致性(轉(zhuǎn)載)

原文地址:原文鏈接:dzone 翻譯:ImportNew-paddx
翻譯作者:paddx
摘抄申明:我們不占有不侵權(quán)扣唱,我們只是好文的搬運工!轉(zhuǎn)發(fā)請帶上原文申明唐瀑。

更新
請參考來自 Jean-philippe Bempel 的評論群凶。他提到了一個真實因 JVM 優(yōu)化導(dǎo)致死鎖的例子。我盡可能多地寫博客的原因之一是一旦自己理解錯了哄辣,可以從社區(qū)中學(xué)到很多请梢。謝謝!

什么是 volatile 變量柔滔?

volatile 是 Java 中的一個關(guān)鍵字溢陪。你不能將它設(shè)置為變量或者方法名萍虽,句號睛廊。

認真點,別開玩笑,什么是 volatile 變量杉编?我們應(yīng)該什么時候使用它超全?
哈哈,對不起邓馒,沒法提供幫助嘶朱。

volatile 關(guān)鍵字的典型使用場景是在多線程環(huán)境下,多個線程共享變量光酣,由于這些變量會緩存在 CPU 的緩存中疏遏,為了避免出現(xiàn)內(nèi)存一致性錯誤而采用 volatile 關(guān)鍵字。

考慮下面這個生產(chǎn)者/消費者的例子,我們每次生成/消費一個元素:

public class ProducerConsumer {
    private String value = "";
    private Boolean hasValue = false;
        
    public void produce(String value) {
        while (hasValue) {
            try {
                Thread.sleep(500);
            }
            catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("Producing " + value + " as the next consumable");
        this.value = value;
        hasValue = true;
    }

    public String consume() {
        while (!hasValue) {
            try {
                Thread.sleep(500);
            }
            catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        String value = this.value;
        hasValue = false;
        System.out.println("Consumed " + value);
        return value;
    }
}

在上面的類中财异,produce 方法通過存儲參數(shù)來生成一個新的值倘零,然后將 hasValue 設(shè)置為 true。while 循環(huán)檢測標(biāo)識變量(hasValue)是否 true戳寸,true 表示一個新的值沒有被消費呈驶,要求當(dāng)前線程睡眠(sleep),該睡眠一直循環(huán)直到標(biāo)識變量 hasValue 變?yōu)?false疫鹊,只有在新的值被 consume 方法消費完成后才能變?yōu)?false袖瞻。如果沒有有效的新值,consume 方法要求當(dāng)前睡眠拆吆,當(dāng)一個 produce 方法生成一個新值時聋迎,睡眠循環(huán)終止,并改變標(biāo)識變量的值锈拨。

現(xiàn)在想象有兩個線程在使用這個類的對象砌庄,一個生成值(寫線程),另個一個消費值(讀線程)奕枢。通過下面的測試來解釋這種方式:

public class ProducerConsumerTest {
    @Test
    public void testProduceConsume() throws InterruptedException {
        ProducerConsumer producerConsumer = new ProducerConsumer();
        List<String> values = Arrays.asList("1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13");
        Thread writerThread = new Thread(() -> values.stream()
                                               .forEach(producerConsumer::produce));
        Thread readerThread = new Thread(() -> {
            for (int i = 0; i > values.size(); i++) {
                producerConsumer.consume();
            }
        });
        writerThread.start();
        readerThread.start();
        writerThread.join();
        readerThread.join();
    }
}

這個例子大部分時候都能輸出期望的結(jié)果娄昆,但是也有很大概率會出現(xiàn)死鎖!

怎么會缝彬?

我們先簡單討論一下計算機的結(jié)構(gòu)萌焰。

我們都知道計算機是由內(nèi)存單元和 CPU (還有許多其他部分)組成。主內(nèi)存就是程序指令谷浅、變量扒俯、數(shù)據(jù)存儲的地方。程序執(zhí)行期間一疯,為了獲得更好的性能撼玄,CPU 可能會將變量拷貝到自己的內(nèi)存中(即所謂的 CPU 緩存)。由于現(xiàn)代計算機有多個 CPU墩邀,同樣也存在多個 CPU 緩存掌猛。

多線程環(huán)境下,有可能多個線程同時執(zhí)行眉睹,每個線程使用不同的 CPU(雖然這完全依賴于底層的操作系統(tǒng))荔茬,每個 CPU 都從主內(nèi)存中拷貝變量到它自己的緩存中。當(dāng)一個線程訪問這些變量時竹海,是直接訪問緩存中的副本慕蔚,而不是真正訪問主內(nèi)存中的變量。

現(xiàn)在斋配,假設(shè)在我們的測試中有兩個線程運行在不同的 CPU 上孔飒,并且其中的有一個緩存了標(biāo)識變量(或者兩個都緩存了)」喙耄現(xiàn)在考慮如下的執(zhí)行順序

  • 1、寫線程生成一個值坏瞄,并將 hasValue 設(shè)置為 true菩鲜。但是只更新緩存中的值,而不是主內(nèi)存惦积。
  • 2接校、讀線程嘗試消費一個值,但是它的緩存副本中 hasValue 被設(shè)置為 false狮崩,所以即使寫線程生產(chǎn)了一個新的值蛛勉,也不能被消費,因為讀線程無法跳出睡眠循環(huán)(hasValue 的值為 false)睦柴。
  • 3诽凌、因為讀線程不能消費新生成的值,所以寫線程也不能繼續(xù)坦敌,因為標(biāo)識變量沒有設(shè)置回 false侣诵,因此寫線程阻塞在睡眠循環(huán)中。
  • 4狱窘、這樣杜顺,就產(chǎn)生了死鎖!

這種情況只有在 hasValue 同步到所有緩存才能改變蘸炸,這完全依賴于底層的操作系統(tǒng)躬络。

那怎么解決這個問題? volatile 怎么會適合這個例子搭儒?

如果我們將 hasValue 標(biāo)示為 volatile穷当,我就能確定這種死鎖就不會再發(fā)生。

private volatile boolean hasValue = false;

volatile 變量強制線程每次讀取的時候都直接從主內(nèi)存中讀取淹禾,同時馁菜,每次寫 volatile 變量的時候也要立即刷新主內(nèi)存中的值。如果線程決定緩存變量铃岔,就需要每次讀寫的時候都與主內(nèi)存進行同步汪疮。

做這個改變之后,我們再來考慮前面導(dǎo)致死鎖的執(zhí)行步驟

  • 1德撬、寫線程生成一個值铲咨,并將 hasValue 設(shè)置為 true躲胳,這次直接更新主內(nèi)存中的值(即使這個變量被緩存了)蜓洪。
  • 2、讀線程嘗試消費一個值坯苹,先檢查 hasValue 的值隆檀,每次讀取都強制直接從主內(nèi)存中獲取值,所以能獲取到寫線程改變后的值。
  • 3恐仑、讀線程消費完生成的值后泉坐,重新設(shè)置標(biāo)識變量的值,這個新的值也會同步到主內(nèi)存(如果這個值被緩存了裳仆,緩存的副本也會更新)腕让。
  • 4、寫線程獲每次都是從主內(nèi)存中取這個改變了的值歧斟,這樣就能繼續(xù)生成新的值纯丸。

現(xiàn)在,大家都很幸福了_ !

我知道了静袖,強制線程直接從內(nèi)存中讀寫線程觉鼻,這是 volatile 所能做全部的事情嗎?
實際上,它還有更多的功能队橙。訪問一個 volatile 變量會在語句間建立 happens-before 關(guān)系坠陈。

什么是 happens-before 關(guān)系?

happens-before 關(guān)系是程序語句之間的排序保證捐康,這能確保任何內(nèi)存的寫仇矾,對其他語句都是可見的。

這與 volatile 是怎么關(guān)聯(lián)的解总?

當(dāng)寫一個 volatile 變量時若未,隨后對該變量讀時會創(chuàng)建一個 happens-before 關(guān)系。所以倾鲫,所有在 volatile 變量寫操作之前完成的寫操作粗合,將會對隨后該 volatile 變量讀操作之后的所有語句可見。

嗯…,好吧…乌昔,我有點明白了隙疚,但是可能通過一個例子會更清楚。
好磕道,對這個模糊的概念我表示很抱歉供屉。考慮下面這個例子:

// Definition: Some variables
// 變量定義
private int first = 1;
private int second = 2;
private int third = 3;
private volatile Boolean hasValue = false;
// First Snippet: A sequence of write operations being executed by Thread 1
//片段 1:線程 1 順序的寫操作
first = 5;
second = 6;
third = 7;
hasValue = true;
// Second Snippet: A sequence of read operations being executed by Thread 2
//片段 2:線程 2 順序的讀操作
System.out.println("Flag is set to : " + hasValue);
System.out.println("First: " + first);
// will print 5 打印 5
System.out.println("Second: " + second);
// will print 6 打印 6
System.out.println("Third: " + third);
// will print 7 打印 7

我們假設(shè)上面的兩個代碼片段有由兩個線程執(zhí)行:線程 1 和線程 2溺蕉。當(dāng)?shù)谝粋€線程改變 hasValue 的值時伶丐,它不僅僅是刷新這個改變的值到主存,也會引起前面三個值的寫(之前任何的寫操作)刷新到主存疯特。結(jié)果哗魂,當(dāng)?shù)诙€線程訪問這三個變量的時候,就可以訪問到被線程 1 寫入的值漓雅,即使這些變量之前被緩存(這些緩存的副本都會被更新)录别。

這就是為什么我們不需要像第一個示例一樣將變量標(biāo)示為 volatile 朽色。因為我們的寫操作在訪問 hasValue 之前,讀操作在 hasValue 的讀之后组题,它會自動與主內(nèi)存同步葫男。

還有另一個有趣的結(jié)論。JVM 因它的程序優(yōu)化機制而聞名崔列。有時對程序語句的重排序可以大幅度提高性能梢褐,并且不會改變程序的輸出結(jié)果。例如赵讯,它可能會修改如語句的順序:

first = 5;
second = 6;
third = 7;

為:

second = 6;
third = 7;
first = 5;

但是利职,當(dāng)多條語句涉及到對 volatile 變量的訪問時,它永遠不會將 volatile 變量前的寫語句放在 volatile 變量之后瘦癌,意思就是猪贪,它永遠不會轉(zhuǎn)換下列順序:

first = 5;  // write before volatile write //volatile 寫之前的寫
second = 6;  // write before volatile write //volatile 寫之前的寫
third = 7;  // write before volatile write //volatile 寫之前的寫
hasValue = true;

為:

first = 5;
second = 6;
hasValue = true;
third = 7;  // Order changed to appear after volatile write! This will never happen!
// 譯:  third = 7;  順序發(fā)生了改變,出現(xiàn)在了 volatile 寫之后讯私。這永遠不會發(fā)生热押。

即使從程序的正確性的角度來說,上面兩種情況是相等的斤寇。但請注意桶癣,JVM 仍然允許對前三個變量的寫操作進行重排序,只要它們都出現(xiàn)在 volatile 寫之前即可娘锁。

類似的牙寞,JVM 也不會將 volatile 變量讀之后的讀操作重排序到 volatile 變量之前。意思就是說莫秆,下面的順序:

System.out.println("Flag is set to : " + hasValue);  // volatile read  譯: volatile 讀
System.out.println("First: " + first);  // Read after volatile read 譯:  volatile 讀之后的讀
System.out.println("Second: " + second); // Read after volatile read 譯: volatile 讀之后的讀
System.out.println("Third: " + third);  // Read after volatile read 譯: volatile 讀之后的讀

JVM 永遠不會轉(zhuǎn)換為如下的順序:

System.out.println("First: " + first);  // Read before volatile read! Will never happen! 譯: volatile 讀之前的讀间雀!永遠不可能出現(xiàn)!
System.out.println("Fiag is set to : " + hasValue); // volatile read 譯: volatile 讀
System.out.println("Second: " + second); 
System.out.println("Third: " + third);

但是镊屎,JVM 也有可能會對最后的三個讀操作重排序惹挟,只要它們在 volatile 變量讀之后即可。

我感覺 volatile 變量會對性能有一定的影響缝驳。
你的感覺是對的连锯,因為 volatile 變量強制訪問主存,而訪問主存肯定被訪問 CPU 緩存慢用狱。同時运怖,它還防止 JVM 對程序的優(yōu)化,這也會降低性能夏伊。

我們總能用 Volatile 變量來維護多線程之間的數(shù)據(jù)一致性嗎摇展?
非常不幸,這是不行的署海。當(dāng)多個線程讀寫同一個變量時吗购,僅僅靠 volatile 是不足以保證一致性的,考慮下面這個 UnsafeCounter 類:

public class UnsafeCounter {
    private volatile int counter;
    public void inc() {
        counter++;
    }
    public void dec() {
        counter--;
    }
    public int get() {
        return counter;
    }
}

測試如下:

public class UnsafeCounterTest {
    @Test
    public void testUnsafeCounter() throws InterruptedException {
        UnsafeCounter unsafeCounter = new UnsafeCounter();
        Thread first = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                unsafeCounter.inc();
            }
        });
        Thread second = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                unsafeCounter.dec();
            }
        });
        first.start();
        second.start();
        first.join();
        second.join();
        System.out.println("Current counter value: " + unsafeCounter.get());
    }
}

這段代碼具有非常好的自說明性砸狞。一個線程增加計數(shù)器捻勉,另一個線程將計數(shù)器減少同樣次數(shù)。運行這個測試刀森,期望的結(jié)果是計數(shù)器的值為 0踱启,但這無法得到保證。大部分時候是 0研底,但有的時候是 -1, -2, 1, 2 等埠偿,任何位于[-5, 5]之間的整數(shù)都有可能。

為什么會發(fā)生這種情況? 這是因為對計數(shù)器的遞增和遞減操作都不是原子的——它們不是一次完成的榜晦。這兩種操作都由多個步驟組成冠蒋,這些步驟可能相互交叉。

你可以認為遞增操作如下:

讀取計數(shù)器的值乾胶。
加 1抖剿。
將新的值寫回計數(shù)器。

遞減操作的過程如下:

讀取計數(shù)器的值识窿。
減 1斩郎。
將新的值寫回計數(shù)器。

現(xiàn)在我們考慮一下如下的執(zhí)行步驟:

第一個線程從主存中讀取計數(shù)器的值喻频,初始值是 0缩宜,然后加 1。
第二個線程也從主存中讀取計數(shù)器的值甥温,它讀取到的值也是 0锻煌,然后進行減 1 操作。
第一線程將新的計數(shù)器的值寫回內(nèi)存姻蚓,將值設(shè)置為 1炼幔。
第二個線程也將新的值寫回內(nèi)存,將值設(shè)置為 -1史简。

怎么防止這類事件的發(fā)生乃秀?
使用同步:

public class SynchronizedCounter {
    private int counter;
    public synchronized void inc() {
        counter++;
    }
    public synchronized void dec() {
        counter--;
    }
    public synchronized int get() {
        return counter;
    }
}

或者使用 java.util.concurrent.atomic.AtomicInteger:

public class AtomicCounter {
    private AtomicInteger atomicInteger = new AtomicInteger();
    public void inc() {
        atomicInteger.incrementAndGet();
    }
    public void dec() {
        atomicInteger.decrementAndGet();
    }
    public int get() {
        return atomicInteger.intValue();
    }
}

我個人的選擇是使用 AtomicInteger,因為 synchronized 只允許一個線程訪問 inc/dec/get 方法圆兵,對性能影響較大跺讯。

我注意到采用 synchronized 的版本并沒有將計數(shù)器標(biāo)識為 volatile,難道這意味著……?
對的殉农。使用 synchronized 關(guān)鍵字也會在語句之間建立 happens-before 關(guān)系刀脏。進入一個同步方法或塊時,會將之前的語句和該方法或塊內(nèi)部的語句建立 happens-before 關(guān)系超凳。
查看完整的建立 happens-before 關(guān)系的情況列表愈污,請查看這里耀态。

關(guān)于一開始提到的 volatile, 這些是所有我想說的。所有的例子都上傳到了我的 github 倉庫暂雹。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末首装,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子杭跪,更是在濱河造成了極大的恐慌仙逻,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,607評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件涧尿,死亡現(xiàn)場離奇詭異系奉,居然都是意外死亡,警方通過查閱死者的電腦和手機姑廉,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,239評論 3 395
  • 文/潘曉璐 我一進店門缺亮,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人桥言,你說我怎么就攤上這事瞬内。” “怎么了限书?”我有些...
    開封第一講書人閱讀 164,960評論 0 355
  • 文/不壞的土叔 我叫張陵虫蝶,是天一觀的道長。 經(jīng)常有香客問我倦西,道長能真,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,750評論 1 294
  • 正文 為了忘掉前任扰柠,我火速辦了婚禮粉铐,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘卤档。我一直安慰自己蝙泼,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 67,764評論 6 392
  • 文/花漫 我一把揭開白布劝枣。 她就那樣靜靜地躺著汤踏,像睡著了一般。 火紅的嫁衣襯著肌膚如雪舔腾。 梳的紋絲不亂的頭發(fā)上溪胶,一...
    開封第一講書人閱讀 51,604評論 1 305
  • 那天,我揣著相機與錄音稳诚,去河邊找鬼哗脖。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的才避。 我是一名探鬼主播橱夭,決...
    沈念sama閱讀 40,347評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼桑逝!你這毒婦竟也來了棘劣?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,253評論 0 276
  • 序言:老撾萬榮一對情侶失蹤肢娘,失蹤者是張志新(化名)和其女友劉穎呈础,沒想到半個月后舆驶,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體橱健,經(jīng)...
    沈念sama閱讀 45,702評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,893評論 3 336
  • 正文 我和宋清朗相戀三年沙廉,在試婚紗的時候發(fā)現(xiàn)自己被綠了拘荡。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,015評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡撬陵,死狀恐怖珊皿,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情巨税,我是刑警寧澤蟋定,帶...
    沈念sama閱讀 35,734評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站草添,受9級特大地震影響驶兜,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜远寸,卻給世界環(huán)境...
    茶點故事閱讀 41,352評論 3 330
  • 文/蒙蒙 一抄淑、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧驰后,春花似錦肆资、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,934評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至夜涕,卻和暖如春颤专,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背钠乏。 一陣腳步聲響...
    開封第一講書人閱讀 33,052評論 1 270
  • 我被黑心中介騙來泰國打工栖秕, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人晓避。 一個月前我還...
    沈念sama閱讀 48,216評論 3 371
  • 正文 我出身青樓簇捍,卻偏偏與公主長得像只壳,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子暑塑,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,969評論 2 355

推薦閱讀更多精彩內(nèi)容