摘要:假設不發生編譯器重排和指令重排,線程修改了的值,但是修改以后,的值可能還沒有寫回到主存中,那么線程得到就是很自然的事了。同理,線程對于的賦值操作也可能沒有及時刷新到主存中。線程的最后操作與線程發現線程已經結束同步。
很久沒更新文章了,對隔三差五過來刷更新的讀者說聲抱歉。
關于 Java 并發也算是寫了好幾篇文章了,本文將介紹一些比較基礎的內容,注意,閱讀本文需要一定的并發基礎。
本文的主要目的是讓大家對于并發程序中的重排序、內存可見性以及原子性有一定的了解,同時要能準確理解 synchronized、volatile、final 幾個關鍵字的作用。
另外,本文還對雙重檢查形式的單例模式為什么需要使用 volatile 做了深入的解釋。
并發三問題
重排序
內存可見性
原子性
Java 對于并發的規范約束
1.Synchronization Order
2.Happens-before Order
3.synchronized 關鍵字
4.單例模式中的雙重檢查
volatile 關鍵字
1.volatile 的內存可見性
2.volatile 的禁止重排序
3.volatile 小結
6.final 關鍵字
7.小結
這節將介紹重排序、內存可見性以及原子性相關的知識,這些也是并發程序為什么難寫的原因。
1. 重排序請讀者先在自己的電腦上運行一下以下程序:
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 的賦值操作也可能沒有及時刷新到主存中。
2. 內存可見性前面在說重排序的時候,也說到了內存可見性的問題,這里再啰嗦一下。
線程間的對于共享變量的可見性問題不是直接由多核引起的,而是由多緩存引起的。如果每個核心共享同一個緩存,那么也就不存在內存可見性問題了。
現代多核 CPU 中每個核心擁有自己的一級緩存或一級緩存加上二級緩存等,問題就發生在每個核心的獨占緩存上。每個核心都會將自己需要的數據讀到獨占緩存中,數據修改后也是寫入到緩存中,然后等待刷入到主存中。所以會導致有些核心讀取的值是一個過期的值。
Java 作為高級語言,屏蔽了這些底層細節,用 JMM 定義了一套讀寫內存數據的規范,雖然我們不再需要關心一級緩存和二級緩存的問題,但是,JMM 抽象了主內存和本地內存的概念。
所有的共享變量存在于主內存中,每個線程有自己的本地內存,線程讀寫共享數據也是通過本地內存交換的,所以可見性問題依然是存在的。這里說的本地內存并不是真的是一塊給每個線程分配的內存,而是 JMM 的一個抽象,是對于寄存器、一級緩存、二級緩存等的抽象。
3. 原子性在本文中,原子性不是重點,它將作為并發編程中需要考慮的一部分進行介紹。
說到原子性的時候,大家應該都能想到 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 OrderJava 語言規范對于同步定義了一系列的規則: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 語言規范:
如果操作 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 在構造方法中指定的值。
回顧下前面的知識,分析下這里為什么會有這個問題。
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 修飾,那么 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 都是可見的。文中我寫了上面這么一句,讀者可以考慮下這個結論是怎么推出來的。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://m.specialneedsforspecialkids.com/yun/71495.html
摘要:前言并發編程的目的是讓程序跑的更快,但并不是啟動更多的線程,這個程序就跑的更快。盡可能降低上下文切換的次數,有助于提高并發效率。死鎖并發編程中的另一挑戰是死鎖,會造成系統功能不可用。 前言 并發編程的目的是讓程序跑的更快,但并不是啟動更多的線程,這個程序就跑的更快。有以下幾種挑戰。 挑戰及方案 上下文切換 單核CPU上執行多線程任務,通過給每個線程分配CPU時間片的方式來實現這個機制。...
摘要:目前看的部分主要是這個關鍵字。語言提供了,保證了所有線程能看到共享變量最新的值。前綴的指令在多核處理器下會做兩件事情將當前處理器緩存行的數據寫回到系統內存。 這一章節的話,主要是講一下在并發操作中常見的volatile、synchronized以及原子操作的相關知識。 目前看的部分主要是volatile這個關鍵字。 volatile 根據Java語言規范第3版中對volatile的定義...
摘要:我的是忙碌的一年,從年初備戰實習春招,年三十都在死磕源碼,三月份經歷了阿里五次面試,四月順利收到實習。因為我心理很清楚,我的目標是阿里。所以在收到阿里之后的那晚,我重新規劃了接下來的學習計劃,將我的短期目標更新成拿下阿里轉正。 我的2017是忙碌的一年,從年初備戰實習春招,年三十都在死磕JDK源碼,三月份經歷了阿里五次面試,四月順利收到實習offer。然后五月懷著忐忑的心情開始了螞蟻金...
摘要:因為多線程競爭鎖時會引起上下文切換。減少線程的使用。舉個例子如果說服務器的帶寬只有,某個資源的下載速度是,系統啟動個線程下載該資源并不會導致下載速度編程,所以在并發編程時,需要考慮這些資源的限制。 最近私下做一項目,一bug幾日未解決,總惶恐。一日頓悟,bug不可怕,怕的是項目不存在bug,與其懼怕,何不與其剛正面。 系列文章傳送門: Java多線程學習(一)Java多線程入門 Jav...
摘要:相比與其他操作系統包括其他類系統有很多的優點,其中有一項就是,其上下文切換和模式切換的時間消耗非常少。因為多線程競爭鎖時會引起上下文切換。減少線程的使用。很多編程語言中都有協程。所以如何避免死鎖的產生,在我們使用并發編程時至關重要。 系列文章傳送門: Java多線程學習(一)Java多線程入門 Java多線程學習(二)synchronized關鍵字(1) java多線程學習(二)syn...
摘要:線程的啟動與銷毀都與本地線程同步。操作系統會調度所有線程并將它們分配給可用的??蚣艿某蓡T主要成員線程池接口接口接口以及工具類。創建單個線程的接口與其實現類用于表示異步計算的結果。參考書籍并發編程的藝術方騰飛魏鵬程曉明著 在java中,直接使用線程來異步的執行任務,線程的每次創建與銷毀需要一定的計算機資源開銷。每個任務創建一個線程的話,當任務數量多的時候,則對應的創建銷毀開銷會消耗大量...
閱讀 725·2021-10-14 09:42
閱讀 1977·2021-09-22 15:04
閱讀 1585·2019-08-30 12:44
閱讀 2147·2019-08-29 13:29
閱讀 2740·2019-08-29 12:51
閱讀 557·2019-08-26 18:18
閱讀 709·2019-08-26 13:43
閱讀 2821·2019-08-26 13:38