文章目錄
- java運作時資料區域
-
- 程式計數器
- Java 虛拟機棧
- 本地方法棧
- 堆
- 方法區
- 直接記憶體
- java記憶體模型
-
- 概述
- 舉個例子
- java記憶體模型的特性
-
- 原子性
- 可見性
- 有序性
- JMM如何保證3個特性
- 總結
java記憶體模型是java多線程程式設計中一個很重要的專題,啃透這方面的知識無論是對日常開發還是個人成長都有很大的幫助。
java運作時資料區域
在學習記憶體模型前,我認為有必要先了解java運作時資料區域。所謂java運作時資料區域,就是java虛拟機在運作程式的時候,會把記憶體劃分為幾個不同的區域,每個區域存放不同類型的資料。

以上是java運作時資料區域的示意圖,需要注意的是虛線框内的區域是線程私有的,也就是下面要說的線程工作記憶體,裡面的資料隻能由所屬線程通路,其他線程不能通路(先有個概念,這裡很重要)。
程式計數器
屬于線程私有的資料區域,是一小塊記憶體空間,主要代表目前線程所執行的位元組碼行号訓示器。位元組碼解釋器工作時,通過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支、循環、跳轉、異常處理、線程恢複等基礎功能都需要依賴這個計數器來完成。
Java 虛拟機棧
屬于線程私有的資料區域,與線程同時建立,總數與線程關聯,代表Java方法執行的記憶體模型。每個 Java 方法在執行的同時會建立一個棧幀用于存儲局部變量表、操作數棧、常量池引用等資訊。從方法調用直至執行完成的過程,對應着一個棧幀在 Java 虛拟機棧中入棧和出棧的過程。
該區域可能抛出以下異常:
- 當線程請求的棧深度超過最大值,會抛出 StackOverflowError 異常
- 棧進行動态擴充時如果無法申請到足夠記憶體,會抛出 OutOfMemoryError 異常
本地方法棧
本地是英文native翻譯過來的,對應的就是java中一些用native标記的方法,即JNI(java native interface),這些方法是一般是用C/C++編寫的。本地方法棧與 Java 虛拟機棧類似,它們之間的差別隻不過是本地方法棧為本地方法服務,虛拟機棧為java方法服務(平常的方法),這部分也屬于線程私有的。
堆
所有對象都在這裡配置設定記憶體,是垃圾收集的主要區域,是以也稱為“GC堆”。堆不需要連續記憶體,并且可以動态增加其記憶體,增加失敗會抛出 OutOfMemoryError 異常。這部分是線程共享的區域,即所有線程都可以通路這裡的資料。
方法區
方法區屬于線程共享的記憶體區域,又稱Non-Heap(非堆),主要用于存儲已被虛拟機加載的類資訊、常量、靜态變量、即時編譯器編譯後的代碼等資料,根據Java 虛拟機規範的規定,當方法區無法滿足記憶體配置設定需求時,将抛出OutOfMemoryError 異常。值得注意的是在方法區中存在一個叫運作時常量池(Runtime Constant Pool)的區域,它主要用于存放編譯器生成的各種字面量和符号引用,這些内容将在類加載後存放到運作時常量池中,以便後續使用。
直接記憶體
在 JDK 1.4 中新引入了 NIO 類,它可以使用 Native 函數庫直接配置設定堆外記憶體,然後通過 Java 堆裡的 DirectByteBuffer 對象作為這塊記憶體的引用進行操作。這樣能在一些場景中顯著提高性能,因為避免了在堆記憶體和堆外記憶體來回拷貝資料。
注意
上面都是一些概念性的知識,了解它有助于了解下面的内容,至于這些區域具體存放的哪些資料,比如方法區介紹的是存放類資訊、常量等,那具體是類的哪些資訊?常量跟java中的final常量是同一個概念?這些以後再去學習總結,大家也可以自行查閱資料學習,今天的重點是java記憶體模型。
java記憶體模型
概述
Java記憶體模型(即Java Memory Model,簡稱JMM)本身是一種抽象的概念,并不真實存在,它描述的是一組規則或規範,通過這組規範定義了程式中各個變量(包括執行個體字段,靜态字段和構成數組對象的元素)的通路方式。由于JVM運作程式的實體是線程,而每個線程建立時JVM都會為其建立一個工作記憶體(有些地方稱為線程棧),用于存儲線程私有的資料,而Java記憶體模型中規定所有變量都存儲在主記憶體,主記憶體是共享記憶體區域,所有線程都可以通路,但線程對變量的操作(讀取指派等)必須在工作記憶體中進行,首先要将變量從主記憶體拷貝的自己的工作記憶體空間,然後對變量進行操作,操作完成後再将變量寫回主記憶體,不能直接操作主記憶體中的變量,工作記憶體中存儲着主記憶體中的變量副本拷貝,前面說過,工作記憶體是每個線程的私有資料區域,是以不同的線程間無法通路對方的工作記憶體,線程間的通信(傳值)必須通過主記憶體來完成,其簡要通路過程如下圖
需要注意的是,JMM與java運作時區域的劃分是不同的概念,JMM定義的是一套規則,通過這套規則來控制變量的通路,JMM主要是圍繞原子性、可見性、有序性(下面會分析這些特性進行)展開的。而java運作時區域的劃分就是把記憶體劃分為幾個部分,每個部分存放特定的資料。
JMM與Java記憶體區域唯一相似點,都存在共享資料區域和私有資料區域,在JMM中主記憶體屬于共享資料區域,從某個程度上講應該包括了堆和方法區,而工作記憶體資料線程私有資料區域,從某個程度上講則應該包括程式計數器、虛拟機棧以及本地方法棧。關于JMM中的主記憶體和工作記憶體說明如下
-
主記憶體
主要存儲的是Java執行個體對象,所有線程建立的執行個體對象都存放在主記憶體中,不管該執行個體對象是成員變量還是方法中的本地變量(也稱局部變量),當然也包括了共享的類資訊、常量、靜态變量。由于是共享資料區域,多條線程對同一個變量進行通路可能會發現線程安全問題。
-
工作記憶體
主要存儲的是方法仲的成員變量,而且這些成員變量一定要是java中的基本類型( boolean, byte, short, char, int, long, float, double),而對于像執行個體對象、靜态變量等,在工作空間中都是存儲一個指向對象執行個體的引用(這個執行個體對象一定是儲存在主記憶體中的,不管這個對象是類的成員變量還是方法中的局部變量)。
舉個例子
說了那麼多,究竟主記憶體和工作記憶體儲存的是什麼樣的資料?可能有些人還不是很清楚,還不如用例子說明。
public class MyRunnable implements Runnable() {
public void run() {
methodOne();
}
public void methodOne() {
int localVariable1 = 45;
MySharedObject localVariable2 = MySharedObject.sharedInstance;
//... do more with local variables.
methodTwo();
}
public void methodTwo() {
Integer localVariable1 = new Integer(99);
//... do more with local variable.
}
}
public class MySharedObject {
public static final MySharedObject sharedInstance = new MySharedObject();
public Integer object2 = new Integer(22);
public Integer object4 = new Integer(44);
public long member1 = 12345;
public long member1 = 67890;
}
假設兩個線程執行的都是這段代碼,則對應的記憶體模型如下圖所示。
下面分析一下其過程。
- 在run()方法中,線程首先調用methodOne(),對應的就是進棧,這裡有兩個線程執行,對應圖中的兩個Thread Stack,即工作空間。
- 在methodOne()中,定義了一個局部變量localVariable1,而且是int類型的,即java的基本類型,是以這個局部變量就直接存儲在工作空間中,而且每個線程都有一個備份,屬于線程的私有變量。
- 接着定義了一個局部變量localVariable2,它是一個對象,是以它肯定是存儲在主記憶體中的,localVariable2 = MySharedObject.sharedInstance,而MySharedObject.sharedInstance是MySharedObject類中的靜态變量,隻有在類加載的時候執行一次,是以主記憶體中隻有一個執行個體對象,對應圖中Object3,每個線程的私有空間中存儲的都是指向Object3的引用,當線程要通路它裡面的資料時,就是通路主記憶體的資料,可能會出現線程安全問題。
- 在MySharedObject 中,定義了兩個Integer的成員變量object2和object4,因為他們都是對象,是以也是存儲在主記憶體中,可以看到圖中Object3分别指向Object2和Object4,線程通過Object3的引用也可以通路Object2和Object4。
- 在MySharedObject 中,還定義了兩個long類型的成員變量,雖然是java的基本類型,但它們不是方法中的局部變量,是以也是在主記憶體中(在Object3裡)。
- 之後就調用methodTwo(),線程就跳轉去執行methodTwo()的代碼,對應的是methodTwo()方法進棧。
- 在methodTwo() 中new了一個Integer的對象,對象肯定存儲在主記憶體中,因為兩個線程各建立了一個對象執行個體,是以工作記憶體中儲存的是指向不同執行個體的引用。
總結
到這裡大家應該有更深的了解了吧,總的來說,線程的工作記憶體隻把方法中的局部變量而且是基本類型的變量複制一份進行存儲,其他的變量都是存儲一個引用,這個引用指向主記憶體中的變量。然而,當通路主記憶體的特定變量時,線程還是會複制一份在工作空間中,即讀取主記憶體變量的值,隻不過這個備份是臨時的,當操作完成後就會寫回到主記憶體中。
java記憶體模型的特性
JMM主要是為了解決線程安全的問題,而線程安全問題是由原子性、可見性、有序性引起的(不同時確定3個特性就會有線程安全的問題),先了解一下這3個特性。
原子性
原子性指的是一個操作是不可中斷的。一個操作從開始到結束,這個過程不會受其他線程影響,即其他線程不能中斷該線程正在進行的這個操作,那麼,這個操作就具有原子性。java記憶體模型保證了基本類型byte,short,int,float,boolean,char(不包括long和double)的讀寫是原子操作。但是在32位系統中,Java 記憶體模型允許虛拟機将沒有被 volatile 修飾的 64 位資料(long,double)的讀寫操作劃分為兩次 32 位的操作來進行,即不具備原子性。值得注意的是,保證基本類型如int的讀寫是原子操作,是指對int類型的讀是原子操作,對int類型的寫也是原子操作,但讀和寫放在一起就不是原子操作了。很多人認為int類型不會出現線程安全問題,其實就是了解錯了。
雖然基本類型的讀+寫不具備原子性,但java提供了他們對應的原子類Atomicxxx,如AtomicInteger,這種類型能保證線程對變量操作的原子性(讀→操作變量→寫),用這些類能解決一些線程安全的問題。
可見性
可見性指當一個線程修改了共享變量的值,其它線程能夠立即得知這個修改。Java 記憶體模型是通過在變量修改後将新值同步回主記憶體,在變量讀取前從主記憶體重新整理變量值來實作可見性的。
可以認為,可見性是由原子性引起的,正因為線程對變量的操作沒有原子性,導緻了其他線程對這個變量的不可見問題。舉一個對int類型讀寫的例子:例如x=1,線程A執行x + 5,首先讀取x的值,然後加5,此時線上程A裡,x的值是6,但由于讀寫不具備原子性,線上程A寫回主記憶體之前,線程B讀取x的值,在主存中x還是1,這時就說線程A中x的值對線程B不可見。
可以用volatile關鍵字修飾變量,保證變量的可見性。
有序性
了解指令重排序
計算機在執行程式時,為了提高性能,編譯器和處理器的常常會對指令做重排,一般分以下3種
-
編譯器優化的重排
編譯器在不改變單線程程式語義的前提下,可以重新安排語句的執行順序。
-
指令并行的重排
現代處理器采用了指令級并行技術來将多條指令重疊執行。如果不存在資料依賴性(即後一個執行的語句無需依賴前面執行的語句的結果),處理器可以改變語句對應的機器指令的執行順序。
-
記憶體系統的重排
由于處理器使用緩存和讀寫緩存沖區,這使得加載(load)和存儲(store)操作看上去可能是在亂序執行,因為三級緩存的存在,導緻記憶體與緩存的資料同步存在時間差。
這裡隻介紹編譯器優化重排,隻需要知道其他重排都是為了優化性能和CPU使用率,這種重排在單線程環境下沒問題,但在多線程環境下,會引起線程安全問題。
class MixedOrder{
int a = 0;
boolean flag = false;
public void writer(){
a = 1;
flag = true;
}
public void read(){
if(flag){
int i = a + 1;
}
}
}
這裡舉一個例子,假如線程A調用writer()方法,線程B調用read()方法,正常的邏輯下,線程B執行時,如果判斷flag為true,那證明線程A已經執行了a=1這個操作,因為a=1在flag=true前面,是以線程B計算出來的i值應該是2,這是正常的邏輯。
然而,出于某種原因,編譯器認為先執行flag = true 再執行a = 1性能會得到優化,而這種重排序對線程A是沒有影響的,因為兩句代碼沒有依賴的關系。這樣就有可能出現一種情況:當線程A執行了flag = true後,被線程B中斷,線程B判斷為true,并計算i的結果,這是a的值還是0(因為編譯器重排序了執行順序,a = 1還沒有執行),是以i = 1,跟我們正常的邏輯不一樣,這就是重排序導緻的線程安全問題。
有序性是指對于單線程的執行代碼,我們總是認為代碼的執行是按順序依次執行的,這樣的了解并沒有毛病,畢竟對于單線程而言确實如此,但對于多線程環境,則可能出現亂序現象,因為程式編譯成機器碼指令後可能會出現指令重排現象,重排後的指令與原指令的順序未必一緻,要明白的是,在Java程式中,倘若在本線程内,所有操作都視為有序行為,如果是多線程環境下,一個線程中觀察另外一個線程,所有操作都是無序的,前半句指的是單線程内保證串行語義執行的一緻性,後半句則指指令重排現象和工作記憶體與主記憶體同步延遲現象。
JMM如何保證3個特性
-
原子性
對于原子性,除了上面所說的JVM保證基本類型的讀寫原子性、AtomicInteger等原子類之外,對于方法或代碼塊級别的原子性,可以用synchronized關鍵字或ReentrantLock類來保證。
-
可見性
對于工作記憶體與主記憶體同步延遲現象導緻的可見性問題,可以使用synchronized關鍵字或者volatile關鍵字解決,它們都可以使一個線程修改後的變量立即對其他線程可見。
-
有序性
可以用volatile關鍵字解決,因為volatile的另一個作用是禁止重排序優化。
volatile作用詳解
- 保證可見性
對于用volatile修飾的變量,線程每次讀取都會在主記憶體中讀取,當這個變量的值發生變化時,立刻寫回主記憶體,保證其他線程可見。
-
禁止指令重排序
這一點在一道非常經典的面試題中有很好的展現,如下面的單例模式所示,面試官可能會問你這是線程安全的嗎?
ublic class DoubleCheckLock {
private static DoubleCheckLock instance;
private DoubleCheckLock(){}
public static DoubleCheckLock getInstance(){
//第一次檢測
if (instance==null){
//同步
synchronized (DoubleCheckLock.class){
if (instance == null){
//多線程環境下可能會出現問題的地方
instance = new DoubleCheckLock();
}
}
}
return instance;
}
}
乍一看,雙重校驗鎖,既保證了線程安全,又確定了效率,于是很自信地回答:“通過synchronized關鍵字保證隻有一個線程可以建立執行個體,又通過雙重校驗提高多線程下的效率,這是完美的線程安全的單例模式。”這時,在你以為自己說出了其優點的時候,面試官可能就在你的履歷上打了個叉。
其實這裡并非線程安全的,原因在于某一個線程執行到第一次檢測,讀取到的instance不為null時,instance的引用對象可能沒有完成初始化。什麼意思?
instance = new DoubleCheckLock();
可以分3步完成。
//1.配置設定對象記憶體空間
//2.初始化對象
//3.設定instance指向剛配置設定的記憶體位址,此時instance!=null
步驟2和步驟3有可能重排序:
//1.配置設定對象記憶體空間
//3.設定instance指向剛配置設定的記憶體位址,此時instance!=null,但是對象還沒有初始化完成!
//2.初始化對象
由于步驟2和步驟3不存在資料依賴關系,而且無論重排前還是重排後程式的執行結果在單線程中并沒有改變,是以這種重排優化是允許的。但是指令重排隻會保證串行語義的執行的一緻性(單線程),但并不會關心多線程間的語義一緻性。是以當一條線程通路instance不為null時,由于instance執行個體未必已初始化完成,也就造成了線程安全問題。那麼該如何解決呢,很簡單,我們使用volatile禁止instance變量被執行指令重排優化即可。
//禁止指令重排優化
private volatile static DoubleCheckLock instance;
-
volatile關鍵字不能保證線程安全
volatile隻是保證了變量的可見性和禁止重排序,但沒有保證原子性,不同時確定這3個特性,就有可能發生線程安全問題。假設有兩個線程對下面的變量進行
操作x++
因為x++不是原子操作,它至少分為3步(讀取x的值,x=x+1,把x寫回主記憶體),我們再回想volatile對可見性的保證(線程每次讀取都會在主記憶體中讀取,當這個變量的值發生變化時,立刻寫回主記憶體,保證其他線程可見),注意,是當變量發生變化時,線程才會立刻寫回主記憶體,在這個例子中相當于把x=x+1、将x寫回主記憶體兩個操作合并變成原子操作,但是讀和寫還是分開的,也就是讀寫一樣不是原子操作。想象一種情況:線程A讀取x的值,還沒加1時,線程B就搶奪CPU讀取x的值,此時還是0,進行加1操作後寫入主記憶體(現在主記憶體x=1),線程A已經讀取過x的值了(線上程A中為0),加1後寫入主記憶體,還是1,是以兩個線程各進行了x++操作,理想情況下x=2,現在卻為1。
總的來說,volatile沒有確定原子性,有可能出現線程安全問題。
happens-before 原則
除了sychronized和volatile關鍵字等來保證原子性、可見性以及有序性之外,java記憶體模型還提供了一套happens-before 原則來輔助保證這些特性。happens-before 原則内容如下:
- 程式順序原則,即在一個線程内必須保證語義串行性,也就是說按照代碼順序執行。
- 鎖規則 解鎖(unlock)操作必然發生在後續的同一個鎖的加鎖(lock)之前,也就是說,如果對于一個鎖解鎖後,再加鎖,那麼加鎖的動作必須在解鎖動作之後(同一個鎖)。
- volatile規則 volatile變量的寫,先發生于讀,這保證了volatile變量的可見性,簡單的了解就是,volatile變量在每次被線程通路時,都強迫從主記憶體中讀該變量的值,而當該變量發生變化時,又會強迫将最新的值重新整理到主記憶體,任何時刻,不同的線程總是能夠看到該變量的最新值。
- 線程啟動規則 線程的start()方法先于它的每一個動作,即如果線程A在執行線程B的start方法之前修改了共享變量的值,那麼當線程B執行start方法時,線程A對共享變量的修改對線程B可見。
- 傳遞性 A先于B ,B先于C 那麼A必然先于C
- 線程終止規則 線程的所有操作先于線程的終結,Thread.join()方法的作用是等待目前執行的線程終止。假設線上程B終止之前,修改了共享變量,線程A從線程B的join方法成功傳回後,線程B對共享變量的修改将對線程A可見。
- 線程中斷規則 對線程 interrupt()方法的調用先行發生于被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測線程是否中斷。
- 對象終結規則 對象的構造函數執行,結束先于finalize()方法。
總結
以上就是java記憶體模型的具體内容,學習它可以讓我們在開發中更好地應對多線程程式設計,出現bug時也可從記憶體模型層面去考慮和排查。