好久沒更新文章了,對隔三差五過來刷更新的讀者說聲抱歉。java
關於 Java 併發也算是寫了好幾篇文章了,本文將介紹一些比較基礎的內容,注意,閱讀本文須要必定的併發基礎。程序員
本文的主要目的是讓你們對於併發程序中的重排序、內存可見性以及原子性有必定的瞭解,同時要能準確理解 synchronized、volatile、final 幾個關鍵字的做用。面試
另外,本文還對雙重檢查形式的單例模式爲何須要使用 volatile 作了深刻的解釋。編程
併發三問題緩存
3.synchronized 關鍵字
4.單例模式中的雙重檢查性能優化
6.final 關鍵字
7.小結架構
這節將介紹重排序、內存可見性以及原子性相關的知識,這些也是併發程序爲何難寫的緣由。併發
請讀者先在本身的電腦上運行一下如下程序:app
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 編程語言規範須要規定一些基本規則,JVM 實現者會在這些規則的約束下來實現 JVM,而後開發者也要按照規則來寫代碼,這樣寫出來的併發代碼咱們才能準確預測執行結果。下面進行一些簡單的介紹。
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 來肯定它們的執行順序,若是一個操做 happens-before 於另外一個操做,那麼咱們說第一個操做對於第二個操做是可見的。
若是咱們分別有操做 x 和操做 y,咱們寫成 hb(x, y) 來表示 x happens-before y。如下幾個規則也是來自於 Java 8 語言規範:
若是操做 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 是可見的,只要知足可見性,發生了重排序也是能夠的。
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 在構造方法中指定的值。
回顧下前面的知識,分析下這裏爲何會有這個問題。
一、編譯器能夠將構造方法內聯過來,以後再發生重排序就很容易理解了。
二、即便不發生代碼重排序,線程 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 的做用,記住兩點:內存可見性和禁止指令重排序。
咱們仍是用 JMM 的主內存和本地內存抽象來描述,這樣比較準確。還有,並非只有 Java 語言纔有 volatile 關鍵字,因此後面的描述必定要創建在 Java 跨平臺之後抽象出了內存模型的這個大環境下。
還記得 synchronized 的語義嗎?進入 synchronized 時,使得本地緩存失效,synchronized 塊中對共享變量的讀取必須從主內存讀取;退出 synchronized 時,會將進入 synchronized 塊以前和 synchronized 塊中的寫操做刷入到主存中。
volatile 有相似的語義,讀一個 volatile 變量以前,須要先使相應的本地緩存失效,這樣就必須到主內存讀取最新值,寫一個 volatile 屬性會當即刷入到主內存。因此,volatile 讀和 monitorenter 有相同的語義,volatile 寫和 monitorexit 有相同的語義。
你們還記得以前的雙重檢查的單例模式吧,前面提到,加個 volatile 能解決問題。其實就是利用了 volatile 的禁止重排序功能。
volatile 的禁止重排序並不侷限於兩個 volatile 的屬性操做不能重排序,並且是 volatile 屬性操做和它周圍的普通屬性的操做也不能重排序。
以前 instance = new Singleton() 中,若是 instance 是 volatile 的,那麼對於 instance 的賦值操做(賦一個引用給 instance 變量)就不會和構造函數中的屬性賦值發生重排序,能保證構造方法結束後,纔將此對象引用賦值給 instance。
根據 volatile 的內存可見性和禁止重排序,那麼咱們不可貴出一個推論:線程 a 若是寫入一個 volatile 變量,此時線程 b 再讀取這個變量,那麼此時對於線程 a 可見的全部屬性對於線程 b 都是可見的。
volatile 修飾符適用於如下場景:某個屬性被多個線程共享,其中有一個線程修改了此屬性,其餘線程能夠當即獲得修改後的值。在併發包的源碼中,它使用得很是多。
volatile 屬性的讀寫操做都是無鎖的,它不能替代 synchronized,由於它沒有提供原子性和互斥性。由於無鎖,不須要花費時間在獲取鎖和釋放鎖上,因此說它是低成本的。
volatile 只能做用於屬性,咱們用 volatile 修飾屬性,這樣 compilers 就不會對這個屬性作指令重排序。
volatile 提供了可見性,任何一個線程對其的修改將立馬對其餘線程可見。volatile 屬性不會被線程緩存,始終從主存中讀取。
volatile 提供了 happens-before 保證,對 volatile 變量 v 的寫入 happens-before 全部其餘線程後續對 v 的讀操做。
volatile 可使得 long 和 double 的賦值是原子的,前面在說原子性的時候提到過。
用 final 修飾的類不能夠被繼承,用 final 修飾的方法不能夠被覆寫,用 final 修飾的屬性一旦初始化之後不能夠被修改。固然,咱們不關心這些段子,這節,咱們來看看 final 帶來的內存可見性影響。
以前在說雙重檢查的單例模式的時候,提過了一句,若是全部的屬性都使用了 final 修飾,那麼 volatile 也是能夠不要的,這就是 final 帶來的可見性影響。
在對象的構造方法中設置 final 屬性,同時在對象初始化完成前,不要將此對象的引用寫入到其餘線程能夠訪問到的地方(不要讓引用在構造函數中逸出)。若是這個條件知足,當其餘線程看到這個對象的時候,那個線程始終能夠看到正確初始化後的對象的 final 屬性。
上面說得很明白了,final 屬性的寫操做不會和此引用的賦值操做發生重排序,如:
x.finalField = v; ...; sharedRef = x;
若是你還想查看更多的關於 final 的介紹,能夠移步到我以前翻譯的 Java 語言規範的 final屬性的語義 部分。
併發問題是程序員都離不開的話題,說到這裏順便給你們推薦一個交流學習羣:650385180,裏面會分享一些資深架構師錄製的視頻錄像:有Spring,MyBatis,Netty源碼分析,高併發、高性能、分佈式、微服務架構的原理,JVM性能優化這些成爲架構師必備的知識體系。還能領取免費的學習資源,相信對於已經工做和遇到技術瓶頸的碼友,在這個羣裏必定有你須要的內容。
以前看過 Java8 語言規範《深刻分析 java 8 編程語言規範:Threads and Locks》,本文中的不少知識是和它相關的,不過那篇直譯的文章的可讀性差了些,但願本文能給讀者帶來更多的收穫。
描述該類知識須要很是嚴謹的語言描述,雖然我仔細檢查了好幾篇,但仍是擔憂有些地方會說錯,一來這些內容的正誤很是受我自身的知識積累影響,二來也和我在行文中使用的話語有很大的關係。但願讀者能幫助指正我表述錯誤的地方。
update:2018-07-06 留個小問題給讀者咱們不可貴出一個推論:線程 a 若是寫入一個 volatile 變量,此時線程 b 再讀取這個變量,那麼此時對於線程 a
可見的全部屬性對於線程 b 都是可見的。文中我寫了上面這麼一句,讀者能夠考慮下這個結論是怎麼推出來的。