JVM-內存模型
概念
JVM
JVM,即 Java Virtual Machine 。它通過模擬一個計算機來達到一個計算機所具有的的計算功能。JVM 能夠跨計算機體系結構來執行 Java 字節碼,主要是由於 JVM 屏蔽了與各個計算機平臺相關的軟件或者硬件之間的差異,使得與平臺相關的耦合統一由 JVM 提供者來實現。
虛擬機
VM: Virtual Machine 是通過軟件模擬物理機器執行程序的執行器。最初Java語言被設計為基於虛擬機器在而非物理機器,重而實現WORA(Write Once, Run Anywhere一次編寫,到處運行)的目的,盡管這個目標幾乎被世人所遺忘。所以,JVM可以在所有的硬件環境上執行Java字節碼而無須調整Java的執行模式。
JVM的基本特性:
- 基於棧(Stack-based)的虛擬機: 不同於Intel x86和ARM等比較流行的計算機處理器都是基於寄存器(register)架構,JVM是基於棧執行的。
- 符號引用(Symbolic reference): 除基本類型外的所有Java類型(類和接口)都是通過符號引用取得關聯的,而非顯式的基於內存地址的引用。
- 垃圾回收機制: 類的實例通過用戶代碼進行顯式創建,但卻通過垃圾回收機制自動銷毀。
- 通過明確清晰基本類型確保平臺無關性: 像C/C++等傳統編程語言對於int類型數據在同平臺上會有不同的字節長度。JVM卻通過明確的定義基本類型的字節長度來維持代碼的平臺兼容性,從而做到平臺無關。
- 網絡字節序(Network byte order): Java class文件的二進制表示使用的是基於網絡的字節序(network byte order)。為了在使用小端(little endian)的Intel x86平臺和在使用了大端(big endian)的RISC系列平臺之間保持平臺無關,必須要定義一個固定的字節序。JVM選擇了網絡傳輸協議中使用的網絡字節序,即基於大端(big endian)的字節序。
JRE/JDK/JVM
JRE(JavaRuntimeEnvironment),也就是Java平臺。所有的Java 程序都要在JRE下才能運行。普通用戶只需要運行已開發好的java程序,安裝JRE即可。
JDK(Java Development Kit)是程序開發者用來來編譯、調試java程序用的開發工具包。JDK的工具也是Java程序,也需要JRE才能運行。為了保持JDK的獨立性和完整性,在JDK的安裝過程中,JRE也是 安裝的一部分。所以,在JDK的安裝目錄下有一個名為jre的目錄,用於存放JRE文件。
JVM(JavaVirtualMachine)是JRE的一部分。它是一個虛構出來的計算機,是通過在實際的計算機上仿真模擬各種計算機功能來實現的。JVM有自己完善的硬件架構,如處理器、堆棧、寄存器等,還具有相應的指令系統。Java語言最重要的特點就是跨平臺運行。使用JVM就是為了支持與操作系統無關,實現跨平臺。
運行流程
java程序經過一次編譯之後,將java代碼編譯為字節碼也就是class文件,然後在不同的操作系統上依靠不同的java虛擬機進行解釋,最後再轉換為不同平臺的機器碼,最終得到執行。這樣我們是不是可以推演,如果要在mac系統上運行,是不是只需要安裝mac java虛擬機就行了。那麽了解了這個基本原理後,我們嘗試去做更深的研究,一個普通的java程序它的執行流程到底是怎樣的呢?例如我們寫了一段這樣的代碼:
public class HelloWorld {
public static void main(String[] args) {
System.out.print("Hello world");
}
}
這段程序從編譯到運行,最終打印出“Hello world”中間經過了哪些步驟呢?我們直接上圖:
java代碼通過編譯之後生成字節碼文件(class文件),通過:java HelloWorld執行,此時java根據系統版本找到jvm.cfg
其中-server KNOWN就表示名稱為server的jvm可用。如果這時你搜索一下你電腦上jvm.dll,你就會發現它一定在你的某個server目錄下,比如我的是在C:\Program Files\Java\jdk1.8.0_171\jre\bin\server目錄下 . 簡而言之就是通過jvm.cfg文件找到對應的jvm.dll,jvm.dll則是java虛擬機的主要實現。
接下來會初始化JVM,並且獲取JNI接口,將被編譯成class文件後的java代碼從硬盤裝載到JVM中,找到main方法執行
JNI Java Native Interface
它提供了若幹的API實現了Java和其他語言的通信(主要是C&C++)。從Java1.1開始,JNI標準成為java平臺的一部分,它允許Java代碼和其他語言寫的代碼進行交互。JNI一開始是為了本地已編譯語言,尤其是C和C++而設計的,但是它並不妨礙你使用其他編程語言,只要調用約定受支持就可以了
運行時數據區
類加載器完成加載後將交由JVM執行引擎執行,在程序執行過程中,JVM會劃分一段空間來存儲程序執行期間要用到的數據相關信息. 這段空間稱為Runtime Data Area(運行時數據區),也就是JVM內存.
棧 (VM Stack)
Java棧中存放的是一個個的棧幀,每個棧幀對應一個被調用的方法,在棧幀中包括:
局部變量表(LocalVariables),就是用來存儲方法中的局部變量(包括在方法中聲明的非靜態變量以及函數形參)。對於基本數據類型的變量,則直接存儲它的值,對於引用類型的變量,則存的是指向對象的引用。局部變量表的大小在編譯器就可以確定其大小了,因此在程序執行期間局部變量表的大小是不會改變的。
操作數棧(Operand Stack),想必學過數據結構中的棧的朋友想必對表達式求值問題不會陌生,棧最典型的一個應用就是用來對表達式求值。想想一個線程執行方法的過程中,實際上就是不斷執行語句的過程,而歸根到底就是進行計算的過程。因此可以這麽說,程序中的所有計算過程都是在借助於操作數棧來完成的。
指向運行時常量池的引用(Reference to runtime constant pool),因為在方法執行的過程中有可能需要用到類中的常量,所以必須要有一個引用指向運行時常量。
方法返回地址(Return Address),當一個方法執行完畢之後,要返回之前調用它的地方,因此在棧幀中必須保存一個方法返回地址。
當線程執行一個方法時,就會隨之創建一個對應的棧幀,並將建立的棧幀壓棧。當方法執行完畢之後,便會將棧幀出棧。因此可知,線程當前執行的方法所對應的棧幀必定位於Java棧的頂部。
講到這裏,大家就應該會明白為什麽在 使用 遞歸方法的時候容易導致棧內存溢出的現象了以及為什麽棧區的空間不用程序員去管理了(當然在Java中,程序員基本不用關系到內存分配和釋放的事情,因為Java有自己的垃圾回收機制),這部分空間的分配和釋放都是由系統自動實施的。對於所有的程序設計語言來說,棧這部分空間對程序員來說是不透明的。下圖表示了一個Java棧的模型:
本地方法棧(Native Method Stack)
本地方法棧的功能和特點類似於虛擬機棧,均具有線程隔離的特點以及都能拋出StackOverflowError和OutOfMemoryError異常。
本地方法可以通過本地方法接口 來訪問虛擬機得運行時數據區,但不止於此,他還可以做任何他想做的事情。比如,他甚至可以直接使用本地處理器中的寄存器,或者直接從本地內存的堆中分配任意數量的內存等等。總之,他和虛擬機擁有同樣的權限(或者說能力)
任何本地方法接口都會使用某種本地方法棧。然而當他調用的是本地方法時,虛擬機會保持Java棧不變,不再在線程的java棧中壓入新的幀,虛擬機只是簡單地動態連接並直接調用指定的本地方法。可以把這看做是虛擬機利用本地方法來動態擴展自己 。就如同Java虛擬機的實現在按照其中運行的Java程序的吩咐,調用屬於虛擬機內部的另一個(動態連接的)方法
如果某個虛擬機實現的本地方法接口是使用C連接模型的話,那個他的本地方法棧就是C棧。我們知道,當C程序調用一個C函數時,其棧操作都是確定的。傳遞 給該函數的參數已某個確定的順序壓入棧,他的返回值也以確定的方式傳回調用者。同樣,這就是改虛擬機實現中本地方法棧的行為。
描繪了這種情況,就是當一個線程調用一個本地方法時,本地方法又回調虛擬機中的另一個Java方法。
上圖所示,該線程首先調用了兩個Java方法,而第二個Java方法又調用了一個本地方法,這樣導致虛擬機使用了一個本地方法棧。圖中的本地方法棧顯示為一個連續的內存空間。假設這是一個C語言棧,期間有兩個C函數,他們都以包圍在虛線中的灰色塊表示。
第一個C函數被第二個Java方法當做本地方法調用,而這個C函數又調用了第二個C函數。之後第二個C函數被第二個Java方法當做本地方法調用,而這個C函數又調用了第二個C函數。之後第二個C函數又通過本地方法接口回調了一個Java方法(第三個Java方法)。最終這個Java方法又調用了一個Java方法(他成為圖中的當前方法)。
堆 (Heap)
在C語言中,堆這部分空間是唯一一個程序員可以管理的內存區域。程序員可以通過malloc函數和free函數在堆上申請和釋放空間。那麽在Java中是怎麽樣的呢?
Java中的堆是用來存儲對象本身的以及數組(當然,數組引用是存放在Java棧中的)。只不過和C語言中的不同,在Java中,程序員基本不用去關心空間釋放的問題,Java的垃圾回收機制會自動進行處理。另外,堆是被所有線程共享的,在JVM中只有一個堆。
Perm Gen
Permanent Generation Perm Gen 區是一個特殊的JVM內存區,因為它用來存儲用來描述 Class 的 元數據(Class 可以不屬於Java語言的一部分,也可以屬於)用來描述類及其方法的原始信息。 在大的應用中該區一會兒就滿了,並拋出錯誤:java.lang.OutOfMemoryError: PermGen 然而無論你怎麽設置 -Xmx 也不管用。
註意:
Perm Gen 不是 Heap 的一部分。
Perm Gen 被 JVM 使用於應用程序運行期間(runtime),基於應用所使用到的類。
Perm Gen 中同時包括 Java SE 包中的類。
Perm Gen 只有在執行 Full GC 時才會被 GC。
Heap / Stack / Perm 對比
Heap(堆內存): 使用Java語言創建的所有的引用對象類型,都在此存儲。並由 GC (Garbage Collection)對其進行管理, 諸如:釋放不再被程序引用的對象所占據的內存。
Stack(棧內存): 與 Heap 相對的是,Stack 存放基礎數據類型。諸如:int, char 等。 由程序的執行順序控制變量的進出棧順序,而不是由 GC 控制棧內存的管理。
Perm(持久內存): 用於存儲類的元數據。諸如:類的定義,方法的定義等。
Perm 的生命周期與 JVM 綁定,而 Heap 的生命周期與程序綁定。
Young Gen
所有新創建的 Object 首先被放在 Young Generation 內存區。 如果 Young Generation 內存區滿了,則執行 Garbage Collection 。這種 GC 稱為 Minor GC。
Young Generation 區又分為三部分: Eden Memory,Survivor0 Memory (S0),Survivor1 Memory(S1).
Young Generation 內存區要點:
- 絕大多數新建的 Object 被放在 Eden Memory
- 如果 Eden Memory 內存滿了,則進行 GC 操作。
同時把未被 GC 的 Object 移動到 S0 或 S1 中。
此時 Minor GC 也會檢查和移動 S0 和 S1 中的對象。
最後使 S0,S1 其中一個置為空。 - 多次 GC 後仍然未被 GC 的 Object 將被移動到 Old Gen 內存區中。
通常 Object 會被 GC 設定一個輪詢的閥值。
Old Gen
Old Gen 內存區存放了經過多次 Minor GC 後仍然不能被 GC 的 Object。
與 Young Gen 相同,當 Old Gen 區滿了之後將執行 GC 操作,該操作稱為:Major GC。 耗用的時間也相對較長。
stop-the-world 事件
Young Gen 和 Old Gen 都可以主動觸發 stop-the-world 事件,掛起所有任務,執行 GC 操作。 被掛起的任務只有在 GC 執行完畢後,才會恢復執行。 多數情況下, GC 性能調優(GC tuning)就是指降低 stop-the-world 時 GC 執行的時間。
堆內存分配:
- JVM初始分配的內存由-Xms指定,默認是物理內存的1/64
- JVM最大分配的內存由-Xmx指定,默認是物理內存的1/4
- 默認空余堆內存小於40%時,JVM就會增大堆直到-Xmx的最大限制;空余堆內存大於70%時,JVM會減少堆直到 -Xms的最小限制。
- 因此服務器一般設置-Xms、-Xmx相等以避免在每次GC 後調整堆的大小。對象的堆內存由稱為垃圾回收器的自動內存管理系統回收。
非堆內存分配:
- JVM使用-XX:PermSize設置非堆內存初始值,默認是物理內存的1/64;
- 由XX:MaxPermSize設置最大非堆內存的大小,默認是物理內存的1/4。
- -Xmn2G:設置年輕代大小為2G。
- -XX:SurvivorRatio,設置年輕代中Eden區與Survivor區的比值。
PC寄存器 / 程序計數器 (Program Counter Register)
指令計數器是線程私有的,每個線程都有獨立的指令計數器,計數器記錄著虛擬機正在執行的字節碼指令的地址,分支、循環、跳轉、異常處理和線程恢復等操作都依賴這個計數器完成。
如果線程執行的是native方法,這個計數器則為空。
方法區 (Method Area)
方法區在JVM中也是一個非常重要的區域,它與堆一樣,是被線程共享的區域。在方法區中,存儲了每個類的信息(包括類的名稱、方法信息、字段信息)、靜態變量、類中定義為final類型的常量以及編譯器編譯後的代碼等。
在方法區中有一個非常重要的部分就是運行時常量池,它是每一個類或接口的常量池的運行時表示形式,在類和接口被加載到JVM後,對應的運行時常量池就被創建出來。當然並非Class文件常量池中的內容才能進入運行時常量池,在運行期間也可將新的常量放入運行時常量池中,比如String的intern方法。
運行時常量池:一個存儲了類文件格式中的常量池表的內存空間。除了每個類或接口中定義的常量,它還包含了所有對方法和字段的引用。因此當需要一個方法或字段時,JVM通過運行時常量池中的信息從內存空間中來查找其相應的實際地址。
內存模型
線程間通信
Java內存模型(即Java Memory Model,簡稱JMM)本身是一種抽象的概念,並不真實存在,它描述的是一組規則或規範,通過這組規範定義了程序中各個變量(包括實例字段,靜態字段和構成數組對象的元素)的訪問方式。
由於JVM運行程序的實體是線程,而每個線程創建時JVM都會為其創建一個工作內存(有些地方稱為棧空間),用於存儲線程私有的數據,而Java內存模型中規定所有變量都存儲在主內存,主內存是共享內存區域,所有線程都可以訪問,但線程對變量的操作(讀取賦值等)必須在工作內存中進行,首先要將變量從主內存拷貝的自己的工作內存空間,然後對變量進行操作,操作完成後再將變量寫回主內存,不能直接操作主內存中的變量,
工作內存中存儲著主內存中的變量副本拷貝,前面說過,工作內存是每個線程的私有數據區域,因此不同的線程間無法訪問對方的工作內存,線程間的通信(傳值)必須通過主內存來完成,其簡要訪問過程如下圖
需要註意的是,JMM與Java內存區域的劃分是不同的概念層次,更恰當說JMM描述的是一組規則,通過這組規則控制程序中各個變量在共享數據區域和私有數據區域的訪問方式,JMM是圍繞原子性,有序性、可見性展開的。JMM與Java內存區域唯一相似點,都存在共享數據區域和私有數據區域,在JMM中主內存屬於共享數據區域,從某個程度上講應該包括了堆和方法區,而工作內存數據線程私有數據區域,從某個程度上講則應該包括程序計數器、虛擬機棧以及本地方法棧。或許在某些地方,我們可能會看見主內存被描述為堆內存,工作內存被稱為線程棧,實際上他們表達的都是同一個含義。關於JMM中的主內存和工作內存說明如下
主內存
主要存儲的是Java實例對象,所有線程創建的實例對象都存放在主內存中,不管該實例對象是成員變量還是方法中的本地變量(也稱局部變量),當然也包括了共享的類信息、常量、靜態變量。由於是共享數據區域,多條線程對同一個變量進行訪問可能會發現線程安全問題。
工作內存
主要存儲當前方法的所有本地變量信息(工作內存中存儲著主內存中的變量副本拷貝),每個線程只能訪問自己的工作內存,即線程中的本地變量對其它線程是不可見的,就算是兩個線程執行的是同一段代碼,它們也會各自在自己的工作內存中創建屬於當前線程的本地變量,當然也包括了字節碼行號指示器、相關Native方法的信息。註意由於工作內存是每個線程的私有數據,線程間無法相互訪問工作內存,因此存儲在工作內存的數據不存在線程安全問題。
接了解一下主內存與工作內存的數據存儲類型以及操作方式,根據虛擬機規範,對於一個實例對象中的成員方法而言,
如果方法中包含本地變量是基本數據類型(boolean,byte,short,char,int,long,float,double),將直接存儲在工作內存的幀棧結構中,
但倘若本地變量是引用類型,那麽該變量的引用會存儲在功能內存的幀棧中,而對象實例將存儲在主內存(共享數據區域,堆)中。
但對於實例對象的成員變量,不管它是基本數據類型或者包裝類型(Integer、Double等)還是引用類型,都會被存儲到堆區。
至於static變量以及類本身相關信息將會存儲在主內存中。
需要註意的是,在主內存中的實例對象可以被多線程共享,倘若兩個線程同時調用了同一個對象的同一個方法,那麽兩條線程會將要操作的數據拷貝一份到自己的工作內存中,執行完成操作後才刷新到主內存,簡單示意圖如下所示:
在消息傳遞的並發模型裏,線程之間沒有公共狀態,線程之間必須通過明確的發送消息來顯式進行通信,在 Java 中典型的消息傳遞方式就是 wait() 和 notify()。
對象的定位訪問
句柄定位
- Java 堆會畫出一塊內存來作為句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自的具體地址信息
指針訪問
- java堆對象的不居中就必須考慮如何放置訪問類型數據的相關信息,而reference中存儲的直接就是對象地址
比較
使用直接指針就是速度快
使用句柄reference指向穩定的句柄,對象被移動改變的也只是句柄中實例數據的指針,而reference本身並不需要修改。
參考文獻
JVM的內存區域劃分
全面理解Java內存模型(JMM)及volatile關鍵字
JVM-內存模型