JDK1.8-Java虛擬機運行時數據區域和HotSpot虛擬機的內存模型


介紹

  • 初學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內存模型圖的終極版應該是這樣子
  • 到此為止, 本篇結束, 希望對你有幫助.

參考資料

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM