天天看點

談談Java記憶體管理

一. 背景知識

二. Jvm虛拟機記憶體簡介

三. 垃圾收集

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

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

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

Google: C/C++ Go Python Java JavaScript,不得不提的是Google貢獻給java社群的guava包品質非常高,非常值得學習和使用。

Youtube、豆瓣: Python

Fackbook、Yahoo、Flickr、新浪:php(優化過的php vm)

網易、阿裡、搜狐: Java、PHP、Node.js

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來講。

Java的運作時記憶體組成如下圖所示:

其中,對于這各個部分有一些是線程私有的,其他則是線程共享的。

線程私有的如下:

程式計數器

目前線程所執行的位元組碼的行号訓示器

Java虛拟機棧

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

每個線程都有自己獨立的棧空間

線程棧隻存基本類型和對象位址

方法中局部變量線上程空間中

本地方法棧

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

線程共享的如下:

Java堆

存放對象執行個體,幾乎所有的對象執行個體以及其屬性都在這裡配置設定記憶體。

方法區

存儲已經被虛拟機加載的類資訊、常量、靜态變量、JIT編譯後的代碼等資料。

運作時常量池

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

直接記憶體

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

Java是面向對象的一種程式設計語言,那麼如何通過引用來通路對象呢?一般有兩種方式:

通過句柄通路

直接指針

此種方式也是HotSpot虛拟機采用的方式。

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

虛拟機棧和本地方法棧溢出

StackOverflowError: 線程請求的棧深度大于虛拟機所允許的最大深度(循環遞歸)

OutOfMemoryError: 虛拟機在擴充棧是無法申請到足夠的記憶體空間,一般可以通過不停地建立線程引起此種情況

Java堆溢出: 當建立大量對象并且對象生命周期都很長的情況下,會引發OutOfMemoryError

運作時常量區溢出:OutOfMemoryError:PermGen space,這裡一個典型的例子就是String的intern方法,當大量字元串使用intern時,會觸發此記憶體溢出

方法區溢出:方法區存放Class等中繼資料資訊,如果産生大量的類(使用cglib),那麼就會引發此記憶體溢出,OutOfMemoryError:PermGen space,在使用Hibernate等架構時會容易引起此種情況。

在通常情況下,我們掌握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的垃圾回收來說,不同的垃圾回收器會不同程度地影響這兩個名額。例如:并行的垃圾收集器,其保證的是吞吐量,會在一定程度上犧牲響應時間。而并發的收集器,則主要保證的是請求的響應時間。

找出堆中活着的對象

釋放死對象占用的資源

定期調整活對象的位置

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

記憶體消耗:copy需要兩倍的最大live set記憶體;mark-sweep則隻需要一倍。

效率上: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。

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

-XX:+UserSerialGC參數打開此收集器

Client模式下新生代預設的收集器。

較長的stop the world時間

簡單而高效

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

收集前:

收集後:

-XX:+UserParNewGC

+UseConcuMarkSweepGC時預設開啟

Serial收集器的多線程版本

預設線程數與CPU數目相同

-XX:ParrallelGCThreads指定線程數目

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

新生代并行收集器

采用Copy算法

主要關注的是達到可控制的吞吐量,“吞吐量優先”

-XX:MaxGCPauseMillis -XX:GCTimeRation兩個參數精确控制吞吐量

-XX:UseAdaptiveSizePolicy GC自适應調節政策,預設開啟

Server模式的預設新生代收集器

Serial的老年代版本

Client模式的預設老年代收集器

CMS收集器的後備預案,Concurrent Mode Failure時使用

-XX:+UseSerialGC開啟此收集器

-XX:+UseParallelGC -XX:+UseParallelOldGC啟用此收集器

Server模式的預設老年代收集器

Parallel Scavenge的老年代版本,使用多線程和”mark-sweep”算法

關注點在吞吐量以及CPU資源敏感的場合使用

一般使用Parallel Scavenge + Parallel Old可以達到最大吞吐量保證

并發低停頓收集器

-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算法在Java6中還是試驗性質的,在Java7中正式引入,但還未被廣泛運用到生産環境中。它的特點如下:

使用标記-清理算法

不會産生碎片

可預測的停頓時間

化整為零:将整個Java堆劃分為多個大小相等的獨立區域

-XX:+UseG1GC可以打開此垃圾回收器

-XX:MaxGCPauseMillis=200可以設定最大GC停頓時間,當然JVM并不保證一定能夠達到,隻是盡力。

需要打開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

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

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

Java7帶來的記憶體方面的一個很大的改變就是String常量池從Perm區移動到了Heap中。調用String的intern方法時,如果存在堆中的對象,則會直接儲存對象的引用,而不會重新建立對象。

Java7正式引入G1垃圾收集器用于替換CMS。

Java8中,取消掉了方法區(永久代),使用“元空間”替代,元空間隻與系統記憶體相關,也可以通過MaxMeatSize設定,防止無限耗盡系統記憶體。

Java 8 update 20所引入的一個很棒的優化就是G1回收器中的字元串去重(String deduplication)。由于字元串(包括它們内部的char[]數組)占用了大多數的堆空間,這項新的優化旨在使得G1回收器能識别出堆中那些重複出現的字元串并将它們指向同一個内部的char[]數組,以避免同一個字元串的多份拷貝,那樣堆的使用效率會變得很低。可以使用-XX:+UseStringDeduplication這個JVM參數來試一下這個特性。

原文出處:後端技術雜談

<a href="http://www.rowkey.me/blog/2016/05/07/javamm/" target="_blank">原文連結</a>

轉載請與作者聯系,同時請務必标明文章原始出處和原文連結及本聲明。