引言
本篇文章結合我個人對Java內存模型的理解以及相關書籍資料為前提全面剖析JMM內存模型,本文的書寫思路先闡述JVM內存模型、硬件與OS(操作系統)內存區域架構、Java多線程原理以及Java內存模型JMM之間的串聯關系之后再對Java內存模型進行進一步剖析,因為大部分小伙伴在描述Java內存模型JMM時總是和JVM內存模型的概念相互混淆,那么本文的目的就是幫助各位小伙伴徹底理解JMM內存模型。(本人文章都是以個人理解+相關書籍資料為前提進行撰寫,如果錯誤或疑問歡迎各位看官評論區留言糾正,謝謝!)
一、徹底理解JVM內存模型與Java內存模型JMM的區別
1.1、JVM內存模型(JVM內存區域劃分)
眾所周知,Java程序如果想要運行那么必須是要建立在JVM的前提下的,Java使用JVM虛擬機屏蔽了像C那樣直接與操作系統或者OS接觸,讓Java語言操作全部建立在JVM的基礎之上從而做到了無視平臺,一次編譯到處運行。
JVM在運行Java程序時會把自己管理的內存劃分為以上區域(運行時數據區),每個區域都有各自的用途以及在Java程序運行時發揮著自己的作用,而其實運行時數據區又會將運行時數據區劃分為線程私有區以及線程共享區(GC不會發生在線程私有區),以下為各大區域具體作用:
方法區(Method Area):
方法區(在Java8之后方法區的概念更改為元數據空間)屬于線程共享的內存區域,又稱Non-Heap(非堆),主要用于存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯后的代碼等數據,根據Java 虛擬機規范的規定,當方法區無法滿足內存分配需求時,將拋出OutOfMemoryError 異常。值得注意的是在方法區中存在一個叫運行時常量池(Runtime Constant Pool)的區域,它主要用于存放編譯器生成的各種字面量和符號引用,這些內容將在類加載后存放到運行時常量池中,以便后續使用。
JVM堆(Java Heap):
Java 堆也是屬于線程共享的內存區域,它在虛擬機啟動時創建,是Java 虛擬機所管理的內存中最大的一塊,主要用于存放對象實例,幾乎所有的對象實例都在這里分配內存(并不是所有新建對象new Object()
在分配時都會在堆中),注意Java 堆是垃圾收集器管理的主要區域,因此很多時候也被稱做GC 堆,如果在堆中沒有內存完成實例分配,并且堆也無法再擴展時,將會拋出OutOfMemoryError 異常。
程序計數器(Program Counter Register):
屬于線程私有的數據區域,是一小塊內存空間,主要代表當前線程所執行的字節碼行號指示器。字節碼解釋器工作時,通過改變這個計數器的值來選取下一條需要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數器來完成,主要作用其實就是因為CPU的時間片在調度線程工作時會發生“中斷”某個線程讓另外一個線程開始工作,那么當這個“中斷”的線程重新被CPU調度時如何得知上次執行到那行代碼了?就是通過負責此類的程序計數器來得知。
虛擬機棧(Java Virtual Machine Stacks):
屬于線程私有的數據區域,與線程同時創建,總數與線程關聯,代表Java方法執行的內存模型。當線程開始執行時,每個方法執行時都會創建一個棧楨來存儲方法的的變量表、操作數棧、動態鏈接方法、返回值、返回地址等信息。每個方法從調用直結束就對于一個棧楨在虛擬機棧中的入棧和出棧過程,如下:
本地方法棧(Native Method Stacks):
本地方法棧屬于線程私有的數據區域,這部分主要與虛擬機用到的 C所編寫的 Native 方法相關,當有程序需要調用 Native 方法時,JVM會在本地方法棧中維護著一張本地方法登記表,這里只是做登記是哪個線程調用的哪個本地方法接口,并不會在本地方法棧中直接發生調用,因為這里只是做一個調用登記,而真正的調用需要通過本地方法接口去調用本地方法庫中C編寫的函數,一般情況下,我們無需關心此區域。
之所以說這塊的內容是需要讓大家理解清楚JVM內存模型和JMM內存模型是完全兩個不同的概念,JVM內存模型是處于Java的JVM虛擬機層面的,實際上對于操作系統來說,本質上JVM還是存在于主存中,而JMM是Java語言與OS和硬件架構層面的,主要作用是規定硬件架構與Java語言的內存模型,而本質上不存在JMM這個東西,JMM只是一種規范,并不能說是某些技術實現。
1.2、Java內存模型JMM概述
Java內存模型(即Java Memory Model,簡稱JMM)本身是一種抽象的概念,并不真實存在,它描述的是一組規則或規范,通過這組規范定義了程序中各個變量(包括實例字段,靜態字段和構成數組對象的元素)的訪問方式。由于JVM運行程序的實體是線程,而每個線程創建時JVM都會為其創建一個工作內存(有些地方稱為棧空間),用于存儲線程私有的數據,而Java內存模型中規定所有變量都存儲在主內存,主內存是共享內存區域,所有線程都可以訪問,但線程如果想要對一個變量讀取賦值等操作那么必須在工作內存中進行,所以線程想操作變量時首先要將變量從主內存拷貝的自己的工作內存空間,然后對變量進行操作,操作完成后再將變量刷寫回主內存,不能直接操作主內存中的變量,工作內存中存儲著主內存中的變量副本拷貝(PS:有些小伙伴可能會疑惑,Java中線程在執行一個方法時就算里面引用或者創建了對象,他不是也存在堆中嗎?棧內存儲的不僅僅只是對象的引用地址嗎?這里簡單說一下,當線程真正運行到這一行時會根據局部表中的對象引用地址去找到主存中的真實對象,然后會將對象拷貝到自己的工作內存再操作.....,但是當所操作的對象是一個大對象時(1MB+)并不會完全拷貝,而是將自己操作和需要的那部分成員拷貝),前面說過,工作內存是每個線程的私有數據區域,因此不同的線程間無法訪問對方的工作內存,線程間的通信(傳值)必須通過主內存來完成,其簡要訪問過程如下圖:
重點注意!!!JMM與JVM內存區域的劃分是不同的概念層次,在理解JMM的時候不要帶著JVM的內存模型去理解,更恰當說JMM描述的是一組規則,通過這組規則控制程Java序中各個變量在共享數據區域和私有數據區域的訪問方式,JMM是圍繞原子性,有序性、可見性拓展延伸的。JMM與Java內存區域唯一相似點,都存在共享數據區域和私有數據區域,在JMM中主內存屬于共享數據區域,從某個程度上講應該包括了堆和方法區,而工作內存數據線程私有數據區域,從某個程度上講則應該包括程序計數器、虛擬機棧以及本地方法棧。或許在某些地方,我們可能會看見主內存被描述為堆內存,工作內存被稱為線程棧,實際上他們表達的都是同一個含義。關于JMM中的主內存和工作內存說明如下:
主內存: 主要存儲的是Java實例對象,所有線程創建的實例對象都存放在主內存中(除開開啟了逃逸分析和標量替換的棧上分配和TLAB分配),不管該實例對象是成員變量還是方法中的本地變量(也稱局部變量),當然也包括了共享的類信息、常量、靜態變量。由于是共享數據區域,多條線程對同一個變量進行非原子性操作時可能會發現線程安全問題。
工作內存: 主要存儲當前方法的所有本地變量信息(工作內存中存儲著主內存中的變量副本拷貝),每個線程只能訪問自己的工作內存,即線程中的本地變量對其它線程是不可見的,就算是兩個線程執行的是同一段代碼,它們也會各自在自己的工作內存中創建屬于當前線程的本地變量,當然也包括了字節碼行號指示器、相關Native方法的信息。注意由于工作內存是每個線程的私有數據,線程間無法相互訪問工作內存,線程之間的通訊還是需要依賴于主存,因此存儲在工作內存的數據不存在線程安全問題。
弄清楚主內存和工作內存后,接了解一下主內存與工作內存的數據存儲類型以及操作方式,根據虛擬機規范,對于一個實例對象中的成員方法而言,如果方法中包含本地變量是基本數據類型(boolean,byte,short,char,int,long,float,double),將直接存儲在工作內存的幀棧結構中的局部變量表,但倘若本地變量是引用類型,那么該對象的在內存中的具體引用地址將會被存儲在工作內存的幀棧結構中的局部變量表,而對象實例將存儲在主內存(共享數據區域,堆)中。但對于實例對象的成員變量,不管它是基本數據類型或者包裝類型(Integer、Double等)還是引用類型,都會被存儲到堆區(棧上分配與TLAB分配除外)。至于static變量以及類本身相關信息將會存儲在主內存中。需要注意的是,在主內存中的實例對象可以被多線程共享,倘若兩條線程同時調用了同一個類的同一個方法,那么兩條線程會將要操作的數據拷貝一份到自己的工作內存中,執行完成操作后才刷新到主內存,簡單示意圖如下所示:
二、計算機硬件內存架構、OS與Java多線程實現原理及Java內存模型
2.1、計算機硬件內存架構
正如上圖所示,經過簡化CPU與內存操作的簡易圖,實際上沒有這么簡單,這里為了理解方便,我們省去了南北橋。就目前計算機而言,一般擁有多個CPU并且每個CPU可能存在多個核心,多核是指在一枚處理器(CPU)中集成兩個或多個完整的計算引擎(內核),這樣就可以支持多任務并行執行,從多線程的調度來說,每個線程都會映射到各個CPU核心中并行運行。在CPU內部有一組CPU寄存器,寄存器是cpu直接訪問和處理的數據,是一個臨時放數據的空間。一般CPU都會從內存取數據到寄存器,然后進行處理,但由于內存的處理速度遠遠低于CPU,導致CPU在處理指令時往往花費很多時間在等待內存做準備工作,于是在寄存器和主內存間添加了CPU緩存,CPU緩存比較小,但訪問速度比主內存快得多,如果CPU總是操作主內存中的同一址地的數據,很容易影響CPU執行速度,此時CPU緩存就可以把從內存提取的數據暫時保存起來,如果寄存器要取內存中同一位置的數據,直接從緩存中提取,無需直接從主內存取。需要注意的是,寄存器并不每次數據都可以從緩存中取得數據,萬一不是同一個內存地址中的數據,那寄存器還必須直接繞過緩存從內存中取數據。所以并不每次都得到緩存中取數據,這種現象有個專業的名稱叫做緩存的命中率,從緩存中取就命中,不從緩存中取從內存中取,就沒命中,可見緩存命中率的高低也會影響CPU執行性能,這就是CPU、緩存以及主內存間的簡要交互過程,總而言之當一個CPU需要訪問主存時,會先讀取一部分主存數據到CPU緩存(當然如果CPU緩存中存在需要的數據就會直接從緩存獲取),進而在讀取CPU緩存到寄存器,當CPU需要寫數據到主存時,同樣會先刷新寄存器中的數據到CPU緩存,然后再把數據刷新到主內存中。實則就類似于Appcalition(Java) --> Cache(Redis) --> DB(MySQL)的關系,Java程序的性能由于DB需要走磁盤受到了影響,導致Java程序在處理請求時需要等到DB的處理結果,而此時負責處理該請求的線程一直處于阻塞等待狀態,只有當DB處理結果返回了再繼續負責工作,那么實際上整個模型中的問題是:DB的速度跟不上Java程序的性能,導致整個請求處理起來變的很慢,但是實際上在DB處理的過程Java的線程是處于阻塞不工作的狀態的,那么實際上是沒有必要的,因為這樣最終會導致整體系統的吞吐量下降,此時我們可以加入Cache(Redis)來提升程序響應效率,從而整體提升系統吞吐和性能。(實際上我們做性能優化的目的就是讓系統的每個層面處理的速度加快,而架構實際上就是設計一套能夠吞吐更大量的請求的系統)。
2.2、OS與JVM線程關系及Java線程實現原理
在以上的闡述中我們大致了解完了硬件的內存架構和JVM內存模型以及Java內存模型之后,接著了解Java中線程的實現原理,理解線程的實現原理,有助于我們了解Java內存模型與硬件內存架構的關系,在Windows OS和Linux OS上,Java線程的實現是基于一對一的線程模型,所謂的一對一模型,實際上就是通過語言級別層面程序去間接調用系統內核的線程模型,即我們在使用Java線程時,比如:new Thread(Runnable);
JVM內部是轉而調用當前操作系統的內核線程來完成當前Runnable任務。這里需要了解一個術語,內核線程(Kernel-Level Thread,KLT),它是由操作系統內核(Kernel)支持的線程,這種線程是由操作系統內核來完成線程切換,內核通過操作調度器進而對線程執行調度,并將線程的任務映射到各個處理器上。每個內核線程可以視為內核的一個分身,這也就是操作系統可以同時處理多任務的原因。由于我們編寫的多線程程序屬于語言層面的,程序一般不會直接去調用內核線程,取而代之的是一種輕量級的進程(Light Weight Process),也是通常意義上的線程,由于每個輕量級進程都會映射到一個內核線程,因此我們可以通過輕量級進程調用內核線程,進而由操作系統內核將任務映射到各個處理器,這種輕量級進程與內核線程間1對1的關系就稱為Java程序中的線程與OS的一對一模型。如下圖:
Java程序中的每個線程都會經過OS被映射到CPU中進行處理,當然,如果CPU存在多核,那么一個CPU同時也能并行調度執行多個線程。
2.3、JMM與硬件內存架構的關系
通過對前面的JVM內存模型、Java內存模型JMM、硬件內存架構以及Java多線程的實現原理,我們可以發現,多線程的執行最終都會映射到硬件處理器上進行執行,但Java內存模型和硬件內存架構并不完全一致。對于硬件內存來說只有寄存器、緩存內存、主內存的概念,并沒有工作內存(線程私有數據區域)和主內存(堆內存)之分,也就是說Java內存模型對內存的劃分對硬件內存并沒有任何影響,因為JMM只是一種抽象的概念,是一組規則,并不實際存在,不管是工作內存的數據還是主內存的數據,對于計算機硬件來說都會存儲在計算機主內存中,當然也有可能存儲到CPU緩存或者寄存器中,因此總體上來說,Java內存模型和計算機硬件內存架構是一個相互交叉的關系,是一種抽象概念劃分與真實物理硬件的交叉。(注意對于JVM內存區域劃分也是同樣的道理)
2.4、為什么需要有JMM的存在?
接著來談談Java內存模型存在的必要性,因為我們去學習某個知識的話要做知其然知其所以然。由于線程是OS的最小操作單位,那么所有的程序運行時的實體本質上都是是一條條線程,Java程序需要運行在OS上也不例外,而每個線程創建時JVM都會為其創建一個工作內存(有些地方稱為棧空間),用于存儲線程私有的數據,線程如果想要操作主存中的某個變量,那么必須通過工作內存間接完成,主要過程是將變量從主內存拷貝的線程自己的工作內存空間,然后對變量先在工作內存中進行操作,操作完成后再將變量刷寫回主內存,如果存在兩個線程同時對一個主內存中的實例對象的變量進行操作就有可能誘發線程安全問題。如下圖,主內存中存在一個共享變量int i = 0,
第一種情況(左圖):
現在有A和B兩條線程分別對該變量i進行操作,A/B線程各自的都會先將主存中的i拷貝到自己的工作內存存儲為共享變量副本i,然后再對i進行自增操作,那么假設此時A/B同時將主存中i=0拷貝到自己的工作內存中進行操作,那么其實A在自己工作內存中的i進行自增操作是對B的工作內存的副本i不可見的,那么A做了自增操作之后會將結果1刷寫回主存,此時B也做了i++操作,那么實際上B刷寫回主存的值也是基于之前從主存中拷貝到自己工作內存的值i=0,那么實際上B刷寫回主存的值也是1,但是實際上我是兩條線程都對主存中 i 進行了自增操作,理想結果應該是i=2,但是此時的情況結果確實i=1。
第二種情況(右圖):
假設現在A線程想要修改 i 的值為2,而B線程卻想要讀取 i 的值,那么B線程讀取到的值是A線程更新后的值2還是更新前的值1呢?答案是不確定,即B線程有可能讀取到A線程更新前的值1,也有可能讀取到A線程更新后的值2,這是因為工作內存是每個線程私有的數據區域,而線程A修改變量 i 時,首先是將變量從主內存拷貝到A線程的工作內存中,然后對變量進行操作,操作完成后再將變量 i 寫回主內,而對于B線程的也是類似的,這樣就有可能造成主內存與工作內存間數據存在一致性問題,假如A線程修改完后正在將數據寫回主內存,而B線程此時正在讀取主內存,即將i=1拷貝到自己的工作內存中,這樣B線程讀取到的值就是x=1,但如果A線程已將x=2寫回主內存后,B線程才開始讀取的話,那么此時B線程讀取到的就是x=2,但到底是哪種情況先發生呢?這是不確定的。
所以如上兩種情況對于程序來說是不應該的,假設把這個變量i換成淘寶雙十一的商品庫存數,A/B線程換成參加雙十一的用戶,那么這樣會導致的問題就是對于淘寶業務團隊來說,可能會導致超賣,重復賣等問題的出現,這會由于因為技術上的問題導致出現業務經濟上的損失,尤其是是在類似于淘寶雙十一此類的大促活動上此類問題如果不控制恰當,出現問題的風險會成倍增長,其實這也就是所謂的線程安全問題。
為了解決類似如上闡述的問題,JVM定義了一組規則,通過這組規則來決定一個線程對共享變量的寫入何時對另一個線程可見,這組規則也稱為Java內存模型(JMM),JMM整體是圍繞著程序執行的原子性、有序性、可見性展開的,下面我們看看這三個特性。
2.5、Java內存模型JMM圍繞的三大特性
2.5.1、原子性
原子性指的是一個操作是不可中斷的,即使是在多線程環境下,一個操作一旦開始就不會被其他線程影響。比如對于一個靜態變量int i = 0,兩條線程同時對他賦值,線程A操作為 i = 1,而線程B操作為 i = 2,不管線程如何運行,最終 i 的值要么是1,要么是2,線程A和線程B間的操作是沒有干擾的,這就是原子性操作,不可被中斷的特點。
有點要注意的是,對于32位系統的來說,long類型數據和double類型數據(對于基本數據類型,byte,short,int,float,boolean,char讀寫是原子操作),它們的讀寫并非原子性的,也就是說如果存在兩條線程同時對long類型或者double類型的數據進行讀寫是存在相互干擾的,因為對于32位虛擬機來說,每次原子讀寫是32位的,而long和double則是64位的存儲單元,這樣會導致一個線程在寫時,操作完前32位的原子操作后,輪到B線程讀取時,恰好只讀取到了后32位的數據,這樣可能會讀取到一個既非原值又不是線程修改值的變量,它可能是“半個變量”的數值,即64位數據被兩個線程分成了兩次讀取。但也不必太擔心,因為讀取到“半個變量”的情況比較少見,至少在目前的商用的虛擬機中,幾乎都把64位的數據的讀寫操作作為原子操作來執行,因此對于這個問題不必太在意,知道這么回事即可。
那么其實本質上原子性操作指的就是一組大操作要么就全部執行成功,要么就全部失敗,舉個例子:下單:{增加訂單,減庫存} 那么對于用戶來說下單是一個操作,那么系統就必須保證下單操作的原子性,要么就增加訂單和減庫存全部成功,不存在增加訂單成功,減庫存失敗,那么這個例子從宏觀上來就就是一個原子性操作,非原子性操作反之,線程安全問題產生的根本原因也是由于多線程情況下對一個共享資源進行非原子性操作導致的。
但是有個點在我們深入研究Java的并發編程以及在研究可見性之前時需要注意的,就是計算機在程序執行的時候對它的優化操作 -- 指令重排。計算機在執行程序時,為了提高性能,編譯器和處理器的常常會對指令做重排,一般分以下3種:
- 編譯器優化的重排: 編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。
- 指令并行的重排: 現代處理器采用了指令級并行技術來將多條指令重疊執行。如果不存在數據依賴性(即后一個執行的語句無需依賴前面執行的語句的結果),處理器可以改變語句對應的機器指令的執行順序。
-
內存系統的重排: 由于處理器使用緩存和讀寫緩存沖區,這使得加載(load)和存儲(store)操作看上去可能是在亂序執行,因為三級緩存的存在,導致內存與緩存的數據同步存在時間差。
其中編譯器優化的重排屬于編譯期重排,指令并行的重排和內存系統的重排屬于處理器重排,在多線程環境中,這些重排優化可能會導致程序出現內存可見性問題,下面分別闡明這兩種重排優化可能帶來的問題。
2.5.1.1、編譯器優化指令重排
int a = 0;
int b = 0;
//線程A 線程B
代碼1:int x = a; 代碼3:int y = b;
代碼2:b = 1; 代碼4:a = 2;
此時有4行代碼1、2、3、4,其中1、2屬于線程A,其中3、4屬于線程B,兩個線程同時執行,從程序的執行上來看由于并行執行的原因最終的結果 x = 0;y=0; 本質上是不會出現 x = 2;y = 1; 這種結果,但是實際上來說這種情況是有概率出現的,因為編譯器一般會對一些代碼前后不影響、耦合度為0的代碼行進行編譯器優化的指令重排,假設此時編譯器對這段代碼指令重排優化之后,可能會出現如下情況:
//線程A 線程B
代碼2:b = 1; 代碼4:a = 2;
代碼1:int x = a; 代碼3:int y = b;
這種情況下再結合之前的線程安全問題一起理解,那么就可能出現 x = 2;y = 1; 這種結果,這也就說明在多線程環境下,由于編譯器會對代碼做指令重排的優化的操作(因為一般代碼都是由上往下執行,指令重排是OS對單線程運行的優化),最終導致在多線程環境下時多個線程使用變量能否保證一致性是無法確定的(PS:編譯器重排的基礎是代碼不存在依賴性時才會進行的,而依賴性可分為兩種:數據依賴(int a = 1;int b = a;)和控制依賴(boolean f = ture;if(f){sout("123");}))。
2.5.1.2、處理器指令重排
先了解一下指令重排的概念,處理器指令重排是對CPU的性能優化,從指令的執行角度來說一條指令可以分為多個步驟完成,如下:
取指:IF
譯碼和取寄存器操作數:ID
執行或者有效地址計算:EX
存儲器訪問:MEM
寫回:WB
CPU在工作時,需要將上述指令分為多個步驟依次執行(注意硬件不同有可能不一樣),由于每一個步會使用到不同的硬件操作,比如取指時會只有PC寄存器和存儲器,譯碼時會執行到指令寄存器組,執行時會執行ALU(算術邏輯單元)、寫回時使用到寄存器組。為了提高硬件利用率,CPU指令是按流水線技術來執行的,如下:
(流水線技術:類似于工廠中的生產流水線,工人們各司其職,做完自己的就往后面傳,然后開始一個新的,做完了再往后面傳遞.....而指令執行也是一樣的,如果等到一條指令執行完畢之后再開始下一條的執行,就好比工廠的生產流水線,先等到一個產品生產完畢之后再開始下一個,效率非常低下并且浪費人工,這樣一條流水線上同時只會有一個工人在做事,其他的看著,只有當這個產品走了最后一個人手上了并且最后一個工人完成了組裝之后第一個工人再開始第二個產品的工作)
從圖中可以看出當指令1還未執行完成時,第2條指令便利用空閑的硬件開始執行,這樣做是有好處的,如果每個步驟花費1ms,那么如果第2條指令需要等待第1條指令執行完成后再執行的話,則需要等待5ms,但如果使用流水線技術的話,指令2只需等待1ms就可以開始執行了,這樣就能大大提升CPU的執行性能。雖然流水線技術可以大大提升CPU的性能,但不幸的是一旦出現流水中斷,所有硬件設備將會進入一輪停頓期,當再次彌補中斷點可能需要幾個周期,這樣性能損失也會很大,就好比工廠組裝手機的流水線,一旦某個零件組裝中斷,那么該零件往后的工人都有可能進入一輪或者幾輪等待組裝零件的過程。因此我們需要盡量阻止指令中斷的情況,指令重排就是其中一種優化中斷的手段,我們通過一個例子來闡明指令重排是如何阻止流水線技術中斷的,如下:
i = a + b;
y = c - d;
指令 | 描述 |
---|---|
LW R1,a | LW指令表示 load,其中LW R1,a表示把a的值加載到寄存器R1中 |
LW R2,b | 表示把b的值加載到寄存器R2中 |
ADD R3,R1,R2 | ADD指令表示加法,把R1 、R2的值相加,并存入R3寄存器中。 |
SW i,R3 | SW表示 store 即將 R3寄存器的值保持到變量i中 |
LW R4,c | 表示把c的值加載到寄存器R4中 |
LW R5,d | 表示把d的值加載到寄存器R5中 |
SUB R6,R4,R5 | SUB指令表示減法,把R4、R5的值相減,并存入R6寄存器中。 |
SW y,R6 | 表示將R6寄存器的值保持到變量y中 |
上述便是匯編指令的執行過程,在某些指令上存在X的標志,X代表中斷的含義,也就是只要有X的地方就會導致指令流水線技術停頓,同時也會影響后續指令的執行,可能需要經過1個或幾個指令周期才可能恢復正常,那為什么停頓呢?這是因為部分數據還沒準備好,如執行ADD指令時,需要使用到前面指令的數據R1,R2,而此時R2的MEM操作沒有完成,即未拷貝到存儲器中,這樣加法計算就無法進行,必須等到MEM操作完成后才能執行,也就因此而停頓了,其他指令也是類似的情況。前面講過,停頓會造成CPU性能下降,因此我們應該想辦法消除這些停頓,這時就需要使用到指令重排了,如下圖,既然ADD指令需要等待,那我們就利用等待的時間做些別的事情,如把LW R4,c 和 LW R5,d 移動到前面執行,畢竟LW R4,c 和 LW R5,d執行并沒有數據依賴關系,對他們有數據依賴關系的SUB R6,R5,R4指令在R4,R5加載完成后才執行的,沒有影響,過程如下:
正如上圖所示,所有的停頓都完美消除了,指令流水線也無需中斷了,這樣CPU的性能也能帶來很好的提升,這就是處理器指令重排的作用。關于編譯器重排以及指令重排(這兩種重排我們后面統一稱為指令重排)相關內容已闡述清晰了,我們必須意識到對于單線程而已指令重排幾乎不會帶來任何影響,比竟重排的前提是保證串行語義執行的一致性,但對于多線程環境而已,指令重排就可能導致嚴重的程序輪序執行問題,如下:
int a = 0;
boolean f = false;
public void methodA(){
a = 1;
f = true;
}
public void methodB(){
if(f){
int i = a + 1;
}
}
如上述代碼,同時存在線程A和線程B對該實例對象進行操作,其中A線程調用methodA方法,而B線程調用methodB方法,由于指令重排等原因,可能導致程序執行順序變為如下:
線程A 線程B
methodA: methodB:
代碼1:f= true; 代碼1:f= true;
代碼2:a = 1; 代碼2: a = 0 ; //讀取到了未更新的a
代碼3: i = a + 1;
由于指令重排的原因,線程A的f置為true被提前執行了,而線程A還在執行a=1,此時因為f=true了,所以線程B正好讀取f的值為true,直接獲取a的值,而此時線程A還在自己的工作內存中對當中拷貝過來的變量副本a進行賦值操作,結果還未刷寫到主存,那么此時線程B讀取到的a值還是為0,那么拷貝到線程B工作內存的a=0;然后并在自己的工作內存中執行了 i = a + 1操作,而此時線程B因為處理器的指令重排原因讀取a是為0的,導致最終 i 結果的值為1,而不是預期的2,這就是多線程環境下,指令重排導致的程序亂序執行的結果。因此,請記住,指令重排只會保證單線程中串行語義的執行的一致性,能夠在單線程環境下通過指令重排優化程序,消除CPU停頓,但是并不會關心多線程間的語義一致性。
2.5.2、可見性
經過前面的闡述,如果真正理解了指令重排現象之后的小伙伴再來理解可見性容易了,可見性指的是當一個線程修改了某個共享變量的值,其他線程是否能夠馬上得知這個修改的值。對于串行程序來說,可見性是不存在的,因為我們在任何一個操作中修改了某個變量的值,后續的操作中都能讀取這個變量值,并且是修改過的新值。但在多線程環境中可就不一定了,前面我們分析過,由于線程對共享變量的操作都是線程拷貝到各自的工作內存進行操作后才寫回到主內存中的,這就可能存在一個線程A修改了共享變量 i 的值,還未寫回主內存時,另外一個線程B又對主內存中同一個共享變量 i 進行操作,但此時A線程工作內存中共享變量 i 對線程B來說并不可見,這種工作內存與主內存同步延遲現象就造成了可見性問題,另外指令重排以及編譯器優化也可能導致可見性問題,通過前面的分析,我們知道無論是編譯器優化還是處理器優化的重排現象,在多線程環境下,確實會導致程序輪序執行的問題,從而也就導致可見性問題。
2.5.3、有序性
有序性是指對于單線程的執行代碼,我們總是認為代碼的執行是按順序依次執行的,這樣的理解如果是放在單線程環境下沒有問題,畢竟對于單線程而言確實如此,代碼由編碼的順序從上往下執行,就算發生指令重排序,由于所有硬件優化的前提都是必須遵守as-if-serial語義,所以不管怎么排序,都不會且不能影響單線程程序的執行結果,我們將這稱之為有序執行。反之,對于多線程環境,則可能出現亂序現象,因為程序編譯成機器碼指令后可能會出現指令重排現象,重排后的指令與原指令的順序未必一致。要明白的是,在Java程序中,倘若在本線程內,所有操作都視為有序行為,如果是多線程環境下,一個線程中觀察另外一個線程,所有操作都是無序的,前半句指的是單線程內保證串行語義執行的一致性,后半句則指指令重排現象和工作內存與主內存同步延遲現象。
2.6、Java中JMM是怎么去解決如上問題的?
在真正的理解了如上所以內容之后,再來看Java為我們提供的解決方案,如原子性問題,除了JVM自身提供的對基本數據類型讀寫操作的原子性外,對于方法級別或者代碼塊級別的原子性操作,可以使用synchronized關鍵字或者Lock鎖接口的實現類來保證程序執行的原子性,關于synchronized的詳解(能保證三特性不能禁止指令重排),后續我們會講到。而工作內存與主內存同步延遲現象導致的可見性問題,可以使用加鎖或者Volatile關鍵字解決,它們都可以使一個線程修改后的變量立即對其他線程可見。對于指令重排導致的可見性問題和有序性問題,則可以利用volatile關鍵字解決,因為volatile的另外一個作用就是禁止重排序優化,關于volatile稍后會進一步分析。除了靠sychronized和volatile關鍵字(volatile關鍵字不能保證原子性,只能保證的是禁止指令重排與可見性問題)來保證原子性、可見性以及有序性外,JMM內部還定義一套happens-before 原則來保證多線程環境下兩個操作間的原子性、可見性以及有序性。
2.7、Java內存模型JMM中的happens-before 原則
2.7.1、線程在執行的過程中與內存的交互
不過在了解JMM中的happens-before 原則之前先對于線程執行過程中與內存的交互操作要有一個簡單的認知,Java程序在執行的過程中實際上就是OS在調度JVM的“線程”執行,而在執行的過程中是與內存的交互操作,而內存交互操作有8種(虛擬機實現必須保證每一個操作都是原子的,不可在分的,對于double和long類型的變量來說,load、store、read和write操作在某些平臺上允許例外):
- lock(鎖定):作用于主內存的變量,把一個變量標識為線程獨占狀態;
- unlock(解鎖):作用于主內存的變量,它把一個處于鎖定狀態的變量釋放出來,釋放后的變量才可以被其他線程鎖定;
- read(讀取):作用于主內存變量,它把一個變量的值從主內存傳輸到線程的工作內存中,以便隨后的load動作使用;
- load(載入):作用于工作內存的變量,它把read操作從主存中變量放入工作內存中;
- use(使用):作用于工作內存中的變量,它把工作內存中的變量傳輸給執行引擎,每當虛擬機遇到一個需要使用到變量的值,就會使用到這個指令;
- assign(賦值):作用于工作內存中的變量,它把一個從執行引擎中接受到的值放入工作內存的變量副本中;
- store(存儲):作用于主內存中的變量,它把一個從工作內存中一個變量的值傳送到主內存中,以便后續的write使用;
- write(寫入):作用于主內存中的變量,它把store操作從工作內存中得到的變量的值放入主內存的變量中
JMM對這八種指令的使用,制定了如下規則:
- 1)、不允許read和load、store和write操作之一單獨出現。即使用了read必須load,使用了store必須write;
- 2)、不允許線程丟棄他最近的assign操作,即工作變量的數據改變了之后,必須告知主存;
- 3)、不允許一個線程將沒有assign的數據從工作內存同步回主內存;
- 4)、一個新的變量必須在主內存中誕生,不允許工作內存直接使用一個未被初始化的變量。就是懟變量實施use、store操作之前,必須經過assign和load操作;
- 5)、一個變量同一時間只有一個線程能對其進行lock。多次lock后,必須執行相同次數的unlock才能解鎖;
- 6)、如果對一個變量進行lock操作,會清空所有工作內存中此變量的值,在執行引擎使用這個變量前,必須重新load或assign操作初始化變量的值;
- 7)、如果一個變量沒有被lock,就不能對其進行unlock操作。也不能unlock一個被其他線程鎖住的變量;
- 8)、對一個變量進行unlock操作之前,必須把此變量同步回主內存;
JMM對這八種操作規則和對volatile的一些特殊規則就能確定哪里操作是線程安全,哪些操作是線程不安全的了。但是這些規則實在復雜,很難在實踐中直接分析。所以一般我們也不會通過上述規則進行分析。更多的時候,使用JMM中的happens-before 規則來進行分析。
2.7.2、JMM中的happens-before 原則
假如在多線程開發過程中我們都需要通過加鎖或者volatile來解決這些問題的話那么編寫程序的時候會非常麻煩,而且加鎖其實本質上是讓多線程的并行執行變為了串行執行,這樣會大大的影響程序的性能,那么其實真的需要嘛?不需要,因為在JMM中還為我們提供了happens-before 原則來輔助保證程序執行的原子性、可見性以及有序性的問題,它是判斷數據是否存在競爭、線程是否安全的依據,happens-before 原則內容如下:
- 一、程序順序原則: 即在一個線程內必須保證語義串行性,也就是說按照代碼順序執行。
- 二、鎖規則: 解鎖(unlock)操作必然發生在后續的同一個鎖的加鎖(lock)之前,也就是說,如果對于一個鎖解鎖后,再加鎖,那么加鎖的動作必須在解鎖動作之后(同一個鎖)。
- 三、volatile規則: volatile變量的寫,先發生于讀,這保證了volatile變量的可見性,簡單的理解就是,volatile變量在每次被線程訪問時,都強迫從主內存中讀該變量的值,而當該變量發生變化時,又會強迫將最新的值刷新到主內存,任何時刻,不同的線程總是能夠看到該變量的最新值。
- 四、線程啟動規則: 線程的start()方法先于它的每一個動作,即如果線程A在執行線程B的start方法之前修改了共享變量的值,那么當線程B執行start方法時,線程A對共享變量的修改對線程B可見。
- 五、傳遞性優先級規則: A先于B ,B先于C 那么A必然先于C。
- 六、線程終止規則: 線程的所有操作先于線程的終結,Thread.join()方法的作用是等待當前執行的線程終止。假設在線程B終止之前,修改了共享變量,線程A從線程B的join方法成功返回后,線程B對共享變量的修改將對線程A可見。
- 七、線程中斷規則: 對線程 interrupt()方法的調用先行發生于被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測線程是否中斷。
-
八、對象終結規則: 對象的構造函數執行,結束先于finalize()方法。
happens-before 原則無需添加任何手段來保證,這是由JMM規定的,Java程序默認遵守如上八條原則,下面我們再通過之前的案例重新認識這八條原則是如何判斷線程是否會出現安全問題:
int a = 0;
boolean f = false;
public void methodA(){
a = 1;
f = true;
}
public void methodB(){
if(f){
int i = a + 1;
}
}
同樣的道理,存在兩條線程A和B,線程A調用實例對象的methodA()方法,而線程B調用實例對象的methodB()方法,線程A先啟動而線程B后啟動,那么線程B讀取到的i值是多少呢?現在依據8條原則,由于存在兩條線程同時調用,因此程序次序原則不合適。methodA()方法和methodB()方法都沒有使用同步手段,鎖規則也不合適。沒有使用volatile關鍵字,volatile變量原則不適應。線程啟動規則、線程終止規則、線程中斷規則、對象終結規則、傳遞性和本次測試案例也不合適。線程A和線程B的啟動時間雖然有先后,但線程B執行結果卻是不確定,也是說上述代碼沒有適合8條原則中的任意一條,也沒有使用任何同步手段,所以上述的操作是線程不安全的,因此線程B讀取的值自然也是不確定的。修復這個問題的方式很簡單,要么給methodA()方法和methodB()方法添加同步手段(加鎖)或者給共享變量添加volatile關鍵字修飾,保證該變量在被一個線程修改后總對其他線程可見。
三、Volatile關鍵字
3.1、Volatile關鍵字保證的可見性
Volatile是Java提供的輕量級同步工具,它能保證可見性和做到禁止指令重排做到有序性,但是它不能保證原子性,如果你的程序必須做到原子性的話那么可以考慮使用JUC的原子包下的原子類(后續篇章會講到)或者加鎖的方式來保證,但是我們假設如果使用volatile來修飾共享變量,那么它能夠保證的是一個線程對它所修飾的變量進行更改操作后總是能對其他線程可見,如下:
volatile int i = 0;
public void add(){
i++;
}
對于如上代碼,我們任何線程調用add()方法之后對 i 進行i++ 操作之后都是對其他線程可見的,但是這段代碼不存在線程安全問題嗎?存在,為什么?因為 i++ 并不是原子性操作, i++實際上是三個操作的組成,從主存讀取值、工作內存中+1操作、操作結果刷寫回主存三步操作所組成的,它們三步中其中一條線程在執行任何一步的時候都有可能被打斷,那么還是會出現線程安全問題(具體參考上述線程安全問題第一種情況),但是我們要清楚,此時如果有多條線程調用add()方法,那么此時還是會出現線程安全問題,如果想要解決還是需要使用sync或者lock或者原子類來保證,volatile關鍵字只能禁止指令重排以及可見性。
那么我們再來看一個案例,此類場景可以使用volatile關鍵字修飾變量達到線程安全的目的,如下:
volatile boolean flag;
public void toTrue(){
flag = true;
}
public void methodA(){
while(!flag){
System.out.println("我是false....false.....false.......");
}
}
由于對于boolean變量flag值的修改屬于原子性操作,因此可以通過使用volatile修飾變量flag,使用該變量對其他線程立即可見,從而達到線程安全的目的。那么JMM是如何實現讓volatile變量對其他線程立即可見的呢?實際上,當寫一個volatile變量時,JMM會把該線程對應的工作內存中的共享變量值刷新到主內存中,當讀取一個volatile變量時,JMM會把該線程對應的工作內存置為無效,那么該線程將只能從主內存中重新讀取共享變量。volatile變量正是通過這種寫-讀方式實現對其他線程可見(但其內存語義實現則是通過內存屏障,稍后會說明)。
3.2、Volatile關鍵字怎么做到禁止指令重排序的?
volatile關鍵字另一個作用就是禁止編譯器或者處理器對進行指令重排優化,從而避免多線程環境下程序出現亂序執行的現象,那么volatile是如何實現禁止指令重排優化的。先了解一個概念,內存屏障(Memory Barrier)。
內存屏障,又稱內存柵欄,是一個CPU指令,它的作用有兩個,一是保證特定操作的執行順序,二是保證某些變量的內存可見性(利用該特性實現volatile的內存可見性)。由于編譯器和處理器都能執行指令重排優化。如果在指令間插入一條Memory Barrier則會告訴編譯器和CPU,不管什么指令都不能和這條Memory Barrier指令重排序,也就是說通過插入內存屏障禁止在內存屏障前后的指令執行重排序優化。Memory Barrier的另外一個作用是強制刷出各種CPU的緩存數據,因此任何CPU上的線程都能讀取到這些數據的最新版本。
屏障類型 | 指令示例 | 說明 |
---|---|---|
LoadLoad Barriers | Load1; LoadLoad; Load2; | 確保Load1指令數據的裝載之前發生于Load2及后續所有裝載指令的數據裝載。 |
StoreStore Barriers | Store1; StoreStore; Store2; | 確保Store1數據的存儲對其他處理器可見(刷新到內存中)并之前發生于Store2及后續所有存儲指令的數據寫入。 |
LoadStore Barriers | Load1; LoadStore; Store2; | 確保Load1指令數據的裝載之前發生于Store2及后續所有存儲指令的數據寫入。 |
StoreLoad Barriers | Store1; StoreLoad; Load2; | 確保Store1數據的存儲對其他處理器可見(刷新到內存中)并之前發生于Load2及后續所有裝載指令的數據裝載。StoreLoad Barriers會使該屏障之前的所有內存訪問指令(存儲和裝載)完成之后,才執行該屏障之后的內存訪問指令。 |
Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序,從而讓程序按我們預想的流程去執行。
JMM把內存屏障指令分為4類,StoreLoad Barriers是一個“全能型”的屏障,它同時具有其他3個屏障的效果。現代的多處理器大多支持該屏障(其他類型的屏障不一定被所有處理器支持)。
總之,volatile變量正是通過內存屏障實現其在內存中的語義,即可見性和禁止重排優化。案例如下:
public class Singleton{
private static Singleton singleton;
private Singleton(){}
public static Singleton getInstance(){
if(singleton == null){
synchronized(Singleton.class){
if(singleton == null){
singleton = new Singleton();
}
}
}
}
}
上述代碼一個經典的雙重檢測的單例模式的代碼,這段代碼在單線程環境下并沒有什么問題,但如果在多線程環境下就可以出現線程安全問題。原因在于某一個線程執行到第一次檢測,讀取到的singleton不為null時,singleton的引用對象可能沒有完成初始化。因為singleton= new Singleton();可以分為以下3步完成(偽代碼)
memory = allocate(); //1.分配對象內存空間
singleton(memory); //2.初始化對象
singleton = memory; //3.設置singleton指向剛分配的內存地址,此時singleton != null
由于步驟1和步驟2間可能會重排序,如下:
memory = allocate(); //1.分配對象內存空間
singleton = memory; //3.設置singleton指向剛分配的內存地址,此時singleton != null
singleton(memory); //2.初始化對象
由于步驟2和步驟3不存在數據依賴關系,而且無論重排前還是重排后程序的執行結果在單線程中并沒有改變,因此這種重排優化是允許的。但是指令重排只會保證串行語義的執行的一致性(單線程),但并不會關心多線程間的語義一致性。所以當一條線程訪問singleton不為null時,由于singleton實例未必已初始化完成,也就造成了線程安全問題。那么該如何解決呢,很簡單,我們使用volatile禁止singleton變量被執行指令重排優化即可。
private volatile static Singleton singleton;
四、總結
哪么到這里如果是認真閱讀的小伙伴其實通過對這篇文章的理解,相信對Java內存模型JMM已經有了一個清晰的認知,那么其實這篇文章是我們在探究Java并發編程時的第一道門檻,我后續也會繼續發布有關并發專題相關的文章,如果你對于文章中的某些點有其他看法或者文章中你認為存在問題或者你對文章中某些點存在任何疑問,歡迎你評論區留言一起探討,謝謝!
五、參考資料與書籍
- 《深入理解JVM虛擬機》
- 《Java并發編程之美》
- 《Java高并發程序設計》
- 《億級流量網站架構核心技術》
- 《Java并發編程實戰》