您好,登錄后才能下訂單哦!
這篇文章給大家介紹JAVA內存模型是怎樣的,內容非常詳細,感興趣的小伙伴們可以參考借鑒,希望對大家能有所幫助。
并發三問題 這節將介紹重排序、內存可見性以及原子性相關的知識,這些也是并發程序為什么難寫的原因。
重排序 請讀者先在自己的電腦上運行一下以下程序:
public class Test {
private static int x = 0, y = 0; private static int a = 0, b =0; public static void main(String[] args) throws InterruptedException { int i = 0; for(;;) { i++; x = 0; y = 0; a = 0; b = 0; CountDownLatch latch = new CountDownLatch(1); Thread one = new Thread(() -> { try { latch.await(); } catch (InterruptedException e) { } a = 1; x = b; }); Thread other = new Thread(() -> { try { latch.await(); } catch (InterruptedException e) { } b = 1; y = a; }); one.start();other.start(); latch.countDown(); one.join();other.join(); String result = "第" + i + "次 (" + x + "," + y + ")"; if(x == 0 && y == 0) { System.err.println(result); break; } else { System.out.println(result); } } }
} 幾秒后,我們就可以得到 x == 0 && y == 0 這個結果,仔細看看代碼就會知道,如果不發生重排序的話,這個結果是不可能出現的。
重排序由以下幾種機制引起:
編譯器優化:對于沒有數據依賴關系的操作,編譯器在編譯的過程中會進行一定程度的重排。
大家仔細看看線程 1 中的代碼,編譯器是可以將 a = 1 和 x = b 換一下順序的,因為它們之間沒有數據依賴關系,同理,線程 2 也一樣,那就不難得到 x == y == 0 這種結果了。
指令重排序:CPU 優化行為,也是會對不存在數據依賴關系的指令進行一定程度的重排。
這個和編譯器優化差不多,就算編譯器不發生重排,CPU 也可以對指令進行重排,這個就不用多說了。
內存系統重排序:內存系統沒有重排序,但是由于有緩存的存在,使得程序整體上會表現出亂序的行為。
假設不發生編譯器重排和指令重排,線程 1 修改了 a 的值,但是修改以后,a 的值可能還沒有寫回到主存中,那么線程 2 得到 a == 0 就是很自然的事了。同理,線程 2 對于 b 的賦值操作也可能沒有及時刷新到主存中。
內存可見性 前面在說重排序的時候,也說到了內存可見性的問題,這里再啰嗦一下。
線程間的對于共享變量的可見性問題不是直接由多核引起的,而是由多緩存引起的。如果每個核心共享同一個緩存,那么也就不存在內存可見性問題了。
現代多核 CPU 中每個核心擁有自己的一級緩存或一級緩存加上二級緩存等,問題就發生在每個核心的獨占緩存上。每個核心都會將自己需要的數據讀到獨占緩存中,數據修改后也是寫入到緩存中,然后等待刷入到主存中。所以會導致有些核心讀取的值是一個過期的值。
Java 作為高級語言,屏蔽了這些底層細節,用 JMM 定義了一套讀寫內存數據的規范,雖然我們不再需要關心一級緩存和二級緩存的問題,但是,JMM 抽象了主內存和本地內存的概念。
所有的共享變量存在于主內存中,每個線程有自己的本地內存,線程讀寫共享數據也是通過本地內存交換的,所以可見性問題依然是存在的。這里說的本地內存并不是真的是一塊給每個線程分配的內存,而是 JMM 的一個抽象,是對于寄存器、一級緩存、二級緩存等的抽象。
原子性 在本文中,原子性不是重點,它將作為并發編程中需要考慮的一部分進行介紹。
說到原子性的時候,大家應該都能想到 long 和 double,它們的值需要占用 64 位的內存空間,Java 編程語言規范中提到,對于 64 位的值的寫入,可以分為兩個 32 位的操作進行寫入。本來一個整體的賦值操作,被拆分為低 32 位賦值和高 32 位賦值兩個操作,中間如果發生了其他線程對于這個值的讀操作,必然就會讀到一個奇怪的值。
這個時候我們要使用 volatile 關鍵字進行控制了,JMM 規定了對于 volatile long 和 volatile double,JVM 需要保證寫入操作的原子性。
另外,對于引用的讀寫操作始終是原子的,不管是 32 位的機器還是 64 位的機器。
Java 編程語言規范同樣提到,鼓勵 JVM 的開發者能保證 64 位值操作的原子性,也鼓勵使用者盡量使用 volatile 或使用正確的同步方式。關鍵詞是”鼓勵“。
在 64 位的 JVM 中,不加 volatile 也是可以的,同樣能保證對于 long 和 double 寫操作的原子性。關于這一點,我沒有找到官方的材料描述它,如果讀者有相關的信息,希望可以給我反饋一下。
Java 對于并發的規范約束 并發問題使得我們的代碼有可能會產生各種各樣的執行結果,顯然這是我們不能接受的,所以 Java 編程語言規范需要規定一些基本規則,JVM 實現者會在這些規則的約束下來實現 JVM,然后開發者也要按照規則來寫代碼,這樣寫出來的并發代碼我們才能準確預測執行結果。下面進行一些簡單的介紹。
Synchronization Order Java 語言規范對于同步定義了一系列的規則:17.4.4. Synchronization Order,包括了如下同步關系:
對于監視器 m 的解鎖與所有后續操作對于 m 的加鎖同步
對 volatile 變量 v 的寫入,與所有其他線程后續對 v 的讀同步
啟動線程的操作與線程中的第一個操作同步。
對于每個屬性寫入默認值(0, false,null)與每個線程對其進行的操作同步。
盡管在創建對象完成之前對對象屬性寫入默認值有點奇怪,但從概念上來說,每個對象都是在程序啟動時用默認值初始化來創建的。
線程 T1 的最后操作與線程 T2 發現線程 T1 已經結束同步。
線程 T2 可以通過 T1.isAlive() 或 T1.join() 方法來判斷 T1 是否已經終結。
如果線程 T1 中斷了 T2,那么線程 T1 的中斷操作與其他所有線程發現 T2 被中斷了同步(通過拋出 InterruptedException 異常,或者調用 Thread.interrupted 或 Thread.isInterrupted )
Happens-before Order 兩個操作可以用 happens-before 來確定它們的執行順序,如果一個操作 happens-before 于另一個操作,那么我們說第一個操作對于第二個操作是可見的。
如果我們分別有操作 x 和操作 y,我們寫成 hb(x, y) 來表示 x happens-before y。以下幾個規則也是來自于 Java 8 語言規范 Happens-before Order:
如果操作 x 和操作 y 是同一個線程的兩個操作,并且在代碼上操作 x 先于操作 y 出現,那么有 hb(x, y)
對象構造方法的最后一行指令 happens-before 于 finalize() 方法的第一行指令。
如果操作 x 與隨后的操作 y 構成同步,那么 hb(x, y)。這條說的是前面一小節的內容。 hb(x, y) 和 hb(y, z),那么可以推斷出 hb(x, z) 這里再提一點,x happens-before y,并不是說 x 操作一定要在 y 操作之前被執行,而是說 x 的執行結果對于 y 是可見的,只要滿足可見性,發生了重排序也是可以的。
synchronized 關鍵字 monitor,這里翻譯成監視器鎖,為了大家理解方便。
synchronized 這個關鍵字大家都用得很多了,這里不會教你怎么使用它,我們來看看它對于內存可見性的影響。
一個線程在獲取到監視器鎖以后才能進入 synchronized 控制的代碼塊,一旦進入代碼塊,首先,該線程對于共享變量的緩存就會失效,因此 synchronized 代碼塊中對于共享變量的讀取需要從主內存中重新獲取,也就能獲取到最新的值。
退出代碼塊的時候的,會將該線程寫緩沖區中的數據刷到主內存中,所以在 synchronized 代碼塊之前或 synchronized 代碼塊中對于共享變量的操作隨著該線程退出 synchronized 塊,會立即對其他線程可見(這句話的前提是其他讀取共享變量的線程會從主內存讀取最新值)。
因此,我們可以總結一下:線程 a 對于進入 synchronized 塊之前或在 synchronized 中對于共享變量的操作,對于后續的持有同一個監視器鎖的線程 b 可見。雖然是挺簡單的一句話,請讀者好好體會。
注意一點,在進入 synchronized 的時候,并不會保證之前的寫操作刷入到主內存中,synchronized 主要是保證退出的時候能將本地內存的數據刷入到主內存。
單例模式中的雙重檢查 我們趁熱打鐵,為大家解決下單例模式中的雙重檢查問題。關于這個問題,大神們發過文章對此進行闡述了,這里搬運一下。
來膜拜下文章署名中的大神們:David Bacon (IBM Research) Joshua Bloch (Javasoft), Jeff Bogda, Cliff Click (Hotspot JVM project), Paul Haahr, Doug Lea, Tom May, Jan-Willem Maessen, Jeremy Manson, John D. Mitchell (jGuru) Kelvin Nilsen, Bill Pugh, Emin Gun Sirer,至少 Joshua Bloch 和 Doug Lea 大家都不陌生吧。
廢話少說,看以下單例模式的寫法:
public class Singleton {
private static Singleton instance = null; private int v; private Singleton() { this.v = 3; } public static Singleton getInstance() { if (instance == null) { // 1. 第一次檢查 synchronized (Singleton.class) { // 2 if (instance == null) { // 3. 第二次檢查 instance = new Singleton(); // 4 } } } return instance; }
} 很多人都知道上述的寫法是不對的,但是可能會說不清楚到底為什么不對。
我們假設有兩個線程 a 和 b 調用 getInstance() 方法,假設 a 先走,一路走到 4 這一步,執行 instance = new Singleton() 這句代碼。
instance = new Singleton() 這句代碼首先會申請一段空間,然后將各個屬性初始化為零值(0/null),執行構造方法中的屬性賦值[1],將這個對象的引用賦值給 instance[2]。在這個過程中,[1] 和 [2] 可能會發生重排序。
此時,線程 b 剛剛進來執行到 1(看上面的代碼塊),就有可能會看到 instance 不為 null,然后線程 b 也就不會等待監視器鎖,而是直接返回 instance。問題是這個 instance 可能還沒執行完構造方法(線程 a 此時還在 4 這一步),所以線程 b 拿到的 instance 是不完整的,它里面的屬性值可能是初始化的零值(0/false/null),而不是線程 a 在構造方法中指定的值。
回顧下前面的知識,分析下這里為什么會有這個問題。
1、編譯器可以將構造方法內聯過來,之后再發生重排序就很容易理解了。
2、即使不發生代碼重排序,線程 a 對于屬性的賦值寫入到了線程 a 的本地內存中,此時對于線程 b 不可見。
最后提一點,如果線程 a 從 synchronized 塊出來了,那么 instance 一定是正確構造的完整實例,這是我們前面說過的 synchronized 的內存可見性保證。
—————分割線—————
對于大部分讀者來說,這一小節其實可以結束了,很多讀者都知道,解決方案是使用 volatile 關鍵字,這個我們在介紹 volatile 的時候再說。當然,如果你還有耐心,也可以繼續看看本小節。
我們看下下面這段代碼,看看它能不能解決我們之前碰到的問題。
public static Singleton getInstance() { if (instance == null) { // Singleton temp; synchronized (Singleton.class) { // temp = instance; if (temp == null) { // synchronized (Singleton.class) { // 內嵌一個 synchronized 塊 temp = new Singleton(); } instance = temp; // } } } return instance; } 上面這個代碼很有趣,想利用 synchronized 的內存可見性語義,不過這個解決方案還是失敗了,我們分析下。
前面我們也說了,synchronized 在退出的時候,能保證 synchronized 塊中對于共享變量的寫入一定會刷入到主內存中。也就是說,上述代碼中,內嵌的 synchronized 結束的時候,temp 一定是完整構造出來的,然后再賦給 instance 的值一定是好的。
可是,synchronized 保證了釋放監視器鎖之前的代碼一定會在釋放鎖之前被執行(如 temp 的初始化一定會在釋放鎖之前執行完 ),但是沒有任何規則規定了,釋放鎖之后的代碼不可以在釋放鎖之前先執行。
也就是說,代碼中釋放鎖之后的行為 instance = temp 完全可以被提前到前面的 synchronized 代碼塊中執行,那么前面說的重排序問題就又出現了。
最后扯一點,如果所有的屬性都是使用 final 修飾的,其實之前介紹的雙重檢查是可行的,不需要加 volatile,這個等到 final 那節再介紹。
volatile 關鍵字 大部分開發者應該都知道怎么使用這個關鍵字,只是可能不太了解個中緣由。
如果你下次面試的時候有人問你 volatile 的作用,記住兩點:內存可見性和禁止指令重排序。
volatile 的內存可見性 我們還是用 JMM 的主內存和本地內存抽象來描述,這樣比較準確。還有,并不是只有 Java 語言才有 volatile 關鍵字,所以后面的描述一定要建立在 Java 跨平臺以后抽象出了內存模型的這個大環境下。
還記得 synchronized 的語義嗎?進入 synchronized 時,使得本地緩存失效,synchronized 塊中對共享變量的讀取必須從主內存讀取;退出 synchronized 時,會將進入 synchronized 塊之前和 synchronized 塊中的寫操作刷入到主存中。
volatile 有類似的語義,讀一個 volatile 變量之前,需要先使相應的本地緩存失效,這樣就必須到主內存讀取最新值,寫一個 volatile 屬性會立即刷入到主內存。所以,volatile 讀和 monitorenter 有相同的語義,volatile 寫和 monitorexit 有相同的語義。
volatile 的禁止重排序 大家還記得之前的雙重檢查的單例模式吧,前面提到,加個 volatile 能解決問題。其實就是利用了 volatile 的禁止重排序功能。
volatile 的禁止重排序并不局限于兩個 volatile 的屬性操作不能重排序,而且是 volatile 屬性操作和它周圍的普通屬性的操作也不能重排序。
之前 instance = new Singleton() 中,如果 instance 是 volatile 的,那么對于 instance 的賦值操作(賦一個引用給 instance 變量)就不會和構造函數中的屬性賦值發生重排序,能保證構造方法結束后,才將此對象引用賦值給 instance。
根據 volatile 的內存可見性和禁止重排序,那么我們不難得出一個推論:線程 a 如果寫入一個 volatile 變量,此時線程 b 再讀取這個變量,那么此時對于線程 a 可見的所有屬性對于線程 b 都是可見的。
volatile 小結 volatile 修飾符適用于以下場景:某個屬性被多個線程共享,其中有一個線程修改了此屬性,其他線程可以立即得到修改后的值。在并發包的源碼中,它使用得非常多。 volatile 屬性的讀寫操作都是無鎖的,它不能替代 synchronized,因為它沒有提供原子性和互斥性。因為無鎖,不需要花費時間在獲取鎖和釋放鎖上,所以說它是低成本的。 volatile 只能作用于屬性,我們用 volatile 修飾屬性,這樣 compilers 就不會對這個屬性做指令重排序。 volatile 提供了可見性,任何一個線程對其的修改將立馬對其他線程可見。volatile 屬性不會被線程緩存,始終從主存中讀取。 volatile 提供了 happens-before 保證,對 volatile 變量 v 的寫入 happens-before 所有其他線程后續對 v 的讀操作。 volatile 可以使得 long 和 double 的賦值是原子的,前面在說原子性的時候提到過。 final 關鍵字 用 final 修飾的類不可以被繼承,用 final 修飾的方法不可以被覆寫,用 final 修飾的屬性一旦初始化以后不可以被修改。當然,我們不關心這些段子,這節,我們來看看 final 帶來的內存可見性影響。
之前在說雙重檢查的單例模式的時候,提過了一句,如果所有的屬性都使用了 final 修飾,那么 volatile 也是可以不要的,這就是 final 帶來的可見性影響。
在對象的構造方法中設置 final 屬性,同時在對象初始化完成前,不要將此對象的引用寫入到其他線程可以訪問到的地方(不要讓引用在構造函數中逸出)。如果這個條件滿足,當其他線程看到這個對象的時候,那個線程始終可以看到正確初始化后的對象的 final 屬性。
上面說得很明白了,final 屬性的寫操作不會和此引用的賦值操作發生重排序,如:
x.finalField = v; ...; sharedRef = x; 如果你還想查看更多的關于 final 的介紹,可以移步到我之前翻譯的 Java 語言規范的 final屬性的語義 部分。
關于JAVA內存模型是怎樣的就分享到這里了,希望以上內容可以對大家有一定的幫助,可以學到更多知識。如果覺得文章不錯,可以把它分享出去讓更多的人看到。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。