Java 內存區域和內存模型是不一樣的東西,內存區域是指 Jvm 運行時將數據分區域存儲,強調對內存空間的划分。
而內存模型(Java Memory Model,簡稱 JMM )是定義了線程和主內存之間的抽象關系,即 JMM 定義了 JVM 在計算機內存(RAM)中的工作方式,如果我們要想深入了解Java並發編程,就要先理解好Java內存模型。
Java運行時數據區域
眾所周知,Java 虛擬機有自動內存管理機制,如果出現內存泄漏和溢出方面的問題,排查錯誤就必須要了解虛擬機是怎樣使用內存的。
下圖是 JDK8 之后的 JVM 內存布局。
JDK8 之前的內存區域圖如下:
在 HotSpot JVM 中,永久代中用於存放類和方法的元數據以及常量池,比如
Class
和Method
。每當一個類初次被加載的時候,它的元數據都會放到永久代中。
永久代是有大小限制的,因此如果加載的類太多,很有可能導致永久代內存溢出,即萬惡的 java.lang.OutOfMemoryError: PermGen ,為此我們不得不對虛擬機做調優。
那么,Java 8 中 PermGen 為什么被移出 HotSpot JVM 了?我總結了兩個主要原因:
- 由於 PermGen 內存經常會溢出,引發惱人的 java.lang.OutOfMemoryError: PermGen,因此 JVM 的開發者希望這一塊內存可以更靈活地被管理,不要再經常出現這樣的 OOM
- 移除 PermGen 可以促進 HotSpot JVM 與 JRockit VM 的融合,因為 JRockit 沒有永久代。
根據上面的各種原因,PermGen 最終被移除,方法區移至 Metaspace,字符串常量移至 Java Heap。
程序計數器
程序計數器(Program Counter Register)是一塊較小的內存空間,它可以看作是當前線程所執行的字節碼的行號指示器。
由於 Java 虛擬機的多線程是通過線程輪流切換並分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器內核都只會執行一條線程中的指令。
因此,為了線程切換后能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各條線程之間計數器互不影響,獨立存儲,我們稱這類內存區域為“線程私有”的內存。
如果線程正在執行的是一個 Java 方法,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址;如果正在執行的是 Native 方法,這個計數器值則為空(Undefined)。此內存區域是唯一一個在 Java 虛擬機規范中沒有規定任何 OutOfMemoryError 情況的區域。
Java虛擬機棧
與程序計數器一樣,Java 虛擬機棧(Java Virtual Machine Stacks)也是線程私有的,它的生命周期與線程相同。
虛擬機棧描述的是 Java 方法執行的內存模型:每個方法在執行的同時都會創建一個棧幀(Stack Frame,是方法運行時的基礎數據結構)用於存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。每一個方法從調用直至執行完成的過程,就對應着一個棧幀在虛擬機棧中入棧到出棧的過程。
在活動線程中,只有位千棧頂的幀才是有效的,稱為當前棧幀。正在執行的方法稱為當前方法,棧幀是方法運行的基本結構。在執行引擎運行時,所有指令都只能針對當前棧幀進行操作。
1. 局部變量表
局部變量表是存放方法參數和局部變量的區域。 局部變量沒有准備階段, 必須顯式初始化。如果是非靜態方法,則在 index[0] 位置上存儲的是方法所屬對象的實例引用,一個引用變量占 4 個字節,隨后存儲的是參數和局部變量。字節碼指令中的 STORE 指令就是將操作棧中計算完成的局部變呈寫回局部變量表的存儲空間內。
虛擬機棧規定了兩種異常狀況:如果線程請求的棧深度大於虛擬機所允許的深度,將拋出 StackOverflowError 異常;如果虛擬機棧可以動態擴展(當前大部分的 Java 虛擬機都可動態擴展),如果擴展時無法申請到足夠的內存,就會拋出 OutOfMemoryError 異常。
2. 操作棧
操作棧是個初始狀態為空的桶式結構棧。在方法執行過程中, 會有各種指令往
棧中寫入和提取信息。JVM 的執行引擎是基於棧的執行引擎, 其中的棧指的就是操
作棧。字節碼指令集的定義都是基於棧類型的,棧的深度在方法元信息的 stack 屬性中。
i++ 和 ++i 的區別:
- i++:從局部變量表取出 i 並壓入操作棧(load memory),然后對局部變量表中的 i 自增 1(add&store memory),將操作棧棧頂值取出使用,如此線程從操作棧讀到的是自增之前的值。
- ++i:先對局部變量表的 i 自增 1(load memory&add&store memory),然后取出並壓入操作棧(load memory),再將操作棧棧頂值取出使用,線程從操作棧讀到的是自增之后的值。
之前之所以說 i++ 不是原子操作,即使使用 volatile 修飾也不是線程安全,就是因為,可能 i 被從局部變量表(內存)取出,壓入操作棧(寄存器),操作棧中自增,使用棧頂值更新局部變量表(寄存器更新寫入內存),其中分為 3 步,volatile 保證可見性,保證每次從局部變量表讀取的都是最新的值,但可能這 3 步可能被另一個線程的 3 步打斷,產生數據互相覆蓋問題,從而導致 i 的值比預期的小。
3. 動態鏈接
每個棧幀中包含一個在常量池中對當前方法的引用, 目的是支持方法調用過程的動態連接。
4.方法返回地址
方法執行時有兩種退出情況:
- 正常退出,即正常執行到任何方法的返回字節碼指令,如 RETURN、IRETURN、ARETURN 等;
- 異常退出。
無論何種退出情況,都將返回至方法當前被調用的位置。方法退出的過程相當於彈出當前棧幀,退出可能有三種方式:
- 返回值壓入上層調用棧幀。
- 異常信息拋給能夠處理的棧幀。
- PC計數器指向方法調用后的下一條指令。
本地方法棧
本地方法棧(Native Method Stack)與虛擬機棧所發揮的作用是非常相似的,它們之間的區別不過是虛擬機棧為虛擬機執行 Java 方法(也就是字節碼)服務,而本地方法棧則為虛擬機使用到的 Native 方法服務。Sun HotSpot 虛擬機直接就把本地方法棧和虛擬機棧合二為一。與虛擬機棧一樣,本地方法棧區域也會拋出 StackOverflowError 和 OutOfMemoryError 異常。
線程開始調用本地方法時,會進入 個不再受 JVM 約束的世界。本地方法可以通過 JNI(Java Native Interface)來訪問虛擬機運行時的數據區,甚至可以調用寄存器,具有和 JVM 相同的能力和權限。 當大量本地方法出現時,勢必會削弱 JVM 對系統的控制力,因為它的出錯信息都比較黑盒。對內存不足的情況,本地方法棧還是會拋出 nativeheapOutOfMemory。
JNI 類本地方法最著名的應該是 System.currentTimeMillis()
,JNI使 Java 深度使用操作系統的特性功能,復用非 Java 代碼。 但是在項目過程中, 如果大量使用其他語言來實現 JNI , 就會喪失跨平台特性。
Java堆
對於大多數應用來說,Java 堆(Java Heap)是 Java 虛擬機所管理的內存中最大的一塊。Java 堆是被所有線程共享的一塊內存區域,在虛擬機啟動時創建。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例都在這里分配內存。
堆是垃圾收集器管理的主要區域,因此很多時候也被稱做“GC堆”(Garbage Collected Heap)。從內存回收的角度來看,由於現在收集器基本都采用分代收集算法,所以 Java 堆中還可以細分為:新生代和老年代;再細致一點的有 Eden 空間、From Survivor 空間、To Survivor 空間等。從內存分配的角度來看,線程共享的 Java 堆中可能划分出多個線程私有的分配緩沖區(Thread Local Allocation Buffer,TLAB)。
Java 堆可以處於物理上不連續的內存空間中,只要邏輯上是連續的即可,當前主流的虛擬機都是按照可擴展來實現的(通過 -Xmx 和 -Xms 控制)。如果在堆中沒有內存完成實例分配,並且堆也無法再擴展時,將會拋出 OutOfMemoryError 異常。
方法區
方法區(Method Area)與 Java 堆一樣,是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯后的代碼等數據。雖然
Java 虛擬機規范把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做 Non-Heap(非堆),目的應該是與 Java 堆區分開來。
Java 虛擬機規范對方法區的限制非常寬松,除了和 Java 堆一樣不需要連續的內存和可以選擇固定大小或者可擴展外,還可以選擇不實現垃圾收集。垃圾收集行為在這個區域是比較少出現的,其內存回收目標主要是針對常量池的回收和對類型的卸載。當方法區無法滿足內存分配需求時,將拋出 OutOfMemoryError 異常。
JDK8 之前,Hotspot 中方法區的實現是永久代(Perm),JDK8 開始使用元空間(Metaspace),以前永久代所有內容的字符串常量移至堆內存,其他內容移至元空間,元空間直接在本地內存分配。
為什么要使用元空間取代永久代的實現?
- 字符串存在永久代中,容易出現性能問題和內存溢出。
- 類及方法的信息等比較難確定其大小,因此對於永久代的大小指定比較困難,太小容易出現永久代溢出,太大則容易導致老年代溢出。
- 永久代會為 GC 帶來不必要的復雜度,並且回收效率偏低。
- 將 HotSpot 與 JRockit 合二為一。
運行時常量池
運行時常量池(Runtime Constant Pool)是方法區的一部分。Class 文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池(Constant Pool Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類加載后進入方法區的運行時常量池中存放。
一般來說,除了保存 Class 文件中描述的符號引用外,還會把翻譯出來的直接引用也存儲在運行時常量池中。
運行時常量池相對於 Class 文件常量池的另外一個重要特征是具備動態性,Java 語言並不要求常量一定只有編譯期才能產生,也就是並非預置入 Class 文件中常量池的內容才能進入方法區運行時常量池,運行期間也可能將新的常量放入池中,這種特性被開發人員利用得比較多的便是 String 類的 intern() 方法。
既然運行時常量池是方法區的一部分,自然受到方法區內存的限制,當常量池無法再申請到內存時會拋出 OutOfMemoryError 異常。
直接內存
直接內存(Direct Memory)並不是虛擬機運行時數據區的一部分,也不是 Java 虛擬機規范中定義的內存區域。
在 JDK 1.4 中新加入了 NIO,引入了一種基於通道(Channel)與緩沖區(Buffer)的 I/O 方式,它可以使用 Native 函數庫直接分配堆外內存,然后通過一個存儲在 Java 堆中的 DirectByteBuffer 對象作為這塊內存的引用進行操作。這樣能在一些場景中顯著提高性能,因為避免了在 Java 堆和 Native 堆中來回復制數據。
顯然,本機直接內存的分配不會受到 Java 堆大小的限制,但是,既然是內存,肯定還是會受到本機總內存(包括 RAM 以及 SWAP 區或者分頁文件)大小以及處理器尋址空間的限制。服務器管理員在配置虛擬機參數時,會根據實際內存設置 -Xmx 等參數信息,但經常忽略直接內存,使得各個內存區域總和大於物理內存限制(包括物理的和操作系統級的限制),從而導致動態擴展時出現 OutOfMemoryError 異常。
Java內存模型
Java內存模型是共享內存的並發模型,線程之間主要通過讀-寫共享變量(堆內存中的實例域,靜態域和數組元素)來完成隱式通信。
Java 內存模型(JMM)控制 Java 線程之間的通信,決定一個線程對共享變量的寫入何時對另一個線程可見。
計算機高速緩存和緩存一致性
計算機在高速的 CPU 和相對低速的存儲設備之間使用高速緩存,作為內存和處理器之間的緩沖。將運算需要使用到的數據復制到緩存中,讓運算能快速運行,當運算結束后再從緩存同步回內存之中。
在多處理器的系統中(或者單處理器多核的系統),每個處理器內核都有自己的高速緩存,它們有共享同一主內存(Main Memory)。
當多個處理器的運算任務都涉及同一塊主內存區域時,將可能導致各自的緩存數據不一致。
為此,需要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議進行操作,來維護緩存的一致性。
JVM主內存與工作內存
Java 內存模型的主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量(線程共享的變量)存儲到內存和從內存中取出變量這樣底層細節。
Java內存模型中規定了所有的變量都存儲在主內存中,每條線程還有自己的工作內存,線程對變量的所有操作都必須在工作內存中進行,而不能直接讀寫主內存中的變量。
這里的工作內存是 JMM 的一個抽象概念,也叫本地內存,其存儲了該線程以讀 / 寫共享變量的副本。
就像每個處理器內核擁有私有的高速緩存,JMM 中每個線程擁有私有的本地內存。
不同線程之間無法直接訪問對方工作內存中的變量,線程間的通信一般有兩種方式進行,一是通過消息傳遞,二是共享內存。Java 線程間的通信采用的是共享內存方式,線程、主內存和工作內存的交互關系如下圖所示:
這里所講的主內存、工作內存與 Java 內存區域中的 Java 堆、棧、方法區等並不是同一個層次的內存划分,這兩者基本上是沒有關系的,如果兩者一定要勉強對應起來,那從變量、主內存、工作內存的定義來看,主內存主要對應於Java堆中的對象實例數據部分,而工作內存則對應於虛擬機棧中的部分區域。
重排序和happens-before規則
在執行程序時為了提高性能,編譯器和處理器常常會對指令做重排序。重排序分三種類型:
- 編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。
- 指令級並行的重排序。現代處理器采用了指令級並行技術(Instruction-Level Parallelism, ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
- 內存系統的重排序。由於處理器使用緩存和讀 / 寫緩沖區,這使得加載和存儲操作看上去可能是在亂序執行。
從 java 源代碼到最終實際執行的指令序列,會分別經歷下面三種重排序:
JMM 屬於語言級的內存模型,它確保在不同的編譯器和不同的處理器平台之上,通過禁止特定類型的編譯器重排序和處理器重排序,為程序員提供一致的內存可見性保證。
java 編譯器禁止處理器重排序是通過在生成指令序列的適當位置會插入內存屏障(重排序時不能把后面的指令重排序到內存屏障之前的位置)指令來實現的。
happens-before
從 JDK5 開始,java 內存模型提出了 happens-before 的概念,通過這個概念來闡述操作之間的內存可見性。
如果一個操作執行的結果需要對另一個操作可見,那么這兩個操作之間必須存在 happens-before 關系。這里提到的兩個操作既可以是在一個線程之內,也可以是在不同線程之間。
這里的“可見性”是指當一條線程修改了這個變量的值,新值對於其他線程來說是可以立即得知的。
如果 A happens-before B,那么 Java 內存模型將向程序員保證—— A 操作的結果將對 B 可見,且 A 的執行順序排在 B 之前。
重要的 happens-before 規則如下:
- 程序順序規則:一個線程中的每個操作,happens- before 於該線程中的任意后續操作。
- 監視器鎖規則:對一個監視器鎖的解鎖,happens- before 於隨后對這個監視器鎖的加鎖。
- volatile 變量規則:對一個 volatile 域的寫,happens- before 於任意后續對這個 volatile 域的讀。
- 傳遞性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。
下圖是 happens-before 與 JMM 的關系
volatile關鍵字
volatile 可以說是 JVM 提供的最輕量級的同步機制,當一個變量定義為volatile之后,它將具備兩種特性:
- 保證此變量對所有線程的可見性。而普通變量不能做到這一點,普通變量的值在線程間傳遞均需要通過主內存來完成。
注意,volatile 雖然保證了可見性,但是 Java 里面的運算並非原子操作,導致 volatile 變量的運算在並發下一樣是不安全的。而 synchronized 關鍵字則是由“一個變量在同一個時刻只允許一條線程對其進行 lock 操作”這條規則獲得線程安全的。
- 禁止指令重排序優化。普通的變量僅僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變量賦值操作的順序與程序代碼中的執行順序一致。
最后,推薦與感謝:
深入理解Java虛擬機(第2版)
碼出高效:Java開發手冊
Java內存模型原理,你真的理解嗎?)
深入理解 Java 內存模型