從零開始瞭解多線程知識之開始篇目 -- jvm&volatile

本文章主要介紹到到了一些CPU緩存一致性協議的基礎知識,由此引出的多線程知識,同時談到了多線程中數據操做 原子性 可見性 有序性 的問題
從線程的基本概念到多線程下工做的數據安全問題,主要談到了java知識中volatile關鍵字,使用實例的模式講解了 volatile可見性,有序性,指令重排的問題
接下來你們一塊兒來學習學習吧java

CPU多核緩存存儲結構圖算法

1.電腦存儲結構概念編程

多CPU數組

一個現代計算機一般由兩個或者多個CPU,若是要運行多個程序(進程)的話,假如只有 一個CPU的話,就意味着要常常進行進程上下文切換
     由於單CPU即使是多核的,也只是多個 處理器核心,其餘設備都是共用的,因此多個進程就必然要常常進行進程上下文切換,這個代價是很高的。

CPU多核緩存

一個現代CPU除了處理器核心以外還包括寄存器、L1L2L3緩存這些存儲設備、浮點運算 單元、整數運算單元等一些輔助運算設備以及內部總線等。
    一個多核的CPU也就是一個CPU上 有多個處理器核心,這樣有什麼好處呢?好比說如今咱們要在一臺計算機上跑一個多線程的程序
    由於是一個進程裏的線程,因此須要一些共享一些存儲變量,若是這臺計算機都是單核單線程CPU的話,就意味着這個程序的不一樣線程須要常常在CPU之間的外部總線上通訊,
    同時還 要處理不一樣CPU之間不一樣緩存致使數據不一致的問題,因此在這種場景下多核單CPU的架構就 能發揮很大的優點,通訊都在內部總線,共用同一個緩存。

CPU寄存器安全

每一個CPU都包含一系列的寄存器,它們是CPU內內存的基礎。CPU在寄存器上執行操做的速度遠大於在主存上執行的速度。
    這是由於CPU訪問寄存器的速度遠大於主存。

CPU緩存多線程

即高速緩衝存儲器,是位於CPU與主內存間的一種容量較小但速度很高的存儲器。
    因爲CPU的速度遠高於主內存,CPU直接從內存中存取數據要等待必定時間週期,所以出現了CPU緩存
    Cache中保存着CPU剛用過或循環使用的一部分數據,當CPU再次使用該部分數據時可從Cache中直接調用, 減小CPU的等待時間,提升了系統的效率。 
    CPU緩存包括 一級Cache(L1 Cache) 二級Cache(L2 Cache) 三級Cache(L3 Cache)

內存架構

一個計算機還包含一個主存。
    全部的CPU均可以訪問主存。主存一般比CPU中的緩存大得多。

CPU讀取存儲器數據過程併發

CPU要取寄存器XX的值,只須要一步:直接讀取。 
    CPU要取L1 cache的某個值,須要1-3步(或者更多):把cache行鎖住,把某個數據拿 來,解鎖,若是沒鎖住就慢了。
    CPU要取L2 cache的某個值,先要到L1 cache裏取,L1當中不存在,在L2裏,L2開始加 鎖,加鎖之後,把L2裏的數據複製到L1,再執行讀L1的過程,上面的3步,再解鎖。
    CPU取L3 cache的也是同樣,只不過先由L3複製到L2,從L2複製到L1,從L1到CPU。 
    CPU取內存則複雜:通知內存控制器佔用總線帶寬,通知內存加鎖,發起內存讀請求, 
    等待迴應,迴應數據保存到L3(若是沒有就到L2),再從L3/2到L1,再從L1到CPU,以後解除總線鎖定。

多線程環境下存在的問題app

緩存一致性問題

在多處理器系統中,每一個處理器都有本身的高速緩存,而它們又共享同一主內存 (MainMemory)。
基於高速緩存的存儲交互很好地解決了處理器與內存的速度矛盾,可是 也引入了新的問題:緩存一致性(CacheCoherence)。
當多個處理器的運算任務都涉及同一 塊主內存區域時,將可能致使各自的緩存數據不一致的狀況,若是真的發生這種狀況,
那同步回到主內存時以誰的緩存數據爲準呢?爲了解決一致性的問題,須要各個處理器訪問緩存時都 遵循一些協議,
在讀寫時要根據協議來進行操做,這類協議有MSI、 MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol,等等

指令重排序問題

爲了使得處理器內部的運算單元能儘可能被充分利用,處理器可能會對輸入代碼進行亂序執行(Out-Of-Order Execution)優化,
處理器會在計算以後將亂序執行的結果重組,保證該 結果與順序執行的結果是一致的,但並不保證程序中各個語句計算的前後順序與輸入代碼中的 順序一致。
所以,若是存在一個計算任務依賴另外一個計算任務的中間結果,那麼其順序性並不 能靠代碼的前後順序來保證。
與處理器的亂序執行優化相似,Java虛擬機的即時編譯器中也有 相似的指令重排序(Instruction Reorder)優化

2.什麼是線程

現代操做系統在運行一個程序時,會爲其建立一個進程。例如,啓動一個Java程序,操做系統就會建立一個Java進程。
現代操做系統調度CPU的最小單元是線程,也叫輕量級進程,在一個進程裏能夠建立多個線程,
這些線程都擁有各自的計數器、堆棧和局部變量等屬性,而且可以訪問共享的內存變量。
處理器在這些線程上高速切換,讓使用者感受到這些線程在同時執行。

線程的實現能夠分爲兩類:

一、用戶級線程(User-Level Thread)
二、內核線線程(Kernel-Level Thread)

在瞭解線程以前,須要知道系統存在兩種空間:用戶空間和內核空間
其中內核空間只能由內核代碼進行方訪問,用戶代碼沒法直接訪問若是用戶代碼要訪問內核空間,
須要藉助內核空間提供的訪問接口
系統中用戶程序運行在用戶方式下,而系統調用運行在內核方式下。
在這兩種方式下所用的堆棧不同:用戶方式下用的是通常的堆棧,而內核方式下用的是固定大小的堆棧(通常爲一個內存頁的大小)

好比一個4G內存的空間,可能只有3GB能夠用於用戶應用程序。一個進程只能運行在用戶方式(usermode)或內核方式(kernelmode)下,
每一個進程都有本身的3G用戶空間,它們共享1GB的內核空間。當一個進程從用戶空間進入內核空間時,它就再也不有本身的進程空間了。
這也就是爲何咱們常常說線程上下文切換會涉及到用戶態到內核態的切換緣由所在

用戶線程:

指不須要內核支持而在用戶程序中實現的線程,其不依賴於操做系統核心,應用進程利用線程庫提供建立、同步、調度和管理線程的函數來控制用戶線程。
另外,用戶線程是由應用進程利用線程庫建立和管理,不依賴於操做系統核心。不須要用戶態/核心態切換,速度快。
操做系統內核不知道多線程的存在,所以一個線程阻塞將使得整個進程(包括它的全部線程)阻塞(可理解爲串行化的)
因爲這裏的處理器時間片分配是以進程爲基本單位,因此每一個線程執行的時間相對減小。

內核線程:

線程的全部管理操做都是由操做系統內核完成的。內核保存線程的狀態和上下文信息,
當一個線程執行了引發阻塞的系統調用時,內核能夠調度該進程的其餘線程執行。
在多處理器系統上,內核能夠分派屬於同一進程的多個線程在多個處理器上運行,提升進程執行的並行度。
因爲須要內核完成線程的建立、調度和管理,因此和用戶級線程相比這些操做要慢得多,可是仍然比進程的建立和管理操做要快。
大多數市場上的操做系統,如Windows,Linux等都支持內核級線程。

如下是用戶線程和內核線程的接口圖,用戶線程空間中,並無進程-線程對應關係表,但內核線程中有

Java線程與系統內核線程關係模型

Java線程
JVM中建立線程有2種方式

1. new java.lang.Thread().start()
   2. 使用JNI將一個native thread attach到JVM中
   針對 new java.lang.Thread().start()這種方式,只有調用start()方法的時候,纔會真正的在

JVM中去建立線程,主要的生命週期步驟有:

1. 建立對應的JavaThread的instance
2. 建立對應的OSThread的instance
3. 建立實際的底層操做系統的native thread
4. 準備相應的JVM狀態,好比ThreadLocal存儲空間分配等
5. 底層的native thread開始運行,調用java.lang.Thread生成的Object的run()方法
6. 當java.lang.Thread生成的Object的run()方法執行完畢返回後,或者拋出異常終止後,終止native thread
7. 釋放JVM相關的thread的資源,清除對應的JavaThread和OSThread

針對JNI將一個native thread attach到JVM中,主要的步驟有:

1. 經過JNI call AttachCurrentThread申請鏈接到執行的JVM實例
2. JVM建立相應的JavaThread和OSThread對象
3. 建立相應的java.lang.Thread的對象
4. 一旦java.lang.Thread的Object建立以後,JNI就能夠調用Java代碼了
5. 當經過JNI call DetachCurrentThread以後,JNI就從JVM實例中斷開鏈接
6. JVM清除相應的JavaThread, OSThread, java.lang.Thread對象

3.爲何用到併發?併發會產生什麼問題

併發編程的本質其實就是利用多線程技術,在現代多核的CPU的背景下,催生了併發編程
的趨勢,經過併發編程的形式能夠將多核CPU的計算能力發揮到極致,性能獲得提高。除此之
外,面對複雜業務模型,並行程序會比串行程序更適應業務需求,而併發編程更能吻合這種業務拆分。

即便是單核處理器也支持多線程執行代碼,CPU經過給每一個線程分配CPU時間片來實現
這個機制。時間片是CPU分配給各個線程的時間,由於時間片很是短,因此CPU經過不停地切
換線程執行,讓咱們感受多個線程是同時執行的,時間片通常是幾十毫秒(ms)。
併發不等於並行:併發指的是多個任務交替進行,而並行則是指真正意義上的「同時進
行」。實際上,若是系統內只有一個CPU,而使用多線程時,那麼真實系統環境下不能並行,
只能經過切換時間片的方式交替進行,而成爲併發執行任務。真正的並行也只能出如今擁有多個CPU的系統中。

併發的優勢:

1. 充分利用多核CPU的計算能力;
2. 方便進行業務拆分,提高應用性能;

併發產生的問題:

高併發場景下,致使頻繁的上下文切換
臨界區線程安全問題,容易出現死鎖的,產生死鎖就會形成系統功能不可用

其它

CPU經過時間片分配算法來循環執行任務,當前任務執行一個時間片後會切換到下一個任務。
可是,在切換前會保存上一個任務的狀態,以便下次切換回這個任務時,能夠再加載這個任務的狀態。
因此任務從保存到再加載的過程就是一次上下文切換。

什麼是JMM模型?

Java內存模型(Java Memory Model簡稱JMM)是一種抽象的概念,並不真實存在,它描
述的是一組規則或規範,經過這組規範定義了程序中各個變量(包括實例字段,靜態字段和構
成數組對象的元素)的訪問方式。JVM運行程序的實體是線程,而每一個線程建立時JVM都會爲
其建立一個工做內存(有些地方稱爲棧空間),用於存儲線程私有的數據,而Java內存模型中規
定全部變量都存儲在主內存,主內存是共享內存區域,全部線程均可以訪問,但線程對變量的
操做(讀取賦值等)必須在工做內存中進行,首先要將變量從主內存拷貝的本身的工做內存空
間,而後對變量進行操做,操做完成後再將變量寫回主內存,不能直接操做主內存中的變量,
工做內存中存儲着主內存中的變量副本拷貝,前面說過,工做內存是每一個線程的私有數據區
域,所以不一樣的線程間沒法訪問對方的工做內存,線程間的通訊(傳值)必須經過主內存來完
成。

JMM不一樣於JVM內存區域模型 (JVM是是實際存在的,JMM只是邏輯規則)

JMM與JVM內存區域的劃分是不一樣的概念層次,更恰當說JMM描述的是一組規則,經過
這組規則控制程序中各個變量在共享數據區域和私有數據區域的訪問方式,JMM是圍繞原子性,有序性、可見性展開。

JMM與Java內存區域惟一類似點,都存在共享數據區域和私有數據區域,在JMM中主內存屬於共享數據區域,
從某個程度上講應該包括了堆和方法區,而工做內存數據線程私有數據區域,
從某個程度上講則應該包括程序計數器、虛擬機棧以及本地方法棧。

線程,工做內存,主內存工做交互圖(基於JMM規範):

主內存

主要存儲的是Java實例對象,全部線程建立的實例對象都存放在主內存中,無論該實例對
 象是成員變量仍是方法中的本地變量(也稱局部變量),固然也包括了共享的類信息、常量、靜
 態變量。因爲是共享數據區域,多條線程對同一個變量進行訪問可能會發生線程安全問題。

工做內存

主要存儲當前方法的全部本地變量信息(工做內存中存儲着主內存中的變量副本拷貝),每
個線程只能訪問本身的工做內存,即線程中的本地變量對其它線程是不可見的,就算是兩個線
程執行的是同一段代碼,它們也會各自在本身的工做內存中建立屬於當前線程的本地變量,當
然也包括了字節碼行號指示器、相關Native方法的信息。注意因爲工做內存是每一個線程的私有
數據,線程間沒法相互訪問工做內存,所以存儲在工做內存的數據不存在線程安全問題。

根據JVM虛擬機規範主內存與工做內存的數據存儲類型以及操做方式,對於一個實例對象中的成員方法而言,
若是方法中包含本地變量是基本數據類型(boolean,byte,short,char,int,long,float,double),
將直接存儲在工做內存的幀棧結構中,但假若本地變量是引用類型,那麼該變量的引用會存儲在功能內存的幀棧中,
而對象實例將存儲在主內存(共享數據區域,堆)中。但對於實例對象的成員變量,無論它是基本數據類型或者
包裝類型(Integer、Double等)仍是引用類型,都會被存儲到堆區。至於static變量以及類自己
相關信息將會存儲在主內存中。須要注意的是,在主內存中的實例對象能夠被多線程共享,倘
若兩個線程同時調用了同一個對象的同一個方法,那麼兩條線程會將要操做的數據拷貝一份到
本身的工做內存中,執行完成操做後才刷新到主內存

模型以下圖所示

JMM存在的必要性

在明白了Java內存區域劃分、硬件內存架構、Java多線程的實現原理與Java內存模型的具
體關係後,接着來談談Java內存模型存在的必要性。因爲JVM運行程序的實體是線程,而每一個
線程建立時JVM都會爲其建立一個工做內存(有些地方稱爲棧空間),用於存儲線程私有的數
據,線程與主內存中的變量操做必須經過工做內存間接完成,主要過程是將變量從主內存拷貝
的每一個線程各自的工做內存空間,而後對變量進行操做,操做完成後再將變量寫回主內存,如
果存在兩個線程同時對一個主內存中的實例對象的變量進行操做就有可能誘發線程安全問題。

假設主內存中存在一個共享變量x,如今有A和B兩條線程分別對該變量x=1進行操做,
A/B線程各自的工做內存中存在共享變量副本x。假設如今A線程想要修改x的值爲2,而B線程
卻想要讀取x的值,那麼B線程讀取到的值是A線程更新後的值2仍是更新前的值1呢?答案是,不肯定,
即B線程有可能讀取到A線程更新前的值1,也有可能讀取到A線程更新後的值2,
這是由於工做內存是每一個線程私有的數據區域,而線程A變量x時,首先是將變量從主內存拷貝到A
線程的工做內存中,而後對變量進行操做,操做完成後再將變量x寫回主內,而對於B線程的也
是相似的,這樣就有可能形成主內存與工做內存間數據存在一致性問題,假如A線程修改完後
正在將數據寫回主內存,而B線程此時正在讀取主內存,即將x=1拷貝到本身的工做內存中,
這樣B線程讀取到的值就是x=1,但若是A線程已將x=2寫回主內存後,B線程纔開始讀取的
話,那麼此時B線程讀取到的就是x=2,但究竟是哪一種狀況先發生呢?

如圖

以上關於主內存與工做內存之間的具體交互協議,即一個變量如何從主內存拷貝到工做內
存、如何從工做內存同步到主內存之間的實現細節,Java內存模型定義瞭如下八種操做來完
成。

JMM-同步八種操做介紹

(1)lock(鎖定):做用於主內存的變量,把一個變量標記爲一條線程獨佔狀態
(2)unlock(解鎖):做用於主內存的變量,把一個處於鎖定狀態的變量釋放出來,釋放後的變量才能夠被其餘線程鎖定
(3)read(讀取):做用於主內存的變量,把一個變量值從主內存傳輸到線程的工做內存中,以便隨後的load動做使用
(4)load(載入):做用於工做內存的變量,它把read操做從主內存中獲得的變量值放入工做內存的變量副本中
(5)use(使用):做用於工做內存的變量,把工做內存中的一個變量值傳遞給執行引擎
(6)assign(賦值):做用於工做內存的變量,它把一個從執行引擎接收到的值賦給工做內存的變量
(7)store(存儲):做用於工做內存的變量,把工做內存中的一個變量的值傳送到主內存中,以便隨後的write的操做
(8)write(寫入):做用於工做內存的變量,它把store操做從工做內存中的一個變量的值傳送到主內存的變量中

若是要把一個變量從主內存中複製到工做內存中,就須要按順序地執行read和load操做,
若是把變量從工做內存中同步到主內存中,就須要按順序地執行store和write操做。但Java內
存模型只要求上述操做必須按順序執行,而沒有保證必須是連續執行。

同步規則分析

1)不容許一個線程無緣由地(沒有發生過任何assign操做)把數據從工做內存同步回主內存中

2)一個新的變量只能在主內存中誕生,不容許在工做內存中直接使用一個未被初始化(load或者assign)的變量。
   即就是對一個變量實施use和store操做以前,必須先自行assign和load操做。

3)一個變量在同一時刻只容許一條線程對其進行lock操做,但lock操做能夠被同一線程重複執行屢次,屢次執行lock後,
  只有執行相同次數的unlock操做,變量纔會被解鎖。lock和unlock必須成對出現。

4)若是對一個變量執行lock操做,將會清空工做內存中此變量的值,在執行引擎使用這個變
  量以前須要從新執行load或assign操做初始化變量的值。

5)若是一個變量事先沒有被lock操做鎖定,則不容許對它執行unlock操做;也不容許去unlock一個被其餘線程鎖定的變量。

6)對一個變量執行unlock操做以前,必須先把此變量同步到主內存中(執行store和write操做)

併發編程的可見性,原子性與有序性問題

原子性

原子性指的是一個操做是不可中斷的,即便是在多線程環境下,一個操做一旦開始就不會
被其餘線程影響。
在java中,對基本數據類型的變量的讀取和賦值操做是原子性操做有點要注意的是,對於
32位系統的來講,long類型數據和double類型數據(對於基本數據類型,
byte,short,int,float,boolean,char讀寫是原子操做),它們的讀寫並不是原子性的,也就是說如
果存在兩條線程同時對long類型或者double類型的數據進行讀寫是存在相互干擾的,由於對
於32位虛擬機來講,每次原子讀寫是32位的,而long和double則是64位的存儲單元,這樣會
致使一個線程在寫時,操做完前32位的原子操做後,輪到B線程讀取時,剛好只讀取到了後32
位的數據,這樣可能會讀取到一個既非原值又不是線程修改值的變量,它多是「半個變
量」的數值,即64位數據被兩個線程分紅了兩次讀取。但也沒必要太擔憂,由於讀取到「半個變
量」的狀況比較少見,至少在目前的商用的虛擬機中,幾乎都把64位的數據的讀寫操做做爲原
子操做來執行,所以對於這個問題沒必要太在乎,知道這麼回事便可。
X=10;  //原子性(簡單的讀取、將數字賦值給變量)
Y = x;  //變量之間的相互賦值,不是原子操做
X++;  //對變量進行計算操做
X = x+1;

可見性

理解了指令重排現象後,可見性容易了,可見性指的是當一個線程修改了某個共享變量的
值,其餘線程是否可以立刻得知這個修改的值。對於串行程序來講,可見性是不存在的,由於
咱們在任何一個操做中修改了某個變量的值,後續的操做中都能讀取這個變量值,而且是修改
過的新值。
但在多線程環境中可就不必定了,前面咱們分析過,因爲線程對共享變量的操做都是線程
拷貝到各自的工做內存進行操做後才寫回到主內存中的,這就可能存在一個線程A修改了共享
變量x的值,還未寫回主內存時,另一個線程B又對主內存中同一個共享變量x進行操做,但
此時A線程工做內存中共享變量x對線程B來講並不可見,這種工做內存與主內存同步延遲現象
就形成了可見性問題,另外指令重排以及編譯器優化也可能致使可見性問題,經過前面的分
析,咱們知道不管是編譯器優化仍是處理器優化的重排現象,在多線程環境下,確實會致使程
序輪序執行的問題,從而也就致使可見性問題。

有序性

有序性是指對於單線程的執行代碼,咱們老是認爲代碼的執行是按順序依次執行的,這樣
的理解並無毛病,畢竟對於單線程而言確實如此,但對於多線程環境,則可能出現亂序現
象,由於程序編譯成機器碼指令後可能會出現指令重排現象,重排後的指令與原指令的順序未
必一致,要明白的是,在Java程序中,假若在本線程內,全部操做都視爲有序行爲,若是是多
線程環境下,一個線程中觀察另一個線程,全部操做都是無序的,前半句指的是單線程內保
證串行語義執行的一致性,後半句則指指令重排現象和工做內存與主內存同步延遲現象。

JMM如何解決原子性&可見性&有序性問題?

原子性問題

除了JVM自身提供的對基本數據類型讀寫操做的原子性外,能夠經過 synchronized和Lock實現原子性。
 由於synchronized和Lock可以保證任一時刻只有一個線程訪問該代碼塊。

可見性問題

volatile關鍵字保證可見性。當一個共享變量被volatile修飾時,它會保證修改的值當即被
其餘的線程看到,即修改的值當即更新到主存中,當其餘線程須要讀取時,它會去內存中讀取
新值。synchronized和Lock也能夠保證可見性,由於它們能夠保證任一時刻只有一個線程能
訪問共享資源,並在其釋放鎖以前將修改的變量刷新到內存中

有序性問題

在Java裏面,能夠經過volatile關鍵字來保證必定的「有序性」(具體原理在下一節講述
volatile關鍵字)。另外能夠經過synchronized和Lock來保證有序性,很顯然,synchronized
和Lock保證每一個時刻是有一個線程執行同步代碼,至關因而讓線程順序執行同步代碼,天然就
保證了有序性。

Java內存模型:

每一個線程都有本身的工做內存(相似於前面的高速緩存)。線程對變量的
全部操做都必須在工做內存中進行,而不能直接對主存進行操做。而且每一個線程不能訪問其餘
線程的工做內存。Java內存模型具有一些先天的「有序性」,即不須要經過任何手段就可以得
到保證的有序性,這個一般也稱爲happens-before 原則。若是兩個操做的執行次序沒法從
happens-before原則推導出來,那麼它們就不能保證它們的有序性,虛擬機能夠隨意地對它
們進行重排序。

指令重排序:

java語言規範規定JVM線程內部維持順序化語義。即只要程序的最終結果與
它順序化狀況的結果相等,那麼指令的執行順序能夠與代碼順序不一致,此過程叫指令的重排
序。指令重排序的意義是什麼?JVM能根據處理器特性(CPU多級緩存系統、多核處理器等)
適當的對機器指令進行重排序,使機器指令能更符合CPU的執行特性,最大限度的發揮機器性
能

下圖爲從源碼到最終執行的指令序列示意圖

as-if-serial語義

as-if-serial語義的意思是:無論怎麼重排序(編譯器和處理器爲了提升並行度),(單線
    程)程序的執行結果不能被改變。編譯器、runtime和處理器都必須遵照as-if-serial語義。
    爲了遵照as-if-serial語義,編譯器和處理器不會對存在數據依賴關係的操做作重排序,因
    爲這種重排序會改變執行結果。可是,若是操做之間不存在數據依賴關係,這些操做就可能被
    編譯器和處理器重排序。

happens-before 原則

只靠sychronized和volatile關鍵字來保證原子性、可見性以及有序性,那麼編寫併發程序可能會顯得十分麻煩,
幸運的是,從JDK5開始,Java使用新的JSR-133內存模型,提供了happens-before原則來輔助保證程序執行的原子性、可見性以及有序性的問題,
它是判斷數據是否存在競爭、線程是否安全的依據,happens-before 原則內容以下

1. 程序順序原則,即在一個線程內必須保證語義串行性,也就是說按照代碼順序執行。

2. 鎖規則解鎖(unlock)操做必然發生在後續的同一個鎖的加鎖(lock)以前,也就是說,
    若是對於一個鎖解鎖後,再加鎖,那麼加鎖的動做必須在解鎖動做以後(同一個鎖)。
    
3. volatile規則 volatile變量的寫,先發生於讀,這保證了volatile變量的可見性,簡單
    的理解就是,volatile變量在每次被線程訪問時,都強迫從主內存中讀該變量的值,而當
    該變量發生變化時,又會強迫將最新的值刷新到主內存,任什麼時候刻,不一樣的線程老是能
    夠看到該變量的最新值。
    
4. 線程啓動規則 線程的start()方法先於它的每個動做,即若是線程A在執行線程B的
    start方法以前修改了共享變量的值,那麼當線程B執行start方法時,線程A對共享變量
    的修改對線程B可見
    
5. 傳遞性 A先於B ,B先於C 那麼A必然先於C

6. 線程終止規則 線程的全部操做先於線程的終結,Thread.join()方法的做用是等待當前
    執行的線程終止。假設在線程B終止以前,修改了共享變量,線程A從線程B的join方法
    成功返回後,線程B對共享變量的修改將對線程A可見。
    
7. 線程中斷規則 對線程 interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中
    斷事件的發生,能夠經過Thread.interrupted()方法檢測線程是否中斷。
    
8. 對象終結規則 對象的構造函數執行,結束先於finalize()方法

volatile內存語義
volatile是Java虛擬機提供的輕量級的同步機制。
volatile保證可見性與有序性,可是不能保證原子性,要保證原子性須要藉助synchronized、Lock鎖機制,同理也能保證有序性與可見性。
由於synchronized和Lock可以保證任一時刻只有一個線程訪問該代碼塊。

volatile關鍵字有以下兩個做用

保證被volatile修飾的共享變量對全部線程總數可見的,也就是當一個線程修改了一個被volatile修飾共享變量的值,新值老是能夠被其餘線程當即得知。
禁止指令重排序優化。

volatile的可見性
關於volatile的可見性做用,咱們必須意識到被volatile修飾的變量對全部線程總數當即可見的,會showtime,底層被編譯的時候會有lock信號
對volatile變量的全部寫操做老是能馬上反應到其餘線程中
示例

/
     * 如下代碼先執行線程A,一直執行i++,而後執行線程B,更改initFlag的值爲true,想要退出循環
     * 可是若是變量不加volatile或者不加鎖,因爲線程A中的initFlag從第一次從主內存中load到線程A工做內存後
     * 一直使用的線程A的緩存數據,即使在線程B中更改了initFlag,可是並無showtime給線程A
     * 線程A使用的仍然是他緩存中的,並無去主內存中獲取,因此當前代碼要實現initFlag可見,
     * 能夠加volatile關鍵字實現volatile寫(更改後必定會寫到主內存中而且會showtime),(保證可見性)
     * 或者加同步代碼塊synchronized
     *      加synchronized緣由:看代碼第三版
     *
     */
    public class VolatileVisibilitySample {
        private boolean  initFlag = false;
        static Object object = new Object();
        public void refresh(){
            //普通寫操做,(主要改爲volatile寫就能夠)
            this.initFlag = true;
            String threadname = Thread.currentThread().getName();
            System.out.println("線程:"+threadname+":修改共享變量initFlag");
        }
        public void load(){
            String threadname = Thread.currentThread().getName();
            int i = 0;
    
    
            // 初版 initFlag沒加volatile,後面的打印不會出現  (空跑會一直佔用CPU使用權,優先級別很是高)
            //while (!initFlag){ }
    
            // 第二版,加一個變量 initFlag沒加volatile,後面的打印不會出現,由於i和他不要緊
            //while (!initFlag){ i++;}
    
            // 第三版 加同步塊 initFlag沒加volatile,後面的打印會出現,
            // 存在同步塊,這裏可能引發阻塞,競爭可能致使上下文切換,線程的上下文切換會把線程的信息等數據回寫到內存的 任務狀態段 裏面
            // 因此可能從新去主內存load數據,能知道initFlag已經改變,得以更新線程A的內存副本
            while (!initFlag){
                synchronized (object){
                    i++;
                }
            }
            System.out.println("線程:"+threadname+"當前線程嗅探到initFlag的狀態的改變"+i);
        }
        public static void main(String[] args){
            VolatileVisibilitySample sample = new VolatileVisibilitySample();
            Thread threadA = new Thread(()->{ sample.refresh(); },"threadA");
            Thread threadB = new Thread(()->{ sample.load(); },"threadB");
            threadB.start();
            try {
                 Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            threadA.start();
        }
    }

volatile沒法保證原子性

public class VolatileVisibility {
        public  static  volatile int  i =0;
        public  static  void  increase(){i++;}
    }

    在併發場景下,i變量的任何改變都會立馬反應到其餘線程中,可是如此存在多條線程同時調用increase()方法的話,
    就會出現線程安全問題,畢竟i++;操做並不具有原子性,該操做是先讀取值,而後寫回一個新值,至關於原來的值加上1,
    分兩步完成,若是第二個線程在第一個線程讀取舊值和寫回新值期間讀取i的域值,那麼第二個線程就會與第一個線程一塊兒看到同一
    個值,並執行相同值的加1操做,這也就形成了線程安全失敗,所以對於increase方法必須使用synchronized修飾,以便保證線程安全,需
    要注意的是一旦使用synchronized修飾方法後,因爲synchronized自己也具有與volatile相同的特性,便可見性,
    所以在這樣種狀況下就徹底能夠省去volatile修飾變量

volatile禁止重排優化

volatile關鍵字另外一個做用就是禁止指令重排優化,從而避免多線程環境下程序出現亂序
執行的現象,關於指令重排優化前面已詳細分析過,這裏主要簡單說明一下volatile是如何實
現禁止指令重排優化的。先了解一個概念,內存屏障(Memory Barrier)。
內存屏障,又稱內存柵欄,是一個CPU指令,它的做用有兩個,一是保證特定操做的執行順序,
二是保證某些變量的內存可見性(利用該特性實現volatile的內存可見性)。
因爲編譯器和處理器都能執行指令重排優化。若是在指令間插入一條Memory Barrier則會告訴編譯器和CPU,
無論什麼指令都不能和這條Memory Barrier指令重排序,也就是說經過插入內存屏
障禁止在內存屏障先後的指令執行重排序優化。Memory Barrier的另一個做用是強制刷出
各類CPU的緩存數據,所以任何CPU上的線程都能讀取到這些數據的最新版本。
總之,volatile變量正是經過內存屏障實現其在內存中的語義,便可見性和禁止重排優化。

下面看一個很是典型的禁止重排優化的例子DCL,以下

    /
     * volatile保證指令重排(原理是插入了屏障)
     */
    public class Singleton {
    
        /
         * 查看彙編指令
         * -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp
         */
        private volatile static Singleton myinstance;
    
        public static Singleton getInstance() {
            if (myinstance == null) {
                synchronized (Singleton.class) {
                    if (myinstance == null) {
    
                        //多線程環境下可能會出現問題的地方
    
                        / 對象建立過程,本質能夠分文三步
                         * 1. 申請地址 address=allocate
                         * 2.地址上實例化對象 new Singleton()
                         * 3.第三步 myinstance=address
                         *
                         * 要加volatile關鍵字,爲了阻止指令重排,緣由:
                         *  其中這三步沒法保證原子性,第二步和第三步可能存在指令重排
                         *  當很高的高併發請求下,若是不進行兩層判斷,
                         *  若是程序執行了第一步申請地址以後
                         *  若是第三步和第二步進行了指令重排,那麼會致使myinstance=address
                         *  可是這時候address是空的,在使用的時候就會報錯
                         *
                         *
                         *
                         */
                        myinstance = new Singleton();
                        //對象延遲初始化
                        //
                    }
                }
            }
            return myinstance;
        }
        public static void main(String[] args) {
            Singleton.getInstance();
        }
    
        /
         * 若是在多線程環境下就能夠出現線程安全問題。緣由在於某一個線程執行到第一次檢測,讀
         * 取到的instance不爲null時,instance的引用對象可能沒有完成初始化。
         * 由於instance = new Singleton();能夠分爲如下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不存在數據依賴關係,並且不管重排前仍是重排後程序的執行結果在單
         * 線程中並無改變,所以這種重排優化是容許的。可是指令重排只會保證串行語義的執行的一
         * 致性(單線程),但並不會關心多線程間的語義一致性。因此當一條線程訪問instance不爲null
         * 時,因爲instance實例未必已初始化完成,也就形成了線程安全問題。那麼該如何解決呢,很
         * 簡單,咱們使用volatile禁止instance變量被執行指令重排優化便可。
         * 
         //禁止指令重排優化
         private volatile static Singleton myinstance;
         */
    }

指令重排+讀寫屏障實例

/
 * 指令重排,不容許使用volatile的話,手動插入屏障理解
 *
 * 從代碼上理解,正常的邏輯思惟狀況下下,可能打印的結果只有三種
 *  1,1 (當線程1執行了a=1,同時線程2執行了b=1的時候)
 *  1,0(當線程1執行了a=1,線程2還沒執行,y=b取了默認值)
 *  0,1(當線程2執行了b=1,線程1還沒執行,x=a取了默認值)
 *
 *  指令重排的結果
 *  0,0(a=1和x=b進行了指令重排,b=1和y=a進行了指令重排,xy都取了ab默認值)
 *
 */
public class VolatileReOrderSample {
    private static int x = 0, y = 0;
    private static int a = 0, b =0;
    //private volatile static int a = 0, b =0;
    static Object object = new Object();

    public static void main(String[] args) throws InterruptedException {
        int i = 0;

        for (;;){
            i++;
            x = 0; y = 0;
            a = 0; b = 0;
            Thread t1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    //因爲線程one先啓動,下面這句話讓它等一等線程two. 讀着可根據本身電腦的實際性能適當調整等待時間.
                    shortWait(10000);
                    a = 1; //是讀仍是寫?store,volatile寫
                    //storeload ,讀寫屏障,不容許volatile寫與第二部volatile讀發生重排
                    //手動加內存屏障
                    //UnsafeInstance.reflectGetUnsafe().storeFence();

                    // 若是a,b使用volatile修飾,防止指令重排:這個操做 先讀volatile,而後寫普通變量b
                    x = b;
                    //分兩步進行,第一步先volatile讀,第二步再普通寫
                }
            });
            Thread t2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    //UnsafeInstance.reflectGetUnsafe().storeFence();
                    y = a;
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            String result = "第" + i + "次 (" + x + "," + y + ")";
            if(x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }

    }
    public static void shortWait(long interval){
        long start = System.nanoTime();
        long end;
        do{
            end = System.nanoTime();
        }while(start + interval >= end);
    }
}

volatile內存語義的實現

前面提到太重排序分爲編譯器重排序和處理器重排序。爲了實現volatile內存語義,JMM
會分別限制這兩種類型的重排序類型。
下面是JMM針對編譯器制定的volatile重排序規則表

    是否能重排序                    第二個操做
      第一個操做       普通讀/寫      volatile讀      volatile寫
      普通讀/寫                                        NO
      volatile讀       NO              NO             NO 
      volatile寫                       NO             NO

舉例來講,第三行最後一個單元格的意思是:

在程序中,當第一個操做爲普通變量的讀或寫時,若是第二個操做爲volatile寫,則編譯器不能重排序這兩個操做。
從上圖能夠看出:

當第二個操做是volatile寫時,無論第一個操做是什麼,都不能重排序。
這個規則確保volatile寫以前的操做不會被編譯器重排序到volatile寫以後。

當第一個操做是volatile讀時,無論第二個操做是什麼,都不能重排序。
這個規則確保volatile讀以後的操做不會被編譯器重排序到volatile讀以前。

當第一個操做是volatile寫,第二個操做是volatile讀時,不能重排序。

爲了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。
對於編譯器來講,發現一個最優佈置來最小化插入屏障的總數幾乎不可能。
爲此,JMM採起保守策略。下面是基於保守策略的JMM內存屏障插入策略。

∙在每一個volatile寫操做的前面插入一個StoreStore屏障。
∙在每一個volatile寫操做的後面插入一個StoreLoad屏障。
∙在每一個volatile讀操做的後面插入一個LoadLoad屏障。
∙在每一個volatile讀操做的後面插入一個LoadStore屏障。

上述內存屏障插入策略很是保守,但它能夠保證在任意處理器平臺,任意的程序中都能得
到正確的volatile內存語義。
下面是保守策略下,volatile寫插入內存屏障後生成的指令序列示意圖

上圖中StoreStore屏障能夠保證在volatile寫以前,其前面的全部普通寫操做已經對任意處理器可見了。
這是由於StoreStore屏障將保障上面全部的普通寫在volatile寫以前刷新到主內存。

這裏比較有意思的是,volatile寫後面的StoreLoad屏障。此屏障的做用是避免volatile寫與後面可能有的volatile讀/寫操做重排序。
由於編譯器經常沒法準確判斷在一個volatile寫的後面是否須要插入一個StoreLoad屏障(好比,一個volatile寫以後方法當即return)。

爲了保證能正確實現volatile的內存語義,JMM在採起了保守策略:

在每一個volatile寫的後面,或者在每一個volatile 讀的前面插入一個StoreLoad屏障。從整
體執行效率的角度考慮,JMM最終選擇了在每一個 volatile寫的後面插入一個StoreLoad屏障。

由於volatile寫-讀內存語義的常見使用模式是:

一個寫線程寫volatile變量,多個讀線程讀同一個volatile變量。
當讀線程的數量大大超過寫線程時,選擇在volatile寫以後插入StoreLoad屏障將帶來可觀的執行效率的提高。
從這裏能夠看到JMM 在實現上的一個特色:首先確保正確性,而後再去追求執行效率。

下圖是在保守策略下,volatile讀插入內存屏障後生成的指令序列示意圖

上圖中LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序。
LoadStore屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序。
上述volatile寫和volatile讀的內存屏障插入策略很是保守。在實際執行時,只要不
改變 volatile寫-讀的內存語義,編譯器能夠根據具體狀況省略沒必要要的屏障。
下面經過具體的示例

public class VolatileBarrierExample {
    int a;
    volatile int m1 = 1;
    volatile int m2 = 2;

    void readAndWrite() {
        int i = m1;   // 第一個volatile讀
        int j = m2;   // 第二個volatile讀

        a = i + j;    // 普通寫

        m1 = i + 1;   // 第一個volatile寫
        m2 = j * 2;   // 第二個 volatile寫
    }
}

針對readAndWrite()方法,編譯器在生成字節碼時能夠作以下的優化

注意,最後的StoreLoad屏障不能省略。由於第二個volatile寫以後,方法當即return。
此時編 譯器可能沒法準確判定後面是否會有volatile讀或寫,爲了安全起見,編譯器一般會在這裏插 入一個StoreLoad屏障。
上面的優化針對任意處理器平臺,因爲不一樣的處理器有不一樣「鬆緊度」的處理器內 存模 型,
內存屏障的插入還能夠根據具體的處理器內存模型繼續優化。以X86處理器爲
例,圖3-21 中除最後的StoreLoad屏障外,其餘的屏障都會被省略。
前面保守策略下的volatile讀和寫,在X86處理器平臺能夠優化成以下圖所示。前文提到過,X86處理器僅會對寫-讀操做作重排序。
X86不會對讀-讀、讀-寫和寫-寫操做作重排序,所以在X86處理器中會省略掉這3種操做類型對應的內存屏障。
在X86中,JMM僅需 在volatile寫後面插入一個StoreLoad屏障便可正確實現volatile寫-讀的內存
語義。這意味着在 X86處理器中,volatile寫的開銷比volatile讀的開銷會大不少(由於執行StoreLoad屏障開銷會比較大)

過多使用cas(compareandswap)和volatile致使的bus總線風暴
volatile 基於底層緩存一致協議

cpu --> 工做內存 --> bus總線(緩存一致性協議) ---> 主內存
cpu1 --> 工做內存 --> bus總線(緩存一致性協議) ---> 主內存

若是使用volatile特別多或者熱別多原子的cas,會致使工做內存見產生特別多無效工做內存變量,因爲volatile在bus中無限showtime, 致使bus總線交互變得特別多,其餘有意義的操做交互變得延遲 這時候和synchronized比較,還不如使用synchronized

相關文章
相關標籤/搜索