天天看點

JDK1.8-Java虛拟機運作時資料區域和HotSpot虛拟機的記憶體模型

目錄

  • ​​介紹​​
  • ​官方文檔規定的運作時資料區域​
  • ​​示範方法區溢出​​
  • ​​示範堆記憶體溢出​​
  • ​​虛拟機棧和本地方法棧溢出​​
  • 程式計數器
  • Java虛拟機棧
  • 本地方法棧
  • Java堆
  • 方法區
  • 運作時常量池
  • ​HotSpot虛拟機的記憶體模型​
  • ​​JVM實作的堆記憶體和方法區​​
  • ​​新生代和老年代.​​
  • ​​新生代中的記憶體劃分​​
  • ​​由JDK1.7及以前的永久代到JDK1.8的中繼資料區​​
  • ​​參考資料​​

介紹

  • 初學Java虛拟機幾天, 被方法區, 永久代這些混雜的概念搞混了. 我覺得學習這部分知識應該把官方定義的虛拟機運作時資料區域和虛拟機記憶體結構分開叙述, 要不然容易誤導.
  • 本文先介紹官方文檔規定的運作時資料區域, 然後以JDK1.8的HotSpot虛拟機為例, 介紹虛拟機的記憶體結構.

官方文檔規定的運作時資料區域

  • 官方文檔中規定的運作時資料區一共就幾塊: PC計數器, 虛拟機棧, 本地方法棧, 堆區, 方法區, 運作時常量池. 這裡的官方規定是說, 如果你要做一個Java虛拟機的話, 必須要包含這幾個區域, 但是這幾個區域在你的虛拟機中是用哪塊記憶體實作的, 這由虛拟機制作者決定.

  • The pc Register, 程式計數器. 如果了解過計算機系統, 對這個名詞應該不陌生了, 它指向下一條指令的位址, 程式靠它跑起來.
  • Java虛拟機支援多線程, 每條線程都有自己的程式計數器.
  • 如果目前線程正在執行一個Java方法, 它的計數器記錄的是正在執行的Java虛拟機指令的位址. 如果執行的是本地方法(比如系統的C語言函數), 計數器中的值為空(Undefined).
  • 正因為程式計數器記錄的是指令位址, 是以它占用的空間較少, Java虛拟機規範中并沒有規定這塊記憶體有OutOfMemoryError(記憶體溢出)的情況.

  • 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堆記憶體還可以被繼續劃分, 并且和具體的虛拟機實作有關.
  • 目前主流的虛拟機都是支援堆記憶體動态擴充的, 就是說當堆記憶體的大不夠時, 它會擴充容量; 當不要太多的空間時, 它能自己進行壓縮. 我們可以人為地通過-Xmx和-Xms設定堆記憶體的最大值和最小值(初始大小). 如果我們把-Xmx和-Xms設定為相同的值, 就等同于設定了固定大小的Java堆. (這是gc調優的一種手段)
  • 若堆記憶體配置設定記憶體時發現已經沒有更過可用空間時, 會抛出​

    ​OutOfMemoryError​

    ​.

示範堆記憶體溢出

  • 堆記憶體是存放對象執行個體的地方, 這個應該比較好了解, 直接上代碼
/**
 * VM Args: -Xms20m -Xmx20m
 */
public class HeapErrorTest {
    static class Object{
    }

    public static void main(String[] args) {
        Listlist = 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-Java虛拟機運作時資料區域和HotSpot虛拟機的記憶體模型
  • 這裡有個小失誤, 題目中明明講的是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.8-Java虛拟機運作時資料區域和HotSpot虛拟機的記憶體模型

由JDK1.7及以前的永久代到JDK1.8的中繼資料區

  • 搞定完堆區在JVM記憶體模型中的實作, 下面談論方法區的實作.
  • 在JDK1.7及以前, JVM使用永久代來實作方法區. 這裡用"實作"二字是經過斟酌的, 因為永久代并不等同于方法區. 從名字也可以看出它和新生代, 老年代是一脈相承的, 邏輯上是一體的, 命名為永久代是因為這部分記憶體很少幾乎不被回收. 這一很少幾乎不被回收的特性正好對應方法區中存儲的類資訊, 常量, 靜态變量等元素. 是以說用永久代來實作方法區.
  • 但是用永久代來實作方法區并不是最優解, 比如容易出現記憶體溢出問題(具體分析去除永久代, 改用Metaspace的原因可以參考文章末尾所列出的資料). 在JDK1.8中JVM改為使用中繼資料區來實作方法區.
  • 中繼資料區和永久代有着本質的差別, 永久代屬于虛拟機記憶體的一部分, 也就是說當在作業系統中啟動虛拟機程序時為它配置設定了一塊記憶體, 而虛拟機為永久代配置設定記憶體時用的是它自己配置設定得的記憶體.
  • 而中繼資料區Metaspace是直接在本地記憶體(Native Memory)中申請的, 這樣中繼資料區的大小(方法區大小)隻會受本地記憶體大小限制, 和虛拟機程序所分得記憶體無關.
  • 是以最後JVM記憶體模型圖的終極版應該是這樣子
JDK1.8-Java虛拟機運作時資料區域和HotSpot虛拟機的記憶體模型
  • 到此為止, 本篇結束, 希望對你有幫助.

參考資料

  • 《深入了解Java虛拟機》
  • ​​JVM參數分析​​
  • ​​JDK1.8 Java虛拟機官方文檔​​
  • ​​為什麼要去除永久代, Metaspace分析​​
  • 擴充閱讀
  • ​​Java實作簡易聯網坦克對戰小遊戲​​

繼續閱讀