1. 程式人生 > >詳解JVM內存管理與垃圾回收機制 (上)

詳解JVM內存管理與垃圾回收機制 (上)

JVM 內存結構

Java應用程序是運行在JVM上的,得益於JVM的內存管理和垃圾收集機制,開發人員的效率得到了顯著提升,也不容易出現內存溢出和泄漏問題。但正是因為開發人員把內存的控制權交給了JVM,一旦出現內存方面的問題,如果不了解JVM的工作原理,將很難排查錯誤。本文將從理論角度介紹虛擬機的內存管理和垃圾回收機制,算是入門級的文章,希望對大家的日常開發有所助益。

一、內存管理

也許大家都有過這樣的經歷,在啟動時通過-Xmx或者-XX:MaxPermSize這樣的參數來顯式的設置應用的堆(Heap)和永久代(Permgen)的內存大小,但為什麽不直接設置JVM所占內存的大小,而要分別去設置不同的區域?JVM所管理的內存被分成多少區域?每個區域有什麽作用?如何來管理這些區域?

1.1 運行時數據區

JVM在執行Java程序時會把其所管理的內存劃分成多個不同的數據區域,每個區域的創建時間、銷毀時間以及用途都各不相同。比如有的內存區域是所有線程共享的,而有的內存區域是線程隔離的。線程隔離的區域就會隨著線程的啟動和結束而創建和銷毀。JVM所管理的內存將會包含以下幾個運行時數據區域,如下圖的上半部分所示。
技術分享圖片

Method Area (方法區)

方法區是所有線程共享的內存區域,它用於存儲已被虛擬機加載的類信息、常量、靜態變量、JIT編譯後的代碼等數據。在Java虛擬機規範中,方法區屬於堆的一個邏輯部分,但很多情況下,都把方法區與堆區分開來說。大家平時開發中通過反射獲取到的類名、方法名、字段名稱、訪問修飾符等信息都是從這塊區域獲取的。

對於HotSpot虛擬機,方法區對應為永久代(Permanent Generation),但本質上,兩者並不等價,僅僅是因為HotSpot虛擬機的設計團隊是用永久代來實現方法區而已,對於其他的虛擬機(JRockit、J9)來說,是不存在永久代這一概念的。

但現在看來,使用永久代來實現方法區並不是一個好註意,由於方法區會存放Class的相關信息,如類名、訪問修飾符、常量池、字段描述、方法描述等,在某些場景下非常容易出現永久代內存溢出。如Spring、Hibernate等框架在對類進行增強時,都會使用到CGLib這類字節碼技術,增強的類越多,就需要越大的方法區來保證動態生成的Class可以加載入內存。在JSP頁面較多的情況下,也會出現同樣的問題。可以通過如下代碼來測試:

/**
 * VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M(JDK6.0)
 * VM Args: -XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M(JDK8.0)
 */
public class CGlibProxy {
    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(ProxyObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                @Override
                public Object intercept(Object o, Method method, Object[] os, MethodProxy proxy) throws Throwable {
                    System.out.println("I am proxy");
                    return proxy.invokeSuper(o,os);
                }
            });
            ProxyObject proxy = (ProxyObject) enhancer.create();
            proxy.greet();
        }
    }
    static class ProxyObject {
        public String greet() {
            return "Thanks for you";
        }
    }
}

在JDK1.8中運行一小會兒出現內存溢出錯誤:

Exception in thread "main" I am proxy
java.lang.OutOfMemoryError: Metaspace
    at org.mockito.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:238)
    at org.mockito.cglib.proxy.Enhancer.createHelper(Enhancer.java:378)
    at org.mockito.cglib.proxy.Enhancer.create(Enhancer.java:286)
    at com.lwork.mdo.CGlibProxy.main(CGlibProxy.java:23)

在JDK1.8下並沒有出現我們期望的永久代內存溢出錯誤,而是Metaspace內存溢出錯誤。這是因為Java團隊從JDK1.7開始就逐漸移除了永久代,到JDK1.8時,永久代已經被Metaspace取代,因此在JDK1.8並沒有出現我們期望的永久代內存溢出錯誤。在JDK1.8中,JVM參數-XX:PermSize-XX:MaxPermSize已經失效,取而代之的是-XX:MetaspaceSizeXX:MaxMetaspaceSize。註意:Metaspace已經不再使用堆空間,轉而使用Native Memory。關於Native Memory,下文會詳細說明。

還有一點需要說明的是,在JDK1.6中,方法區雖然被稱為永久代,但並不意味著這些對象真的能夠永久存在了,JVM的內存回收機制,仍然會對這一塊區域進行掃描,即使回收這部分內存的條件相當苛刻。

Runtime Constant Pool (運行時常量池)

回過頭來看下圖1的下半部分,方法區主要包含:

  1. 運行時常量池(Runtime Constant Pool)
  2. 類信息(Class & Field & Method data)
  3. 編譯器編譯後的代碼(Code)等等
    後面兩項都比較好理解,但運行時常量池有何作用,其意義何在?拋開運行時3個字,首先了解下何為常量池。

Java源文件經編譯後得到存儲字節碼的Class文件,Class文件是一組以8位字節為基礎單位的二進制流,各個數據項目嚴格按照順序緊湊地排列在Class文件中。也就是說,哪個字節代表什麽含義,長度多少,先後順序如何都是被嚴格限定的,是不允許改變的。比如:開頭的4個字節存放在魔數,用於確定這個文件是否能夠被JVM接受,接下來的4個字節用於存放版本號,再接著存放的就是常量池,常量池的長度是不固定的,所以,在常量池的入口存放著常量池容量的計數值。

常量池主要用於存放兩大類常量:字面量和符號引用量,字面量相當於Java語言層面常量的概念,比如:字符串常量、聲明為final的常量等等。符號引用是用一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義的定位到目標即可。理解不了?舉個例子,有如下代碼:

public class M {
    private int m;
    private String mstring = "chen";
    public void f() {
    }
}

使用javap工具輸出M.class文件字節碼的部分內容如下:

? javap -verbose M
  ......
Constant pool:
   #1 = Methodref          #5.#20         // java/lang/Object."<init>":()V
   #2 = String             #21            // chen
   #3 = Fieldref           #4.#22         // com/lwork/mdo/M.mstring:Ljava/lang/String;
   #4 = Class              #23            // com/lwork/mdo/M
   #5 = Class              #24            // java/lang/Object
   #6 = Utf8               m
   #7 = Utf8               I
   #8 = Utf8               mstring
   #9 = Utf8               Ljava/lang/String;
  #10 = Utf8               <init>
  #11 = Utf8               ()V
  #12 = Utf8               Code
  #13 = Utf8               LineNumberTable
  #14 = Utf8               LocalVariableTable
  #15 = Utf8               this
  #16 = Utf8               Lcom/lwork/mdo/M;
// 方法名稱
  #17 = Utf8               f
  #18 = Utf8               SourceFile
// 類名稱
  #19 = Utf8               M.java
  #20 = NameAndType        #10:#11        // "<init>":()V
  #21 = Utf8               chen
  #22 = NameAndType        #8:#9          // mstring:Ljava/lang/String;
// 類的完整路徑,註意class文件中是用"/"來代替"."
  #23 = Utf8               com/lwork/mdo/M
  #24 = Utf8               java/lang/Object
......

這裏只保留了常量池的部分,從中可以看到M.class文件的常量池總共24項,其中包含類的完整名稱、字段名稱和描述符、方法名稱和描述符等等。當然其中還包含IV&lt;init&gt;LineNumberTableLocalVariableTable等代碼中沒有出現過的常量,其實這些常量是用來描述如下信息:方法的返回值是什麽?有多少個參數?每個參數的類型是什麽…… 這個示例非常直觀的向大家展示了常量池中存儲的內容。

接下來就比較好理解運行時常量池了。我們都知道:Class文件中存儲的各種信息,最終都需要加載到虛擬機中之後才能運行和使用。運行時常量池就可以理解為常量池被加載到內存之後的版本,但並非只有Class文件中常量池的內容才能進入方法區的運行時常量池,運行期間也可能產生新的常量,它們也可以放入運行時常量池中。

Heap Space (Java堆)

Java堆是JVM所管理的最大一塊內存,所有線程共享這塊內存區域,幾乎所有的對象實例都在這裏分配內存,因此,它也是垃圾收集器管理的主要區域。從內存回收的角度來看,由於現在的收集器基本都采用分代收集算法,所以Java堆又可以細分成:新生代和老年代,新生代裏面有分為:Eden空間、From Survivor空間、To Survivor空間,如圖1所示。有一點需要註意:Java堆空間只是在邏輯上是連續的,在物理上並不一定是連續的內存空間。

默認情況下,新生代中Eden空間與Survivor空間的比例是8:1,註意不要被示意圖誤導,可以使用參數-XX:SurvivorRatio對其進行配置。大多數情況下,新生對象在新生代Eden區中分配,當Eden區沒有足夠的空間進行分配時,則觸發一次Minor GC,將對象Copy到Survivor區,如果Survivor區沒有足夠的空間來容納,則會通過分配擔保機制提前轉移到老年代去。

何為分配擔保機制?在發送Minor GC前,JVM會檢查老年代最大可用的連續空間是否大於新生代所有對象的總空間,如果是,那麽可以確保Minor GC是安全的,如果不是,那麽會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小,如果小於,直接進行Full GC,如果大於,將嘗試著進行一次Minor GC,Minor GC失敗才會觸發Full GC。註:不同版本的JDK,流程略有不同

Survivor區作為Eden區和老年代的緩沖區域,常規情況下,在Survivor區的對象經過若幹次垃圾回收仍然存活的話,才會被轉移到老年代。JVM通過這種方式,將大部分命短的對象放在一起,將少數命長的對象放在一起,分別采取不同的回收策略。關於JVM內存分配更直觀的介紹,請閱讀參考資料3。

VM Stack (虛擬機棧) & Native Method Stack (本地方法棧)

虛擬機棧與本地方法棧都屬於線程私有,它們的生命周期與線程相同。虛擬機棧用於描述Java方法執行的內存模型:每個方法在執行的同時都會創建一個棧幀(Stack Frame)用於存儲局部變量表、操作數棧、動態連接、方法出口等信息。

其中局部變量表用於存儲方法參數和方法內部定義的局部變量,它只在當前函數調用中有效,當函數調用結束,隨著函數棧幀的銷毀,局部變量表也隨之消失;操作數棧是一個後入先出棧,用於存放方法運行過程中的各種中間變量和字節碼指令 (在學習棧的時候,有一個經典的例子就是用棧來實現4則運算,其實方法執行過程中操作數棧的變化過程,與4則預算中棧中數字與符號的變化類似);動態連接其實是指一個過程,即在程序運行過程中將符號引用解析為直接引用的過程。

如何理解動態連接?我們知道Class文件的常量池中存有大量的符號引用,在加載過程中會被原樣的拷貝到內存裏先放著,到真正使用的時候就會被解析為直接引用 (直接引用包含:直接指向目標的指針、相對偏移量、能間接定位到目標的句柄等)。有些符號引用會在類的加載階段或者第一次使用的時候轉化為直接引用,這種轉化稱為靜態解析,而有的將在運行期間轉化為直接引用,這部分稱為動態連接。

全部靜態解析不是更好,為何會存在動態連接?Java多態的實現會導致一個引用變量到底指向哪個類的實例對象,或者說該引用變量發出的方法調用到底是調用哪個類中實現方法都需要在運行期間才能確定。因此有些符號引用在類加載階段是不知道它對應的直接引用的

每一個方法從調用直至執行完成的過程,就對應著一個棧幀在虛擬機棧中入棧到出棧的過程,下面通過一個非常簡單的圖例來描述這一過程,有如下的代碼片段:

public void sayHello(String name) {
    System.out.println("hello " + name);
    greet(name);
    bye();
}

其調用過程中虛擬機棧的大致示意圖如下圖所示:
技術分享圖片
調用sayHello方法時,在棧中分配有一塊內存用來保存該方法的局部變量等信息,①當函數執行到greet()方法時,棧中同樣有一塊內存用來保存greet方法的相關信息,當然第二個內存塊位於第一個內存塊上面,②接著從greet方法返回,③現在棧頂的內存塊就是sayHello方法的,這表示你已經返回到sayHello方法,④接著繼續調用bye方法,在棧頂添加了bye方法的內存塊,⑤接著再從bye方法返回到sayHello方法中,由於沒有別的事了,現在就從sayHello方法返回。

本地方法棧與虛擬機棧所發揮的作用是非常相似的,它們之間的區別不過是虛擬機棧為虛擬機執行Java方法 (也就是字節碼) 服務,而本地方法棧則為虛擬機使用到的Native方法服務。

Program Counter Register (程序計數器)

程序計數器(Program Counter Register),很多地方也被稱為PC寄存器,但寄存器是CPU的一個部件,用於存儲CPU內部重要的數據資源,比如在匯編語言中,它保存的是程序當前執行的指令的地址(也可以說保存下一條指令的所在存儲單元的地址),當CPU需要執行指令時,需要從程序計數器中得到當前需要執行的指令所在存儲單元的地址,然後根據得到的地址獲取到指令,在得到指令之後,程序計數器便自動加1或者根據轉移指針得到下一條指令的地址,如此循環,直至執行完所有的指令。

類似的,JVM規範中規定,如果線程執行的是非native方法,則程序計數器中保存的是當前需要執行的指令的地址;如果線程執行的是native方法,則程序計數器中的值是undefined。

Java虛擬機可以支持多條線程同時執行,多線程是通過線程輪流切換來獲得CPU執行時間的,因此,在任一具體時刻,一個CPU的內核只會執行一條線程中的指令,因此,為了能夠使得每個線程都在線程切換後能夠恢復在切換之前的程序執行位置,每個線程都需要有自己獨立的程序計數器,並且不能互相被幹擾,否則就會影響到程序的正常執行次序。因此,JVM中的程序計數器是每個線程私有的。

1.2 堆外內存

堆外內存又被稱為直接內存(Direct Memory),它並不是虛擬機運行時數據區的一部分,Java虛擬機規範中也沒有定義這部分內存區域,使用時由Java程序直接向系統申請,訪問直接內存的速度要優於Java堆,因此,讀寫頻繁的場景下使用直接內存,性能會有提升,比如Java NIO庫,就是使用Native函數直接分配堆外內存,然後通過一個存儲在Java堆中的DirectBytedBuffer對象作為這塊內存的引用進行操作。

由於直接內存在Java堆外,其大小不會直接受限於Xmx指定的堆大小,但它肯定會受到本機總內存大小以及處理器尋址空間的限制,因此我們在配置JVM參數時,特別是有大量網絡通訊場景下,要特別註意,防止各個內存區域的總內存大於物理內存限制 (包括物理的和OS的限制)。

1.3 小結

花了很大篇幅來介紹Java虛擬機的內存結構,其中在講解Java堆時,還簡單的介紹了JVM的內存分配機制;在介紹虛擬機棧的同時,也對方法調用過程中棧的數據變化作了形象的說明。當然這樣的篇幅肯定不足以完全理清整個內存結構以及其內存分配機制,你盡可以把它當做簡單的入門,帶你更好的學習。接下來會以此為背景介紹一些常用的JVM參數。

二、常用JVM參數

2.1 關於JVM參數必須知道的小知識

  1. JVM參數分為標準參數和非標準參數,所有以-X-XX開頭的參數都是非標準參數,標準參數可以通過java -help命令查看,比如:-server就是一個標準參數。
  2. 非標準參數中,以-XX開頭的都是不穩定的且不推薦在生成環境中使用。但現在的情況已經有所改變,很多-XX開頭的參數也已經非常穩定了,但不管什麽參數在使用前都應該了解它可能產生的影響。
  3. 布爾型參數,-XX:+表示激活選項,-XX:-表示關閉此選項。
  4. 部分參數可以使用jinfo工具動態設置,比如:jinfo -flag +PrintGCDetails 12278,能夠動態設置的參數很少,所以用處有限,至於哪些參數可以動態設置,可以參考jinfo工具的使用方法。

2.2 GC日誌

GC日誌是一個非常重要的工具,它準確的記錄了每一次GC的執行時間和結果,通過分析GC日誌可以幫助我們優化內存設置,也可以幫助改進應用的對象分配方式。如何閱讀GC日誌不在本文的範疇內,大家可以參考網上相關文章。

下面幾個關於GC日誌的參數應該加入到應用啟動參數列表中:

  • -XX:+PrintGCDetails 開啟詳細GC日誌模式
  • -XX:+PrintGCTimeStamps在每行GC日誌頭部加上GC發生的時間,這個時間是指相對於JVM的啟動時間,單位是秒
  • -XX:+PrintGCDateStamps在GC日誌的每一行加上絕對日期和時間,推薦同時使用這兩個參數,這樣在關聯不同來源的GC日誌時很有幫助
  • -XX:+PrintHeapAtGC輸出GC回收前和回收後的堆信息,使用這個參數可以更好的觀察GC對堆空間的影響
  • -Xloggc設置GC日誌目錄

設置這幾個參數後,發生GC時輸出的日誌就類似於下面的格式 (不同的垃圾收集器格式可能略有差異):

2018-01-07T19:45:08.627+0800: 0.794: [GC (Allocation Failure) [PSYoungGen: 153600K->4564K(179200K)] 153600K->4580K(384000K), 0.0051736 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
......

簡單的說明:

  • 2018-01-07T19:45:08.627+0800 - GC開始時間
  • 0.794 - GC開始時間相對於JVM啟動時間
  • GC - 用來區分是Minor GC 還是 Full GC,這裏是Minor GC
  • Allocation Failure - GC原因,這裏是因為年輕代中沒有任何足夠空間,也就是分配失敗
  • PSYoungGen - 垃圾收集算法,這裏是Parallel Scavenge
  • 153600K->4564K(179200K) - 本次垃圾回收前後年輕代內存使用情況,括號內表示年輕代總大小
  • 153600K->4580K(384000K) - 在本次垃圾回收前後整個堆內存的使用情況,括號內表示總的可用堆內存
  • 0.0051736 secs - GC持續時間
  • [Times: user=0.01 sys=0.00, real=0.01 secs] - 多個維度衡量GC持續時間

2.3 內存優化

我們的程序可能會經常出現性能問題,但如何分析和定位?知道一些常用的JVM內存管理參數,對我們開發人員有莫大的幫助。

堆空間設置

使用-Xms-Xmx來指定JVM堆空間的初始值和最大值,比如:

java -Xms128m -Xmx2g app

雖然JVM可以在運行時動態的調整堆內存大小,但很多時候我們都直接將-Xms-Xmx設置相等的值,這樣可以減少程序運行時進行垃圾回收的次數。

新生代設置

參數-Xmn用於設置新生代大小,設置一個較大的新生代會減少老年代的大小,這個參數堆GC行為影響很大。一般情況下不需要使用這個參數,在分析GC日誌後,發現確實是因為新生代設置過小導致頻繁的Full GC,可以配置這個參數,一般情況下,新生代設置為堆空間的1/3 - 1/4左右。

還可以通過-XX:SurviorRatio設置新生代中eden區和Survivor from/to區空間的比例關系,也可使用-XX:NewRatio設置新生代和老年代的比例。

配置這3個參數的基本策略是:盡可能將對象預留在新生代,減少老年代GC的次數,所以需要更謹慎的對其進行修改,不要太隨意。

生成快照文件

我們可能沒有辦法給最大堆內存設置一個合適的值,因為我們時常面臨內存溢出的狀況,當然我們可以在內存溢出情況出現後,再監控程序,dump出內存快照來定位,但這種方法的前提條件是內存溢出問題要再次發生。更好方法是通過設置-XX:+HeapDumpOnOutOfMemoryError讓JVM在發生內存溢出時自動的生成堆內存快照。有了這個參數,當我們在面對內存溢出異常的時候會節約大量的時間,-XX:HeapDumpPath則可以設置快照的生成路徑。堆內存快照文件可能很龐大,要註意存儲的磁盤空間。

方法區設置

方法區中存放中JVM加載的類信息,如果JVM加載的類過多,就需要合理設置永久大的大小,在JDK1.6和JDK1.7中,可以使用 -XX:PermSize-XX:MaxPermSize來達到這個目的,前者用於設置永久代的初始大小,後者用於設置永久代的最大值。前面我們知道,方法區並不在堆內存中,所以要註意所有JVM參數設置的內存總大小。

在JDK1.8中已經使用元空間代替永久代,同樣的目的,需要使用-XX:MetaspaceSize-XX:MaxMetaspaceSize來代替。

直接內存

參數-XX:MaxDirectMemorySize用於配置直接內存大小 ,如果不設置,默認值為最大堆空間,即-Xmx,當直接內存使用量達到設置的值時,就會觸發垃圾回收,如果垃圾回收不能有效釋放足夠空間,仍然會引起OOM。如果堆外內存發生OOM,請檢查此參數是否配置過小。

2.4 小結

這部分主要介紹一些常用的JVM參數,理解這些JVM參數的前提是需要理解JVM的內存結構以及各個內存區域的作用,希望通過這些參數的介紹,能夠加深大家對JVM內存結構的理解,也希望在平時的工作中能夠註意這些參數的運用。下篇文章將著重介紹常用的垃圾回收算法與垃圾收集器。

參考資料

  1. 周誌明 著; 深入理解Java虛擬機(第2版); 機械工業出版社,2013
  2. Java8內存模型—永久代(PermGen)和元空間(Metaspace)
  3. java虛擬機:運行時常量池
  4. 最簡單例子圖解JVM內存分配和回收
  5. JVM的內存區域劃分
  6. JVM實用參數(八)GC日誌
  7. JVM實用參數(四)內存調優

詳解JVM內存管理與垃圾回收機制 (上)