引言
在現代計算機中,cpu的指令速度遠超內存的存取速度,由於計算機的存儲設備與處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度盡可能接近處理器運算速度的高速緩存(Cache)來作為內存與處理器之間的緩沖:將運算需要使用到的數據復制到緩存中,讓運算能快速進行,當運算結束后再從緩存同步回內存之中,這樣處理器就無須等待緩慢的內存讀寫了。
基於高速緩存的存儲交互很好地解決了處理器與內存的速度矛盾,但是也為計算機系統帶來更高的復雜度,因為它引入了一個新的問題:緩存一致性(Cache Coherence)。在多處理器系統中,每個處理器都有自己的高速緩存,而它們又共享同一主內存(MainMemory)。當多個處理器的運算任務都涉及同一塊主內存區域時,將可能導致各自的緩存數據不一致,舉例說明變量在多個CPU之間的共享。如果真的發生這種情況,那同步回到主內存時以誰的緩存數據為准呢?為了解決一致性的問題,需要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。

一、JMM(Java Memory Model)
java虛擬機規范定義java內存模型屏蔽掉各種硬件和操作系統的內存訪問差異,以實現讓java程序在各種平台下都能達到一致的並發效果。
java內存模型規定了一個線程如何和何時可以看到由其他線程修改過后的共享變量的值,以及在必須時如何同步的訪問共享變量。
注意:我們這里強調的是共享變量,不是私有變量。
java內存模型規定了所有的變量都存儲在主內存中(JVM內存的一部分)。每條線程都有自己的工作內存,工作內存中保存了該線程使用的主內存中共享變量的副本,線程對變量的所有操作(讀取、賦值等)都必須在工作內存中進行,而不能直接讀寫主內存中的變量;工作內存在線程間是隔離的,不能直接訪問對方工作內存中的變量。所以在多線程操作共享變量時,就通過JMM來進行控制。
我們來看一看線程,工作內存、主內存三者的交互關系圖。

二、JMM的8種內存交互操作
9龍就疑問,JMM是如何保證並發下數據的一致性呢?
內存交互操作有8種,虛擬機實現必須保證每一個操作都是原子的,不可再分的(對於double和long類型的變量來說,load、store、read和write操作在某些平台上允許例外)
- lock (鎖定):作用於主內存的變量,把一個變量標識為線程獨占狀態。
- read (讀取):作用於主內存變量,它把一個變量的值從主內存傳輸到線程的工作內存中,以便隨后的load動作使用。
- load (載入):作用於工作內存的變量,它把read操作從主存中得到變量放入工作內存的變量副本中。
- use (使用):作用於工作內存中的變量,它把工作內存中的變量傳輸給執行引擎,每當虛擬機遇到一個需要使用到變量的值的字節碼指令時將會執行這個操作。
- assign (賦值):作用於工作內存中的變量,它把一個從執行引擎中接受到的值賦值給工作內存的變量副本中,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
- store (存儲):作用於工作內存中的變量,它把一個從工作內存中一個變量的值傳送到主內存中,以便后續的write使用。
- write (寫入):作用於主內存中的變量,它把store操作從工作內存中得到的變量的值放入主內存的變量中。
- unlock (解鎖):作用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放后的變量才可以被其他線程鎖定。

如果是將變量從主內存復制到工作內存,必須先執行read,后執行load操作;如果是將變量從工作內存同步到主內存,必須先執行store,后執行write。JMM要求read和load, store和write必須按順序執行,但不是必須連續執行,中間可以插入其他的操作。
2.1、JMM指令使用規則
- 不允許read和load、store和write操作之一單獨出現。即使用了read必須load,使用了store必須write
- 不允許線程丟棄他最近的assign操作,即工作變量的數據改變了之后,必須告知主存
- 不允許一個線程將沒有assign的數據從工作內存同步回主內存
- 一個新的變量必須在主內存中誕生,不允許工作內存直接使用一個未被初始化的變量。就是對變量實施use、store操作之前,必須經過assign和load操作
- 一個變量同一時間只有一個線程能對其進行lock。多次lock后,必須執行相同次數的unlock才能解鎖
- 如果對一個變量進行lock操作,會清空所有工作內存中此變量的值,在執行引擎使用這個變量前,必須重新load或assign操作初始化變量的值
- 如果一個變量沒有被lock,就不能對其進行unlock操作。也不能unlock一個被其他線程鎖住的變量
- 對一個變量進行unlock操作之前,必須把此變量同步回主內存
三、volatile
很多並發編程中都使用了volatile,你知道為什么一個變量要使用volatile修飾嗎?
volatile有兩個語義:
- volatile可以保證線程間變量的可見性。
- volatile禁止CPU進行指令重排序。
volatile修飾的變量,如果某個線程更改了變量值,其他線程可以立即觀察到這個值。而普通變量不能做到這一點,變量值在線程間傳遞均需要主內存來完成。如果線程修改了普通變量值,則需要刷新回主內存,另一個線程需要從主內存重新讀取才能知道最新值。
3.1、volatile只能保證可見性,不能保證原子性
雖然volatile只能保證可見性,但不能認為volatile修飾的變量可以在並發下是線程安全的。
public class VolatileTest {
/**
* 進行自增操作的變量
* 使用volatile修飾
*/
private static volatile int count;
public static void main(String[] args) {
int threadNums = 2000;
ExecutorService service = Executors.newCachedThreadPool();
for (int i = 0; i < threadNums; i++) {
service.execute(VolatileTest::addCount);
}
System.out.println(count);
service.shutdown();
}
private static void addCount() {
count++;
}
}
//輸出結果
//1994
我們可以從例子中看出,共享變量使用了volatile修飾,啟動2000個線程對其進行自增操作,如果是線程安全的,結果應該是2000;但結果卻小於2000。證明volatile修飾的變量並不能保證原子性,如果想保證原子性,還需要額外加鎖。
3.2、volatile禁止指令重排序
雖然程序從表象上看到是按照我們書寫的順序進行執行,但由於CPU可能會由於性能原因,對執行指令進行重排序,以此提高性能。
比如我們有一個方法是關於“談戀愛”的方法。偽代碼如下
{
//線程A執行1,2,3
//1、先認識某個女生,有好感
//2、開展追求
//3、追求成功
//線程B,等待線程A追求成功后開始進入甜蜜的愛情
while(!追求成功){
sleep();
}
//一起看電影,吃飯,牽手,接吻,xxx
}
我們看到線程A需要執行3步,由於cpu執行重排序優化,可能執行順序變為1、3、2,亂套了,剛認識別人就成功了,接着就牽手,接吻,然后可能再執行追求的過程。。。。。。。。不敢想象,我還只是個孩子啊。這就是指令重排序可能在多線程環境下出現的問題。
如果我們使用volatile修飾“追求成功”的變量,則可以禁止CPU進行指令重排序,讓談戀愛是一件輕松而快樂的事情。
volatile使用內存屏障來禁止指令重排序。
在每個volatile寫操作的前面插入一個StoreStore屏障,在每個volatile寫操作的后面插入一個StoreLoad屏障。

在每個volatile讀操作的后面插入一個LoadLoad屏障,在每個volatile讀操作的后面插入一個LoadStore屏障。

四、原子性、可見性、順序性
我們看到JMM圍繞這三個特征來建立的。
4.1、原子性
JMM提供了read、load、use、assign、store、write六個指令直接提供原子操作,我們可以認為java的基本變量的讀寫操作是原子的(long,double除外,因為有些虛擬機可以將64位分為高32位,低32位分開運算)。對於lock、unlock,虛擬機沒有將操作直接開放給用戶使用,但提供了更高層次的字節碼指令,monitorenterm和monitorexit來隱式使用這兩個操作,對應於java的synchronized關鍵字,因此synchronized塊之間的操作也具有原子性。
4.2、可見性
我們上面說了線程之間的變量是隔離的,線程拿到的是主存變量的副本,更改變量,需要刷新回主存,其他線程需要從主存重新獲取才能拿到變更的值。所有變量都要經過這個過程,包括被volatile修飾的變量;但volatile修飾的變量,可以在修改后強制刷新到主存,並在使用時從主存獲取刷新,普通變量則不行。
除了volatile修飾的變量,synchronized和final。synchronized在執行完畢后,進行unlock之前,必須將共享變量同步回主內存中(執行store和write操作)。前面規則其中一條。
而final修飾的字段,只要在構造函數中一旦初始化完成,並且沒有對象逃逸(指對象為初始化完成就可以被別的線程使用),那么在其他線程中就可以看到final字段的值。
4.3、有序性
有序性在volatile已經詳細說明了。可以總結為,在本線程觀察到的結果,所有操作都是有序的;如果多線程環境下,一個線程觀察到另一個線程的操作,就說雜亂無序的。
java提供了volatile和synchronized兩個關鍵字保證線程之間的有序性,volatile使用內存屏障,而synchronized基於lock之后,必須unlock后,其他線程才能重新lock的規則,讓同步塊在在多線程間串行執行。
五、Happends-Before原則
先行發生是java內存模型中定義的兩個操作的順序,如果說操作A先行發生於線程B,就是說在發生操作B之前,操作A產生的影響能被操作B觀察到,“影響”包括修改了內存中共享變量的值,發送了消息,調用了方法等。
我們舉個例子說一下。
//線程A執行
i = 1
//線程B執行
j = i
//線程C執行
i = 2
我們還是定義A線程執行 i = 1 先行發生於 線程B執行的 j = i;那么我們可以確定,在線程B執行之后,j的值是1。因為根據先行發生原則,線程A執行之后,i的值為1,可以被B觀察到;並且線程A執行之后,線程B執行之前,沒有線程對i的值進行變更。
這時候我們考慮線程C,如果我們還是保證線程A先行發生於B,但線程C出現在A與B之間,那么,你可以確定j的值是多少嗎?答案是否定的。因為線程C的結果也可能被B觀察到,這時候可能是1,也可能是2。這就存在線程安全問題。
在JMM下具有一些天然的先行發生關系,這些原則在無須任何同步協助下就已經存在,可以直接使用。如果兩個操作之間的關系不在此列,並且無法從以下先行發生原則推導出來,它們就沒有順序性保證,虛擬機就會進行隨意的重排序。
-
程序次序規則(Program Order Rule):在一個線程內,程序的執行規則跟程序的書寫規則是一致的,從上往下執行。
-
鎖定規則(Monitor Lock Rule):一個Unlock的操作肯定先於下一次Lock的操作。這里必須是同一個鎖。同理我們可以認為在synchronized同步同一個鎖的時候,鎖內先行執行的代碼,對后續同步該鎖的線程來說是完全可見的。
-
volatile變量規則(volatile Variable Rule):對同一個volatile的變量,先行發生的寫操作,肯定早於后續發生的讀操作
-
線程啟動規則(Thread Start Rule):Thread對象的start()方法先行發生於此線程的每一個動作
-
線程終止規則(Thread Termination Rule):Thread對象的中止檢測(如:Thread.join(),Thread.isAlive()等)操作,必晚於線程中所有操作
-
線程中斷規則(Thread Interruption Rule):對線程的interruption()調用,先於被調用的線程檢測中斷事件(Thread.interrupted())的發生
-
對象終止規則(Finalizer Rule):一個對象的初始化方法先於執行它的finalize()方法
-
傳遞性(Transitivity):如果操作A先於操作B、操作B先於操作C,則操作A先於操作C
總結
本篇詳細總結了Java內存模型。再來品一品這句話。
java內存模型規定了一個線程如何和何時可以看到由其他線程修改過后的共享變量的值,以及在必須時如何同步的訪問共享變量。
各位看官,如果覺得9龍的文章對你有幫助,求點贊,求關注。如果轉載請注明出處。
本篇主要總結於:
深入理解Java虛擬機++JVM高級特性與最佳實踐