JVM運行時內存結構回顧
在JVM相關的介紹中,有說到JAVA運行時的內存結構,簡單回顧下
整體結構如下圖所示,大致分為五大塊
而對於方法區中的數據,是屬於所有線程共享的數據結構
而對於虛擬機棧中數據結構,則是線程獨有的,被保存在線程私有的內存空間中,所以這部分數據不涉及線程安全的問題
不管是堆還是棧,他們都是保存在主內存中的
線程堆棧包含正在執行的每個方法的所有局部變量(調用堆棧上的所有方法)。線程只能訪問它自己的線程堆棧。
由線程創建的局部變量對於創建它的線程以外的所有其他線程是不可見的。
即使兩個線程正在執行完全相同的代碼,兩個線程仍將在每個自己的線程堆棧中創建該代碼的局部變量。因此,每個線程都有自己的每個局部變量的版本。
局部變量可以是基本類型,在這種情況下,很顯然它完全保留在線程堆棧上
局部變量也可以是對象的引用,這種情況下,局部變量本身仍舊是在線程堆棧上,但是所指向的對象本身卻是在堆中的
很顯然,所有具有對象引用的線程都可以訪問堆上的對象,盡管是多個局部變量(引用),但是實際上是同一個對象,所以如果這個對象有成員變量,那么將會出現數據安全問題。
如上圖所示,兩個線程,localVariable1並 localVariable2兩個局部變量位於不同的線程,但是同時指向的是Object3
簡單說,從上面可以看得出來,在Java中所有實例域、靜態域和數組元素存儲在堆內存中,堆內存在線程之間共享。
對於多線程的線程安全問題,根本在於共享數據的讀寫。
JMM(Java內存模型)
Java 內存模型作為JVM的一種抽象內存模型,屏蔽掉各種硬件和操作系統的內存差異,達到跨平台的內存訪問效果。
Java語言規范定義了一個統一的內存管理模型JMM(Java Memory Model)
不管是堆還是棧,數據都是保存在主存中的,整個的內存,都只是物理內存的一部分,也就是操作系統分配給JVM進程的那一部分
這部分內存按照運行區域的划分規則進行了區域划分
運行時內存區域的划分,可以簡單理解為空間的分配,比如一個房間多少平,這邊用於衣帽間,那邊用於卧室,卧室多大,衣帽間多大
而對於內存的訪問,規定Java內存模型分為主內存,和工作內存;工作內存就是線程私有的部分,主內存是所有的線程所共享的
每條線程自己的工作內存中保存了被該線程使用到的變量的主內存副本拷貝,所有的工作都是在工作內存這個操作台上,線程並不能直接操作主存,也不能訪問其他線程的工作內存
你划分好了區域,比如有的地方用於存放局部變量,有的地方用於存放實例變量,但是這些數據的存取規則是什么?
換句話說,如何正確有效的進行數據的讀取?顯然光找好地方存是不行的,怎么存?怎么讀?怎么共享?這又是另外的一個很復雜的問題
比如上面的兩個線程對於Object3的數據讀取順序、限制都是什么樣子的?
所以內存區域的分塊划分和工作內存與主存的交互訪問是兩個不同的維度
文檔如下:
在對JMM進行介紹之前,先回想下計算機對於數據的讀取
數據本質上是存放於主存(最終是存放於磁盤)中的,但是計算卻又是在CPU中,很顯然他們的速度有天壤之別
所以在計算機硬件的發展中,出現了緩存(一級緩存、二級緩存),借助於緩存與主存進行數據交互,而且現代計算機中已經不僅僅只是有一個CPU
一個簡單的示意圖如下
對於訪問速度來說,寄存器--緩存--主存 依次遞減,但是空間卻依次變大
有了緩存,CPU將不再需要頻繁的直接從主存中讀取數據,性能有了很大程度的提高(當然,如果需要的數據不在緩存中,那么還是需要從主存中去讀取數據,是否存在,被稱為緩存的命中率,顯然,命中率對於CPU效率有很大影響)
在速度提高的同時,很顯然,出現了一個問題:
如果兩個CPU同時對主存中的一個變量x (值為1)進行處理,假設一個執行x+1 另外一個執行x-1
如果其中一個處理后另一個才開始讀取,顯然並沒有什么問題
但是如果最初緩存中都沒有數據或者說一個CPU處理過程中還沒來得及將緩存寫入主存,另一個CPU開始進行處理,那么最后的結果將會是不確定的
這個問題被稱為:緩存一致性問題
所以說:對於多個處理器運算任務都涉及同一塊主存,需要一種協議可以保障數據的一致性,這類協議有MSI、MESI、MOSI及Dragon Protocol等
關於緩存一致性的更多信息可以查閱
百度百科
緩存一致性(Cache Coherency)入門
緩存、緩存算法和緩存框架簡介
總之,多個CPU,大家使用同一個主存,但是各自不同的緩存,自然會有不一致的安全問題。
再回到JMM上來,Java Memory Model
網址:
文中有說到:
The Java memory model specifies how the Java virtual machine works with the computer's memory (RAM). The Java virtual machine is a model of a whole computer so this model naturally includes a memory model - AKA the Java memory model.
Java內存模型指定Java虛擬機如何與計算機內存(RAM)一起工作。
Java虛擬機是整個計算機的模型,因此這個模型自然包括一個內存模型——也就是Java內存模型
對於多線程場景下,對於線程私有的數據是本地的,這個無可置疑,但是對於共享數據,前面已經提到,也是“私有的”
因為每個線程對於共享數據,都會讀取一份拷貝到本地內存中(也是線程私有的內存),所有的工作都是在本地內存這個操作台上進行的,如下圖所示
這本質就是一種read-modify-write模式,所以必然有線程安全問題的隱患
與計算機硬件對於主存數據的訪問是不是很相似?
需要注意的是,此處的主存並不是像前面硬件架構中的主存(RAM),是一個泛指,保存共享數據的地方,可能是主存也可能是緩存,總之是操作系統提供的服務,在JMM中可以統一認為是主存
這里的本地內存,就好似對於CPU來說的緩存一樣,很顯然,也會有一致性方面的問題
如果兩個線程之間不是串行的,必然對於數據處理后的結果會出現不確定性
所以JMM規范到底是什么?
他其實就是JVM內部的內存數據的訪問規則,線程進行共享數據讀寫的一種規則,在JVM內部,多線程就是這么讀取數據的
具體的數據是如何設置到上圖中“主存”這個概念中的?本地內存如何具體的與主存進行交互的?這都是操作系統以及JVM底層實現層面的問題
單純的對於多線程編程來說,就不用管什么RAM、寄存器、緩存一致性等等問題,就只需要知道:
數據分為兩部分,共享的位於主存,線程局部的位於私有的工作內存,所有的工作都是在工作內存中進行的,也就意味着有“讀取-拷貝-操作-回寫”這樣一個大致的過程
既然人家叫做JVM java虛擬機,自然是五臟俱全,而且如果不能做到統一形式的內存訪問模型,還叫什么跨平台?
如果把線程類比為CPU,工作內存類比寄存器、緩存,主存類比為RAM
那么JMM就相當於解決硬件緩存一致性問題的、類似的一種解決Java多線程讀寫共享數據的協議規范
所以說,如果要設計正確的並發程序,了解Java內存模型非常重要。Java內存模型指定了不同線程如何以及何時可以看到其他線程寫入共享變量的值,以及如何在必要時同步對共享變量的訪問
所以再次強調,單純的從多線程編程的角度看,記住下面這張圖就夠了!!!
所以再次強調,單純的從多線程編程的角度看,記住下面這張圖就夠了!!!
每個線程局部數據自己獨有,共享數據會讀取拷貝一份到工作內存,操作后會回寫到主存
換一個說法,可以認為JMM的核心就是用於解決線程安全問題的,而線程安全問題根本就是對於共享數據的操作,所以說JMM對於數據操作的規范要求,本質也就是多線程安全問題的解決方案(緩存一致性也是數據安全的解決方案)
所以說理解了可能出現問題的原因與場景,就了解了線程安全的問題,了解了問題,才能理解解決方案,那多線程到底有哪些主要的安全問題呢?
競爭場景
線程安全問題的本質就是共享數據的訪問,沒有共享就沒有安全問題,所以說有時干脆一個類中都沒有成員變量,也就避免了線程安全問題,但是很顯然,這只是個別場景下適合,如果一味如此,就是因噎廢食了
如果對於數據的訪問是串行的,也不會出現問題,因為不存在競爭,但是很顯然,隨着計算機硬件的升級,多核處理器的出現,並發(並行)是必然,你不能為了安全就犧牲掉性能,也是一種因噎廢食
所以換一個說法,為何會有線程安全問題?是因為對於共享數據的競爭訪問!
常見的兩種競爭場景
- read-modify-write(讀-改-寫)
- check-then-act(檢查后行動)
read-modify-write(讀-改-寫)
read-modify-write(讀-改-寫)可以簡單地分為三個步驟:
- 讀取數據
- 修改數據
- 回寫數據
很顯然,如果多個線程同時進行,將會出現不可預知的后果,假設兩個線程,A和B,他們的三個步驟為A1,A2,A3 和 B1,B2,B3
如果按照A1,A2,A3,B1,B2,B3 或者 B1,B2,B3,A1,A2,A3的順序,並不會出現問題
但是如果是交叉進行,比如A1,A2,B1,B2,B3,A3,那么就會出現問題,B對數據的寫入被覆蓋了!
check-then-act(檢查后行動)
比如
if(x >1){
//do sth....
x--;
}
如果A線程條件滿足后,還沒有繼續進行,此時B線程開始執行,條件判斷后滿足繼續執行,執行后x的值並不滿足條件了!
這也是一種常見的線程安全問題
很顯然,單線程情況下,或者說所有的變量全部都是局部變量的話,不會出現問題,否則就很可能出現問題(線程安全問題並不是必然出現的,長時間不出問題也很可能)
對於線程安全的問題主要分為三類
- 原子性
- 可見性
- 有序性
原子性
原子 Atomic,意指不可分割,也就是作為一個整體,要么全部執行,要么不會執行
對於共享變量訪問的一個操作,如果對於除了當前執行線程以外的任何線程來說,都是不可分割的,那么就是具有原子性
簡言之,對於別的線程而言,他要么看到的是該線程還沒有執行的情況,要么就是看到了線程執行后的情況,不會出現執行一半的場景,簡言之,其他線程永遠不會看到中間結果
生活中有一個典型的例子,就是ATM機取款
盡管中間有很多的工作,比如賬戶扣款,ATM吐出鈔票等,但是從取錢的角度來看,對於用戶卻是不可分割的一個過程
要么,取錢成功了,要么取款失敗了,對於共享變量也就是賬戶余額來說,要么會減少,要么不變,不會出現錢去了余額不變或者余額減少,但是卻沒有看到錢的情況
既然是原子操作,既然是不可分割的,那么就是要么做了,要么沒做,不會中間被耽擱,最終的結果看起來就好似串行的執行一樣,不會出現線程安全問題
Java中有兩種方式實現原子性
一種是使用鎖機制,鎖具有排他性,也就是說它能夠保證一個共享變量在任意一個時刻僅僅被一個線程訪問,這就消除了競爭;
另外一種是借助於處理器提供的專門的CAS指令(compare-and-swap)
在Java中,long和double以外的任何類型的變量的寫操作都是原子操作
也就是基礎類型(byte int short char float boolean)以及引用類型的變量的寫操作都是原子的,由Java語言規范規定,JVM實現
對於long和double,64位長度,如果是在32位機器上,寫操作可能分為兩個步驟,分別處理高低32位,兩個步驟就打破了原子性,可能出現數據安全問題
有一點需要注意的是,原子操作+原子操作,並非仍舊是原子操作
比如
a=1;
b=1;
很顯然,都是原子操作,但是在a=1執行后,如果此時另外的線程過來讀取數據,會讀取到a=1,而b卻是沒設置的中間狀態
可見性
在多線程環境下,一個線程對某個共享變量進行更新之后,后續訪問該變量的線程可能無法立刻讀取到這個更新的結果,甚至永遠也無法讀取到這個更新的結果。
這就是線程安全問題的另外一個表現形式:可見性(Visibility )
如果一個線程對某個共享變量進行更新之后,后續訪問該變量的線程可以讀取到該更新的結果,那么就稱這個線程對該共享變量的更新對其他線程可見,否則就稱這個線程對該共享變量的更新對其他線程不可見。
簡言之,如果一個線程對共享數據做出了修改,而另外的線程卻並沒有讀取到最新的結果,這是有問題的
多線程程序在可見性方面存在問題意味着某些線程讀取到了舊數據,通常也是不被希望的
為什么會出現可見性問題?
因為數據本質是要從主存存取的,但是對於線程來說,有了工作內存,這個私有的工作台,也就是read-modify-write模式
即使線程正確的處理了結果,但是卻沒有及時的被其他的線程讀取,而別人卻讀取了錯誤的結果(舊數據),這是一個很大的問題
所以此處也可以看到,如果僅僅是保障原子性,對於線程安全來說,完全是不夠的(有些場景可能足夠了)
原子性保障了不會讀取到中間結果,要么是結束要么是未開始,但是如果操作結束了,這個結果真的就能看到么?所以還需要可見性的保障
有序性
關於有序性,首先要說下重排序的概念,如果不曾有重排序,那么也就不涉及這方面的問題了
比如下面兩條語句
a=1;
b=2;
在源代碼中是有順序的,經過編譯后形成指令后,也必然是有順序的
在一個線程中從代碼執行的角度來看,也總是有先后順序的
比如上面兩條語句,a的賦值在前,b的賦值在后,但是實際上,這種順序是沒有保障的
處理器可能並不會完全按照已經形成的指令(目標代碼)順序執行,這種現象就叫做重排序
為什么要重排序?
重排序是對內存訪問操作的一種優化,他可以在不影響單線程程序正確性的前提下進行一定的調整,進而提高程序的性能
但是對於多線程場景下,就可能產生一定的問題
當然,重排序導致的問題,也不是必然出現的
比如,編譯器進行編譯時,處理器進行執行時,都有可能發生重排序
先聲明幾個概念
- 源代碼順序,很明顯字面意思就是源代碼的順序
- 程序順序,源碼經過處理后的目標代碼順序(解釋后或者JIT編譯后的目標代碼或者干脆理解成源代碼解析后的機器指令)
- 執行順序,處理器對目標代碼執行時的順序
- 感知順序,處理器執行了,但是別人看到的並不一定就是你執行的順序,因為操作后的數據涉及到數據的回寫,可能會經過寄存器、緩存等,即使你先計算的a后計算的b,如果b先被寫回呢?這就是感知順序,簡單說就是別人看到的結果
在此基礎上,可以將重排序可以分為兩種,指令重排序和存儲重排序
下圖來自《Java多線程編程實戰指南-核心篇》
編譯器可能導致目標代碼與源代碼順序不一致;即時編譯器JIT和處理器可能導致執行順序與程序順序不一致;
緩存、緩沖器可能導致感知順序不一致
指令重排序
不管是程序順序與源代碼順序不一致還是執行順序與程序順序不一致,結果都是指令重排序,因為最終的效果就是源代碼與最終被執行的指令順序不一致
如下圖所示,不管是哪一段順序被重拍了,最終的結果都是最終執行的指令亂序了
ps:Java有兩種編譯器,一種是Javac靜態編譯器,將源文件編譯為字節碼,代碼編譯階段運行;JIT是在運行時,動態的將字節碼編譯為本地機器碼(目標代碼)
通常javac不會進行重排序,而JIT則很可能進行重排序
此處不對為什么要重排序展開,簡單說就是硬件或者編譯器等為了能夠更好地執行指令,提高性能,所做出的一定程度的優化,重排序也不是隨隨便便的就改變了順序的,它具有一定的規則,叫做貌似串行語義As-if-serial Semantics,也就是從單線程的角度保障不會出現問題,但是對於多線程就可能出現問題。
貌似串行語義的規則主要是對於具有數據依賴關系的數據不會進行重排序,沒有依賴關系的則可能進行重排序
比如下面的三條語句,c=a+b;依賴a和b,所以不會與他們進行重排序,但是a和b沒有依賴關系,就可能發生重排序
a=1;
b=2;
c=a+b;
存儲重排序
為什么會出現執行一種順序,而結果的寫入是另外的一種順序?
前面說過,對於CPU來說並不是直接跟主存交互的,因為速度有天壤之別,所以有多級緩存,有讀緩存,其實也有寫緩存
有了緩存,也就意味着這中間就多了一些步驟,那么就可能即使嚴格按照指令的順序執行,但是從結果上看起來卻是亂序的
指令重排序是一種動作,實際發生了,而存儲重排序則是一種現象,從結果看出來的一種現象,其實本身並沒有在執行上重拍,但是這也可能引起問題
如何保證順序?
貌似串行語義As-if-serial Semantics,只是保障單線程不會出問題,所以有序性保障,可以理解為,將貌似貌似串行語義As-if-serial Semantics擴展到多線程,在多線程中也不會出現問題
換句話說,有序性的保障,就是貌似串行語義在邏輯上看起來,有些必要的地方禁止重排序
從底層的角度來看,是借助於處理器提供的相關指令內存屏障來實現的
對於Java語言本身來說,Java已經幫我們與底層打交道,我們不會直接接觸內存屏障指令,java提供的關鍵字synchronized和volatile,可以達到這個效果,保障有序性(借助於顯式鎖Lock也是一樣的,Lock邏輯與synchronized一致)
happens-before 原則
關鍵字volatile和synchronized都可以保證有序性,他們都會告知底層,相關的處理需要保障有序,但是很顯然,如果所有的處理都需要主動地去借助於這兩個關鍵字去維護有序,這將是一件繁瑣痛苦的事情,而且,也說到了重排序也並不是隨意的
Java有一個內置的有序規則,也就是說,對於重排序有一個內置的規則實現,你不需要自己去動腦子思考,動手去寫代碼,有一些有序的保障Java天然存在,簡化了你對重排序的設計與思考
這個規則就叫做happens-before 原則
如果可以從這個原則中推測出來順序,那么將會對他們進行有序性保障;如果不能推導出來,換句話說不與這些要求相違背,那么就可能會被重排序,JVM不會對有序性進行保障。
程序次序規則(Program Order Rule)
在一個線程內,按照程序代碼順序,書寫在前面的操作先行發生於書寫在后面的操作。准確地說,應該是控制流順序而不是程序代碼順序,因為要考慮分支、循環等結構,只要確保在一個線程內最終的結果和代碼順序執行的結果一致即可,仍舊可能發生重排序,但是得保證這個前提
管程鎖定規則(Monitor Lock Rule)
一個unlock操作先行發生於后面對同一個鎖的 lock操作。這里必須強調的是同一個鎖,而“后面”是指時間上的先后順序
volatile變量規則(Volatile Variable Rule)
對一個volatile變量的寫操作先行發生於后面對這個變量的讀操作,這里的“后面”同樣是指時間上的先后順序。
線程啟動規則(Thread Start Rule)
Thread對象的start()方法先行發生於此線程的每一個動作。你必須得先啟動一個線程才能有后續
線程終止規則(Thread Termination Rule)
線程中的所有操作都先行發生於對此線程的終止檢測,也就是說所有的操作肯定是要在線程終止之前的,終止之后就不能有操作了,可以通過Thread.join()方法結束、Thread. isAlive()的返回值等手段檢測到線程已經終止執行。
線程中斷規則(Thread Interruption Rule)
對線程 interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生,也就是你得先調用方法,才會產生中斷,你不能別人發現中斷信號了,你竟然你都還沒調用interrupt方法,可以通過Thread.isinterrupted ()方法檢測到是否有中斷發生。
對象終結規則(Finalizer Rule)
一個對象的初始化完成(構造函數執行結束)先行發生於它的finalizeO方法的開始,先生后死,這個是必須的
傳遞性(Transitivity)
如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。
再次強調:對於happens-before規則,不需要做任何的同步限制,Java是天然支持的
《深入理解Java虛擬機:JVM高級特性與最佳實踐》中有一個例子對於理解該原則有所幫助
private int value = 0;
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
假設兩個線程A和B,線程A先(在時間上先)調用了這個對象的setValue(1),接着線程B調用getValue方法,那么B的返回值是多少?
對照着hp原則
不是同一個線程,所以不涉及:程序次序規則
不涉及同步,所以不涉及:管程鎖定規則
沒有volatile關鍵字,所以不涉及:volatile變量規則
沒有線程的啟動,中斷,終止,所以不涉及:線程啟動規則,線程終止規則,線程中斷規則
沒有對象的創建於終結,所以不涉及:對象終結規則
更沒有涉及到傳遞性
所以一條規則都不滿足,所以,盡管線程A在時間上與線程B具有先后順序,但是,卻並不涉及hp原則,也就是有序性並不會保障,所以線程B的數據獲取是不安全的!!
比如的確是先執行了,但是沒有及時寫入呢?
簡言之,時間上的先后順序,並不代表真正的先行發生(hp),而且,先行發生(hp)也並不能說明時間上的先后順序是什么
這也說明,不要被時間先后迷惑,只有真正的有序了,才能保障安全
也就是要么滿足hp原則了(天然就支持有序了),或者借助於volatile或者synchronized關鍵字或者顯式鎖Lock對他們進行保障(顯式手動控制有序),才能保障有序
happens-before是JMM的一個核心概念,因為對於程序員來說,希望一個簡單高效最重要的是要易用的,易於理解的編程模型,但是反過來說從編譯器和處理器執行的角度來看,自然是希望約束越少越好,沒有約束,那么就可以高度優化,很顯然兩者是矛盾的,一個希望嚴格、簡單、易用,另一個則希望盡可能少的約束;
happens-before則相當於一個折中后的方案,二者的一個權衡,以上是基本大致的的一個規范,有興趣的可以深入研究happens-before原則
原子性、可見性、有序性
前面說過,原子性保障了要么執行要么不執行,不會出現中間結果,但是即使原子了,不可分割了,但是是否對另外一個可見,是無法保障的,所以需要可見性
而有序性則是另外的線程對當前線程執行看起來的順序,所以如果都不可見,何談有序性,所以可見性是有序性的基礎
另外,有序性對於可見性是有影響的,比如某些操作本來在前,結果是可見的,但是重排序后,被排序到了后面,這就可能導致不可見,比如父線程的操作對子線程是可見的,但是如果有些位置順序調整了呢?
總結
Java內存區域的划分是對於主存的一種划分,存儲的划分,而這個主存則是分配給JVM進程的內存空間,而JVM的這部分內存只是物理內存的一部分
這部分內存有共享的主存儲空間,還有一部分是線程私有的本地內存空間
線程所用到的所有的變量都位於線程的本地內存中,局部變量本身就在本地內存,而共享變量則會持有一份私有拷貝
線程的操作台就是這個本地內存,既不能直接訪問主存也不能訪問其他線程本地內存,只能借助於主存進行交互
JMM模型則是對於JVM對於內存訪問的一種規范,多線程工作內存與主內存之間的交互原則進行了指示,他是獨立於具體物理機器的一種內存存取模型
對於多線程的數據安全問題,三個方面,原子性、可見性、有序性是三個相互協作的方面,不是說保障了任何一個就萬事大吉了,另外也並不一定是所有的場景都需要全部都保障才能夠線程安全
比如volatile關鍵字只能保障可見性和有序性以及自身修飾變量的原子性,但是如果是一個代碼段卻並不能保障原子性,所以是一種弱的同步,而synchronized則可以從三個維度進行保障
這三個特性也是JMM的核心,對相關的原則進行了規范,所以概括的說什么是JMM?他就只是一個規范概念
Java通過提供同步機制(synchronized、volatile)關鍵字借助於編譯器、JVM實現,依賴於底層操作系統,對這些規范進行了實現,提供了對於這些特性的一個保障
反復提到的下面的這個圖就是JMM的基礎結構,而延展出來的規范特性,就是基於這個結構,並且針對於多線程安全問題提出的一些解決方案
只要正確的使用提供的同步機制,就能夠開發出正確的並發程序
以下圖為結構基礎,定義的線程私有數據空間與主存之間的交互原則