1. 程式人生 > >談談Java記憶體管理

談談Java記憶體管理

目錄

java

對於一個Java程式設計師來說,大多數情況下的確是無需對記憶體的分配、釋放做太多考慮,對Jvm也無需有多麼深的理解的。但是在寫程式的過程中卻也往往因為這樣而造成了一些不容易察覺到的記憶體問題,並且在記憶體問題出現的時候,也不能很快的定位並解決。因此,瞭解並掌握Java的記憶體管理是一個合格的Java程式設計師必需的技能,也只有這樣才能寫出更好的程式,更好地優化程式的效能。

一. 背景知識

根據網路可以找到的資料以及筆者能夠打聽到的訊息,目前國內外著名的幾個大型網際網路公司的語言選型概括如下:

  1. Google: C/C++ Go Python Java JavaScript,不得不提的是Google貢獻給java社群的guava包質量非常高,非常值得學習和使用。
  2. Youtube、豆瓣: Python
  3. Fackbook、Yahoo、Flickr、新浪:php(優化過的php vm)
  4. 網易、阿里、搜狐: Java、PHP、Node.js
  5. Twitter: Ruby->Java,之所以如此就在於與Jvm相比,Ruby的runtime是非常慢的。並且Ruby的應用比起Java還是比較小眾的。不過最近twitter有往scala上遷移的趨勢。

可見,雖然最近這些年很多言論都號稱java已死或者不久即死,但是Java的語言應用佔有率一直居高不下。與高效能的C/C++相比,Java具有gc機制,並且沒有那讓人望而生畏的指標,上手門檻相對較低;而與上手成本更低的PHP、Ruby等指令碼語言來說,又比這些指令碼語言有效能上的優勢(這裡暫時忽略FB自己開發的HHVM)。

對於Java來說,最終是要依靠位元組碼執行在jvm上的。目前,常見的jvm有以下幾種:

  • Sun HotSpot
  • BEA Jrockit
  • IBM J9
  • Dalvik(Android)

其中以HotSpot應用最廣泛。目前sun jdk的最新版本已經到了8,但鑑於新版的jdk使用並未普及,因此本文僅僅針對HotSpot虛擬機器的jdk6來講。

二. Jvm虛擬機器記憶體簡介

2.1 Java執行時記憶體區

Java的執行時記憶體組成如下圖所示:

java-runtime-memory.jpg

其中,對於這各個部分有一些是執行緒私有的,其他則是執行緒共享的。

執行緒私有的如下:

  • 程式計數器

    當前執行緒所執行的位元組碼的行號指示器

  • Java虛擬機器棧

    Java方法執行的記憶體模型,每個方法被執行時都會建立一個棧幀,儲存區域性變量表、操作棧、動態連結、方法出口等資訊。

    • 每個執行緒都有自己獨立的棧空間
    • 執行緒棧只存基本型別和物件地址
    • 方法中區域性變數線上程空間中
  • 本地方法棧

    Native方法服務。在HotSpot虛擬機器中和Java虛擬機器棧合二為一。

執行緒共享的如下:

  • Java堆

    存放物件例項,幾乎所有的物件例項以及其屬性都在這裡分配記憶體。

  • 方法區

    儲存已經被虛擬機器載入的類資訊、常量、靜態變數、JIT編譯後的程式碼等資料。

  • 執行時常量池

    方法區的一部分。用於存放編譯期生成的各種字面量和符號引用。

  • 直接記憶體

    NIO、Native函式直接分配的堆外記憶體。DirectBuffer引用也會使用此部分記憶體。

2.2 物件訪問

Java是面向物件的一種程式語言,那麼如何通過引用來訪問物件呢?一般有兩種方式:

  1. 通過控制代碼訪問

  2. 直接指標

    此種方式也是HotSpot虛擬機器採用的方式。

2.3 記憶體溢位

在JVM申請記憶體的過程中,會遇到無法申請到足夠記憶體,從而導致記憶體溢位的情況。一般有以下幾種情況:

  • 虛擬機器棧和本地方法棧溢位
    • StackOverflowError: 執行緒請求的棧深度大於虛擬機器所允許的最大深度(迴圈遞迴)
    • OutOfMemoryError: 虛擬機器在擴充套件棧是無法申請到足夠的記憶體空間,一般可以通過不停地建立執行緒引起此種情況
  • Java堆溢位: 當建立大量物件並且物件生命週期都很長的情況下,會引發OutOfMemoryError
  • 執行時常量區溢位:OutOfMemoryError:PermGen space,這裡一個典型的例子就是String的intern方法,當大量字串使用intern時,會觸發此記憶體溢位
  • 方法區溢位:方法區存放Class等元資料資訊,如果產生大量的類(使用cglib),那麼就會引發此記憶體溢位,OutOfMemoryError:PermGen space,在使用Hibernate等框架時會容易引起此種情況。

三. 垃圾收集

3.1 理論基礎

在通常情況下,我們掌握java的記憶體管理就是為了應對網站/服務訪問慢,慢的原因一般有以下幾點:

  • 記憶體:垃圾收集佔用cpu;放入了太多資料,造成記憶體洩露(java也是有這種問題的^_^)
  • 執行緒死鎖
  • I/O速度太慢
  • 依賴的其他服務響應太慢
  • 複雜的業務邏輯或者演算法造成響應的緩慢

其中,垃圾收集對效能的影響一般有以下幾個:

  • 記憶體洩露
  • 程式暫停
  • 程式吞吐量顯著下降
  • 響應時間變慢

垃圾收集的一些基本概念

  • Concurrent Collector:收集的同時可執行其他的工作程序
  • Parallel Collector: 使用多CPU進行垃圾收集
  • Stop-the-word(STW):收集時必須暫停其他所有的工作程序
  • Sticky-reference-count:對於使用“引用計數”(reference count)演算法的GC,如果物件的計數器溢位,則起不到標記某個物件是垃圾的作用了,這種錯誤稱為sticky-reference-count problem,通常可以增加計數器的bit數來減少出現這個問題的機率,但是那樣會佔用更多空間。一般如果GC演算法能迅速清理完物件,也不容易出現這個問題。
  • Mutator:mutate的中文是變異,在GC中即是指一種JVM程式,專門更新物件的狀態的,也就是讓物件“變異”成為另一種型別,比如變為垃圾。
  • On-the-fly:用來描述某個GC的型別:on-the-fly reference count garbage collector。此GC不用標記而是通過引用計數來識別垃圾。
  • Generational gc:這是一種相對於傳統的“標記-清理”技術來說,比較先進的gc,特點是把物件分成不同的generation,即分成幾代人,有年輕的,有年老的。這類gc主要是利用計算機程式的一個特點,即“越年輕的物件越容易死亡”,也就是存活的越久的物件越有機會存活下去(薑是老的辣)。

吞吐量與響應時間

牽扯到垃圾收集,還需要搞清楚吞吐量與響應時間的含義

  • 吞吐量是對單位時間內完成的工作量的量度。如:每分鐘的 Web 伺服器請求數量
  • 響應時間是提交請求和返回該請求的響應之間使用的時間。如:訪問Web頁面花費的時間

吞吐量與訪問時間的關係很複雜,有時可能以響應時間為代價而得到較高的吞吐量,而有時候又要以吞吐量為代價得到較好的響應時間。而在其他情況下,一個單獨的更改可能對兩者都有提高。通常,平均響應時間越短,系統吞吐量越大;平均響應時間越長,系統吞吐量越小; 但是,系統吞吐量越大, 未必平均響應時間越短;因為在某些情況(例如,不增加任何硬體配置)吞吐量的增大,有時會把平均響應時間作為犧牲,來換取一段時間處理更多的請求。

針對於Java的垃圾回收來說,不同的垃圾回收器會不同程度地影響這兩個指標。例如:並行的垃圾收集器,其保證的是吞吐量,會在一定程度上犧牲響應時間。而併發的收集器,則主要保證的是請求的響應時間。

GC的流程

  • 找出堆中活著的物件
  • 釋放死物件佔用的資源
  • 定期調整活物件的位置

GC演算法

  • Mark-Sweep 標記-清除
  • Mark-Sweep-Compact 標記-整理
  • Copying Collector 複製演算法

  • Mark-標記

    從”GC roots”開始掃描(這裡的roots包括執行緒棧、靜態常量等),給能夠沿著roots到達的物件標記為”live”,最終所有能夠到達的物件都被標記為”live”,而無法到達的物件則為”dead”。效率和存活物件的數量是線性相關的。

  • Sweep-清除

    掃描堆,定位到所有”dead”物件,並清理掉。效率和堆的大小是線性相關的。

  • Compact-壓縮

    對於物件的清除,會產生一些記憶體碎片,這時候就需要對這些記憶體進行壓縮、整理。包括:relocate(將存貨的物件移動到一起,從而釋放出連續的可用記憶體)、remap(收集所有的物件引用指向新的物件地址)。效率和存活物件的數量是線性相關的。

  • Copy-複製

    將記憶體分為”from”和”to”兩個區域,垃圾回收時,將from區域的存活物件整體複製到to區域中。效率和存活物件的數量是線性相關的。

其中,Copy對比Mark-sweep

  1. 記憶體消耗:copy需要兩倍的最大live set記憶體;mark-sweep則只需要一倍。
  2. 效率上:copy與live set成線性相關,效率高;mark-sweep則與堆大小線性相關,效率較低。

分代收集

分代收集是目前比較先進的垃圾回收方案。有以下幾個相關理論

  • 分代假設:大部分物件的壽命很短,“朝生夕死”,重點放在對年青代物件的收集,而且年青代通常只佔整個空間的一小部分。
  • 把年青代裡活的很長的物件移動到老年代。
  • 只有當老年代滿了才去收集。
  • 收集效率明顯比不分代高。

HotSpot虛擬機器的分代收集,分為一個Eden區、兩個Survivor去以及Old Generation/Tenured區,其中Eden以及Survivor共同組成New Generatiton/Young space。通常將對New Generation進行的回收稱為Minor GC;對Old Generation進行的回收稱為Major GC,但由於Major GC除併發GC外均需對整個堆以及Permanent Generation進行掃描和回收,因此又稱為Full GC。

  • Eden區是分配物件的區域。
  • Survivor是minor/younger gc後儲存存活物件的區域。
  • Tenured區域儲存長時間存活的物件。

分代收集中典型的垃圾收集演算法組合描述如下:

  • 年青代通常使用Copy演算法收集,會stop the world
  • 老年代收集一般採用Mark-sweep-compact, 有可能會stop the world,也可以是concurrent或者部分concurrent。

那麼何時進行Minor GC、何時進行Major GC? 一般的過程如下:

  • 物件在Eden Space完成記憶體分配
  • 當Eden Space滿了,再建立物件,會因為申請不到空間,觸發Minor GC,進行New(Eden + S0 或 Eden S1) Generation進行垃圾回收
  • Minor GC時,Eden Space不能被回收的物件被放入到空的Survivor(S0或S1,Eden肯定會被清空),另一個Survivor裡不能被GC回收的物件也會被放入這個Survivor,始終保證一個Survivor是空的
  • 在Step3時,如果發現Survivor區滿了,則這些物件被copy到old區,或者Survivor並沒有滿,但是有些物件已經足夠Old,也被放入Old Space。
  • 當Old Space被放滿之後,進行Full GC

但這個具體還要看JVM是採用的哪種GC方案。

New Generation的GC有以下三種:

  • Serial
  • ParallelScavenge
  • ParNew

對於上述三種GC方案均是在Eden Space分配不下時,觸發GC。

Old Generation的GC有以下四種:

  • Serial Old
  • Parallel
  • CMS

對於Serial Old, Parallel Old而言觸發機制為

  • Old Generation空間不足
  • Permanent Generation空間不足
  • Minor GC時的悲觀策略
  • Minor GC後在Eden上分配記憶體仍然失敗
  • 執行Heap Dump時
  • 外部呼叫System.gc,可通過-XX:+DisableExplicitGC來禁止,。這裡需要注意的是禁用System.gc()會引起使用NIO時的OOM,所以此選項慎重使用。具體可見:http://hllvm.group.iteye.com/group/topic/27945

對於CMS而言觸發機制為:

  • 當Old Generation空間使用到一定比率時觸發,HopSpot V1.6中預設是92%,可通過PrintCMSInitiationStatistics(此引數在V1.5中不能用)來檢視這個值到底是多少,通過CMSInitiatingOccupancyFaction來強制指定。預設值是根據如下公式計算出來的:((100 -MinHeapFreeRatio) +(double)(CMSTriggerRatio* MinHeapFreeRatio) / 100.0)/ 100.0,MinHeapFreeRatio預設值為40,CMSTriggerRatio預設值為80。
  • 當Permanent Generation採用CMS收集且空間使用到一定比率觸發,Permanent Generation採用CMS收集需設定:-XX:+CMSClassUnloadingEnabled。 Hotspot V1.6中預設為92%,可通過CMSInitiatingPermOccupancyFraction來強制指定。同樣,它是根據如下公式計算出來的:((100 -MinHeapFreeRatio) +(double)(CMSTriggerPermRatio* MinHeapFreeRatio) / 100.0)/ 100.0,MinHeapFreeRatio預設值為40,CMSTriggerPermRatio預設值為80。
  • Hotspot根據成本計算決定是否需要執行CMS GC,可通過-XX:+UseCmsInitiatingOccupancyOnly來去掉這個動態執行的策略。
  • 外部呼叫System.gc,且設定了ExplicitGCIInvokesConcurrent或者ExplicitGCInvokesConcurrentAndUnloadsClasses。

3.2 HotSpot垃圾收集器

上圖即為HotSpot虛擬機器的垃圾收集器組成。

Serial收集器

  • -XX:+UserSerialGC引數開啟此收集器
  • Client模式下新生代預設的收集器。
  • 較長的stop the world時間
  • 簡單而高效

此收集器的一個工作流程如下如所示:

收集前:

收集後:

ParNew收集器

  • -XX:+UserParNewGC
  • +UseConcuMarkSweepGC時預設開啟
  • Serial收集器的多執行緒版本
  • 預設執行緒數與CPU數目相同
  • -XX:ParrallelGCThreads指定執行緒數目

對比Serial收集器如下圖所示:

Parallel Scavenge收集器

  • 新生代並行收集器
  • 採用Copy演算法
  • 主要關注的是達到可控制的吞吐量,“吞吐量優先”
  • -XX:MaxGCPauseMillis -XX:GCTimeRAtion兩個引數精確控制吞吐量
  • -XX:UseAdaptiveSizePolicy GC自適應調節策略
  • Server模式的預設新生代收集器

Serial Old收集器

  • Serial的老年代版本
  • Client模式的預設老年代收集器
  • CMS收集器的後備預案,Concurrent Mode Failure時使用
  • -XX:+UseSerialGC開啟此收集器

Parallel Old收集器

  • -XX:+UseParallelGC -XX:+UseParallelOldGC啟用此收集器
  • Server模式的預設老年代收集器
  • Parallel Scavenge的老年代版本,使用多執行緒和”mark-sweep”演算法
  • 關注點在吞吐量以及CPU資源敏感的場合使用
  • 一般使用Parallel Scavenge + Parallel Old可以達到最大吞吐量保證

CMS收集器

併發低停頓收集器

  • -XX:UseConcMarkSweepGC 開啟CMS收集器,(預設使用ParNew作為年輕代收集器,SerialOld作為收集失敗的垃圾收集器)
  • 以獲取最短回收停頓時間為目標的收集器,重視響應速度,希望系統停頓時間最短,會和網際網路應用。

四個步驟:

  • 初始標記 Stop the world: 只標記GC roots能直接關聯到的物件,速度很快。
  • 併發標記:進行GC roots tracing,與使用者執行緒併發進行
  • 重新標記 Stop the world:修正併發標記期間因程式繼續執行導致變動的標記記錄
  • 併發清除

對比serial old收集器如下圖所示:

CMS有以下的缺點:

  • CMS是唯一不進行compact的垃圾收集器,當cms釋放了垃圾物件佔用的記憶體後,它不會把活動物件移動到老年代的一端
  • 對CPU資源非常敏感。不會導致執行緒停頓,但會導致程式變慢,總吞吐量降低。CPU核越多越不明顯
  • 無法處理浮動垃圾。可能出現“concurrent Mode Failure”失敗, 導致另一次full GC ,可以通過調整-XX:CMSInitiatingOccupancyFraction來控制記憶體佔用達到多少時觸發gc
  • 大量空間碎片。這個可以通過設定-XX:UseCMSCompacAtFullCollection(是否在full gc時開啟compact)以及-XX:CMSFullGCsBeforeCompaction(在進行compact前full gc的次數)

G1收集器

G1演算法在Java6中還是試驗性質的,在Java7中正式引入,但還未被廣泛運用到生產環境中。它的特點如下:

  • 使用標記-清理演算法
  • 不會產生碎片
  • 可預測的停頓時間
  • 化整為零:將整個Java堆劃分為多個大小相等的獨立區域
  • -XX:+UseG1GC可以開啟此垃圾回收器
  • -XX:MaxGCPauseMillis=200可以設定最大GC停頓時間,當然JVM並不保證一定能夠達到,只是盡力。

3.3 調優經驗

  • 需要開啟gc日誌並讀懂gc日誌:-XX:PrintHeapAtGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamp -Xloggc:$CATALINA_BASE/logs/gc.log
  • 垃圾回收的最佳狀態是隻有young gc,也就是避免生命週期很長的物件的存在。
  • 從young gc開始,儘量給年青代大點的記憶體,避免full gc
  • 注意Survivor大小
  • 注意記憶體牆:4G~5G

GC日誌簡介

1403682.561: [GC [PSYoungGen: 1375104K->11376K(1386176K)] 4145665K->2782002K(4182400K), 0.0174410 secs] [Times: user=0.27 sys=0.00, real=0.02 secs]

  • 1403682.561:發生的時間點,JVM執行的時間長度,以度為單位,也可以格式化成固定的時間格式(使用-XX:+PrintGCDateStamps)
  • PSYoungGen:發生了何種型別的GC,此處代表發生了年輕代的GC
  • 1375104K:回收前的大小
  • 11376K:回收後的大小
  • 1386176K:YOUNG代的大小
  • 4145665 K:回收前總的佔用大小
  • 2782002K:回收後的佔用大小
  • 4182400K:總佔用大小
  • 0.0174410:垃圾收集停頓時間
  • 0.27和0.00:代表在使用者態(user)和系統狀(sys)的CPU執行時間
  • 0.02 secs:代表實際的GC的執行時間

注:上面實際GC的執行時間小於使用者態和系統態的時間總和,是由於前者僅指CPU的執行時間,包括等待或IO阻塞的時間,而現在的GC是採用多執行緒收集的,同時機器也是多個CPU,因此,大部分是二者之和要比前面的值大。如果是採用串形化收集器的話,二者時間幾乎相差不多。

老年代使用建議

  • Parallel GC(-XX:+UseParallel[Old]GC)
    • Parallel GC的minor GC時間是最快的, CMS的young gc要比parallel慢, 因為記憶體碎片
    • 可以保證最大的吞吐量
  • 確實有必要才改成CMS或G1(for old gen collections)

開發建議

  • 小物件allocate的代價很小,通常10個CPU指令;收集掉新物件也非常廉價;不用擔心活的很短的小物件
  • 大物件分配的代價以及初始化的代價很大;不同大小的大物件可能導致java堆碎片,尤其是CMS, ParallelGC 或 G1還好;儘量避免分配大物件
  • 避免改變資料結構大小,如避免改變陣列或array backed collections / containers的大小;物件構建(初始化)時最好顯式批量定陣列大小;改變大小導致不必要的物件分配,可能導致java堆碎片
  • 物件池可能潛在的問題
    • 增加了活物件的數量,可能增加GC時間
    • 訪問(多執行緒)物件池需要鎖,可能帶來可擴充套件性的問題
    • 小心過於頻繁的物件池訪問

GC的龐氏騙局

雖然GC在大多數情況下還是正常的,但有時候JVM也會發生欺騙你的場景, JVM不停的在垃圾回收,可是每次回收完後堆卻還是滿的,很明顯程式記憶體被使用完了,已經無法正常工作了,但JVM就是不丟擲OutOfMemoryError(OOM)這個異常來告訴程式設計師內部發出了什麼,只是不停的做老好人嘗試幫我們做垃圾回收,把伺服器的資源耗光了。

出現這種現象的一種典型情況就是GC的GCTimeLimit和GCHeapFreeLimit引數設定不合適。GCTimeLimit的預設值是98%,也就是說如果大於等於98%的時間都用花在GC上,則會丟擲OutOfMemoryError。GCHeapFreeLimit是回收後可用堆的大小,預設值是2%,也就是說只要有多餘2%的記憶體可用就認為此次gc是成功的。如果GCTimeLimit設定過大或者GCHeapFreeLimit設定過小那麼就會造成GC的龐式騙局,不停地進行垃圾回收。

四. Java7、8帶來的一些變化

  • Java7帶來的記憶體方面的一個很大的改變就是String常量池從Perm區移動到了Heap中。呼叫String的intern方法時,如果存在堆中的物件,則會直接儲存物件的引用,而不會重新建立物件。
  • Java7正式引入G1垃圾收集器用於替換CMS。
  • Java8中,取消掉了方法區(永久代),使用“元空間”替代,元空間只與系統記憶體相關。
  • Java 8 update 20所引入的一個很棒的優化就是G1回收器中的字串去重(String deduplication)。由於字串(包括它們內部的char[]陣列)佔用了大多數的堆空間,這項新的優化旨在使得G1回收器能識別出堆中那些重複出現的字串並將它們指向同一個內部的char[]陣列,以避免同一個字串的多份拷貝,那樣堆的使用效率會變得很低。可以使用-XX:+UseStringDedup