深入淺出了解happens-before原則
看Java內(nèi)存模型(JMM, Java Memory Model)時,總有一個困惑。關(guān)于線程、主存(main memory)、工作內(nèi)存(working memory),我都能找到實(shí)際映射的硬件:線程可能對應(yīng)著一個內(nèi)核線程,主存對應(yīng)著內(nèi)存,而工作內(nèi)存則涵蓋了寫緩沖區(qū)、緩存(cache)、寄存器等一系列為了提高數(shù)據(jù)存取效率的暫存區(qū)域。但是,一提到happens-before原則,就讓人有點(diǎn)“丈二和尚摸不著頭腦”。這個涵蓋了整個JMM中可見性原則的規(guī)則,究竟如何理解,把我個人一些理解記錄下來。
兩個操作間具有happens-before關(guān)系,并不意味著前一個操作必須要在后一個操作之前執(zhí)行。happens-before僅僅要求前一個操作對后一個操作可見。
這個說法我先后在好幾本書中都看到過。也就是說,happens-before原則和一般意義上的時間先后是不同的。那究竟是什么呢?一步步來看。
順序一致性內(nèi)存模型
我們先來看一個理想化的模型:順序一致性(Sequentially Consistent)內(nèi)存模型。在這個模型里,所有操作按程序的順序來執(zhí)行,并且每一個操作都是原子的,且立即對所有線程可見。
這個系統(tǒng)中同一時間只有一個線程能讀或?qū)憙?nèi)存。也就是說,這個系統(tǒng)里的每兩個指令之間,都嚴(yán)格按執(zhí)行的先后,具有著happens-before關(guān)系。所有的線程,都能夠看到一致的全局指令執(zhí)行視圖。如果將總線1看做是線程和內(nèi)存之間的通道,那么順序一致性模型就相當(dāng)于在所有讀/寫內(nèi)存的操作時,鎖住總線。
特別注意一點(diǎn),順序一致性模型,不代表多線程沒有同步問題,只是每個操作之間不存在同步問題,如果你的操作是多個操作的集合體,照樣不能安全工作。圖中所示的是常見的自增操作,兩個線程都有同樣的執(zhí)行視圖:1->2->3->4->5->6。然而,線程A的寫結(jié)果,依然被線程B所覆蓋了。A線程讀寫固然對B線程立即可見,但是由于5/6的寫操作對于內(nèi)存的影響依賴于1/2的讀操作,所以對于多線程仍然存在問題。
顯然,順序一致性模型是一種犧牲并行度、換取多線程對共享內(nèi)存的可見性的一種理想模型。從JMM實(shí)現(xiàn)volatile以及synchronized的內(nèi)存語義的方式,正是鎖住總線或者說鎖住線程自身存儲(指working memory)。
Java內(nèi)存模型
關(guān)于Java內(nèi)存模型的書籍文章,汗牛充棟,想必大家也都有自己的理解。那就僅僅由上面的順序一致性模型來引出JMM,看看具體區(qū)別在哪。
可以看出,工作內(nèi)存是一個明顯區(qū)別于順序一致性內(nèi)存模型的地方。事實(shí)上,造成可見性問題的根源之一,就在于這個工作內(nèi)存(強(qiáng)調(diào)一下,包括緩存、寫緩沖和寄存器等等)。工作內(nèi)存使得每個線程都有了自己的私有存儲,大部分時間對數(shù)據(jù)的存取工作都在這個區(qū)域完成。但是我們寫一個數(shù)據(jù),是直到數(shù)據(jù)寫到主存中才算真正完成。實(shí)際上每個線程維護(hù)了一個副本,所有線程都在自己的工作內(nèi)存中不斷地讀/寫一個共享內(nèi)存中的數(shù)據(jù)的副本。單線程情況下,這個副本不會造成任何問題;但一旦到多線程,有一個線程將變量寫到主存,其他線程卻不知道,其他線程的副本就都過期。比如,由于工作內(nèi)存的存在,程序員寫的一段代碼,寫一個普通的共享變量,其可能先被寫到緩沖區(qū),那指令完成的時間就被推遲了,實(shí)際表現(xiàn)也就是我們常說的“指令重排序”(這實(shí)際上是內(nèi)存模型層面的重排序,重排序還可能是編譯器、機(jī)器指令層級上的亂序)。
因此,在Java內(nèi)存模型中,每個線程不再像順序一致性模型中那樣有確定的指令執(zhí)行視圖,一個指令可能被重排了。從一個線程的角度看,其他線程(甚至是這個線程本身)執(zhí)行的指令順序有多種可能性,也就是說,一個線程的執(zhí)行結(jié)果對其他線程的可見性無法保證。
總結(jié)一下導(dǎo)致可見性問題的原因:
1.數(shù)據(jù)的寫無法及時通知到別的線程,如寫緩沖區(qū)的引入
2.線程不能及時讀到其他線程對共享變量的修改,如緩存的使用
3.各種層級上對指令的重排序,導(dǎo)致指令執(zhí)行的順序無法確定
所以要解決可見性問題,本質(zhì)是要讓線程對共享變量的修改,及時同步到其他線程。我們所使用的硬件架構(gòu)下,不具備順序一致性內(nèi)存模型的全局一致的指令執(zhí)行順序,討論指令執(zhí)行的時間先后并不存在意義或者說根本沒辦法確定時間上的先后??梢钥纯聪旅娉绦?,每個線程中的flag副本會在多久后被更新呢?答案是:無法確定,看線程何時刷新自己的工作內(nèi)存。
public class testVisibility { public static boolean flag = false; public static void main(String[] args) { List<Thread> thdList = new ArrayList<Thread>(); for(int i = 0; i < 10; i++) { Thread t = new Thread(new Runnable(){ public void run() { while (true) { if (flag) { // 多運(yùn)行幾次,可能并不會打印出來也可能會打印出來 // 如果不打印,則表示Thread看到的仍然是工作內(nèi)存中的flag // 可以嘗試將flag變成volatile再運(yùn)行幾次看看 System.out.println(Thread.currentThread().getId() + " is true now"); } } } }); t.start(); thdList.add(t); } flag = true; System.out.println("set flag true"); // 等待線程執(zhí)行完畢 try { for (Thread t : thdList) { t.join(); } } catch (Exception e) { } } }
那么既然我們無法討論指令執(zhí)行的先后,也不需要討論,我們實(shí)際只想知道某線程的操作對另一個線程是否可見,于是就規(guī)定了happens-before這個可見性原則,程序員可以基于這個原則進(jìn)行可見性的判斷。
volatile變量
volatile就是一個踐行happens-before的關(guān)鍵字??匆韵聦olatile的描述,就不難知道,happens-before指的是線程接收其他線程修改共享變量的消息與該線程讀取共享變量的先后關(guān)系。大家可以再細(xì)想一下,如果沒有happens-before原則,豈不是相當(dāng)于一個線程讀取自己的共享變量副本時,其他線程修改這個變量的消息還沒有同步過來?這就是可見性問題。
volatile變量規(guī)則:對一個volatile的寫,happens-before于任意后續(xù)對這個volatile變量的讀。
線程A寫一個volatile變量,實(shí)質(zhì)上是線程A向接下來要獲取這個鎖的某個線程發(fā)出了(線程A對共享變量修改的)消息。
線程B讀一個volatile變量,實(shí)質(zhì)上是線程B接收了之前某個線程發(fā)出的(對共享變量所做修改的)消息。
線程A寫一個volatile變量,隨后線程B讀這個變量,這個過程實(shí)質(zhì)上是線程A通過主內(nèi)存向線程B發(fā)送消息。
其實(shí)仔細(xì)看看volatile的實(shí)現(xiàn)方式,實(shí)際上就是限制了重排序的范圍——加入內(nèi)存屏障(Memory Barrier or Memory Fence)。也即是說,允許指令執(zhí)行的時間先后順序在一定范圍內(nèi)發(fā)生變化,而這個范圍就是根據(jù)happens-before原則來規(guī)定。內(nèi)存屏障概括起來有兩個功能:
1.使寫緩沖區(qū)的內(nèi)容刷新到內(nèi)存,保證對其他線程/CPU可見
2.禁止讀寫操作的越過內(nèi)存屏障進(jìn)行重排序
而這上述功能組合起來,就完成上面所說的happens-before所表達(dá)的線程通信過程。
每個volatile寫操作的前面插入一個StoreStore屏障
每個volatile寫操作的后面插入一個StoreLoad屏障
每個volatile讀操作的后面插入一個LoadLoad屏障
每個volatile讀操作的后面插入一個LoadStore屏障
關(guān)于內(nèi)存屏障的種類,這里不是研究的重點(diǎn)。一直困擾我的是,在多處理器系統(tǒng)下,這個屏障如何能跨越處理器來阻止操作執(zhí)行的順序呢?比如下面的讀寫操作:
public static volatile int race = 0; // Thread A public static void save(int src) { race = src; } // Thread B public static int load() { return race; }
這就要提到從操作系統(tǒng)到硬件層面的觀念轉(zhuǎn)換,可以參看總線事務(wù)(Bus transaction)的概念。當(dāng)CPU要與內(nèi)存進(jìn)行數(shù)據(jù)交換的時候,實(shí)際上總線會同步數(shù)據(jù)交換操作,同一時刻只能有一個CPU進(jìn)行讀/寫內(nèi)存,所以我們所看到的多處理器并行,并行的是CPU的計(jì)算資源。在總線看來,對于存儲的讀寫操作就是串行的,是按照一定順序的。這也就是為什么一個內(nèi)存屏障能夠跨越處理器去限制讀寫、去完成通信。
以上就是本文的全部內(nèi)容,希望對大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
相關(guān)文章
mybatis 自定義實(shí)現(xiàn)攔截器插件Interceptor示例
這篇文章主要介紹了mybatis 自定義實(shí)現(xiàn)攔截器插件Interceptor,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-10-10Java使用訪問者模式解決公司層級結(jié)構(gòu)圖問題詳解
這篇文章主要介紹了Java使用訪問者模式解決公司層級結(jié)構(gòu)圖問題,結(jié)合實(shí)例形式分析了訪問者模式的概念、原理及Java使用訪問者模式解決公司曾經(jīng)結(jié)構(gòu)圖問題的相關(guān)操作技巧與注意事項(xiàng),需要的朋友可以參考下2018-04-04Java+Eclipse+Selenium環(huán)境搭建的方法步驟
這篇文章主要介紹了Java+Eclipse+Selenium環(huán)境搭建的方法步驟,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2019-06-06JavaWeb中使用JavaMail實(shí)現(xiàn)發(fā)送郵件功能實(shí)例詳解
這篇文章主要介紹了JavaWeb中使用JavaMail實(shí)現(xiàn)發(fā)送郵件功能的實(shí)例代碼,非常不錯具有參考借鑒價值,感興趣的朋友一起看看吧2016-05-05