線程共享數(shù)據(jù)可見性


public class Abc {
    
    public static void main(String[] args) {
     int a = 0;
     Test1 test1 = new Test1();
        Thread t = new Thread(test1);
        
        t.start();
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
        
            test1.stop();
            
    }
    

}


class Test1 implements Runnable {
    

    private  boolean flag = true;
    

    @Override
    public void run() {
        // TODO Auto-generated method stub
        
        System.out.println("開始");
        
        while(flag)
        {
            
        }
        
        System.out.println("結束");
    }
    
    
    public void stop() {
        this.flag  = !this.flag;
    }
    
    
    
}

以上代碼會重復運行 梯啤, 不會停止。

JMM(java內存模型)

若想學習好多線程存哲, 那么必須了解一下JMM

Java內存模型(即Java Memory Model因宇,簡稱JMM)本身是一種抽象的概念,并不真實存在祟偷,它描述的是一組規(guī)則或規(guī)范察滑,通過這組規(guī)范定義了程序中各個變量(包括實例字段,靜態(tài)字段和構成數(shù)組對象的元素)的訪問方式修肠。由于JVM運行程序的實體是線程贺辰,而每個線程創(chuàng)建時JVM都會為其創(chuàng)建一個工作內存(有些地方稱為棧空間)嵌施,用于存儲線程私有的數(shù)據(jù)饲化,而Java內存模型中規(guī)定所有變量都存儲在主內存,主內存是共享內存區(qū)域吗伤,所有線程都可以訪問吃靠,但線程對變量的操作(讀取賦值等)必須在工作內存中進行,首先要將變量從主內存拷貝的自己的工作內存空間足淆,然后對變量進行操作巢块,操作完成后再將變量寫回主內存礁阁,不能直接操作主內存中的變量,工作內存中存儲著主內存中的變量副本拷貝族奢,前面說過姥闭,工作內存是每個線程的私有數(shù)據(jù)區(qū)域,因此不同的線程間無法訪問對方的工作內存歹鱼,線程間的通信(傳值)必須通過主內存來完成泣栈,其簡要訪問過程如下圖

image.png

需要注意的是,JMM與Java內存區(qū)域的劃分是不同的概念層次弥姻,更恰當說JMM描述的是一組規(guī)則,通過這組規(guī)則控制程序中各個變量在共享數(shù)據(jù)區(qū)域和私有數(shù)據(jù)區(qū)域的訪問方式掺涛,JMM是圍繞原子性庭敦,有序性、可見性展開的(稍后會分析)薪缆。JMM與Java內存區(qū)域唯一相似點秧廉,都存在共享數(shù)據(jù)區(qū)域和私有數(shù)據(jù)區(qū)域,在JMM中主內存屬于共享數(shù)據(jù)區(qū)域拣帽,從某個程度上講應該包括了堆和方法區(qū)疼电,而工作內存數(shù)據(jù)線程私有數(shù)據(jù)區(qū)域,從某個程度上講則應該包括程序計數(shù)器减拭、虛擬機棧以及本地方法棧蔽豺。或許在某些地方拧粪,我們可能會看見主內存被描述為堆內存修陡,工作內存被稱為線程棧,實際上他們表達的都是同一個含義可霎。關于JMM中的主內存和工作內存說明如下

  • 主內存

    主要存儲的是Java實例對象魄鸦,所有線程創(chuàng)建的實例對象都存放在主內存中,不管該實例對象是成員變量還是方法中的本地變量(也稱局部變量)癣朗,當然也包括了共享的類信息拾因、常量、靜態(tài)變量旷余。由于是共享數(shù)據(jù)區(qū)域绢记,多條線程對同一個變量進行訪問可能會發(fā)現(xiàn)線程安全問題。

  • 工作內存

    主要存儲當前方法的所有本地變量信息(工作內存中存儲著主內存中的變量副本拷貝)荣暮,每個線程只能訪問自己的工作內存庭惜,即線程中的本地變量對其它線程是不可見的,就算是兩個線程執(zhí)行的是同一段代碼穗酥,它們也會各自在自己的工作內存中創(chuàng)建屬于當前線程的本地變量护赊,當然也包括了字節(jié)碼行號指示器惠遏、相關Native方法的信息。注意由于工作內存是每個線程的私有數(shù)據(jù)骏啰,線程間無法相互訪問工作內存节吮,因此存儲在工作內存的數(shù)據(jù)不存在線程安全問題。

弄清楚主內存和工作內存后判耕,接了解一下主內存與工作內存的數(shù)據(jù)存儲類型以及操作方式透绩,根據(jù)虛擬機規(guī)范,對于一個實例對象中的成員方法而言壁熄,如果方法中包含本地變量是基本數(shù)據(jù)類型(boolean,byte,short,char,int,long,float,double)帚豪,將直接存儲在工作內存的幀棧結構中,但倘若本地變量是引用類型草丧,那么該變量的引用會存儲在功能內存的幀棧中狸臣,而對象實例將存儲在主內存(共享數(shù)據(jù)區(qū)域,堆)中昌执。但對于實例對象的成員變量烛亦,不管它是基本數(shù)據(jù)類型或者包裝類型(Integer、Double等)還是引用類型懂拾,都會被存儲到堆區(qū)煤禽。至于static變量以及類本身相關信息將會存儲在主內存中。需要注意的是岖赋,在主內存中的實例對象可以被多線程共享檬果,倘若兩個線程同時調用了同一個對象的同一個方法,那么兩條線程會將要操作的數(shù)據(jù)拷貝一份到自己的工作內存中贾节,執(zhí)行完成操作后才刷新到主內存汁汗,簡單示意圖如下所示:


image.png

硬件內存架構與Java內存模型

硬件內存架構

image.png

正如上圖所示,經過簡化CPU與內存操作的簡易圖栗涂,實際上沒有這么簡單知牌,這里為了理解方便,我們省去了南北橋并將三級緩存統(tǒng)一為CPU緩存(有些CPU只有二級緩存斤程,有些CPU有三級緩存)角寸。就目前計算機而言,一般擁有多個CPU并且每個CPU可能存在多個核心忿墅,多核是指在一枚處理器(CPU)中集成兩個或多個完整的計算引擎(內核),這樣就可以支持多任務并行執(zhí)行扁藕,從多線程的調度來說,每個線程都會映射到各個CPU核心中并行運行疚脐。在CPU內部有一組CPU寄存器亿柑,寄存器是cpu直接訪問和處理的數(shù)據(jù),是一個臨時放數(shù)據(jù)的空間棍弄。一般CPU都會從內存取數(shù)據(jù)到寄存器望薄,然后進行處理疟游,但由于內存的處理速度遠遠低于CPU,導致CPU在處理指令時往往花費很多時間在等待內存做準備工作痕支,于是在寄存器和主內存間添加了CPU緩存颁虐,CPU緩存比較小,但訪問速度比主內存快得多卧须,如果CPU總是操作主內存中的同一址地的數(shù)據(jù)另绩,很容易影響CPU執(zhí)行速度,此時CPU緩存就可以把從內存提取的數(shù)據(jù)暫時保存起來花嘶,如果寄存器要取內存中同一位置的數(shù)據(jù)笋籽,直接從緩存中提取,無需直接從主內存取椭员。需要注意的是干签,寄存器并不每次數(shù)據(jù)都可以從緩存中取得數(shù)據(jù),萬一不是同一個內存地址中的數(shù)據(jù)拆撼,那寄存器還必須直接繞過緩存從內存中取數(shù)據(jù)。所以并不每次都得到緩存中取數(shù)據(jù)喘沿,這種現(xiàn)象有個專業(yè)的名稱叫做緩存的命中率闸度,從緩存中取就命中,不從緩存中取從內存中取蚜印,就沒命中莺禁,可見緩存命中率的高低也會影響CPU執(zhí)行性能,這就是CPU窄赋、緩存以及主內存間的簡要交互過程哟冬,總而言之當一個CPU需要訪問主存時,會先讀取一部分主存數(shù)據(jù)到CPU緩存(當然如果CPU緩存中存在需要的數(shù)據(jù)就會直接從緩存獲取)忆绰,進而在讀取CPU緩存到寄存器浩峡,當CPU需要寫數(shù)據(jù)到主存時,同樣會先刷新寄存器中的數(shù)據(jù)到CPU緩存错敢,然后再把數(shù)據(jù)刷新到主內存中翰灾。

Java線程與硬件處理器

了解完硬件的內存架構后,接著了解JVM中線程的實現(xiàn)原理稚茅,理解線程的實現(xiàn)原理纸淮,有助于我們了解Java內存模型與硬件內存架構的關系,在Window系統(tǒng)和Linux系統(tǒng)上亚享,Java線程的實現(xiàn)是基于一對一的線程模型咽块,所謂的一對一模型,實際上就是通過語言級別層面程序去間接調用系統(tǒng)內核的線程模型欺税,即我們在使用Java線程時侈沪,Java虛擬機內部是轉而調用當前操作系統(tǒng)的內核線程來完成當前任務揭璃。這里需要了解一個術語,內核線程(Kernel-Level Thread峭竣,KLT)塘辅,它是由操作系統(tǒng)內核(Kernel)支持的線程,這種線程是由操作系統(tǒng)內核來完成線程切換皆撩,內核通過操作調度器進而對線程執(zhí)行調度扣墩,并將線程的任務映射到各個處理器上。每個內核線程可以視為內核的一個分身,這也就是操作系統(tǒng)可以同時處理多任務的原因扛吞。由于我們編寫的多線程程序屬于語言層面的呻惕,程序一般不會直接去調用內核線程,取而代之的是一種輕量級的進程(Light Weight Process)滥比,也是通常意義上的線程亚脆,由于每個輕量級進程都會映射到一個內核線程,因此我們可以通過輕量級進程調用內核線程盲泛,進而由操作系統(tǒng)內核將任務映射到各個處理器濒持,這種輕量級進程與內核線程間1對1的關系就稱為一對一的線程模型。如下圖

image.png

如圖所示寺滚,每個線程最終都會映射到CPU中進行處理柑营,如果CPU存在多核,那么一個CPU將可以并行執(zhí)行多個線程任務村视。

Java內存模型與硬件內存架構的關系

通過對前面的硬件內存架構官套、Java內存模型以及Java多線程的實現(xiàn)原理的了解,我們應該已經意識到蚁孔,多線程的執(zhí)行最終都會映射到硬件處理器上進行執(zhí)行奶赔,但Java內存模型和硬件內存架構并不完全一致。對于硬件內存來說只有寄存器杠氢、緩存內存站刑、主內存的概念,并沒有工作內存(線程私有數(shù)據(jù)區(qū)域)和主內存(堆內存)之分修然,也就是說Java內存模型對內存的劃分對硬件內存并沒有任何影響笛钝,因為JMM只是一種抽象的概念,是一組規(guī)則愕宋,并不實際存在玻靡,不管是工作內存的數(shù)據(jù)還是主內存的數(shù)據(jù),對于計算機硬件來說都會存儲在計算機主內存中中贝,當然也有可能存儲到CPU緩存或者寄存器中囤捻,因此總體上來說,Java內存模型和計算機硬件內存架構是一個相互交叉的關系邻寿,是一種抽象概念劃分與真實物理硬件的交叉蝎土。(注意對于Java內存區(qū)域劃分也是同樣的道理)

image.png

JMM存在的必要性

在明白了Java內存區(qū)域劃分稠曼、硬件內存架構疚鲤、Java多線程的實現(xiàn)原理與Java內存模型的具體關系后梧却,接著來談談Java內存模型存在的必要性样刷。由于JVM運行程序的實體是線程,而每個線程創(chuàng)建時JVM都會為其創(chuàng)建一個工作內存(有些地方稱為棻┕梗空間)跪呈,用于存儲線程私有的數(shù)據(jù),線程與主內存中的變量操作必須通過工作內存間接完成取逾,主要過程是將變量從主內存拷貝的每個線程各自的工作內存空間耗绿,然后對變量進行操作,操作完成后再將變量寫回主內存砾隅,如果存在兩個線程同時對一個主內存中的實例對象的變量進行操作就有可能誘發(fā)線程安全問題误阻。如下圖,主內存中存在一個共享變量x晴埂,現(xiàn)在有A和B兩條線程分別對該變量x=1進行操作究反,A/B線程各自的工作內存中存在共享變量副本x。假設現(xiàn)在A線程想要修改x的值為2儒洛,而B線程卻想要讀取x的值奴紧,那么B線程讀取到的值是A線程更新后的值2還是更新前的值1呢?答案是晶丘,不確定,即B線程有可能讀取到A線程更新前的值1唐含,也有可能讀取到A線程更新后的值2浅浮,這是因為工作內存是每個線程私有的數(shù)據(jù)區(qū)域,而線程A變量x時捷枯,首先是將變量從主內存拷貝到A線程的工作內存中滚秩,然后對變量進行操作淮捆,操作完成后再將變量x寫回主內攀痊,而對于B線程的也是類似的桐腌,這樣就有可能造成主內存與工作內存間數(shù)據(jù)存在一致性問題,假如A線程修改完后正在將數(shù)據(jù)寫回主內存案站,而B線程此時正在讀取主內存棘街,即將x=1拷貝到自己的工作內存中蟆盐,這樣B線程讀取到的值就是x=1承边,但如果A線程已將x=2寫回主內存后石挂,B線程才開始讀取的話,那么此時B線程讀取到的就是x=2富岳,但到底是哪種情況先發(fā)生呢城瞎?這是不確定的疾瓮,這也就是所謂的線程安全問題狼电。


image.png

為了解決類似上述的問題肩碟,JVM定義了一組規(guī)則,通過這組規(guī)則來決定一個線程對共享變量的寫入何時對另一個線程可見翅溺,這組規(guī)則也稱為Java內存模型(即JMM)髓抑,JMM是圍繞著程序執(zhí)行的原子性吨拍、有序性羹饰、可見性展開的,下面我們看看這三個特性笑旺。

Java內存模型的承諾

這里我們先來了解幾個概念馍资,即原子性?可見性色洞?有序性冠胯?最后再闡明JMM是如何保證這3個特性荠察。

原子性

原子性指的是一個操作是不可中斷的悉盆,即使是在多線程環(huán)境下,一個操作一旦開始就不會被其他線程影響秋秤。比如對于一個靜態(tài)變量int x灼卢,兩條線程同時對他賦值来农,線程A賦值為1沃于,而線程B賦值為2繁莹,不管線程如何運行,最終x的值要么是1,要么是2雪标,線程A和線程B間的操作是沒有干擾的村刨,這就是原子性操作撰茎,不可被中斷的特點。有點要注意的是募疮,對于32位系統(tǒng)的來說僻弹,long類型數(shù)據(jù)和double類型數(shù)據(jù)(對于基本數(shù)據(jù)類型蹋绽,byte,short,int,float,boolean,char讀寫是原子操作)卸耘,它們的讀寫并非原子性的,也就是說如果存在兩條線程同時對long類型或者double類型的數(shù)據(jù)進行讀寫是存在相互干擾的侈百,因為對于32位虛擬機來說设哗,每次原子讀寫是32位的两蟀,而long和double則是64位的存儲單元赂毯,這樣會導致一個線程在寫時党涕,操作完前32位的原子操作后,輪到B線程讀取時手趣,恰好只讀取到了后32位的數(shù)據(jù)绿渣,這樣可能會讀取到一個既非原值又不是線程修改值的變量中符,它可能是“半個變量”的數(shù)值誉帅,即64位數(shù)據(jù)被兩個線程分成了兩次讀取。但也不必太擔心慢蜓,因為讀取到“半個變量”的情況比較少見,至少在目前的商用的虛擬機中凄诞,幾乎都把64位的數(shù)據(jù)的讀寫操作作為原子操作來執(zhí)行忍级,因此對于這個問題不必太在意戈稿,知道這么回事即可觅闽。

理解指令重排

計算機在執(zhí)行程序時摊求,為了提高性能,編譯器和處理器的常常會對指令做重排硫惕,一般分以下3種

  • 編譯器優(yōu)化的重排

    編譯器在不改變單線程程序語義的前提下恼除,可以重新安排語句的執(zhí)行順序豁辉。

  • 指令并行的重排

    現(xiàn)代處理器采用了指令級并行技術來將多條指令重疊執(zhí)行舀患。如果不存在數(shù)據(jù)依賴性(即后一個執(zhí)行的語句無需依賴前面執(zhí)行的語句的結果)聊浅,處理器可以改變語句對應的機器指令的執(zhí)行順序

  • 內存系統(tǒng)的重排

    由于處理器使用緩存和讀寫緩存沖區(qū)低匙,這使得加載(load)和存儲(store)操作看上去可能是在亂序執(zhí)行顽冶,因為三級緩存的存在,導致內存與緩存的數(shù)據(jù)同步存在時間差佩迟。

其中編譯器優(yōu)化的重排屬于編譯期重排报强,指令并行的重排和內存系統(tǒng)的重排屬于處理器重排秉溉,在多線程環(huán)境中碗誉,這些重排優(yōu)化可能會導致程序出現(xiàn)內存可見性問題哮缺,下面分別闡明這兩種重排優(yōu)化可能帶來的問題

編譯器重排

下面我們簡單看一個編譯器重排的例子:

線程 1             線程 2
1: x2 = a ;      3: x1 = b ;
2: b = 1;         4: a = 2 ;

兩個線程同時執(zhí)行尝苇,分別有1、2直撤、3谋竖、4四段執(zhí)行代碼承匣,其中1悄雅、2屬于線程1 宽闲, 3容诬、4屬于線程2 ,從程序的執(zhí)行順序上看狈定,似乎不太可能出現(xiàn)x1 = 1 和x2 = 2 的情況纽什,但實際上這種情況是有可能發(fā)現(xiàn)的芦缰,因為如果編譯器對這段程序代碼執(zhí)行重排優(yōu)化后让蕾,可能出現(xiàn)下列情況

線程 1              線程 2
2: b = 1;          4: a = 2 ; 
1:x2 = a ;        3: x1 = b ;

這種執(zhí)行順序下就有可能出現(xiàn)x1 = 1 和x2 = 2 的情況探孝,這也就說明在多線程環(huán)境下誉裆,由于編譯器優(yōu)化重排的存在足丢,兩個線程中使用的變量能否保證一致性是無法確定的。

處理器指令重排

先了解一下指令重排的概念,處理器指令重排是對CPU的性能優(yōu)化遇革,從指令的執(zhí)行角度來說一條指令可以分為多個步驟完成萝快,如下

  • 取指 IF
  • 譯碼和取寄存器操作數(shù) ID
  • 執(zhí)行或者有效地址計算 EX
  • 存儲器訪問 MEM
  • 寫回 WB

CPU在工作時揪漩,需要將上述指令分為多個步驟依次執(zhí)行(注意硬件不同有可能不一樣),由于每一個步會使用到不同的硬件操作奄容,比如取指時會只有PC寄存器和存儲器产徊,譯碼時會執(zhí)行到指令寄存器組舟铜,執(zhí)行時會執(zhí)行ALU(算術邏輯單元)谆刨、寫回時使用到寄存器組痊夭。為了提高硬件利用率生兆,CPU指令是按流水線技術來執(zhí)行的鸦难,如下:

image.png

從圖中可以看出當指令1還未執(zhí)行完成時合蔽,第2條指令便利用空閑的硬件開始執(zhí)行拴事,這樣做是有好處的圣蝎,如果每個步驟花費1ms徘公,那么如果第2條指令需要等待第1條指令執(zhí)行完成后再執(zhí)行的話关面,則需要等待5ms等太,但如果使用流水線技術的話缩抡,指令2只需等待1ms就可以開始執(zhí)行了瞻想,這樣就能大大提升CPU的執(zhí)行性能内边。雖然流水線技術可以大大提升CPU的性能漠其,但不幸的是一旦出現(xiàn)流水中斷和屎,所有硬件設備將會進入一輪停頓期柴信,當再次彌補中斷點可能需要幾個周期宽气,這樣性能損失也會很大萄涯,就好比工廠組裝手機的流水線涝影,一旦某個零件組裝中斷,那么該零件往后的工人都有可能進入一輪或者幾輪等待組裝零件的過程臂痕。因此我們需要盡量阻止指令中斷的情況握童,指令重排就是其中一種優(yōu)化中斷的手段舆瘪,我們通過一個例子來闡明指令重排是如何阻止流水線技術中斷的

a = b + c ;
d = e + f ;

下面通過匯編指令展示了上述代碼在CPU執(zhí)行的處理過程

image.png
  • LW指令 表示 load淀衣,其中LW R1,b表示把b的值加載到寄存器R1中
  • LW R2,c 表示把c的值加載到寄存器R2中
  • ADD 指令表示加法,把R1 蛮浑、R2的值相加沮稚,并存入R3寄存器中蕴掏。
  • SW 表示 store 即將 R3寄存器的值保持到變量a中
  • LW R4,e 表示把e的值加載到寄存器R4中
  • LW R5,f 表示把f的值加載到寄存器R5中
  • SUB 指令表示減法盛杰,把R4 即供、R5的值相減逗嫡,并存入R6寄存器中驱证。
  • SW d,R6 表示將R6寄存器的值保持到變量d中

上述便是匯編指令的執(zhí)行過程雷滚,在某些指令上存在X的標志祈远,X代表中斷的含義车份,也就是只要有X的地方就會導致指令流水線技術停頓扫沼,同時也會影響后續(xù)指令的執(zhí)行缎除,可能需要經過1個或幾個指令周期才可能恢復正常器罐,那為什么停頓呢轰坊?這是因為部分數(shù)據(jù)還沒準備好肴沫,如執(zhí)行ADD指令時颤芬,需要使用到前面指令的數(shù)據(jù)R1站蝠,R2沉衣,而此時R2的MEM操作沒有完成存谎,即未拷貝到存儲器中既荚,這樣加法計算就無法進行恰聘,必須等到MEM操作完成后才能執(zhí)行,也就因此而停頓了凿宾,其他指令也是類似的情況。前面闡述過产禾,停頓會造成CPU性能下降亚情,因此我們應該想辦法消除這些停頓楞件,這時就需要使用到指令重排了,如下圖盹愚,既然ADD指令需要等待站故,那我們就利用等待的時間做些別的事情皆怕,如把LW R4,eLW R5,f 移動到前面執(zhí)行,畢竟LW R4,eLW R5,f執(zhí)行并沒有數(shù)據(jù)依賴關系西篓,對他們有數(shù)據(jù)依賴關系的SUB R6,R5,R4指令在R4,R5加載完成后才執(zhí)行的愈腾,沒有影響,過程如下:

image.png

正如上圖所示岂津,所有的停頓都完美消除了虱黄,指令流水線也無需中斷了吮成,這樣CPU的性能也能帶來很好的提升橱乱,這就是處理器指令重排的作用。關于編譯器重排以及指令重排(這兩種重排我們后面統(tǒng)一稱為指令重排)相關內容已闡述清晰了粱甫,我們必須意識到對于單線程而已指令重排幾乎不會帶來任何影響泳叠,比竟重排的前提是保證串行語義執(zhí)行的一致性,但對于多線程環(huán)境而已茶宵,指令重排就可能導致嚴重的程序輪序執(zhí)行問題危纫,如下

class MixedOrder{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1;
        flag = true;
    }

    public void read(){
        if(flag){
            int i = a + 1;
        }
    }
}

如上述代碼,同時存在線程A和線程B對該實例對象進行操作种蝶,其中A線程調用寫入方法契耿,而B線程調用讀取方法,由于指令重排等原因蛤吓,可能導致程序執(zhí)行順序變?yōu)槿缦拢?/p>

 線程A                    線程B
 writer:                 read:
 1:flag = true;           1:flag = true;
 2:a = 1;                 2: a = 0 ; //誤讀
                          3: i = 1 ;

由于指令重排的原因宵喂,線程A的flag置為true被提前執(zhí)行了,而a賦值為1的程序還未執(zhí)行完会傲,此時線程B锅棕,恰好讀取flag的值為true,直接獲取a的值(此時B線程并不知道a為0)并執(zhí)行i賦值操作淌山,結果i的值為1裸燎,而不是預期的2,這就是多線程環(huán)境下泼疑,指令重排導致的程序亂序執(zhí)行的結果德绿。因此,請記住退渗,指令重排只會保證單線程中串行語義的執(zhí)行的一致性移稳,但并不會關心多線程間的語義一致性。

可見性

理解了指令重排現(xiàn)象后会油,可見性容易了个粱,可見性指的是當一個線程修改了某個共享變量的值,其他線程是否能夠馬上得知這個修改的值翻翩。對于串行程序來說都许,可見性是不存在的,因為我們在任何一個操作中修改了某個變量的值嫂冻,后續(xù)的操作中都能讀取這個變量值胶征,并且是修改過的新值。但在多線程環(huán)境中可就不一定了桨仿,前面我們分析過睛低,由于線程對共享變量的操作都是線程拷貝到各自的工作內存進行操作后才寫回到主內存中的,這就可能存在一個線程A修改了共享變量x的值服傍,還未寫回主內存時暇昂,另外一個線程B又對主內存中同一個共享變量x進行操作,但此時A線程工作內存中共享變量x對線程B來說并不可見伴嗡,這種工作內存與主內存同步延遲現(xiàn)象就造成了可見性問題急波,另外指令重排以及編譯器優(yōu)化也可能導致可見性問題,通過前面的分析瘪校,我們知道無論是編譯器優(yōu)化還是處理器優(yōu)化的重排現(xiàn)象澄暮,在多線程環(huán)境下名段,確實會導致程序輪序執(zhí)行的問題,從而也就導致可見性問題泣懊。

有序性

有序性是指對于單線程的執(zhí)行代碼伸辟,我們總是認為代碼的執(zhí)行是按順序依次執(zhí)行的,這樣的理解并沒有毛病馍刮,畢竟對于單線程而言確實如此信夫,但對于多線程環(huán)境,則可能出現(xiàn)亂序現(xiàn)象卡啰,因為程序編譯成機器碼指令后可能會出現(xiàn)指令重排現(xiàn)象静稻,重排后的指令與原指令的順序未必一致,要明白的是匈辱,在Java程序中振湾,倘若在本線程內,所有操作都視為有序行為亡脸,如果是多線程環(huán)境下押搪,一個線程中觀察另外一個線程,所有操作都是無序的浅碾,前半句指的是單線程內保證串行語義執(zhí)行的一致性大州,后半句則指指令重排現(xiàn)象和工作內存與主內存同步延遲現(xiàn)象。

JMM提供的解決方案

在理解了原子性垂谢,可見性以及有序性問題后厦画,看看JMM是如何保證的,在Java內存模型中都提供一套解決方案供Java工程師在開發(fā)過程使用埂陆,如原子性問題苛白,除了JVM自身提供的對基本數(shù)據(jù)類型讀寫操作的原子性外娃豹,對于方法級別或者代碼塊級別的原子性操作焚虱,可以使用synchronized關鍵字或者重入鎖(ReentrantLock)保證程序執(zhí)行的原子性,關于synchronized的詳解懂版,看博主另外一篇文章( 深入理解Java并發(fā)之synchronized實現(xiàn)原理)鹃栽。而工作內存與主內存同步延遲現(xiàn)象導致的可見性問題,可以使用synchronized關鍵字或者volatile關鍵字解決躯畴,它們都可以使一個線程修改后的變量立即對其他線程可見民鼓。對于指令重排導致的可見性問題和有序性問題,則可以利用volatile關鍵字解決蓬抄,因為volatile的另外一個作用就是禁止重排序優(yōu)化丰嘉,關于volatile稍后會進一步分析。除了靠sychronized和volatile關鍵字來保證原子性嚷缭、可見性以及有序性外饮亏,JMM內部還定義一套happens-before 原則來保證多線程環(huán)境下兩個操作間的原子性耍贾、可見性以及有序性。

理解JMM中的happens-before 原則

倘若在程序開發(fā)中路幸,僅靠sychronized和volatile關鍵字來保證原子性荐开、可見性以及有序性,那么編寫并發(fā)程序可能會顯得十分麻煩简肴,幸運的是晃听,在Java內存模型中,還提供了happens-before 原則來輔助保證程序執(zhí)行的原子性砰识、可見性以及有序性的問題能扒,它是判斷數(shù)據(jù)是否存在競爭、線程是否安全的依據(jù)仍翰,happens-before 原則內容如下

  • 程序順序原則赫粥,即在一個線程內必須保證語義串行性,也就是說按照代碼順序執(zhí)行予借。

  • 鎖規(guī)則 解鎖(unlock)操作必然發(fā)生在后續(xù)的同一個鎖的加鎖(lock)之前越平,也就是說,如果對于一個鎖解鎖后灵迫,再加鎖秦叛,那么加鎖的動作必須在解鎖動作之后(同一個鎖)。

  • volatile規(guī)則 volatile變量的寫瀑粥,先發(fā)生于讀挣跋,這保證了volatile變量的可見性,簡單的理解就是狞换,volatile變量在每次被線程訪問時避咆,都強迫從主內存中讀該變量的值,而當該變量發(fā)生變化時修噪,又會強迫將最新的值刷新到主內存查库,任何時刻,不同的線程總是能夠看到該變量的最新值黄琼。

  • 線程啟動規(guī)則 線程的start()方法先于它的每一個動作樊销,即如果線程A在執(zhí)行線程B的start方法之前修改了共享變量的值,那么當線程B執(zhí)行start方法時脏款,線程A對共享變量的修改對線程B可見

  • 傳遞性 A先于B 围苫,B先于C 那么A必然先于C

  • 線程終止規(guī)則 線程的所有操作先于線程的終結宿刮,Thread.join()方法的作用是等待當前執(zhí)行的線程終止仓手。假設在線程B終止之前嫂粟,修改了共享變量僻焚,線程A從線程B的join方法成功返回后之景,線程B對共享變量的修改將對線程A可見逆屡。

  • 線程中斷規(guī)則 對線程 interrupt()方法的調用先行發(fā)生于被中斷線程的代碼檢測到中斷事件的發(fā)生植兰,可以通過Thread.interrupted()方法檢測線程是否中斷焚挠。

  • 對象終結規(guī)則 對象的構造函數(shù)執(zhí)行,結束先于finalize()方法

上述8條原則無需手動添加任何同步手段(synchronized|volatile)即可達到效果湾笛,下面我們結合前面的案例演示這8條原則如何判斷線程是否安全饮怯,如下:

class MixedOrder{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1;
        flag = true;
    }

    public void read(){
        if(flag){
            int i = a + 1;
        }
    }
}

同樣的道理嚎研,存在兩條線程A和B蓖墅,線程A調用實例對象的writer()方法,而線程B調用實例對象的read()方法临扮,線程A先啟動而線程B后啟動论矾,那么線程B讀取到的i值是多少呢?現(xiàn)在依據(jù)8條原則杆勇,由于存在兩條線程同時調用贪壳,因此程序次序原則不合適。writer()方法和read()方法都沒有使用同步手段蚜退,鎖規(guī)則也不合適闰靴。沒有使用volatile關鍵字,volatile變量原則不適應钻注。線程啟動規(guī)則蚂且、線程終止規(guī)則、線程中斷規(guī)則幅恋、對象終結規(guī)則杏死、傳遞性和本次測試案例也不合適。線程A和線程B的啟動時間雖然有先后捆交,但線程B執(zhí)行結果卻是不確定淑翼,也是說上述代碼沒有適合8條原則中的任意一條,也沒有使用任何同步手段品追,所以上述的操作是線程不安全的玄括,因此線程B讀取的值自然也是不確定的。修復這個問題的方式很簡單诵盼,要么給writer()方法和read()方法添加同步手段惠豺,如synchronized或者給變量flag添加volatile關鍵字银还,確保線程A修改的值對線程B總是可見风宁。

volatile內存語義

volatile在并發(fā)編程中很常見,但也容易被濫用蛹疯,現(xiàn)在我們就進一步分析volatile關鍵字的語義戒财。volatile是Java虛擬機提供的輕量級的同步機制。volatile關鍵字有如下兩個作用

  • 保證被volatile修飾的共享gong’x變量對所有線程總數(shù)可見的捺弦,也就是當一個線程修改了一個被volatile修飾共享變量的值饮寞,新值總數(shù)可以被其他線程立即得知孝扛。

  • 禁止指令重排序優(yōu)化。

volatile的可見性

關于volatile的可見性作用幽崩,我們必須意識到被volatile修飾的變量對所有線程總數(shù)立即可見的苦始,對volatile變量的所有寫操作總是能立刻反應到其他線程中,但是對于volatile變量運算操作在多線程環(huán)境并不保證安全性慌申,如下

public class VolatileVisibility {
    public static volatile int i =0;

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

正如上述代碼所示陌选,i變量的任何改變都會立馬反應到其他線程中,但是如此存在多條線程同時調用increase()方法的話蹄溉,就會出現(xiàn)線程安全問題咨油,畢竟i++;操作并不具備原子性,該操作是先讀取值柒爵,然后寫回一個新值役电,相當于原來的值加上1,分兩步完成棉胀,如果第二個線程在第一個線程讀取舊值和寫回新值期間讀取i的域值法瑟,那么第二個線程就會與第一個線程一起看到同一個值,并執(zhí)行相同值的加1操作唁奢,這也就造成了線程安全失敗瓢谢,因此對于increase方法必須使用synchronized修飾,以便保證線程安全驮瞧,需要注意的是一旦使用synchronized修飾方法后氓扛,由于synchronized本身也具備與volatile相同的特性,即可見性论笔,因此在這樣種情況下就完全可以省去volatile修飾變量采郎。

public class VolatileVisibility {
    public static int i =0;

    public synchronized static void increase(){
        i++;
    }
}

現(xiàn)在來看另外一種場景,可以使用volatile修飾變量達到線程安全的目的狂魔,如下

public class VolatileSafe {

    volatile boolean close;

    public void close(){
        close=true;
    }

    public void doWork(){
        while (!close){
            System.out.println("safe....");
        }
    }
}

由于對于boolean變量close值的修改屬于原子性操作蒜埋,因此可以通過使用volatile修飾變量close,使用該變量對其他線程立即可見最楷,從而達到線程安全的目的整份。那么JMM是如何實現(xiàn)讓volatile變量對其他線程立即可見的呢?實際上籽孙,當寫一個volatile變量時烈评,JMM會把該線程對應的工作內存中的共享變量值刷新到主內存中,當讀取一個volatile變量時犯建,JMM會把該線程對應的工作內存置為無效讲冠,那么該線程將只能從主內存中重新讀取共享變量。volatile變量正是通過這種寫-讀方式實現(xiàn)對其他線程可見(但其內存語義實現(xiàn)則是通過內存屏障适瓦,稍后會說明)竿开。

volatile禁止重排優(yōu)化

volatile關鍵字另一個作用就是禁止指令重排優(yōu)化谱仪,從而避免多線程環(huán)境下程序出現(xiàn)亂序執(zhí)行的現(xiàn)象,關于指令重排優(yōu)化前面已詳細分析過否彩,這里主要簡單說明一下volatile是如何實現(xiàn)禁止指令重排優(yōu)化的疯攒。先了解一個概念,內存屏障(Memory Barrier)列荔。
內存屏障卸例,又稱內存柵欄,是一個CPU指令肌毅,它的作用有兩個筷转,一是保證特定操作的執(zhí)行順序,二是保證某些變量的內存可見性(利用該特性實現(xiàn)volatile的內存可見性)悬而。由于編譯器和處理器都能執(zhí)行指令重排優(yōu)化呜舒。如果在指令間插入一條Memory Barrier則會告訴編譯器和CPU,不管什么指令都不能和這條Memory Barrier指令重排序笨奠,也就是說通過插入內存屏障禁止在內存屏障前后的指令執(zhí)行重排序優(yōu)化袭蝗。Memory Barrier的另外一個作用是強制刷出各種CPU的緩存數(shù)據(jù),因此任何CPU上的線程都能讀取到這些數(shù)據(jù)的最新版本般婆〉叫龋總之,volatile變量正是通過內存屏障實現(xiàn)其在內存中的語義蔚袍,即可見性和禁止重排優(yōu)化乡范。下面看一個非常典型的禁止重排優(yōu)化的例子DCL,如下:

/**
 * Created by zejian on 2017/6/11.
 * Blog : http://blog.csdn.net/javazejian [原文地址,請尊重原創(chuàng)]
 */
public class DoubleCheckLock {

    private static DoubleCheckLock instance;

    private DoubleCheckLock(){}

    public static DoubleCheckLock getInstance(){

        //第一次檢測
        if (instance==null){
            //同步
            synchronized (DoubleCheckLock.class){
                if (instance == null){
                    //多線程環(huán)境下可能會出現(xiàn)問題的地方
                    instance = new DoubleCheckLock();
                }
            }
        }
        return instance;
    }
}

上述代碼一個經典的單例的雙重檢測的代碼啤咽,這段代碼在單線程環(huán)境下并沒有什么問題晋辆,但如果在多線程環(huán)境下就可以出現(xiàn)線程安全問題。原因在于某一個線程執(zhí)行到第一次檢測宇整,讀取到的instance不為null時瓶佳,instance的引用對象可能沒有完成初始化。因為instance = new DoubleCheckLock();可以分為以下3步完成(偽代碼)

memory = allocate(); //1.分配對象內存空間
instance(memory);    //2.初始化對象
instance = memory;   //3.設置instance指向剛分配的內存地址鳞青,此時instance霸饲!=null

由于步驟1和步驟2間可能會重排序,如下:

memory = allocate(); //1.分配對象內存空間
instance = memory;   //3.設置instance指向剛分配的內存地址臂拓,此時instance厚脉!=null,但是對象還沒有初始化完成埃儿!
instance(memory);    //2.初始化對象

由于步驟2和步驟3不存在數(shù)據(jù)依賴關系器仗,而且無論重排前還是重排后程序的執(zhí)行結果在單線程中并沒有改變融涣,因此這種重排優(yōu)化是允許的童番。但是指令重排只會保證串行語義的執(zhí)行的一致性(單線程)精钮,但并不會關心多線程間的語義一致性。所以當一條線程訪問instance不為null時剃斧,由于instance實例未必已初始化完成轨香,也就造成了線程安全問題。那么該如何解決呢幼东,很簡單臂容,我們使用volatile禁止instance變量被執(zhí)行指令重排優(yōu)化即可。

  //禁止指令重排優(yōu)化
  private volatile static DoubleCheckLock instance;

ok~根蟹,到此相信我們對Java內存模型和volatile應該都有了比較全面的認識脓杉,總而言之,我們應該清楚知道简逮,JMM就是一組規(guī)則球散,這組規(guī)則意在解決在并發(fā)編程可能出現(xiàn)的線程安全問題,并提供了內置解決方案(happen-before原則)及其外部可使用的同步手段(synchronized/volatile等)散庶,確保了程序執(zhí)行在多線程環(huán)境中的應有的原子性蕉堰,可視性及其有序性。

如有錯誤悲龟,歡迎留言屋讶,謝謝!

參考資料:
http://tutorials.jenkov.com/java-concurrency/java-memory-model.html
http://blog.csdn.net/iter_zc/article/details/41843595
http://ifeve.com/wp-content/uploads/2014/03/JSR133%E4%B8%AD%E6%96%87%E7%89%881.pdf

《深入理解JVM虛擬機》
《Java高并發(fā)程序設計》

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末须教,一起剝皮案震驚了整個濱河市皿渗,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌轻腺,老刑警劉巖羹奉,帶你破解...
    沈念sama閱讀 206,214評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異约计,居然都是意外死亡诀拭,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,307評論 2 382
  • 文/潘曉璐 我一進店門煤蚌,熙熙樓的掌柜王于貴愁眉苦臉地迎上來耕挨,“玉大人,你說我怎么就攤上這事尉桩⊥舱迹” “怎么了?”我有些...
    開封第一講書人閱讀 152,543評論 0 341
  • 文/不壞的土叔 我叫張陵蜘犁,是天一觀的道長翰苫。 經常有香客問我,道長,這世上最難降的妖魔是什么奏窑? 我笑而不...
    開封第一講書人閱讀 55,221評論 1 279
  • 正文 為了忘掉前任导披,我火速辦了婚禮,結果婚禮上埃唯,老公的妹妹穿的比我還像新娘撩匕。我一直安慰自己,他們只是感情好墨叛,可當我...
    茶點故事閱讀 64,224評論 5 371
  • 文/花漫 我一把揭開白布止毕。 她就那樣靜靜地躺著,像睡著了一般漠趁。 火紅的嫁衣襯著肌膚如雪扁凛。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,007評論 1 284
  • 那天闯传,我揣著相機與錄音令漂,去河邊找鬼。 笑死丸边,一個胖子當著我的面吹牛叠必,可吹牛的內容都是我干的。 我是一名探鬼主播妹窖,決...
    沈念sama閱讀 38,313評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼纬朝,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了骄呼?” 一聲冷哼從身側響起共苛,我...
    開封第一講書人閱讀 36,956評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎蜓萄,沒想到半個月后隅茎,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經...
    沈念sama閱讀 43,441評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡嫉沽,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 35,925評論 2 323
  • 正文 我和宋清朗相戀三年辟犀,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片绸硕。...
    茶點故事閱讀 38,018評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡堂竟,死狀恐怖,靈堂內的尸體忽然破棺而出玻佩,到底是詐尸還是另有隱情出嘹,我是刑警寧澤,帶...
    沈念sama閱讀 33,685評論 4 322
  • 正文 年R本政府宣布咬崔,位于F島的核電站税稼,受9級特大地震影響烦秩,放射性物質發(fā)生泄漏。R本人自食惡果不足惜郎仆,卻給世界環(huán)境...
    茶點故事閱讀 39,234評論 3 307
  • 文/蒙蒙 一只祠、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧丸升,春花似錦铆农、人聲如沸牺氨。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,240評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽猴凹。三九已至夷狰,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間郊霎,已是汗流浹背沼头。 一陣腳步聲響...
    開封第一講書人閱讀 31,464評論 1 261
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留书劝,地道東北人进倍。 一個月前我還...
    沈念sama閱讀 45,467評論 2 352
  • 正文 我出身青樓,卻偏偏與公主長得像购对,于是被迫代替她去往敵國和親猾昆。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 42,762評論 2 345