1. 程式人生 > >JVM內存管理及GC機制

JVM內存管理及GC機制

per 內存清理 bject long 變量 percent 大數 標記 編寫程序

一、概述

Java GC(Garbage Collection,垃圾收集,垃圾回收)機制,是Java與C++/C的主要區別之一,作為Java開發者,一般不需要專門編寫內存回收和垃圾清理代碼,對內存泄露和溢出的問題,也不需要像C程序員那樣戰戰兢兢。經過這麽長時間的發展,java GC機制已經日臻完善,幾乎可以自動的為我們做絕大多數的事情。

雖然java不需要開發人員顯示的分配和回收內存,這對開發人員確實降低了不少編程難度,但也可能帶來一些副作用:

1. 有可能不知不覺浪費了很多內存
2. JVM花費過多時間來進行內存回收
3. 內存泄露

因此,作為一名java編程人員,必須學會JVM內存管理和回收機制,這可以幫助我們在日常工作中排查各種內存溢出或泄露問題,解決性能瓶頸,達到更高的並發量,寫出更高效的程序。

二、JVM內存空間管理

根據JVM規範,JVM把內存劃分了如下幾個區域:

1. 方法區
2. 堆區
3. 本地方法棧
4. 虛擬機棧
5. 程序計數器 

其中,方法區和堆是所有線程共享的。

技術分享

2.1 方法區

方法區存放了要加載的類的信息(如類名,修飾符)、類中的靜態變量、final定義的常量、類中的field、方法信息,當開發人員調用類對象中的getName、isInterface等方法來獲取信息時,這些數據都來源於方法區。方法區是全局共享的,在一定條件下它也會被GC。當方法區使用的內存超過它允許的大小時,就會拋出OutOfMemory:PermGen Space異常。

在Hotspot虛擬機中,這塊區域對應的是Permanent Generation(持久代)

,一般的,方法區上執行的垃圾收集是很少的,因此方法區又被稱為持久代的原因之一,但這也不代表著在方法區上完全沒有垃圾收集,其上的垃圾收集主要是針對常量池的內存回收和對已加載類的卸載。在方法區上進行垃圾收集,條件苛刻而且相當困難,關於其回後面再介紹。

運行時常量池(Runtime Constant Pool)是方法區的一部分,用於存儲編譯期就生成的字面常量、符號引用、翻譯出來的直接引用(符號引用就是編碼是用字符串表示某個變量、接口的位置,直接引用就是根據符號引用翻譯出來的地址,將在類鏈接階段完成翻譯);運行時常量池除了存儲編譯期常量外,也可以存儲在運行時間產生的常量,比如String類的intern()方法,作用是String維護了一個常量池,如果調用的字符“abc”已經在常量池中,則返回池中的字符串地址,否則,新建一個常量加入池中,並返回地址。

JVM方法區的相關參數,最小值:--XX:PermSize;最大值 --XX:MaxPermSize

2.2 堆區

堆區是理解JavaGC機制最重要的區域。在JVM所管理的內存中,堆區是最大的一塊,堆區也是JavaGC機制所管理的主要內存區域,堆區由所有線程共享,在虛擬機啟動時創建。堆區用來存儲對象實例及數組值,可以認為java中所有通過new創建的對象都在此分配。

對於堆區大小,可以通過參數-Xms-Xmx來控制,-Xms為JVM啟動時申請的最新heap內存,默認為物理內存的1/64但小於1GB;-Xmx為JVM可申請的最大Heap內存,默認為物理內存的1/4但小於1GB,默認當剩余堆空間小於40%時,JVM會增大Heap到-Xmx大小,可通過-XX:MinHeapFreeRadio參數來控制這個比例;當空余堆內存大於70%時,JVM會減小Heap大小到-Xms指定大小,可通過-XX:MaxHeapFreeRatio來指定這個比例。對於系統而言,為了避免在運行期間頻繁的調整Heap大小,我們通常將-Xms和-Xmx設置成一樣。

為了讓內存回收更加高效(後面會具體講為何要分代劃分),從Sun JDK 1.2開始對堆采用了分代管理方式,如下圖所示:
技術分享

年輕代(Young Generation)

對象在被創建時,內存首先是在年輕代進行分配(註意,大對象可以直接在老年代分配)。當年輕代需要回收時會觸發Minor GC(也稱作Young GC)。

年輕代由Eden Space和兩塊相同大小的Survivor Space(又稱S0和S1)構成,可通過-Xmn參數來調整新生代大小,也可通過-XX:SurvivorRadio來調整Eden Space和Survivor Space大小。不同的GC方式會按不同的方式來按此值劃分Eden Space和Survivor Space,有些GC方式還會根據運行狀況來動態調整Eden、S0、S1的大小。

年輕代的Eden區內存是連續的,所以其分配會非常快;同樣Eden區的回收也非常快(因為大部分情況下Eden區對象存活時間非常短,而Eden區采用的復制回收算法,此算法在存活對象比例很少的情況下非常高效,後面會詳細介紹)。

如果在執行垃圾回收之後,仍沒有足夠的內存分配,也不能再擴展,將會拋出OutOfMemoryError:Java Heap Space異常。

老年代(Old Generation)

老年代用於存放在年輕代中經多次垃圾回收仍然存活的對象,可以理解為比較老一點的對象,例如緩存對象;新建的對象也有可能在老年代上直接分配內存,這主要有兩種情況:一種為大對象,可以通過啟動參數設置-XX:PretenureSizeThreshold=1024,表示超過多大時就不在年輕代分配,而是直接在老年代分配。此參數在年輕代采用Parallel Scavenge GC時無效,因為其會根據運行情況自己決定什麽對象直接在老年代上分配內存;另一種為大的數組對象,且數組對象中無引用外部對象。

當老年代滿了的時候就需要對老年代進行垃圾回收,老年代的垃圾回收稱作Major GC(也稱作Full GC)。

老年代所占用的內存大小為-Xmx對應的值減去-Xmn對應的值。

2.3 本地方法棧(Native Method Stack)

本地方法棧用於支持native方法的執行,存儲了每個native方法調用的狀態。本地方法棧和虛擬機方法棧運行機制一致,它們唯一的區別就是,虛擬機棧是執行Java方法的,而本地方法棧是用來執行native方法的,在很多虛擬機中(如Sun的JDK默認的HotSpot虛擬機),會將本地方法棧與虛擬機棧放在一起使用。

2.4 程序計數器(Program Counter Register)

程序計數器是一個比較小的內存區域,可能是CPU寄存器或者操作系統內存,其主要用於指示當前線程所執行的字節碼執行到了第幾行,可以理解為是當前線程的行號指示器。字節碼解釋器在工作時,會通過改變這個計數器的值來取下一條語句指令。 每個程序計數器只用來記錄一個線程的行號,所以它是線程私有(一個線程就有一個程序計數器)的。

如果程序執行的是一個Java方法,則計數器記錄的是正在執行的虛擬機字節碼指令地址;如果正在執行的是一個本地(native,由C語言編寫完成)方法,則計數器的值為Undefined,由於程序計數器只是記錄當前指令地址,所以不存在內存溢出的情況,因此,程序計數器也是所有JVM內存區域中唯一一個沒有定義OutOfMemoryError的區域。

2.5 虛擬機棧(JVM Stack)

虛擬機棧占用的是操作系統內存,每個線程都對應著一個虛擬機棧,它是線程私有的,而且分配非常高效。一個線程的每個方法在執行的同時,都會創建一個棧幀(Statck Frame),棧幀中存儲的有局部變量表、操作站、動態鏈接、方法出口等,當方法被調用時,棧幀在JVM棧中入棧,當方法執行完成時,棧幀出棧。

局部變量表中存儲著方法的相關局部變量,包括各種基本數據類型,對象的引用,返回地址等。在局部變量表中,只有long和double類型會占用2個局部變量空間(Slot,對於32位機器,一個Slot就是32個bit),其它都是1個Slot。需要註意的是,局部變量表是在編譯時就已經確定好的,方法運行所需要分配的空間在棧幀中是完全確定的,在方法的生命周期內都不會改變。

虛擬機棧中定義了兩種異常,如果線程調用的棧深度大於虛擬機允許的最大深度,則拋出StatckOverFlowError(棧溢出);不過多數Java虛擬機都允許動態擴展虛擬機棧的大小(有少部分是固定長度的),所以線程可以一直申請棧,直到內存不足,此時,會拋出OutOfMemoryError(內存溢出)。

2.6 Java對象訪問方式

一般來說,一個Java的引用訪問涉及到3個內存區域:JVM棧,堆,方法區。以最簡單的本地變量引用:Object objRef = new Object()為例:

  1. Object objRef 表示一個本地引用,存儲在JVM棧的本地變量表中,表示一個reference類型數據;
  2. new Object()作為實例對象數據存儲在堆中;
  3. 堆中還記錄了能夠查詢到此Object對象的類型數據(接口、方法、field、對象類型等)的地址,實際的數據則存儲在方法區中;

在Java虛擬機規範中,只規定了指向對象的引用,對於通過reference類型引用訪問具體對象的方式並未做規定,不過目前主流的實現方式主要有兩種:

2.6.1 通過句柄訪問

通過句柄訪問的實現方式中,JVM堆中會劃分單獨一塊內存區域作為句柄池,句柄池中存儲了對象實例數據(在堆中)和對象類型數據(在方法區中)的指針。這種實現方法由於用句柄表示地址,因此十分穩定。
技術分享

2.6.2 通過直接指針訪問

通過直接指針訪問的方式中,reference中存儲的就是對象在堆中的實際地址,在堆中存儲的對象信息中包含了在方法區中的相應類型數據。這種方法最大的優勢是速度快,在HotSpot虛擬機中用的就是這種方式。
技術分享

三、JVM內存分配

Java對象所占用的內存主要在堆上實現,因為堆是線程共享的,因此在堆上分配內存時需要進行加鎖,這就導致了創建對象的開銷比較大。當堆上空間不足時,會出發GC,如果GC後空間仍然不足,則會拋出OutOfMemory異常。

為了提升內存分配效率,在年輕代的Eden區HotSpot虛擬機使用了兩種技術來加快內存分配 ,分別是bump-the-pointerTLAB(Thread-Local Allocation Buffers)。由於Eden區是連續的,因此bump-the-pointer技術的核心就是跟蹤最後創建的一個對象,在對象創建時,只需要檢查最後一個對象後面是否有足夠的內存即可,從而大大加快內存分配速度;而對於TLAB技術是對於多線程而言的, 它會為每個新創建的線程在新生代的Eden Space上分配一塊獨立的空間,這塊空間稱為TLAB(Thread Local Allocation Buffer),其大小由JVM根據運行情況計算而得。可通過-XX:TLABWasteTargetPercent來設置其可占用的Eden Space的百分比,默認是1%。在TLAB上分配內存不需要加鎖,一般JVM會優先在TLAB上分配內存,如果對象過大或者TLAB空間已經用完,則仍然在堆上進行分配。因此,在編寫程序時,多個小對象比大的對象分配起來效率更高。可在啟動參數上增加-XX:+PrintTLAB來查看TLAB空間的使用情況。

技術分享

對象如果在年輕代存活了足夠長的時間而沒有被清理掉(即在幾次Minor GC後存活了下來),則會被復制到年老代,年老代的空間一般比年輕代大,能存放更多的對象,在年老代上發生的GC次數也比年輕代少。當年老代內存不足時,將執行Major GC,也叫 Full GC。

可以使用-XX:+UseAdaptiveSizePolicy開關來控制是否采用動態控制策略,如果動態控制,則動態調整Java堆中各個區域的大小以及進入老年代的年齡。

如果對象比較大(比如長字符串或大數組),年輕代空間不足,則大對象會直接分配到老年代上(大對象可能觸發提前GC,應少用,更應避免使用短命的大對象)。用 -XX:PretenureSizeThreshold來控制直接升入老年代的對象大小,大於這個值的對象會直接分配在老年代上。

四、內存的回收方式

JVM通過GC來回收堆和方法區中的內存,這個過程是自動執行的。說到Java GC機制,其主要完成3件事:確定哪些內存需要回收;確定什麽時候需要執行GC;如何執行GC。JVM主要采用收集器的方式實現GC,主要的收集器有引用計數收集器和跟蹤收集器。

4.1 引用計數收集器

引用計數器采用分散式管理方式,通過計數器記錄對象是否被引用。當計數器為0時,說明此對象已經不再被使用,可進行回收,如圖所示:

技術分享

在上圖中,ObjectA釋放了對ObjectB的引用後,ObjectB的引用計數器變為0,此時可回收ObjectB所占有的內存。

引用計數器需要在每次對象賦值時進行引用計數器的增減,他有一定消耗。另外,引用計數器對於循環引用的場景沒有辦法實現回收。例如在上面的例子中,如果ObjectB和ObjectC互相引用,那麽即使ObjectA釋放了對ObjectB和ObjectC的引用,也無法回收ObjectB、ObjectC,因此對於java這種會形成復雜引用關系的語言而言,引用計數器是非常不適合的,SunJDK在實現GC時也未采用這種方式。

4.2 跟蹤收集器

跟蹤收集器采用的為集中式的管理方式,會全局記錄數據引用的狀態。基於一定條件的觸發(例如定時、空間不足時),執行時需要從根集合來掃描對象的引用關系,這可能會造成應用程序暫停。主要有復制(Copying)標記-清除(Mark-Sweep)標記-壓縮(Mark-Compact)三種實現算法。

復制(Copying)

復制采用的方式為從根集合掃描出存活的對象,並將找到的存活的對象復制到一塊新的完全未被使用的空間中,如圖所示:

技術分享

復制收集器方式僅需要從根集合掃描所有存活對象,當要回收的空間中存活對象較少時,復制算法會比較高效(年輕代的Eden區就是采用這個算法),其帶來的成本是要增加一塊空的內存空間及進行對象的移動。

標記-清除(Marking-Deleting)

標記-清除采用的方式為從根集合開始掃描,對存活的對象進行標記,標記完畢後,再掃描整個空間中未標記的對象,並進行清除,標記和清除過程如下圖所示:

技術分享

上圖中藍色的部分是有被引用的存活的對象,褐色部分沒被引用的可回收的對象。在marking階段為了mark對象,所有的對象都會被掃描一遍,掃描這個過程是比較耗時的。

技術分享

清除階段回收的是沒有被引用的對象,存活的對象被保留。內存分配器會持有空閑空間的引用列表,當有分配請求時會查詢空閑空間引用列表進行分配。

標記-清除動作不需要進行對象移動,且僅對其不存活的對象進行處理。在空間中存活對象較多的情況下較為高效,但由於標記-清除直接回收不存活對象占用的內存,因此會造成內存碎片。

標記-壓縮(Mark-Compact)

標記-壓縮和標記-清除一樣,是對活的對象進行標記,但是在清除後的處理不一樣,標記-壓縮在清除對象占用的內存後,會把所有活的對象向左端空閑空間移動,然後再更新引用其對象的指針,如下圖所示:

技術分享

很明顯,標記-壓縮在標記-清除的基礎上對存活的對象進行了移動規整動作,解決了內存碎片問題,得到更多連續的內存空間以提高分配效率,但由於需要對對象進行移動,因此成本也比較高。

五、虛擬機中的GC過程

5.1 為什麽要分代回收?

在一開始的時候,JVM的GC就是采用標記-清除-壓縮方式進行的,這麽做並不是很高效,因為當對象分配的越來越多時,對象列表也越來也大,掃描和移動越來越耗時,造成了內存回收越來越慢。然而,經過根據對java應用的分析,發現大部分對象的存活時間都非常短,只有少部分數據存活周期是比較長的,請看下面對java對象內存存活時間的統計:

技術分享

從圖表中可以看出,大部分對象存活時間是非常短的,隨著時間的推移,被分配的對象越來越少。

5.2 虛擬機中GC的過程

經過上面介紹,我們已經知道了JVM為何要分代回收,下面我們就詳細看一下整個回收過程。

  1. 在初始階段,新創建的對象被分配到Eden區,survivor的兩塊空間都為空。
    技術分享

  2. 當Eden區滿了的時候,minor garbage 被觸發
    技術分享

  3. 經過掃描與標記,存活的對象被復制到S0,不存活的對象被回收
    技術分享

  4. 在下一次的Minor GC中,Eden區的情況和上面一致,沒有引用的對象被回收,存活的對象被復制到survivor區。然而在survivor區,S0的所有的數據都被復制到S1,需要註意的是,在上次minor GC過程中移動到S0中的兩個對象在復制到S1後其年齡要加1。此時Eden區S0區被清空,所有存活的數據都復制到了S1區,並且S1區存在著年齡不一樣的對象,過程如下圖所示:
    技術分享

  5. 再下一次MinorGC則重復這個過程,這一次survivor的兩個區對換,存活的對象被復制到S0,存活的對象年齡加1,Eden區和另一個survivor區被清空。
    技術分享

  6. 下面演示一下Promotion過程,再經過幾次Minor GC之後,當存活對象的年齡達到一個閾值之後(可通過參數配置,默認是8),就會被從年輕代Promotion到老年代。
    技術分享

  7. 隨著MinorGC一次又一次的進行,不斷會有新的對象被promote到老年代。
    技術分享

  8. 上面基本上覆蓋了整個年輕代所有的回收過程。最終,MajorGC將會在老年代發生,老年代的空間將會被清除和壓縮。
    技術分享

從上面的過程可以看出,Eden區是連續的空間,且Survivor總有一個為空。經過一次GC和復制,一個Survivor中保存著當前還活著的對象,而Eden區和另一個Survivor區的內容都不再需要了,可以直接清空,到下一次GC時,兩個Survivor的角色再互換。因此,這種方式分配內存和清理內存的效率都極高,這種垃圾回收的方式就是著名的“停止-復制(Stop-and-copy)”清理法(將Eden區和一個Survivor中仍然存活的對象拷貝到另一個Survivor中),這不代表著停止復制清理法很高效,其實,它也只在這種情況下(基於大部分對象存活周期很短的事實)高效,如果在老年代采用停止復制,則是非常不合適的。

老年代存儲的對象比年輕代多得多,而且不乏大對象,對老年代進行內存清理時,如果使用停止-復制算法,則相當低效。一般,老年代用的算法是標記-壓縮算法,即:標記出仍然存活的對象(存在引用的),將所有存活的對象向一端移動,以保證內存的連續。在發生Minor GC時,虛擬機會檢查每次晉升進入老年代的大小是否大於老年代的剩余空間大小,如果大於,則直接觸發一次Full GC,否則,就查看是否設置了-XX:+HandlePromotionFailure(允許擔保失敗),如果允許,則只會進行MinorGC,此時可以容忍內存分配失敗;如果不允許,則仍然進行Full GC(這代表著如果設置-XX:+Handle PromotionFailure,則觸發MinorGC就會同時觸發Full GC,哪怕老年代還有很多內存,所以,最好不要這樣做)。

關於方法區即永久代的回收,永久代的回收有兩種:常量池中的常量,無用的類信息,常量的回收很簡單,沒有引用了就可以被回收。對於無用的類進行回收,必須保證3點:

1. 類的所有實例都已經被回收
2. 加載類的ClassLoader已經被回收
3. 類對象的Class對象沒有被引用(即沒有通過反射引用該類的地方)

永久代的回收並不是必須的,可以通過參數來設置是否對類進行回收。

六、垃圾收集器

通過上面的介紹,我們已經了解到了JVM的內存回收過程,而在虛擬機中,GC是由垃圾回收器來具體執行的,所以,在實際應用場景中我們需要根據應用情況選擇合適的垃圾收集器,下面我們就介紹一下垃圾收集器。

6.1 串行(Serial)收集器

串行收集器JavaSE5和6中客戶端虛擬機所采用的默認配置,它是最簡單的收集器,比較適合於只有一個處理器的系統。在串行收集器中,minor和major GC過程都是用一個線程進行垃圾回收。

使用場景

首先,串行GC一般用在對應用暫停要求不是很高和運行在客戶端模式的場景,它僅僅利用一個CPU核心來進行垃圾回收。在現在的硬件條件下,串行GC可以管理很多小內存的應用,並且能夠保證相對較小的暫停(在Full GC的情況下大約需要幾秒的時間)。另一個通常采用串行GC的場景就是一臺機器運行多個JVM虛擬機的情況(JVM虛擬機個數大於CPU核心數),在這種場景下,當一個JVM進行垃圾回收時只利用一個處理器,不會對其它JVM造成較大的影響。最後,在一些內存比較小和CPU核心數比較少的硬件設備中也比較適合采用串行收集器。

相關參數命令

1 啟用串行收集器: -XX:+UseSerialGC

2 命令行示例:

java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseSerialGC -jar c:\javademos\demo\jfc\Java2D\Java2demo.jar

6.2 並行收集器

並行收集器采用多線程的方式來進行垃圾回收,采用並行的方式能夠帶來極大的CPU吞吐量。它在不進行垃圾回收的時候對正在運行的應用程序沒有任何影響,在進程GC的時候采用多線程的方式來提高回收速度,因此,並行收集器非常適用於批處理的情形。當然,如果應用對程序暫停要求很高的話,建議采用下面介紹的並發收集器。默認一個N cpu的機器上,並行回收的線程數為N。當然,並行的數量可以通過參數進行控制:-XX:ParallelGCThreads=<desired number>。並行收集器是Server級別機器(CPU大於2且內存大於2G)上采用的默認回收方式,

在單核CPU的機器上,即使配置了並行收集器,實際回收時仍然采用的是默認收集器。如果一臺機器上只有兩個CPU,采用並行回收器和默認回收器的效果其實差不多,只有當CPU個數大於2個時,年輕代回收的暫停時間才會減少。

應用場景

並行回收器適用於多CPU、對暫停時間要求短的情況下。通常,一些批處理的應用如報告打印、數據庫查詢可采用並行收集器。

在年輕代用多線程、老年代用單線程

1 啟用命令:-XX:+UseParallelGC

2 命令行示例:

java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseParallelGC -jar c:\javademos\demo\jfc\Java2D\Java2demo.jar

年輕代和老年代都用多線程

1 啟用命令:-XX:+UseParallelOldGC

當啟用 -XX:+UseParallelOldGC 選項時,年輕代和老年代的垃圾收集都會用多線程進行,在壓縮階段也是多線程。因為HotSpot虛擬機在年輕代采用的是停止-復制算法,年輕代沒有壓縮過程,而老年代采用的是標記-清除-壓縮算法,所以僅在老年代有compact過程。

2 命令行示例:

java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseParallelOldGC -jar c:\javademos\demo\jfc\Java2D\Java2demo.jar

6.3 CMS(Concurrent Mark Sweep)收集器

CMS收集器主要用於永久區,它試圖用多線程並發的形式來減少垃圾收集過程中的暫停。CMS收集器不會對存活的對象進行復制或移動。

應用場景

CMS收集器主要用在應用程序對暫停時間要求很高的場景,比如桌面UI應用需要及時響應用戶操作事件、服務器必須能快速響應客戶端請求或者數據庫要快速響應查詢請求等等。

相關命令參數

1 啟用CMS收集器:-XX:+UseConcMarkSweepGC

2 設置線程數:-XX:ParallelCMSThreads=<n>

3 命令行示例:

java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseConcMarkSweepGC -XX:ParallelCMSThreads=2 -jar c:\javademos\demo\jfc\Java2D\Java2demo.jar

6.4 G1收集器

G1即Garbage First,它是在java 7中出現的新的收集器,它的目標是替換掉現有的CMS收集器。G1具有並行、並發、增量壓縮、暫停時間段等特點,在這裏先不做詳細介紹。

相關命令參數

1 啟用G1收集器:-XX:+UseG1GC

2 命令行示例:

java -Xmx12m -Xms3m -XX:+UseG1GC -jar c:\javademos\demo\jfc\Java2D\Java2demo.jar

JVM內存管理及GC機制