全面理解Java內(nèi)存模型(JMM)及volatile關鍵字

Java內(nèi)存區(qū)域


Java虛擬機在運行程序時會把其自動管理的內(nèi)存劃分為以上幾個區(qū)域寺董,每個區(qū)域都有的用途以及創(chuàng)建銷毀的時機,其中藍色部分代表的是所有線程共享的數(shù)據(jù)區(qū)域猛遍,而綠色部分代表的是每個線程的私有數(shù)據(jù)區(qū)域。

方法區(qū)(Method Area):

方法區(qū)屬于線程共享的內(nèi)存區(qū)域号坡,又稱Non-Heap(非堆)懊烤,主要用于存儲已被虛擬機加載的類信息、常量宽堆、靜態(tài)變量腌紧、即時編譯器編譯后的代碼等數(shù)據(jù),根據(jù)Java 虛擬機規(guī)范的規(guī)定畜隶,當方法區(qū)無法滿足內(nèi)存分配需求時壁肋,將拋出OutOfMemoryError 異常号胚。值得注意的是在方法區(qū)中存在一個叫運行時常量池(Runtime Constant Pool)的區(qū)域,它主要用于存放編譯器生成的各種字面量和符號引用浸遗,這些內(nèi)容將在類加載后存放到運行時常量池中猫胁,以便后續(xù)使用。

JVM堆(Java Heap):

Java 堆也是屬于線程共享的內(nèi)存區(qū)域跛锌,它在虛擬機啟動時創(chuàng)建弃秆,是Java 虛擬機所管理的內(nèi)存中最大的一塊,主要用于存放對象實例髓帽,幾乎所有的對象實例都在這里分配內(nèi)存菠赚,注意Java 堆是垃圾收集器管理的主要區(qū)域,因此很多時候也被稱做GC 堆郑藏,如果在堆中沒有內(nèi)存完成實例分配衡查,并且堆也無法再擴展時,將會拋出OutOfMemoryError 異常译秦。

程序計數(shù)器(Program Counter Register):

屬于線程私有的數(shù)據(jù)區(qū)域峡捡,是一小塊內(nèi)存空間,主要代表當前線程所執(zhí)行的字節(jié)碼行號指示器筑悴。字節(jié)碼解釋器工作時,通過改變這個計數(shù)器的值來選取下一條需要執(zhí)行的字節(jié)碼指令稍途,分支阁吝、循環(huán)、跳轉(zhuǎn)械拍、異常處理突勇、線程恢復等基礎功能都需要依賴這個計數(shù)器來完成。

虛擬機棧(Java Virtual Machine Stacks):

屬于線程私有的數(shù)據(jù)區(qū)域坷虑,與線程同時創(chuàng)建甲馋,總數(shù)與線程關聯(lián),代表Java方法執(zhí)行的內(nèi)存模型迄损。每個方法執(zhí)行時都會創(chuàng)建一個棧楨來存儲方法的的變量表定躏、操作數(shù)棧、動態(tài)鏈接方法芹敌、返回值痊远、返回地址等信息。每個方法從調(diào)用直結(jié)束就對于一個棧楨在虛擬機棧中的入棧和出棧過程氏捞,如下(圖有誤碧聪,應該為棧楨):


本地方法棧(Native Method Stacks):

本地方法棧屬于線程私有的數(shù)據(jù)區(qū)域,這部分主要與虛擬機用到的 Native 方法相關液茎,一般情況下逞姿,我們無需關心此區(qū)域辞嗡。

這里之所以簡要說明這部分內(nèi)容,注意是為了區(qū)別Java內(nèi)存模型與Java內(nèi)存區(qū)域的劃分滞造,畢竟這兩種劃分是屬于不同層次的概念续室。

Java內(nèi)存模型概述

Java內(nèi)存模型(即Java Memory Model,簡稱JMM)本身是一種抽象的概念断部,并不真實存在猎贴,它描述的是一組規(guī)則或規(guī)范,通過這組規(guī)范定義了程序中各個變量(包括實例字段蝴光,靜態(tài)字段和構(gòu)成數(shù)組對象的元素)的訪問方式她渴。由于JVM運行程序的實體是線程,而每個線程創(chuàng)建時JVM都會為其創(chuàng)建一個工作內(nèi)存(有些地方稱為椕锼睿空間)趁耗,用于存儲線程私有的數(shù)據(jù),而Java內(nèi)存模型中規(guī)定所有變量都存儲在主內(nèi)存疆虚,主內(nèi)存是共享內(nèi)存區(qū)域苛败,所有線程都可以訪問,但線程對變量的操作(讀取賦值等)必須在工作內(nèi)存中進行径簿,首先要將變量從主內(nèi)存拷貝的自己的工作內(nèi)存空間罢屈,然后對變量進行操作,操作完成后再將變量寫回主內(nèi)存篇亭,不能直接操作主內(nèi)存中的變量缠捌,工作內(nèi)存中存儲著主內(nèi)存中的變量副本拷貝,前面說過译蒂,工作內(nèi)存是每個線程的私有數(shù)據(jù)區(qū)域曼月,因此不同的線程間無法訪問對方的工作內(nèi)存,線程間的通信(傳值)必須通過主內(nèi)存來完成柔昼,其簡要訪問過程如下圖


需要注意的是哑芹,JMM與Java內(nèi)存區(qū)域的劃分是不同的概念層次,更恰當說JMM描述的是一組規(guī)則捕透,通過這組規(guī)則控制程序中各個變量在共享數(shù)據(jù)區(qū)域和私有數(shù)據(jù)區(qū)域的訪問方式聪姿,JMM是圍繞原子性,有序性激率、可見性展開的(稍后會分析)咳燕。JMM與Java內(nèi)存區(qū)域唯一相似點,都存在共享數(shù)據(jù)區(qū)域和私有數(shù)據(jù)區(qū)域乒躺,在JMM中主內(nèi)存屬于共享數(shù)據(jù)區(qū)域招盲,從某個程度上講應該包括了堆和方法區(qū),而工作內(nèi)存數(shù)據(jù)線程私有數(shù)據(jù)區(qū)域嘉冒,從某個程度上講則應該包括程序計數(shù)器曹货、虛擬機棧以及本地方法棧咆繁。或許在某些地方顶籽,我們可能會看見主內(nèi)存被描述為堆內(nèi)存玩般,工作內(nèi)存被稱為線程棧,實際上他們表達的都是同一個含義礼饱。關于JMM中的主內(nèi)存和工作內(nèi)存說明如下

主內(nèi)存

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

工作內(nèi)存

主要存儲當前方法的所有本地變量信息(工作內(nèi)存中存儲著主內(nèi)存中的變量副本拷貝)榄鉴,每個線程只能訪問自己的工作內(nèi)存履磨,即線程中的本地變量對其它線程是不可見的,就算是兩個線程執(zhí)行的是同一段代碼庆尘,它們也會各自在自己的工作內(nèi)存中創(chuàng)建屬于當前線程的本地變量剃诅,當然也包括了字節(jié)碼行號指示器、相關Native方法的信息驶忌。注意由于工作內(nèi)存是每個線程的私有數(shù)據(jù)综苔,線程間無法相互訪問工作內(nèi)存,因此存儲在工作內(nèi)存的數(shù)據(jù)不存在線程安全問題位岔。

弄清楚主內(nèi)存和工作內(nèi)存后,接了解一下主內(nèi)存與工作內(nèi)存的數(shù)據(jù)存儲類型以及操作方式堡牡,根據(jù)虛擬機規(guī)范抒抬,對于一個實例對象中的成員方法而言,如果方法中包含本地變量是基本數(shù)據(jù)類型(boolean,byte,short,char,int,long,float,double)晤柄,將直接存儲在工作內(nèi)存的幀棧結(jié)構(gòu)中擦剑,但倘若本地變量是引用類型,那么該變量的引用會存儲在功能內(nèi)存的幀棧中芥颈,而對象實例將存儲在主內(nèi)存(共享數(shù)據(jù)區(qū)域惠勒,堆)中。但對于實例對象的成員變量爬坑,不管它是基本數(shù)據(jù)類型或者包裝類型(Integer纠屋、Double等)還是引用類型,都會被存儲到堆區(qū)盾计。至于static變量以及類本身相關信息將會存儲在主內(nèi)存中售担。需要注意的是赁遗,在主內(nèi)存中的實例對象可以被多線程共享,倘若兩個線程同時調(diào)用了同一個對象的同一個方法族铆,那么兩條線程會將要操作的數(shù)據(jù)拷貝一份到自己的工作內(nèi)存中岩四,執(zhí)行完成操作后才刷新到主內(nèi)存,簡單示意圖如下所示:


硬件內(nèi)存架構(gòu)與Java內(nèi)存模型

硬件內(nèi)存架構(gòu)


正如上圖所示哥攘,經(jīng)過簡化CPU與內(nèi)存操作的簡易圖剖煌,實際上沒有這么簡單,這里為了理解方便逝淹,我們省去了南北橋并將三級緩存統(tǒng)一為CPU緩存(有些CPU只有二級緩存耕姊,有些CPU有三級緩存)。就目前計算機而言创橄,一般擁有多個CPU并且每個CPU可能存在多個核心箩做,多核是指在一枚處理器(CPU)中集成兩個或多個完整的計算引擎(內(nèi)核),這樣就可以支持多任務并行執(zhí)行,從多線程的調(diào)度來說妥畏,每個線程都會映射到各個CPU核心中并行運行邦邦。在CPU內(nèi)部有一組CPU寄存器,寄存器是cpu直接訪問和處理的數(shù)據(jù)醉蚁,是一個臨時放數(shù)據(jù)的空間燃辖。一般CPU都會從內(nèi)存取數(shù)據(jù)到寄存器,然后進行處理网棍,但由于內(nèi)存的處理速度遠遠低于CPU黔龟,導致CPU在處理指令時往往花費很多時間在等待內(nèi)存做準備工作,于是在寄存器和主內(nèi)存間添加了CPU緩存滥玷,CPU緩存比較小氏身,但訪問速度比主內(nèi)存快得多,如果CPU總是操作主內(nèi)存中的同一址地的數(shù)據(jù)惑畴,很容易影響CPU執(zhí)行速度蛋欣,此時CPU緩存就可以把從內(nèi)存提取的數(shù)據(jù)暫時保存起來,如果寄存器要取內(nèi)存中同一位置的數(shù)據(jù)如贷,直接從緩存中提取陷虎,無需直接從主內(nèi)存取。需要注意的是杠袱,寄存器并不每次數(shù)據(jù)都可以從緩存中取得數(shù)據(jù)尚猿,萬一不是同一個內(nèi)存地址中的數(shù)據(jù),那寄存器還必須直接繞過緩存從內(nèi)存中取數(shù)據(jù)楣富。所以并不每次都得到緩存中取數(shù)據(jù)凿掂,這種現(xiàn)象有個專業(yè)的名稱叫做緩存的命中率,從緩存中取就命中菩彬,不從緩存中取從內(nèi)存中取缠劝,就沒命中潮梯,可見緩存命中率的高低也會影響CPU執(zhí)行性能,這就是CPU惨恭、緩存以及主內(nèi)存間的簡要交互過程秉馏,總而言之當一個CPU需要訪問主存時,會先讀取一部分主存數(shù)據(jù)到CPU緩存(當然如果CPU緩存中存在需要的數(shù)據(jù)就會直接從緩存獲取)脱羡,進而在讀取CPU緩存到寄存器萝究,當CPU需要寫數(shù)據(jù)到主存時,同樣會先刷新寄存器中的數(shù)據(jù)到CPU緩存锉罐,然后再把數(shù)據(jù)刷新到主內(nèi)存中帆竹。

Java線程與硬件處理器

了解完硬件的內(nèi)存架構(gòu)后,接著了解JVM中線程的實現(xiàn)原理脓规,理解線程的實現(xiàn)原理栽连,有助于我們了解Java內(nèi)存模型與硬件內(nèi)存架構(gòu)的關系,在Window系統(tǒng)和Linux系統(tǒng)上侨舆,Java線程的實現(xiàn)是基于一對一的線程模型秒紧,所謂的一對一模型,實際上就是通過語言級別層面程序去間接調(diào)用系統(tǒng)內(nèi)核的線程模型挨下,即我們在使用Java線程時熔恢,Java虛擬機內(nèi)部是轉(zhuǎn)而調(diào)用當前操作系統(tǒng)的內(nèi)核線程來完成當前任務。這里需要了解一個術語臭笆,內(nèi)核線程(Kernel-Level Thread叙淌,KLT)贱傀,它是由操作系統(tǒng)內(nèi)核(Kernel)支持的線程虽抄,這種線程是由操作系統(tǒng)內(nèi)核來完成線程切換,內(nèi)核通過操作調(diào)度器進而對線程執(zhí)行調(diào)度磷瘤,并將線程的任務映射到各個處理器上茵乱。每個內(nèi)核線程可以視為內(nèi)核的一個分身,這也就是操作系統(tǒng)可以同時處理多任務的原因衅谷。由于我們編寫的多線程程序?qū)儆谡Z言層面的,程序一般不會直接去調(diào)用內(nèi)核線程似将,取而代之的是一種輕量級的進程(Light Weight Process),也是通常意義上的線程蚀苛,由于每個輕量級進程都會映射到一個內(nèi)核線程在验,因此我們可以通過輕量級進程調(diào)用內(nèi)核線程,進而由操作系統(tǒng)內(nèi)核將任務映射到各個處理器堵未,這種輕量級進程與內(nèi)核線程間1對1的關系就稱為一對一的線程模型腋舌。如下圖


如圖所示,每個線程最終都會映射到CPU中進行處理渗蟹,如果CPU存在多核块饺,那么一個CPU將可以并行執(zhí)行多個線程任務赞辩。

Java內(nèi)存模型與硬件內(nèi)存架構(gòu)的關系

通過對前面的硬件內(nèi)存架構(gòu)、Java內(nèi)存模型以及Java多線程的實現(xiàn)原理的了解授艰,我們應該已經(jīng)意識到辨嗽,多線程的執(zhí)行最終都會映射到硬件處理器上進行執(zhí)行,但Java內(nèi)存模型和硬件內(nèi)存架構(gòu)并不完全一致淮腾。對于硬件內(nèi)存來說只有寄存器糟需、緩存內(nèi)存、主內(nèi)存的概念谷朝,并沒有工作內(nèi)存(線程私有數(shù)據(jù)區(qū)域)和主內(nèi)存(堆內(nèi)存)之分洲押,也就是說Java內(nèi)存模型對內(nèi)存的劃分對硬件內(nèi)存并沒有任何影響,因為JMM只是一種抽象的概念圆凰,是一組規(guī)則杈帐,并不實際存在,不管是工作內(nèi)存的數(shù)據(jù)還是主內(nèi)存的數(shù)據(jù)专钉,對于計算機硬件來說都會存儲在計算機主內(nèi)存中挑童,當然也有可能存儲到CPU緩存或者寄存器中,因此總體上來說驶沼,Java內(nèi)存模型和計算機硬件內(nèi)存架構(gòu)是一個相互交叉的關系炮沐,是一種抽象概念劃分與真實物理硬件的交叉。(注意對于Java內(nèi)存區(qū)域劃分也是同樣的道理)


JMM存在的必要性

在明白了Java內(nèi)存區(qū)域劃分回怜、硬件內(nèi)存架構(gòu)大年、Java多線程的實現(xiàn)原理與Java內(nèi)存模型的具體關系后,接著來談談Java內(nèi)存模型存在的必要性玉雾。由于JVM運行程序的實體是線程翔试,而每個線程創(chuàng)建時JVM都會為其創(chuàng)建一個工作內(nèi)存(有些地方稱為棧空間)复旬,用于存儲線程私有的數(shù)據(jù)垦缅,線程與主內(nèi)存中的變量操作必須通過工作內(nèi)存間接完成,主要過程是將變量從主內(nèi)存拷貝的每個線程各自的工作內(nèi)存空間驹碍,然后對變量進行操作壁涎,操作完成后再將變量寫回主內(nèi)存,如果存在兩個線程同時對一個主內(nèi)存中的實例對象的變量進行操作就有可能誘發(fā)線程安全問題志秃。如下圖怔球,主內(nèi)存中存在一個共享變量x,現(xiàn)在有A和B兩條線程分別對該變量x=1進行操作浮还,A/B線程各自的工作內(nèi)存中存在共享變量副本x竟坛。假設現(xiàn)在A線程想要修改x的值為2,而B線程卻想要讀取x的值,那么B線程讀取到的值是A線程更新后的值2還是更新前的值1呢担汤?答案是涎跨,不確定,即B線程有可能讀取到A線程更新前的值1崭歧,也有可能讀取到A線程更新后的值2隅很,這是因為工作內(nèi)存是每個線程私有的數(shù)據(jù)區(qū)域,而線程A變量x時驾荣,首先是將變量從主內(nèi)存拷貝到A線程的工作內(nèi)存中外构,然后對變量進行操作,操作完成后再將變量x寫回主內(nèi)播掷,而對于B線程的也是類似的审编,這樣就有可能造成主內(nèi)存與工作內(nèi)存間數(shù)據(jù)存在一致性問題,假如A線程修改完后正在將數(shù)據(jù)寫回主內(nèi)存歧匈,而B線程此時正在讀取主內(nèi)存垒酬,即將x=1拷貝到自己的工作內(nèi)存中,這樣B線程讀取到的值就是x=1件炉,但如果A線程已將x=2寫回主內(nèi)存后勘究,B線程才開始讀取的話,那么此時B線程讀取到的就是x=2斟冕,但到底是哪種情況先發(fā)生呢口糕?這是不確定的,這也就是所謂的線程安全問題磕蛇。?


為了解決類似上述的問題景描,JVM定義了一組規(guī)則,通過這組規(guī)則來決定一個線程對共享變量的寫入何時對另一個線程可見秀撇,這組規(guī)則也稱為Java內(nèi)存模型(即JMM)超棺,JMM是圍繞著程序執(zhí)行的原子性、有序性呵燕、可見性展開的棠绘,下面我們看看這三個特性。

Java內(nèi)存模型的承諾

這里我們先來了解幾個概念再扭,即原子性氧苍?可見性?有序性泛范?最后再闡明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í)行的語句的結(jié)果)怎虫,處理器可以改變語句對應的機器指令的執(zhí)行順序

內(nèi)存系統(tǒng)的重排

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

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

編譯器重排

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

線程 1 線程 21: 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 線程 22: 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í)行的舟扎,如下:


從圖中可以看出當指令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í)行的處理過程


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í)行踢星,可能需要經(jīng)過1個或幾個指令周期才可能恢復正常澳叉,那為什么停頓呢?這是因為部分數(shù)據(jù)還沒準備好斩狱,如執(zhí)行ADD指令時耳高,需要使用到前面指令的數(shù)據(jù)R1,R2所踊,而此時R2的MEM操作沒有完成泌枪,即未拷貝到存儲器中,這樣加法計算就無法進行秕岛,必須等到MEM操作完成后才能執(zhí)行碌燕,也就因此而停頓了误证,其他指令也是類似的情況。前面闡述過修壕,停頓會造成CPU性能下降愈捅,因此我們應該想辦法消除這些停頓,這時就需要使用到指令重排了慈鸠,如下圖蓝谨,既然ADD指令需要等待,那我們就利用等待的時間做些別的事情青团,如把LW R4,e?和?LW R5,f?移動到前面執(zhí)行譬巫,畢竟LW R4,e?和?LW R5,f執(zhí)行并沒有數(shù)據(jù)依賴關系,對他們有數(shù)據(jù)依賴關系的SUB R6,R5,R4指令在R4,R5加載完成后才執(zhí)行的督笆,沒有影響芦昔,過程如下:


正如上圖所示,所有的停頓都完美消除了娃肿,指令流水線也無需中斷了咕缎,這樣CPU的性能也能帶來很好的提升,這就是處理器指令重排的作用料扰。關于編譯器重排以及指令重排(這兩種重排我們后面統(tǒng)一稱為指令重排)相關內(nèi)容已闡述清晰了凭豪,我們必須意識到對于單線程而已指令重排幾乎不會帶來任何影響,比竟重排的前提是保證串行語義執(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線程調(diào)用寫入方法说庭,而B線程調(diào)用讀取方法然磷,由于指令重排等原因,可能導致程序執(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賦值操作,結(jié)果i的值為1躲惰,而不是預期的2致份,這就是多線程環(huán)境下,指令重排導致的程序亂序執(zhí)行的結(jié)果础拨。因此氮块,請記住绍载,指令重排只會保證單線程中串行語義的執(zhí)行的一致性,但并不會關心多線程間的語義一致性滔蝉。

可見性

理解了指令重排現(xiàn)象后击儡,可見性容易了,可見性指的是當一個線程修改了某個共享變量的值蝠引,其他線程是否能夠馬上得知這個修改的值阳谍。對于串行程序來說,可見性是不存在的螃概,因為我們在任何一個操作中修改了某個變量的值边坤,后續(xù)的操作中都能讀取這個變量值,并且是修改過的新值谅年。但在多線程環(huán)境中可就不一定了,前面我們分析過肮韧,由于線程對共享變量的操作都是線程拷貝到各自的工作內(nèi)存進行操作后才寫回到主內(nèi)存中的融蹂,這就可能存在一個線程A修改了共享變量x的值,還未寫回主內(nèi)存時弄企,另外一個線程B又對主內(nèi)存中同一個共享變量x進行操作超燃,但此時A線程工作內(nèi)存中共享變量x對線程B來說并不可見,這種工作內(nèi)存與主內(nèi)存同步延遲現(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程序中应狱,倘若在本線程內(nèi),所有操作都視為有序行為眉撵,如果是多線程環(huán)境下侦香,一個線程中觀察另外一個線程落塑,所有操作都是無序的,前半句指的是單線程內(nèi)保證串行語義執(zhí)行的一致性罐韩,后半句則指指令重排現(xiàn)象和工作內(nèi)存與主內(nèi)存同步延遲現(xiàn)象憾赁。

JMM提供的解決方案

在理解了原子性,可見性以及有序性問題后散吵,看看JMM是如何保證的龙考,在Java內(nèi)存模型中都提供一套解決方案供Java工程師在開發(fā)過程使用,如原子性問題矾睦,除了JVM自身提供的對基本數(shù)據(jù)類型讀寫操作的原子性外晦款,對于方法級別或者代碼塊級別的原子性操作,可以使用synchronized關鍵字或者重入鎖(ReentrantLock)保證程序執(zhí)行的原子性枚冗,關于synchronized的詳解缓溅,看博主另外一篇文章(?深入理解Java并發(fā)之synchronized實現(xiàn)原理)。而工作內(nèi)存與主內(nèi)存同步延遲現(xiàn)象導致的可見性問題赁温,可以使用synchronized關鍵字或者volatile關鍵字解決坛怪,它們都可以使一個線程修改后的變量立即對其他線程可見。對于指令重排導致的可見性問題和有序性問題股囊,則可以利用volatile關鍵字解決袜匿,因為volatile的另外一個作用就是禁止重排序優(yōu)化,關于volatile稍后會進一步分析稚疹。除了靠sychronized和volatile關鍵字來保證原子性居灯、可見性以及有序性外,JMM內(nèi)部還定義一套happens-before 原則來保證多線程環(huán)境下兩個操作間的原子性内狗、可見性以及有序性怪嫌。

理解JMM中的happens-before 原則

倘若在程序開發(fā)中,僅靠sychronized和volatile關鍵字來保證原子性柳沙、可見性以及有序性喇勋,那么編寫并發(fā)程序可能會顯得十分麻煩,幸運的是偎行,在Java內(nèi)存模型中川背,還提供了happens-before 原則來輔助保證程序執(zhí)行的原子性、可見性以及有序性的問題蛤袒,它是判斷數(shù)據(jù)是否存在競爭熄云、線程是否安全的依據(jù),happens-before 原則內(nèi)容如下

程序順序原則妙真,即在一個線程內(nèi)必須保證語義串行性缴允,也就是說按照代碼順序執(zhí)行。

鎖規(guī)則 解鎖(unlock)操作必然發(fā)生在后續(xù)的同一個鎖的加鎖(lock)之前,也就是說练般,如果對于一個鎖解鎖后矗漾,再加鎖,那么加鎖的動作必須在解鎖動作之后(同一個鎖)薄料。

volatile規(guī)則 volatile變量的寫敞贡,先發(fā)生于讀,這保證了volatile變量的可見性摄职,簡單的理解就是誊役,volatile變量在每次被線程訪問時,都強迫從主內(nèi)存中讀該變量的值谷市,而當該變量發(fā)生變化時蛔垢,又會強迫將最新的值刷新到主內(nèi)存,任何時刻迫悠,不同的線程總是能夠看到該變量的最新值鹏漆。

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

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

線程終止規(guī)則 線程的所有操作先于線程的終結(jié)验烧,Thread.join()方法的作用是等待當前執(zhí)行的線程終止。假設在線程B終止之前又跛,修改了共享變量碍拆,線程A從線程B的join方法成功返回后,線程B對共享變量的修改將對線程A可見慨蓝。

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

對象終結(jié)規(guī)則 對象的構(gòu)造函數(shù)執(zhí)行礼烈,結(jié)束先于finalize()方法

上述8條原則無需手動添加任何同步手段(synchronized|volatile)即可達到效果弧满,下面我們結(jié)合前面的案例演示這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調(diào)用實例對象的writer()方法犀忱,而線程B調(diào)用實例對象的read()方法募谎,線程A先啟動而線程B后啟動,那么線程B讀取到的i值是多少呢阴汇?現(xiàn)在依據(jù)8條原則数冬,由于存在兩條線程同時調(diào)用,因此程序次序原則不合適搀庶。writer()方法和read()方法都沒有使用同步手段拐纱,鎖規(guī)則也不合適铜异。沒有使用volatile關鍵字,volatile變量原則不適應秸架。線程啟動規(guī)則揍庄、線程終止規(guī)則、線程中斷規(guī)則咕宿、對象終結(jié)規(guī)則币绩、傳遞性和本次測試案例也不合適。線程A和線程B的啟動時間雖然有先后府阀,但線程B執(zhí)行結(jié)果卻是不確定缆镣,也是說上述代碼沒有適合8條原則中的任意一條,也沒有使用任何同步手段试浙,所以上述的操作是線程不安全的董瞻,因此線程B讀取的值自然也是不確定的。修復這個問題的方式很簡單田巴,要么給writer()方法和read()方法添加同步手段钠糊,如synchronized或者給變量flag添加volatile關鍵字,確保線程A修改的值對線程B總是可見壹哺。

volatile內(nèi)存語義

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變量的任何改變都會立馬反應到其他線程中,但是如此存在多條線程同時調(diào)用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會把該線程對應的工作內(nèi)存中的共享變量值刷新到主內(nèi)存中璃饱,當讀取一個volatile變量時,JMM會把該線程對應的工作內(nèi)存置為無效肪康,那么該線程將只能從主內(nèi)存中重新讀取共享變量知残。volatile變量正是通過這種寫-讀方式實現(xiàn)對其他線程可見(但其內(nèi)存語義實現(xiàn)則是通過內(nèi)存屏障吆鹤,稍后會說明)钉答。

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

volatile關鍵字另一個作用就是禁止指令重排優(yōu)化偏形,從而避免多線程環(huán)境下程序出現(xiàn)亂序執(zhí)行的現(xiàn)象宾肺,關于指令重排優(yōu)化前面已詳細分析過铺董,這里主要簡單說明一下volatile是如何實現(xiàn)禁止指令重排優(yōu)化的遣铝。先了解一個概念爬橡,內(nèi)存屏障(Memory Barrier)冻河。?

內(nèi)存屏障箍邮,又稱內(nèi)存柵欄,是一個CPU指令叨叙,它的作用有兩個锭弊,一是保證特定操作的執(zhí)行順序,二是保證某些變量的內(nèi)存可見性(利用該特性實現(xiàn)volatile的內(nèi)存可見性)擂错。由于編譯器和處理器都能執(zhí)行指令重排優(yōu)化味滞。如果在指令間插入一條Memory Barrier則會告訴編譯器和CPU,不管什么指令都不能和這條Memory Barrier指令重排序,也就是說通過插入內(nèi)存屏障禁止在內(nèi)存屏障前后的指令執(zhí)行重排序優(yōu)化剑鞍。Memory Barrier的另外一個作用是強制刷出各種CPU的緩存數(shù)據(jù)昨凡,因此任何CPU上的線程都能讀取到這些數(shù)據(jù)的最新版本∫鲜穑總之便脊,volatile變量正是通過內(nèi)存屏障實現(xiàn)其在內(nèi)存中的語義,即可見性和禁止重排優(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; }}

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

memory = allocate(); //1.分配對象內(nèi)存空間

instance(memory); //2.初始化對象

instance = memory; //3.設置instance指向剛分配的內(nèi)存地址,此時instance奸笤!=null

memory = allocate(); //1.分配對象內(nèi)存空間

instance = memory; //3.設置instance指向剛分配的內(nèi)存地址惋啃,此時instance!=null监右,但是對象還沒有初始化完成边灭!

instance(memory); //2.初始化對象

由于步驟2和步驟3不存在數(shù)據(jù)依賴關系,而且無論重排前還是重排后程序的執(zhí)行結(jié)果在單線程中并沒有改變健盒,因此這種重排優(yōu)化是允許的绒瘦。但是指令重排只會保證串行語義的執(zhí)行的一致性(單線程),但并不會關心多線程間的語義一致性扣癣。所以當一條線程訪問instance不為null時惰帽,由于instance實例未必已初始化完成,也就造成了線程安全問題父虑。那么該如何解決呢该酗,很簡單,我們使用volatile禁止instance變量被執(zhí)行指令重排優(yōu)化即可士嚎。

//禁止指令重排優(yōu)化

private volatile static DoubleCheckLock instance;

歡迎工作一到五年的Java工程師朋友們加入Java架構(gòu)開發(fā):855801563

本群提供免費的學習指導?架構(gòu)資料?以及免費的解答

不懂得問題都可以在本群提出來?之后還會有職業(yè)生涯規(guī)劃以及面試指導

同時大家可以多多關注一下小編 純干貨?大家一起學習進步

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末呜魄,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子莱衩,更是在濱河造成了極大的恐慌爵嗅,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,755評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件笨蚁,死亡現(xiàn)場離奇詭異睹晒,居然都是意外死亡趟庄,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,305評論 3 395
  • 文/潘曉璐 我一進店門册招,熙熙樓的掌柜王于貴愁眉苦臉地迎上來岔激,“玉大人,你說我怎么就攤上這事是掰÷嵌Γ” “怎么了?”我有些...
    開封第一講書人閱讀 165,138評論 0 355
  • 文/不壞的土叔 我叫張陵键痛,是天一觀的道長炫彩。 經(jīng)常有香客問我,道長絮短,這世上最難降的妖魔是什么江兢? 我笑而不...
    開封第一講書人閱讀 58,791評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮丁频,結(jié)果婚禮上杉允,老公的妹妹穿的比我還像新娘。我一直安慰自己席里,他們只是感情好叔磷,可當我...
    茶點故事閱讀 67,794評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著奖磁,像睡著了一般改基。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上咖为,一...
    開封第一講書人閱讀 51,631評論 1 305
  • 那天秕狰,我揣著相機與錄音,去河邊找鬼躁染。 笑死鸣哀,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的吞彤。 我是一名探鬼主播我衬,決...
    沈念sama閱讀 40,362評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼备畦!你這毒婦竟也來了低飒?” 一聲冷哼從身側(cè)響起许昨,我...
    開封第一講書人閱讀 39,264評論 0 276
  • 序言:老撾萬榮一對情侶失蹤懂盐,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后糕档,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體莉恼,經(jīng)...
    沈念sama閱讀 45,724評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡拌喉,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,900評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了俐银。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片尿背。...
    茶點故事閱讀 40,040評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖捶惜,靈堂內(nèi)的尸體忽然破棺而出田藐,到底是詐尸還是另有隱情,我是刑警寧澤吱七,帶...
    沈念sama閱讀 35,742評論 5 346
  • 正文 年R本政府宣布汽久,位于F島的核電站,受9級特大地震影響踊餐,放射性物質(zhì)發(fā)生泄漏景醇。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,364評論 3 330
  • 文/蒙蒙 一吝岭、第九天 我趴在偏房一處隱蔽的房頂上張望三痰。 院中可真熱鬧,春花似錦窜管、人聲如沸散劫。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,944評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽舷丹。三九已至,卻和暖如春蜓肆,著一層夾襖步出監(jiān)牢的瞬間颜凯,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,060評論 1 270
  • 我被黑心中介騙來泰國打工仗扬, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留症概,地道東北人。 一個月前我還...
    沈念sama閱讀 48,247評論 3 371
  • 正文 我出身青樓早芭,卻偏偏與公主長得像彼城,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子退个,可洞房花燭夜當晚...
    茶點故事閱讀 44,979評論 2 355

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