談談Java內存管理

原文地址:點擊打開鏈接

目錄

java

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

一. 背景知識

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

  1. Google: c/c++ python java js,不得不提的是Google貢獻給java社區的guava包質量非常高。
  2. Youtube、豆瓣: python
  3. fackbook、yahoo、flickr、新浪:php(優化過的php)
  4. 網易、阿里、搜狐: java、php、node.js
  5. Twitter: ruby->java,之所以如此就在於與jvm相比,Ruby的runtime是非常慢的。並且ruby的應用比起java還是比較小衆的。

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

對於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。

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

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

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

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
  • 垃圾回收的最佳狀態是隻有young gc,也就是避免生命週期很長的對象的存在。
  • 從young gc開始,儘量給年青代大點的內存,避免full gc
  • 注意Survivor大小
  • 注意內存牆:4G~5G

GC日誌簡介

  • 第一個箭頭:35592K->1814K(36288K),箭頭指向的是新生段的內存佔用情況; - 第二個箭頭:38508K->7792K(520256K),箭頭指向的是回收後的內存佔用情況。
  • 垃圾收集停頓時間:0.0336

老年代使用建議

  • 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時間
    • 訪問(多線程)對象池需要鎖,可能帶來可擴展性的問題
    • 小心過於頻繁的對象池訪問

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

  • Java7帶來的內存方面的一個很大的改變就是String常量池從Perm區移動到了Heap中。調用String的intern方法時,如果存在堆中的對象,則會直接保存對象的引用,而不會重新創建對象。
  • Java7正式引入G1垃圾收集器用於替換CMS。
  • Java8中,取消掉了方法區(永久代),使用“元空間”替代,元空間只與系統內存相關。
  • Java 8 update 20所引入的一個很棒的優化就是G1回收器中的字符串去重(String deduplication)。由於字符串(包括它們內部的char[]數組)佔用了大多數的堆空間,這項新的優化旨在使得G1回收器能識別出堆中那些重複出現的字符串並將它們指向同一個內部的char[]數組,以避免同一個字符串的多份拷貝,那樣堆的使用效率會變得很低。可以使用-XX:+UseStringDeduplication這個JVM參數來試一下這個特性。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章