Java應用程序是運行在JVM上的,得益于JVM的內(nèi)存管理和垃圾收集機制,開發(fā)人員的效率得到了顯著提升,也不容易出現(xiàn)內(nèi)存溢出和泄漏問題。但正是因為開發(fā)人員把內(nèi)存的控制權交給了JVM,一旦出現(xiàn)內(nèi)存方面的問題,如果不了解JVM的工作原理,將很難排查錯誤。本文將從理論角度介紹虛擬機的內(nèi)存管理和垃圾回收機制,算是入門級的文章,希望對大家的日常開發(fā)有所助益。 一、內(nèi)存管理也許大家都有過這樣的經(jīng)歷,在啟動時通過-Xmx或者-XX:MaxPermSize這樣的參數(shù)來顯式的設置應用的堆(Heap)和永久代(Permgen)的內(nèi)存大小,但為什么不直接設置JVM所占內(nèi)存的大小,而要分別去設置不同的區(qū)域?JVM所管理的內(nèi)存被分成多少區(qū)域?每個區(qū)域有什么作用?如何來管理這些區(qū)域? 1.1 運行時數(shù)據(jù)區(qū)JVM在執(zhí)行Java程序時會把其所管理的內(nèi)存劃分成多個不同的數(shù)據(jù)區(qū)域,每個區(qū)域的創(chuàng)建時間、銷毀時間以及用途都各不相同。比如有的內(nèi)存區(qū)域是所有線程共享的,而有的內(nèi)存區(qū)域是線程隔離的。線程隔離的區(qū)域就會隨著線程的啟動和結束而創(chuàng)建和銷毀。JVM所管理的內(nèi)存將會包含以下幾個運行時數(shù)據(jù)區(qū)域,如下圖的上半部分所示。 Method Area (方法區(qū))方法區(qū)是所有線程共享的內(nèi)存區(qū)域,它用于存儲已被虛擬機加載的類信息、常量、靜態(tài)變量、JIT編譯后的代碼等數(shù)據(jù)。在Java虛擬機規(guī)范中,方法區(qū)屬于堆的一個邏輯部分,但很多情況下,都把方法區(qū)與堆區(qū)分開來說。大家平時開發(fā)中通過反射獲取到的類名、方法名、字段名稱、訪問修飾符等信息都是從這塊區(qū)域獲取的。 對于HotSpot虛擬機,方法區(qū)對應為永久代(Permanent Generation),但本質上,兩者并不等價,僅僅是因為HotSpot虛擬機的設計團隊是用永久代來實現(xiàn)方法區(qū)而已,對于其他的虛擬機(JRockit、J9)來說,是不存在永久代這一概念的。 但現(xiàn)在看來,使用永久代來實現(xiàn)方法區(qū)并不是一個好注意,由于方法區(qū)會存放Class的相關信息,如類名、訪問修飾符、常量池、字段描述、方法描述等,在某些場景下非常容易出現(xiàn)永久代內(nèi)存溢出。如Spring、Hibernate等框架在對類進行增強時,都會使用到CGLib這類字節(jié)碼技術,增強的類越多,就需要越大的方法區(qū)來保證動態(tài)生成的Class可以加載入內(nèi)存。在JSP頁面較多的情況下,也會出現(xiàn)同樣的問題??梢酝ㄟ^如下代碼來測試:
在JDK1.8中運行一小會兒出現(xiàn)內(nèi)存溢出錯誤:
在JDK1.8下并沒有出現(xiàn)我們期望的永久代內(nèi)存溢出錯誤,而是Metaspace內(nèi)存溢出錯誤。這是因為Java團隊從JDK1.7開始就逐漸移除了永久代,到JDK1.8時,永久代已經(jīng)被Metaspace取代,因此在JDK1.8并沒有出現(xiàn)我們期望的永久代內(nèi)存溢出錯誤。在JDK1.8中,JVM參數(shù)-XX:PermSize和-XX:MaxPermSize已經(jīng)失效,取而代之的是-XX:MetaspaceSize和XX:MaxMetaspaceSize。注意:Metaspace已經(jīng)不再使用堆空間,轉而使用Native Memory。關于Native Memory,下文會詳細說明。 還有一點需要說明的是,在JDK1.6中,方法區(qū)雖然被稱為永久代,但并不意味著這些對象真的能夠永久存在了,JVM的內(nèi)存回收機制,仍然會對這一塊區(qū)域進行掃描,即使回收這部分內(nèi)存的條件相當苛刻。 Runtime Constant Pool (運行時常量池)回過頭來看下圖1的下半部分,方法區(qū)主要包含: 運行時常量池(Runtime Constant Pool) 類信息(Class & Field & Method data) 編譯器編譯后的代碼(Code)等等 后面兩項都比較好理解,但運行時常量池有何作用,其意義何在?拋開運行時3個字,首先了解下何為常量池。 Java源文件經(jīng)編譯后得到存儲字節(jié)碼的Class文件,Class文件是一組以8位字節(jié)為基礎單位的二進制流,各個數(shù)據(jù)項目嚴格按照順序緊湊地排列在Class文件中。也就是說,哪個字節(jié)代表什么含義,長度多少,先后順序如何都是被嚴格限定的,是不允許改變的。比如:開頭的4個字節(jié)存放在魔數(shù),用于確定這個文件是否能夠被JVM接受,接下來的4個字節(jié)用于存放版本號,再接著存放的就是常量池,常量池的長度是不固定的,所以,在常量池的入口存放著常量池容量的計數(shù)值。 常量池主要用于存放兩大類常量:字面量和符號引用量,字面量相當于Java語言層面常量的概念,比如:字符串常量、聲明為final的常量等等。符號引用是用一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義的定位到目標即可。理解不了?舉個例子,有如下代碼:
使用javap工具輸出M.class文件字節(jié)碼的部分內(nèi)容如下:
這里只保留了常量池的部分,從中可以看到M.class文件的常量池總共24項,其中包含類的完整名稱、字段名稱和描述符、方法名稱和描述符等等。當然其中還包含I、V、 接下來就比較好理解運行時常量池了。我們都知道:Class文件中存儲的各種信息,最終都需要加載到虛擬機中之后才能運行和使用。運行時常量池就可以理解為常量池被加載到內(nèi)存之后的版本,但并非只有Class文件中常量池的內(nèi)容才能進入方法區(qū)的運行時常量池,運行期間也可能產(chǎn)生新的常量,它們也可以放入運行時常量池中。 Heap Space (Java堆)Java堆是JVM所管理的最大一塊內(nèi)存,所有線程共享這塊內(nèi)存區(qū)域,幾乎所有的對象實例都在這里分配內(nèi)存,因此,它也是垃圾收集器管理的主要區(qū)域。從內(nèi)存回收的角度來看,由于現(xiàn)在的收集器基本都采用分代收集算法,所以Java堆又可以細分成:新生代和老年代,新生代里面有分為:Eden空間、From Survivor空間、To Survivor空間,如圖1所示。有一點需要注意:Java堆空間只是在邏輯上是連續(xù)的,在物理上并不一定是連續(xù)的內(nèi)存空間。 默認情況下,新生代中Eden空間與Survivor空間的比例是8:1,注意不要被示意圖誤導,可以使用參數(shù)-XX:SurvivorRatio對其進行配置。大多數(shù)情況下,新生對象在新生代Eden區(qū)中分配,當Eden區(qū)沒有足夠的空間進行分配時,則觸發(fā)一次Minor GC,將對象Copy到Survivor區(qū),如果Survivor區(qū)沒有足夠的空間來容納,則會通過分配擔保機制提前轉移到老年代去。 何為分配擔保機制?在發(fā)送Minor GC前,JVM會檢查老年代最大可用的連續(xù)空間是否大于新生代所有對象的總空間,如果是,那么可以確保Minor GC是安全的,如果不是,那么會繼續(xù)檢查老年代最大可用的連續(xù)空間是否大于歷次晉升到老年代對象的平均大小,如果小于,直接進行Full GC,如果大于,將嘗試著進行一次Minor GC,Minor GC失敗才會觸發(fā)Full GC。注:不同版本的JDK,流程略有不同 Survivor區(qū)作為Eden區(qū)和老年代的緩沖區(qū)域,常規(guī)情況下,在Survivor區(qū)的對象經(jīng)過若干次垃圾回收仍然存活的話,才會被轉移到老年代。JVM通過這種方式,將大部分命短的對象放在一起,將少數(shù)命長的對象放在一起,分別采取不同的回收策略。關于JVM內(nèi)存分配更直觀的介紹,請閱讀參考資料3。 VM Stack (虛擬機棧) & Native Method Stack (本地方法棧)虛擬機棧與本地方法棧都屬于線程私有,它們的生命周期與線程相同。虛擬機棧用于描述Java方法執(zhí)行的內(nèi)存模型:每個方法在執(zhí)行的同時都會創(chuàng)建一個棧幀(Stack Frame)用于存儲局部變量表、操作數(shù)棧、動態(tài)連接、方法出口等信息。 其中局部變量表用于存儲方法參數(shù)和方法內(nèi)部定義的局部變量,它只在當前函數(shù)調(diào)用中有效,當函數(shù)調(diào)用結束,隨著函數(shù)棧幀的銷毀,局部變量表也隨之消失;操作數(shù)棧是一個后入先出棧,用于存放方法運行過程中的各種中間變量和字節(jié)碼指令 (在學習棧的時候,有一個經(jīng)典的例子就是用棧來實現(xiàn)4則運算,其實方法執(zhí)行過程中操作數(shù)棧的變化過程,與4則預算中棧中數(shù)字與符號的變化類似);動態(tài)連接其實是指一個過程,即在程序運行過程中將符號引用解析為直接引用的過程。 如何理解動態(tài)連接?我們知道Class文件的常量池中存有大量的符號引用,在加載過程中會被原樣的拷貝到內(nèi)存里先放著,到真正使用的時候就會被解析為直接引用 (直接引用包含:直接指向目標的指針、相對偏移量、能間接定位到目標的句柄等)。有些符號引用會在類的加載階段或者第一次使用的時候轉化為直接引用,這種轉化稱為靜態(tài)解析,而有的將在運行期間轉化為直接引用,這部分稱為動態(tài)連接。 全部靜態(tài)解析不是更好,為何會存在動態(tài)連接?Java多態(tài)的實現(xiàn)會導致一個引用變量到底指向哪個類的實例對象,或者說該引用變量發(fā)出的方法調(diào)用到底是調(diào)用哪個類中實現(xiàn)方法都需要在運行期間才能確定。因此有些符號引用在類加載階段是不知道它對應的直接引用的 每一個方法從調(diào)用直至執(zhí)行完成的過程,就對應著一個棧幀在虛擬機棧中入棧到出棧的過程,下面通過一個非常簡單的圖例來描述這一過程,有如下的代碼片段:
其調(diào)用過程中虛擬機棧的大致示意圖如下圖所示: 調(diào)用sayHello方法時,在棧中分配有一塊內(nèi)存用來保存該方法的局部變量等信息,①當函數(shù)執(zhí)行到greet()方法時,棧中同樣有一塊內(nèi)存用來保存greet方法的相關信息,當然第二個內(nèi)存塊位于第一個內(nèi)存塊上面,②接著從greet方法返回,③現(xiàn)在棧頂?shù)膬?nèi)存塊就是sayHello方法的,這表示你已經(jīng)返回到sayHello方法,④接著繼續(xù)調(diào)用bye方法,在棧頂添加了bye方法的內(nèi)存塊,⑤接著再從bye方法返回到sayHello方法中,由于沒有別的事了,現(xiàn)在就從sayHello方法返回。 本地方法棧與虛擬機棧所發(fā)揮的作用是非常相似的,它們之間的區(qū)別不過是虛擬機棧為虛擬機執(zhí)行Java方法 (也就是字節(jié)碼) 服務,而本地方法棧則為虛擬機使用到的Native方法服務。 Program Counter Register (程序計數(shù)器)程序計數(shù)器(Program Counter Register),很多地方也被稱為PC寄存器,但寄存器是CPU的一個部件,用于存儲CPU內(nèi)部重要的數(shù)據(jù)資源,比如在匯編語言中,它保存的是程序當前執(zhí)行的指令的地址(也可以說保存下一條指令的所在存儲單元的地址),當CPU需要執(zhí)行指令時,需要從程序計數(shù)器中得到當前需要執(zhí)行的指令所在存儲單元的地址,然后根據(jù)得到的地址獲取到指令,在得到指令之后,程序計數(shù)器便自動加1或者根據(jù)轉移指針得到下一條指令的地址,如此循環(huán),直至執(zhí)行完所有的指令。 類似的,JVM規(guī)范中規(guī)定,如果線程執(zhí)行的是非native方法,則程序計數(shù)器中保存的是當前需要執(zhí)行的指令的地址;如果線程執(zhí)行的是native方法,則程序計數(shù)器中的值是undefined。 Java虛擬機可以支持多條線程同時執(zhí)行,多線程是通過線程輪流切換來獲得CPU執(zhí)行時間的,因此,在任一具體時刻,一個CPU的內(nèi)核只會執(zhí)行一條線程中的指令,因此,為了能夠使得每個線程都在線程切換后能夠恢復在切換之前的程序執(zhí)行位置,每個線程都需要有自己獨立的程序計數(shù)器,并且不能互相被干擾,否則就會影響到程序的正常執(zhí)行次序。因此,JVM中的程序計數(shù)器是每個線程私有的。 1.2 堆外內(nèi)存堆外內(nèi)存又被稱為直接內(nèi)存(Direct Memory),它并不是虛擬機運行時數(shù)據(jù)區(qū)的一部分,Java虛擬機規(guī)范中也沒有定義這部分內(nèi)存區(qū)域,使用時由Java程序直接向系統(tǒng)申請,訪問直接內(nèi)存的速度要優(yōu)于Java堆,因此,讀寫頻繁的場景下使用直接內(nèi)存,性能會有提升,比如Java NIO庫,就是使用Native函數(shù)直接分配堆外內(nèi)存,然后通過一個存儲在Java堆中的DirectBytedBuffer對象作為這塊內(nèi)存的引用進行操作。 由于直接內(nèi)存在Java堆外,其大小不會直接受限于Xmx指定的堆大小,但它肯定會受到本機總內(nèi)存大小以及處理器尋址空間的限制,因此我們在配置JVM參數(shù)時,特別是有大量網(wǎng)絡通訊場景下,要特別注意,防止各個內(nèi)存區(qū)域的總內(nèi)存大于物理內(nèi)存限制 (包括物理的和OS的限制)。 1.3 小結花了很大篇幅來介紹Java虛擬機的內(nèi)存結構,其中在講解Java堆時,還簡單的介紹了JVM的內(nèi)存分配機制;在介紹虛擬機棧的同時,也對方法調(diào)用過程中棧的數(shù)據(jù)變化作了形象的說明。當然這樣的篇幅肯定不足以完全理清整個內(nèi)存結構以及其內(nèi)存分配機制,你盡可以把它當做簡單的入門,帶你更好的學習。接下來會以此為背景介紹一些常用的JVM參數(shù)。 二、常用JVM參數(shù)2.1 關于JVM參數(shù)必須知道的小知識JVM參數(shù)分為標準參數(shù)和非標準參數(shù),所有以-X和-XX開頭的參數(shù)都是非標準參數(shù),標準參數(shù)可以通過java -help命令查看,比如:-server就是一個標準參數(shù)。 非標準參數(shù)中,以-XX開頭的都是不穩(wěn)定的且不推薦在生成環(huán)境中使用。但現(xiàn)在的情況已經(jīng)有所改變,很多-XX開頭的參數(shù)也已經(jīng)非常穩(wěn)定了,但不管什么參數(shù)在使用前都應該了解它可能產(chǎn)生的影響。 布爾型參數(shù),-XX:+表示激活選項,-XX:-表示關閉此選項。 部分參數(shù)可以使用jinfo工具動態(tài)設置,比如:jinfo -flag +PrintGCDetails 12278,能夠動態(tài)設置的參數(shù)很少,所以用處有限,至于哪些參數(shù)可以動態(tài)設置,可以參考jinfo工具的使用方法。 2.2 GC日志GC日志是一個非常重要的工具,它準確的記錄了每一次GC的執(zhí)行時間和結果,通過分析GC日志可以幫助我們優(yōu)化內(nèi)存設置,也可以幫助改進應用的對象分配方式。如何閱讀GC日志不在本文的范疇內(nèi),大家可以參考網(wǎng)上相關文章。 下面幾個關于GC日志的參數(shù)應該加入到應用啟動參數(shù)列表中: -XX:+PrintGCDetails 開啟詳細GC日志模式 -XX:+PrintGCTimeStamps在每行GC日志頭部加上GC發(fā)生的時間,這個時間是指相對于JVM的啟動時間,單位是秒 -XX:+PrintGCDateStamps在GC日志的每一行加上絕對日期和時間,推薦同時使用這兩個參數(shù),這樣在關聯(lián)不同來源的GC日志時很有幫助 -XX:+PrintHeapAtGC輸出GC回收前和回收后的堆信息,使用這個參數(shù)可以更好的觀察GC對堆空間的影響 -Xloggc設置GC日志目錄 設置這幾個參數(shù)后,發(fā)生GC時輸出的日志就類似于下面的格式 (不同的垃圾收集器格式可能略有差異):
簡單的說明: 2018-01-07T19:45:08.627+0800 - GC開始時間 0.794 - GC開始時間相對于JVM啟動時間 GC - 用來區(qū)分是Minor GC 還是 Full GC,這里是Minor GC Allocation Failure - GC原因,這里是因為年輕代中沒有任何足夠空間,也就是分配失敗 PSYoungGen - 垃圾收集算法,這里是Parallel Scavenge 153600K->4564K(179200K) - 本次垃圾回收前后年輕代內(nèi)存使用情況,括號內(nèi)表示年輕代總大小 153600K->4580K(384000K) - 在本次垃圾回收前后整個堆內(nèi)存的使用情況,括號內(nèi)表示總的可用堆內(nèi)存 0.0051736 secs - GC持續(xù)時間 [Times: user=0.01 sys=0.00, real=0.01 secs] - 多個維度衡量GC持續(xù)時間 2.3 內(nèi)存優(yōu)化我們的程序可能會經(jīng)常出現(xiàn)性能問題,但如何分析和定位?知道一些常用的JVM內(nèi)存管理參數(shù),對我們開發(fā)人員有莫大的幫助。 堆空間設置使用-Xms和-Xmx來指定JVM堆空間的初始值和最大值,比如: java -Xms128m -Xmx2g app 雖然JVM可以在運行時動態(tài)的調(diào)整堆內(nèi)存大小,但很多時候我們都直接將-Xms和-Xmx設置相等的值,這樣可以減少程序運行時進行垃圾回收的次數(shù)。 新生代設置參數(shù)-Xmn用于設置新生代大小,設置一個較大的新生代會減少老年代的大小,這個參數(shù)堆GC行為影響很大。一般情況下不需要使用這個參數(shù),在分析GC日志后,發(fā)現(xiàn)確實是因為新生代設置過小導致頻繁的Full GC,可以配置這個參數(shù),一般情況下,新生代設置為堆空間的1/3 - 1/4左右。 還可以通過-XX:SurviorRatio設置新生代中eden區(qū)和Survivor from/to區(qū)空間的比例關系,也可使用-XX:NewRatio設置新生代和老年代的比例。 配置這3個參數(shù)的基本策略是:盡可能將對象預留在新生代,減少老年代GC的次數(shù),所以需要更謹慎的對其進行修改,不要太隨意。 生成快照文件我們可能沒有辦法給最大堆內(nèi)存設置一個合適的值,因為我們時常面臨內(nèi)存溢出的狀況,當然我們可以在內(nèi)存溢出情況出現(xiàn)后,再監(jiān)控程序,dump出內(nèi)存快照來定位,但這種方法的前提條件是內(nèi)存溢出問題要再次發(fā)生。更好方法是通過設置-XX:+HeapDumpOnOutOfMemoryError讓JVM在發(fā)生內(nèi)存溢出時自動的生成堆內(nèi)存快照。有了這個參數(shù),當我們在面對內(nèi)存溢出異常的時候會節(jié)約大量的時間,-XX:HeapDumpPath則可以設置快照的生成路徑。堆內(nèi)存快照文件可能很龐大,要注意存儲的磁盤空間。 方法區(qū)設置方法區(qū)中存放中JVM加載的類信息,如果JVM加載的類過多,就需要合理設置永久大的大小,在JDK1.6和JDK1.7中,可以使用 -XX:PermSize和-XX:MaxPermSize來達到這個目的,前者用于設置永久代的初始大小,后者用于設置永久代的最大值。前面我們知道,方法區(qū)并不在堆內(nèi)存中,所以要注意所有JVM參數(shù)設置的內(nèi)存總大小。 在JDK1.8中已經(jīng)使用元空間代替永久代,同樣的目的,需要使用-XX:MetaspaceSize和-XX:MaxMetaspaceSize來代替。 直接內(nèi)存參數(shù)-XX:MaxDirectMemorySize用于配置直接內(nèi)存大小 ,如果不設置,默認值為最大堆空間,即-Xmx,當直接內(nèi)存使用量達到設置的值時,就會觸發(fā)垃圾回收,如果垃圾回收不能有效釋放足夠空間,仍然會引起OOM。如果堆外內(nèi)存發(fā)生OOM,請檢查此參數(shù)是否配置過小。 2.4 小結這部分主要介紹一些常用的JVM參數(shù),理解這些JVM參數(shù)的前提是需要理解JVM的內(nèi)存結構以及各個內(nèi)存區(qū)域的作用,希望通過這些參數(shù)的介紹,能夠加深大家對JVM內(nèi)存結構的理解,也希望在平時的工作中能夠注意這些參數(shù)的運用。下篇文章將著重介紹常用的垃圾回收算法與垃圾收集器。 參考資料周志明 著; 深入理解Java虛擬機(第2版); 機械工業(yè)出版社,2013 Java8內(nèi)存模型—永久代(PermGen)和元空間(Metaspace) java虛擬機:運行時常量池 最簡單例子圖解JVM內(nèi)存分配和回收 JVM的內(nèi)存區(qū)域劃分 JVM實用參數(shù)(八)GC日志 JVM實用參數(shù)(四)內(nèi)存調(diào)優(yōu) 作者: CHEN川 鏈接: https://www.jianshu.com/p/f8d71e1e8821 |
|