1. 程式人生 > >詳解JVM記憶體管理與垃圾回收機制1 - 記憶體管理

詳解JVM記憶體管理與垃圾回收機制1 - 記憶體管理

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:MetaspaceSize和XX:MaxMetaspaceSize。注意:Metaspace已經不再使用堆空間,轉而使用Native Memory。關於Native Memory,下文會詳細說明。

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

Runtime Constant Pool (執行時常量池)

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

執行時常量池(Runtime Constant Pool)
類資訊(Class & Field & Method data)
編譯器編譯後的程式碼(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項,其中包含類的完整名稱、欄位名稱和描述符、方法名稱和描述符等等。當然其中還包含I、V、、LineNumberTable、LocalVariableTable等程式碼中沒有出現過的常量,其實這些常量是用來描述如下資訊:方法的返回值是什麼?有多少個引數?每個引數的型別是什麼…… 這個示例非常直觀的向大家展示了常量池中儲存的內容。

接下來就比較好理解執行時常量池了。我們都知道: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引數必須知道的小知識

JVM引數分為標準引數和非標準引數,所有以-X和-XX開頭的引數都是非標準引數,標準引數可以通過java -help命令檢視,比如:-server就是一個標準引數。

非標準引數中,以-XX開頭的都是不穩定的且不推薦在生成環境中使用。但現在的情況已經有所改變,很多-XX開頭的引數也已經非常穩定了,但不管什麼引數在使用前都應該瞭解它可能產生的影響。

布林型引數,-XX:+表示啟用選項,-XX:-表示關閉此選項。
部分引數可以使用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記憶體結構的理解,也希望在平時的工作中能夠注意這些引數的運用。下篇文章將著重介紹常用的垃圾回收演算法與垃圾收集器。

###b參考資料

周志明 著; 深入理解Java虛擬機器(第2版); 機械工業出版社,2013
Java8記憶體模型—永久代(PermGen)和元空間(Metaspace)
java虛擬機器:執行時常量池
最簡單例子圖解JVM記憶體分配和回收
JVM的記憶體區域劃分
JVM實用引數(八)GC日誌
JVM實用引數(四)記憶體調優

作者:
CHEN川
連結:
https://www.jianshu.com/p/f8d71e1e8821


掃碼關注有驚喜

(轉載本站文章請註明作者和出處 方誌朋的部落格