JVM學習筆記(三):Java內存模型

引子

在上一篇文章《JVM學習筆記(二):JVM GC機制與垃圾收集器》中,我總結了一下JVM的GC機制,並且結合着自己寫的實例,分析了一下 標記-清除算法 中的標記過程;同時,我還總結了一下 垃圾收集器 相關的知識點。接下來,在這篇文章中,我就總結一下 Java的內存模型。

緩存與緩存一致性

計算機在處理任務的時候,與內存交互的I/O操作是不可避免的。而計算機的存儲效率與處理器的運算速度有着幾個數量級的差距,所以,現代計算機系統都會加上一層 高速緩存 來作爲內存與處理器之間的緩衝:將運算需要使用的數據複製到緩存中,讓運算能快速進行;當運算結束後,再將數據從緩存同步回內存之中。這樣,處理器就無需等待緩慢的內存讀寫了。

高速緩存解決了處理器與內存讀寫效率之間的矛盾,但是又引入了一個新的問題:緩存一致性(Cache Coherence)。在多處理器系統中,每個處理器都有自己的高速緩存,而他們又共享同一個主內存(Main Memory)。當多個處理器的運算任務都涉及到同一塊主內存區域時,可能各自的緩存並不一致,那麼,同步回主內存時該以誰的緩存數據爲準呢?

爲了解決一致性的問題,需要各個處理器訪問緩存時都遵循一些協議。如下圖:

Java內存模型

Java內存模型主要是用於定義程序中各種變量的訪問規則,也就是 虛擬機把變量值存儲到內存、從內存中取出變量值 這樣的底層細節。這裏所謂的變量,和Java編程中的變量有些不同,這裏的的變量包括了實例字段、靜態字段和構成數組對象的元素,不包含 局部變量和方法參數(此二者是線程私有的,不會被共享,也就不存在競爭)。

Java線程與內存的交互

Java內存模型規定了 所有的變量都存儲在 主內存 中,每條線程還有自己的工作內存。線程的工作內存中保存了被該線程使用的變量在主內存的副本,線程對變量的所有操作都必須在 工作內存 中進行,而不能直接讀寫主內存中的數據。不同的線程之間也無法直接訪問對方工作線程中的變量,線程間變量值的傳遞均需要通過主內存來完成。

如上圖,Java線程、工作內存以及主內存之間的交互關係如圖所示。

原子性、可見性和有序性

Java內存模型是圍繞着在併發過程中如何處理原子性、可見性和有序性這三個特徵展開的。

原子性

原子性指一個操作是不可中斷的。即使多個線程一起執行的時候,一個操作一旦開始,就不會被其他線程干擾。

簡單來說,我們大致可以認爲,基本類型數據的訪問、讀寫都是具備原子性的。(long 和 double 的非原子性協定,後文講)

此外,在 synchronized塊 內的操作也具有原子性。

long和double的非原子協定

對於64位的數據類型(long 和 double),Java內存模型中特別定義了一條寬鬆的規定:允許虛擬機將沒有被 volatile 修飾的64位數據的讀寫操作劃分爲 兩次32位的操作來進行。即 允許虛擬機實現自行選擇是否要保證64位數據類型的讀寫操作的原子性。

也就是說,對於32位的系統來說,long類型的數據讀寫就不是原子性的。而針對 double類型,現代中央處理器中,一般都包含專門用於處理浮點數據的浮點運算器(Floating Point Unit,FPU),用來專門處理單、雙精度的浮點數據。所以,哪怕是32位的虛擬機中通常也不會出現非原子性訪問的問題。

可見性

可見性是指當一個線程修改了共享變量的值時,其他線程能夠立即得知這個修改。顯然,對串行執行的程序來說,不存在可見性問題。

Java內存模型是通過 在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量值 這種依賴主內存作爲傳遞媒介的方式來實現可見性的,無論是 普通變量還是 volatile 變量都是如此。

除了 volatile 之外,synchronized 和 final 關鍵字也可以實現可見性,就不展開講了。

有序性

有序性可以被總結爲:如果從本線程內觀察,所有操作都是有序的;如果在一個線程中觀察另一個線程,所有操作都是無序的。前半句是指“線程內表現爲串行語義”,後半句是指“指令重排”現象和“工作內存與主內存同步延遲”現象。

Java提供了 volatile 和 synchronized 兩個關鍵字來保證線程之間操作的有序性。

volatile 關鍵字

前文也有簡單的描述了,volatile 可以是說是Java虛擬機提供的最輕量級的同步機制。

當一個變量被定義成 volatile 後,它將具備下面兩條特性:

1、保證此變量對所有線程的可見性。

當一條線程修改了這個變量的值,新值對於其他線程來說是可以立即得知的。而普通變量的值在線程間傳遞時均需要通過 主內存 來完成。

從物理角度看,各個線程的工作內存中 volatile 變量也可以存在不一致的情況,但由於每次使用之前都要先刷新,執行引擎看不到不一致的情況,因此可以認爲 volatile 變量在各個線程的工作內存中是不存在一致性問題的。但是,Java中的運算操作符並非原子操作,這將導致volatile 變量的運算在併發下一樣是不安全的。

由於 volatile 只能保證可見性,在一些場合我們仍然需要通過加鎖來保證原子性。

2、禁止指令重排序優化

指令重排對於提高CPU處理性能是十分必要的,這裏不展開講。

Happens-Before原則

Java虛擬機和執行系統會對指令進行一定的重排,但是重排是有原則的。下面就是一些必須遵循的規則:

  • 程序次序原則:一個線程內保證語義的串行性。
  • 管城鎖定原則:unlock 操作必然先發生於後面對同一個鎖的 lock 操作。
  • volatile變量規則:對同一個 volatile 變量的寫操作必然先發生於後面對這個變量的讀操作。
  • 線程啓動原則:Thread.start()方法先行發生於此線程的每一個動作。
  • 線程終止原則:線程中所有的操作都先行發生於對線程的終止檢測。
  • 線程中斷原則:對線程 interrupt() 方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生。
  • 對象終結原則:一個對象的初始化完成(構造方法執行結束)先行發生於它的 finalize() 方法的開始。
  • 傳遞性:如果操作A先於操作B,而操作B先於操作C,則操作A先於操作C。

CPU緩存優化-僞共享問題解決

前文我們提到過,爲了解決處理器與內存讀寫 效率之間的矛盾,CPU有一個高速緩存。在這個緩存中,讀寫數據的最小單位爲 緩存行(Cache Line),它是從主內存複製到緩存的最小單位。

當兩個變量被放在同一個緩存行時,在多線程環境下,可能影響兩個變量的讀寫。

如上圖,假設 變量X 和 變量Y 被放在了同一個緩存行。當CPU1更新了X值後,X值被同步回主內存。此時如果CPU2需要鎖定並讀取Y的值,那在這個操作之前,CPU2中X和Y所在的緩存行將被會清空,並且重新衝主內存同步。也就是說,X的更新導致了在同一個緩存行的Y緩存失效了。之後如果CPU2又更新了 Y,那又會導致CPU1上的 X緩存 也會失效。

爲了避免這種情況,一種可行的做法是在變量 X 的前後空間都加入一些填充(padding)。這樣,當數據被從內存讀入緩存時,這個緩存行中就只有 X 這一個有效的變量。

如上圖,當CPU1更新X後,X值被同步回主內存。若CPU2需要訪問Y,則直接可以通過緩存讀取;如果CPU2需要訪問X,則CPU2中X所在緩存行會失效,並重新從主內存同步。整個過程CPU2中的Y的緩存行並不受影響。

需要補充說明的是,在JDK8中,Java並不採用這種加 padding 的方式來解決僞共享問題(例如:LongAdder中的僞共享問題解決),而是引入了一個全新的註解 @sun.misc.Contended

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE})
public @interface Contended {
    String value() default "";
}

類或者屬性標註了這個註解的後,JVM將自動爲標註了這個註解的元素解決僞共享問題。

在我們自己寫的代碼中也可以使用這個註解,但是需要額外的虛擬機啓動參數:-XX:-RestrictContended。否則,這個註解將被忽略。

總結

文中這些東西,書上都有。但是,自己學習、並總結一下,也是一種學習的好方法。加油吧,劍已配妥,轉身殺入江湖。

參考文檔

1、《深入理解Java虛擬機》第三版,周志明·著,第十二章。

2、《實戰·Java高併發程序設計》第二版,葛一鳴 郭超 著,第一章、第二章、第五章、第六章。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章