目錄
介紹
- 初學Java虛擬機器幾天, 被方法區, 永久代這些混雜的概念搞混了. 我覺得學習這部分知識應該把官方定義的虛擬機器執行時資料區域和虛擬機器記憶體結構分開敘述, 要不然容易誤導.
- 本文先介紹官方文件規定的執行時資料區域, 然後以JDK1.8的HotSpot虛擬機器為例, 介紹虛擬機器的記憶體結構.
官方文件規定的執行時資料區域
- 官方文件中規定的執行時資料區一共就幾塊: PC計數器, 虛擬機器棧, 本地方法棧, 堆區, 方法區, 執行時常量池. 這裡的官方規定是說, 如果你要做一個Java虛擬機器的話, 必須要包含這幾個區域, 但是這幾個區域在你的虛擬機器中是用哪塊記憶體實現的, 這由虛擬機器制作者決定.
程式計數器
- The pc Register, 程式計數器. 如果瞭解過計算機系統, 對這個名詞應該不陌生了, 它指向下一條指令的地址, 程式靠它跑起來.
- Java虛擬機器支援多執行緒, 每條執行緒都有自己的程式計數器.
- 如果當前執行緒正在執行一個Java方法, 它的計數器記錄的是正在執行的Java虛擬機器指令的地址. 如果執行的是本地方法(比如系統的C語言函式), 計數器中的值為空(Undefined).
- 正因為程式計數器記錄的是指令地址, 所以它佔用的空間較少, Java虛擬機器規範中並沒有規定這塊記憶體有OutOfMemoryError(記憶體溢位)的情況.
Java虛擬機器棧
- Java Virtual Machine Stacks, Java虛擬機器棧.
- Java虛擬機器棧是執行緒私有的, 生命週期與執行緒相同. 虛擬機器棧存放棧幀, 棧幀用於儲存區域性變數表, 部分結果值, 方法的初始化引數和返回資訊, 方法的執行通過棧幀的壓棧和出棧實現.
本地方法棧
- 本地方法棧和上面的虛擬機器棧是相似的, 從名字也看出, 虛擬機器方法棧是用來執行Java程式碼的, 而本地方法棧則是用來執行本地系統程式碼的, 比如C程式碼.
- 也因為規範中沒有規定本地方法棧執行的程式碼, 如果想執行Java程式碼也是可以的, 我們可以看到Oracle官方的虛擬機器HotSpot虛擬機器把Java虛擬機器棧和本地方法棧合二為一, 這麼做避免了要為不同的語言設計棧, 提高了虛擬機器的效能.
虛擬機器棧和本地方法棧溢位
- 那麼當出現錯誤資訊後, 我們在什麼錯誤資訊下可以去排查是否虛擬機器棧和本地方法棧這兩塊記憶體出錯呢? 這裡以HotSpot虛擬機器為例講解(HotSpot把兩塊棧結構合在一起實現了), 在JDK1.8的虛擬機器規範中對這兩塊棧空間可能出現的錯誤給出了相同的描述.
- 一: 如果一條執行緒所需要的記憶體大於虛擬機器所分配給它的記憶體, 將丟擲
StackOverflowError
異常. - 二: 如果棧記憶體可以擴充套件並嘗試擴充套件時可用的記憶體不足, 或者建立新執行緒併為其分配棧記憶體時可能的記憶體不足, 會丟擲
OutOfMemoryError
- 下面先演示第一個
StackOverflowError
異常
//設定虛擬機器引數 -Xss128k, 設定單個執行緒的棧空間大小為128k
public class StackErrorTest1 {
private int stackLength = 1;
public void stackLeak(){
stackLength++;
stackLeak();
}
public static void main(String[] args) {
StackErrorTest1 set1 = new StackErrorTest1();
try{
set1.stackLeak();
}catch (Throwable e){
System.out.println("stack length:" + set1.stackLength);
e.printStackTrace();
}
}
}
//輸出異常資訊
stack length:1000
java.lang.StackOverflowError
at jvm.StackErrorTest1.stackLeak(StackErrorTest1.java:7)
at jvm.StackErrorTest1.stackLeak(StackErrorTest1.java:8)
...
- 所以當遇到
StackOverflowError
時可以考慮是否是是虛擬機器的棧容量太小, 比如這裡的無窮遞迴, 棧空間不夠用. 當然生產環境中肯定不會寫無窮遞迴, 這時可以通過設定-Xss引數調整單條執行緒的棧記憶體大小. - 上面描述的棧記憶體可以擴充套件並嘗試擴充套件時可用的記憶體不足導致出現
OutOfMemoryError
的情況暫時沒有好的演示程式碼, 在周志明的《深入理解Java虛擬機器》中提到"定義了大量本地變數,增大方法幀中本地變數表的長度, 結果仍丟擲StackOverflowError
". 不知道是不是沒有觸發虛擬機器動態擴充棧空間, 所以仍然判定是棧所需的空間超出了虛擬機器規定的大小. 總結來說無論是棧幀太大還是棧空間太小都會丟擲StackOverflowError
, 可以考慮調整-Xss引數. - 上面還提到當建立新執行緒並分配新的棧空間時, 如果可用的記憶體不夠, 會丟擲
OutOfMemoryError
異常, 下面是這種情況的程式碼演示.
public class StackErrorTest2 {
private void keepRunning(){
while(true){
}
}
public void stackLeakByThread(){
while(true){
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
keepRunning();
}
});
thread.start();
}
}
public static void main(String[] args){
StackErrorTest2 set2 = new StackErrorTest2();
set2.stackLeakByThread();
}
}
//執行結果, 來源《深入理解Java虛擬機器》
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
- 這段程式碼也來自深入理解jvm, 書中也說明跑這段程式碼要小心, 因為Java的執行緒是對映到核心執行緒上的, 果不其然我的機子一跑就當機了.
- 問什麼會出現這樣的錯誤? 32位Windows系統分配給一個程式的記憶體最大為2GB(32位能定址4GB地址空間, 除去核心的空間剩2GB, 64位則大得多). 這2GB減去最大堆容量, 減去方法區的容量, 剩下的就是虛擬機器棧和本地方法區棧的記憶體空間了. (補充: PC計數器佔的空間很小, 執行時常量池在方法區中, HotSpot中虛擬機器棧和本地方法棧一起實現, 所以能分成這麼三大塊記憶體).
- 瞭解了三大塊記憶體區後(HotSpot下), 解決思路也出來了: 1. 減小最大堆記憶體, 騰出更多位置給棧空間. 2. 如果程式的執行緒數量不可以減少, 那麼就看看是否可以減少每條執行緒的棧記憶體.
- 當然用一臺配置高的機器, 該用64位的Java虛擬機器也是一種方法.
Java堆
- Java堆是隨著虛擬機器的啟動而建立的, 用於存放物件例項, 所有的物件例項和陣列都在堆記憶體分配, 它被所有執行緒共享. Java堆是Java虛擬機器管理的記憶體中最大的一塊, 也是垃圾回收器管理的主要區域. 從記憶體回收的角度看, Java堆記憶體還可以被繼續劃分, 並且和具體的虛擬機器實現有關.
- 當前主流的虛擬機器都是支援堆記憶體動態擴充套件的, 就是說當堆記憶體的大不夠時, 它會擴充容量; 當不要太多的空間時, 它能自己進行壓縮. 我們可以人為地通過-Xmx和-Xms設定堆記憶體的最大值和最小值(初始大小). 如果我們把-Xmx和-Xms設定為相同的值, 就等同於設定了固定大小的Java堆. (這是gc調優的一種手段)
- 若堆記憶體分配記憶體時發現已經沒有更過可用空間時, 會丟擲
OutOfMemoryError
.
演示堆記憶體溢位
- 堆記憶體是存放物件例項的地方, 這個應該比較好理解, 直接上程式碼
/**
* VM Args: -Xms20m -Xmx20m
*/
public class HeapErrorTest {
static class Object{
}
public static void main(String[] args) {
List<Object> list = new ArrayList<>();
while(true){
list.add(new Object());
}
}
}
//執行結果
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
- 由結果可以看到當堆記憶體溢位後除了有
java.lang.OutOfMemoryError
外, 還會提示Java heap space
. 在這個例子中, 我們明確地知道了是由於堆記憶體不夠大而造成的溢位. 然而在生產環境中, 當系統報出堆記憶體溢位時, 我們首先要搞清楚是因為記憶體洩漏導致的記憶體溢位, 還是純粹的記憶體溢位. - 記憶體溢位指的是分配記憶體的時候, 沒有足夠的空間供其使用. 記憶體洩漏指的是在分配一塊記憶體使用完後沒有釋放, 在Java中對應的場景是沒有被垃圾回收器回收. 一點點的記憶體洩漏使用者可能感受不到, 但是當洩漏的記憶體積少成多的時候, 會耗盡記憶體, 導致記憶體溢位.
- 有一些常用的分析記憶體溢位的手段和工具, 這裡就不詳細敘述了, 可以參考書籍或網上的資料. 當我們判斷是記憶體洩漏導致的溢位後, 可以根據工具定位出現洩漏的程式碼位置; 如果不存在洩漏只是單純的溢位的話, 可以通過設定虛擬引數調整堆記憶體大小(前提是機器的配置能夠支援相應的記憶體大小), 或者看看程式碼中是否存在一些生命週期很長的物件例項, 看看能否作出修改.
方法區
- 方法區用於儲存以被虛擬機器載入的類資訊, 常量, 靜態變數, 即時編譯器編譯後的程式碼資料等, 它是所有執行緒共享的. 虛擬機器規範中說方法區在邏輯上是堆的一部分, 但是它的別名叫"non-Heap"也就是非堆的意思, 表明它和堆記憶體是兩塊獨立的記憶體. 至於說在邏輯上是堆區的一部分, 是因為在物理實現上, 方法區的記憶體地址包含於堆中, 所以說是邏輯上的一部分, 實際用的時候是完全不同的部分. 這麼設計可能是因為便於垃圾收集器統一管理吧.
執行時常量池
- 執行時常量池的記憶體由方法區分配, 也就是說它屬於方法區的一部分. 它用於儲存Class檔案中的類版本, 欄位, 方法, 介面和常量池等, 也用於存放編譯期生成的各種字面量和符號引用.
- 執行時常量池區別於Class檔案常量池的一個重要特徵是具備動態特性. 也就說並非在Class檔案中定義的常量才能進入執行時常量池, 在程式執行的過程中也有可能將新的常量放入池中.
演示方法區溢位
- 演示方法區溢位和堆區的思路一樣, 不斷往方法堆中加入東西使其溢位. 只是方法區中儲存的是類資訊, 我們通過不斷動態生成類演示
- 本程式碼示例來源於深入理解jvm, 但是其中的引數需要改變, 該書的最新版本是基於JDK1.7的, JDK1.7中方法區是在永久代中實現的, 而JDK1.8中已經沒有永久代了, 方法區中Metaspace後設資料區中, 通過設定
-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
來指定方法區的大小
/**
* VM Args: -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
*/
public class MethodAreaTest {
static class Object{
}
public static void main(String[] args) {
int count = 0;
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(Object.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
@Override
public java.lang.Object intercept(java.lang.Object o, Method method, java.lang.Object[] objects, MethodProxy methodProxy) throws Throwable {
return methodProxy.invokeSuper(objects, objects);
}
});
enhancer.create();
System.out.println(++count);
}
}
}
執行結果:
Caused by: java.lang.OutOfMemoryError: Metaspace
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
... 8 more
HotSpot虛擬機器的記憶體模型
- 在介紹完Java虛擬機器執行時資料區域後, 接著以HotSpot虛擬機器為例介紹虛擬機器記憶體模型.
- 首先有一個重要的概念要搞清楚, 要不然容易犯暈.
- 在前面介紹Java執行時資料區域時我們談到PC計數器, 虛擬機器棧, 本地方法棧這3塊記憶體都是執行緒私有的, 它們的隨執行緒的建立而分配, 隨執行緒的結束而釋放, 也就是說Java虛擬機器是明確知道這三塊記憶體是什麼時候該被回收的, 只要執行緒沒執行完就不能回收, 否則執行緒跑不起來.
- 而我們在談論虛擬機器的記憶體模型時, 通常要和垃圾回收結合在一起討論. 既然上面的三塊記憶體回收的時間已定, 暫時不需要過多考慮, 虛擬機器分配記憶體時給它們留有空間就行.
- 但另外的兩塊記憶體堆記憶體和方法區則不一樣, 它們是所有執行緒共享的, 在這裡面記憶體的分配和釋放具有不確定性. 比如說在多型的情況下, 一個介面對應的實現類不同, 具體的實現方法也不同, 虛擬機器只有在程式執行的過程中才知道要建立哪些物件, 這部分記憶體的分配和釋放都是動態的, 垃圾收集器關注的也是這部分的內容.
- 所以說我們後續描述的虛擬機器記憶體模型是建立在Java堆記憶體和方法區上的.
JVM實現的堆記憶體和方法區
- 正如上述所說, 當談論JVM的記憶體結構時, 討論的重點就由整個執行時資料區域轉為對堆記憶體和方法區的討論, 因為這兩部分是垃圾回收的重點區域(如果兩者要比較的話, 重點收集區域是堆區).
- 而HotSpot虛擬機器的記憶體結構由三大部分組成: 新生代, 老年代和後設資料區(JDK1.7及以前叫老年代). 其中新生代和老年代是虛擬機器規範中Java堆記憶體的實現, 後設資料區是規範中方法區的實現. 在講述為什麼這麼定義之前, 先明確這個關係對於理解概念是很重要的, 下面有幅圖幫助理解.
- 這裡有個小失誤, 題目中明明講的是JDK1.8, 為什麼還提永久代呢? 由於永久代存在的時間長, 永久代的說法經過這麼多年可能已經深入人心, 所以先並列講, 要知道永久代和後設資料區是有本質的差別的, 這留到後面講, 先認清概念.
- 希望圖片加描述能夠幫助你立即規範定義的資料區域和JVM記憶體結構之間的關係. 下面將對HotSpot虛擬機器的記憶體模型做進一步分析.
新生代和老年代.
- Java堆記憶體被實現為新生代和老年代, 是為了更方便地進行垃圾回收. 我們知道物件是儲存在堆記憶體中的, 從字面上理解新生代就是新建立的物件區域, 老年代就是使用多次生命週期長的物件區域. 新生代物件生命週期通常較短, 很多用完即可以釋放; 老年代物件的生命週期較長, 可能在整個程式的執行過程中都是有用的.
- 由於新物件和老物件具有不同的性質, 為對這兩種物件設計的垃圾回收演算法也不同, 所以要把它們分開.
新生代中的記憶體劃分
- 新生代的記憶體被分為一個Eden區和兩個Survivor區. 為了講述為什麼要這麼分, 需簡單引入垃圾回收演算法.
- 首先最基礎, 最簡單的垃圾回收演算法叫標記-清除演算法. 演算法流程和演算法名完全一致: 首先標記出哪些是可以回收的物件, 標記完後把物件清除. 如果按照這麼個流程, 新生代應該就是一塊簡單的記憶體就行, 現實結論告訴我們這個演算法是可以優化的.
- 標記清除演算法的不足在於一塊完整的記憶體在經過標記-清除演算法後有些記憶體會被釋放掉, 這時會造成記憶體空間不連續, 可能不能夠存放一些較大的物件.
- 標記-清除演算法的升級版是複製演算法, 它在標記-清除的思路上作出了些改變. 首先將記憶體分為兩塊, 當建立新物件分配記憶體的時候只用兩塊中的一塊A. 當進行垃圾回收的時候只對有物件的一塊A記憶體使用標記-清除演算法進行回收, 回收後剩餘的存活物件從記憶體A移到另一塊空的記憶體B中, 這樣A記憶體重新變為空記憶體, 繼續重複此分配回收過程. 這個演算法似乎更好一些, 但是也只是兩塊記憶體, 說明還不是現實中的最優解.
- 考慮新的演算法, 把記憶體分配成均等兩塊, 等同於能夠使用的記憶體變為原來的二分之一了, 根據IBM專門部分研究新生代中百分之98%的物件都是"朝生夕死"的, 也就是說在進行垃圾回收時98%的物件都被回收掉, 只有2%會從A記憶體移動到B記憶體. 這麼一想我們把兩塊記憶體割為相同的兩塊是不是有點太虧了?
- 下面揭曉答案: HotSpot虛擬機器回收虛擬機器時使用的是複製演算法, 但是它分成三塊記憶體, 一個佔80%記憶體的Eden區(堆記憶體), 兩個分別佔10%的Survivor區. 具體操作是這樣的: 程式執行時, 用Eden區和一個Survivor區A存放新建立的物件. 當發生垃圾回收時, 把存活下來的物件(很少)複製到另一塊Survivor區B中, 使得Eden區和Survivor區A重新為空, 然後繼續重複這個分配回收的過程.
- 所以說詳細點的Jvm的記憶體模型是下面這樣的
由JDK1.7及以前的永久代到JDK1.8的後設資料區
- 搞定完堆區在JVM記憶體模型中的實現, 下面談論方法區的實現.
- 在JDK1.7及以前, JVM使用永久代來實現方法區. 這裡用"實現"二字是經過斟酌的, 因為永久代並不等同於方法區. 從名字也可以看出它和新生代, 老年代是一脈相承的, 邏輯上是一體的, 命名為永久代是因為這部分記憶體很少幾乎不被回收. 這一很少幾乎不被回收的特性正好對應方法區中儲存的類資訊, 常量, 靜態變數等元素. 所以說用永久代來實現方法區.
- 但是用永久代來實現方法區並不是最優解, 比如容易出現記憶體溢位問題(具體分析去除永久代, 改用Metaspace的原因可以參考文章末尾所列出的資料). 在JDK1.8中JVM改為使用後設資料區來實現方法區.
- 後設資料區和永久代有著本質的區別, 永久代屬於虛擬機器記憶體的一部分, 也就是說當在作業系統中啟動虛擬機器程式時為它分配了一塊記憶體, 而虛擬機器為永久代分配記憶體時用的是它自己分配得的記憶體.
- 而後設資料區Metaspace是直接在本地記憶體(Native Memory)中申請的, 這樣後設資料區的大小(方法區大小)只會受本地記憶體大小限制, 和虛擬機器程式所分得記憶體無關.
- 所以最後JVM記憶體模型圖的終極版應該是這樣子
- 到此為止, 本篇結束, 希望對你有幫助.
參考資料
- 《深入理解Java虛擬機器》
- JVM引數分析
- JDK1.8 Java虛擬機器官方文件
- 為什麼要去除永久代, Metaspace分析
- 擴充套件閱讀
- Java實現簡易聯網坦克對戰小遊戲
- 換一種方式瞭解人工智慧