JVM內存模型
爲什麼 JVM 在 Java 中如此重要?
首先你應該知道,運行一個 Java 應用程序,我們必須要先安裝 JDK 或者 JRE 包。這是因爲 Java 應用在編譯後會變成字節碼,然後通過字節碼運行在 JVM 中,而 JVM 是 JRE 的核心組成部分。
JVM 不僅承擔了 Java 字節碼的分析(JIT compiler)和執行(Runtime),同時也內置了自動內存分配管理機制。這個機制可以大大降低手動分配回收機制可能帶來的內存泄露和內存溢出風險,使 Java 開發人員不需要關注每個對象的內存分配以及回收,從而更專注於業務本身。
從瞭解內存模型開始
JVM 自動內存分配管理機制的好處很多,但實則是把雙刃劍。這個機制在提升 Java 開發效率的同時,也容易使 Java 開發人員過度依賴於自動化,弱化對內存的管理能力,這樣系統就很容易發生 JVM 的堆內存異常,垃圾回收(GC)的方式不合適以及 GC 次數過於頻繁等問題,這些都將直接影響到應用服務的性能。
因此,要進行 JVM 層面的調優,就需要深入瞭解 JVM 內存分配和回收原理,這樣在遇到問題時,我們才能通過日誌分析快速地定位問題;也能在系統遇到性能瓶頸時,通過分析 JVM 調優來優化系統性能。這也是整個模塊四的重點內容,今天我們就從 JVM 的內存模型學起,爲後續的學習打下一個堅實的基礎。
JVM 內存模型的具體設計
我們先通過一張 JVM 內存模型圖,來熟悉下其具體設計。在 Java 中,JVM 內存模型主要分爲堆、程序計數器、方法區、虛擬機棧和本地方法棧。
JVM 的 5 個分區具體是怎麼實現的呢?我們一一分析。
- 堆(Heap)
堆是 JVM 內存中最大的一塊內存空間,該內存被所有線程共享,幾乎所有對象和數組都被分配到了堆內存中。堆被劃分爲新生代和老年代,新生代又被進一步劃分爲 Eden 和 Survivor 區,最後 Survivor 由 From Survivor 和 To Survivor 組成。
在 Java6 版本中,永久代在非堆內存區;到了 Java7 版本,永久代的靜態變量和運行時常量池被合併到了堆中;而到了 Java8,永久代被元空間取代了。結構如下圖所示:
- 程序計數器(Program Counter Register)
程序計數器是一塊很小的內存空間,主要用來記錄各個線程執行的字節碼的地址,例如,分支、循環、跳轉、異常、線程恢復等都依賴於計數器。
由於 Java 是多線程語言,當執行的線程數量超過 CPU 核數時,線程之間會根據時間片輪詢爭奪 CPU 資源。如果一個線程的時間片用完了,或者是其它原因導致這個線程的 CPU 資源被提前搶奪,那麼這個退出的線程就需要單獨的一個程序計數器,來記錄下一條運行的指令。
- 方法區(Method Area)
很多開發者都習慣將方法區稱爲“永久代”,其實這兩者並不是等價的。
HotSpot 虛擬機使用永久代來實現方法區,但在其它虛擬機中,例如,Oracle 的 JRockit、IBM 的 J9 就不存在永久代一說。因此,方法區只是 JVM 中規範的一部分,可以說,在 HotSpot 虛擬機中,設計人員使用了永久代來實現了 JVM 規範的方法區。
方法區主要是用來存放已被虛擬機加載的類相關信息,包括類信息、運行時常量池、字符串常量池。類信息又包括了類的版本、字段、方法、接口和父類等信息。
JVM 在執行某個類的時候,必須經過加載、連接、初始化,而連接又包括驗證、準備、解析三個階段。在加載類的時候,JVM 會先加載 class 文件,而在 class 文件中除了有類的版本、字段、方法和接口等描述信息外,還有一項信息是常量池 (Constant Pool Table),用於存放編譯期間生成的各種字面量和符號引用。
字面量包括字符串(String a=“b”)、基本類型的常量(final 修飾的變量),符號引用則包括類和方法的全限定名(例如 String 這個類,它的全限定名就是 Java/lang/String)、字段的名稱和描述符以及方法的名稱和描述符。
而當類加載到內存中後,JVM 就會將 class 文件常量池中的內容存放到運行時的常量池中;在解析階段,JVM 會把符號引用替換爲直接引用(對象的索引值)。
例如,類中的一個字符串常量在 class 文件中時,存放在 class 文件常量池中的;在 JVM 加載完類之後,JVM 會將這個字符串常量放到運行時常量池中,並在解析階段,指定該字符串對象的索引值。運行時常量池是全局共享的,多個類共用一個運行時常量池,class 文件中常量池多個相同的字符串在運行時常量池只會存在一份。
方法區與堆空間類似,也是一個共享內存區,所以方法區是線程共享的。假如兩個線程都試圖訪問方法區中的同一個類信息,而這個類還沒有裝入 JVM,那麼此時就只允許一個線程去加載它,另一個線程必須等待。
在 HotSpot 虛擬機、Java7 版本中已經將永久代的靜態變量和運行時常量池轉移到了堆中,其餘部分則存儲在 JVM 的非堆內存中,而 Java8 版本已經將方法區中實現的永久代去掉了,並用元空間(class metadata)代替了之前的永久代,並且元空間的存儲位置是本地內存。之前永久代的類的元數據存儲在了元空間,永久代的靜態變量(class static variables)以及運行時常量池(runtime constant pool)則跟 Java7 一樣,轉移到了堆中。
那你可能又有疑問了,Java8 爲什麼使用元空間替代永久代,這樣做有什麼好處呢?
官方給出的解釋是:
移除永久代是爲了融合 HotSpot JVM 與 JRockit VM 而做出的努力,因爲 JRockit 沒有永久代,所以不需要配置永久代。
永久代內存經常不夠用或發生內存溢出,爆出異常 java.lang.OutOfMemoryError: PermGen。這是因爲在 JDK1.7 版本中,指定的 PermGen 區大小爲 8M,由於 PermGen 中類的元數據信息在每次 FullGC 的時候都可能被收集,回收率都偏低,成績很難令人滿意;還有,爲 PermGen 分配多大的空間很難確定,PermSize 的大小依賴於很多因素,比如,JVM 加載的 class 總數、常量池的大小和方法的大小等。
-
虛擬機棧(VM stack)
Java 虛擬機棧是線程私有的內存空間,它和 Java 線程一起創建。當創建一個線程時,會在虛擬機棧中申請一個線程棧,用來保存方法的局部變量、操作數棧、動態鏈接方法和返回地址等信息,並參與方法的調用和返回。每一個方法的調用都伴隨着棧幀的入棧操作,方法的返回則是棧幀的出棧操作。 -
本地方法棧(Native Method Stack)
本地方法棧跟 Java 虛擬機棧的功能類似,Java 虛擬機棧用於管理 Java 函數的調用,而本地方法棧則用於管理本地方法的調用。但本地方法並不是用 Java 實現的,而是由 C 語言實現的。
JVM 的運行原理
看到這裏,相信你對 JVM 內存模型已經有個充分的瞭解了。接下來,我們通過一個案例來了解下代碼和對象是如何分配存儲的,Java 代碼又是如何在 JVM 中運行的。
public class JVMCase {
// 常量
public final static String MAN_SEX_TYPE = “man”;
// 靜態變量
public static String WOMAN_SEX_TYPE = “woman”;
public static void main(String[] args) {
Student stu = new Student();
stu.setName("nick");
stu.setSexType(MAN_SEX_TYPE);
stu.setAge(20);
JVMCase jvmcase = new JVMCase();
// 調用靜態方法
print(stu);
// 調用非靜態方法
jvmcase.sayHello(stu);
}
// 常規靜態方法
public static void print(Student stu) {
System.out.println("name: " + stu.getName() + “; sex:” + stu.getSexType() + “; age:” + stu.getAge());
}
// 非靜態方法
public void sayHello(Student stu) {
System.out.println(stu.getName() + “say: hello”);
}
}
class Student{
String name;
String sexType;
int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSexType() {
return sexType;
}
public void setSexType(String sexType) {
this.sexType = sexType;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
當我們通過 Java 運行以上代碼時,JVM 的整個處理過程如下:
1.JVM 向操作系統申請內存,JVM 第一步就是通過配置參數或者默認配置參數向操作系統申請內存空間,根據內存大小找到具體的內存分配表,然後把內存段的起始地址和終止地址分配給 JVM,接下來 JVM 就進行內部分配。
2.JVM 獲得內存空間後,會根據配置參數分配堆、棧以及方法區的內存大小。
3.class 文件加載、驗證、準備以及解析,其中準備階段會爲類的靜態變量分配內存,初始化爲系統的初始值
-
完成上一個步驟後,將會進行最後一個初始化階段。在這個階段中,JVM 首先會執行構造器方法,編譯器會在.java 文件被編譯成.class 文件時,收集所有類的初始化代碼,包括靜態變量賦值語句、靜態代碼塊、靜態方法,收集在一起成爲() 方法。
-
執行方法。啓動 main 線程,執行 main 方法,開始執行第一行代碼。此時堆內存中會創建一個 student 對象,對象引用 student 就存放在棧中。
-
此時再次創建一個 JVMCase 對象,調用 sayHello 非靜態方法,sayHello 方法屬於對象 JVMCase,此時 sayHello 方法入棧,並通過棧中的 student 引用調用堆中的 Student 對象;之後,調用靜態方法 print,print 靜態方法屬於 JVMCase 類,是從靜態方法中獲取,之後放入到棧中,也是通過 student 引用調用堆中的 student 對象。
瞭解完實際代碼在 JVM 中分配的內存空間以及運行原理,相信你會更加清楚內存模型中各個區域的職責分工。
總結
這講我們主要深入學習了最基礎的內存模型設計,瞭解其各個分區的作用及實現原理。
如今,JVM 在很大程度上減輕了 Java 開發人員投入到對象生命週期的管理精力。在使用對象的時候,JVM 會自動分配內存給對象,在不使用的時候,垃圾回收器會自動回收對象,釋放佔用的內存。
但在某些情況下,正常的生命週期不是最優的選擇,有些對象按照 JVM 默認的方式,創建成本會很高。比如,我在 String 對象,在特定的場景使用 String.intern 可以很大程度地節約內存成本。我們可以使用不同的引用類型,改變一個對象的正常生命週期,從而提高 JVM 的回收效率,這也是 JVM 性能調優的一種方式。