Java 內存從分配到泄露

引言

  如果你被問及Android內存泄露,而你只能背出幾種常見的內存泄露的場景以及解決方案,卻不能從更深層次的原理上去解釋它,那麼是時候補一補Java虛擬機的基礎知識了。文章較長,請耐心閱讀,相信會有所收穫。

Java內存區域劃分

  許多程序員將Java內存簡單粗暴地分爲棧內存堆內存,事實上,它至少可以分爲堆、棧、方法區、本地方法棧等部分。借用《深入理解Java虛擬機》一書中的一張圖,看圖說話。
Java內存區域劃分

  • 棧(Stack):
      圖中稱作虛擬機棧(VM Stack),這裏簡稱棧。棧內存用作在方法執行時創建棧幀(Stack Frame)存放局部變量表、操作數棧、動態鏈接、方法出口等。而局部變量表存放基本數據類型、對象引用、returnAddress類型的數據。方法調用時棧幀進棧,執行結束時棧幀出棧,局部變量自動銷燬,不需要GC。想一想遞歸調用的“棧溢出”就很好理解了,以及Debug時查看的調用棧。棧內存是線程私有的,即每條線程都有獨立的存儲,各條線程之間互不影響。棧內存的特點是分配效率高但是容量有限。

  • 堆(Heap):
      主要存放對象實例和數組,是線程共享的。堆內存中的對象不會隨着方法執行結束而銷燬,需要GC。它的特點是容量大,是Java內存管理中最大的一塊,是GC主要區域,也是內存泄露的主要區域。

  • 方法區(Mehtod Area):
      很多人習慣稱之爲靜態區,它存放的是類信息、常量、靜態變量、JIT編譯後的代碼數據等。方法區是線程共享的,需要GC。

  • 本地方法棧(Native Mehtod Stack):
      類似棧,線程私有,不需要GC,區別是它服務於JNI。

  • 程序計數器(Computer Counter Register):
      當前線程所執行的字節碼的行號指示器,線程私有,不需要GC。

由以上劃分可知,當我們編寫的一個類被加載後,類信息、靜態變量、常量等放在方法區;代碼中的A a=new A( ),A a部分放在棧中,new A( )部分放在堆中;int b這樣的變量也放在棧中。本地方法棧和程序計數器我們不做過多討論,重點關注棧、堆、和方法區。 

對象的創建過程

稍微瞭解一下對象的創建過程。它大致分爲4個步驟:

  1. 檢查類是否被加載,如果未加載則先加載
  2. 上爲新生對象分配內存 
  3. 設置對象Header,如對象HashCode、GC分代年齡、類型指針等
  4. 執行《init》方法初始化 

Java內存回收機制

  內存區域劃分好了,對象和變量創建好了,程序運行着,部分方法已經執行完了,那麼哪些內存需要回收?何時回收?如何回收?已知所有的對象實例和數組都要在堆上分配,那我們重點關注這塊最大的內存是如何回收的。

堆的回收

  C++中,程序員在一個對象使用完畢時手動free/delete並賦值爲null來回收對象佔用的內存。Java使用衆所周知的垃圾回收器來回收對象,那麼它如何確定一個對象已經完成使命,可以被回收了呢?

引用計數算法

  最簡單的方法是給每個對象維護一個引用計數器,每當有一個地方引用它時,對應的計數器加1;引用失效時,計數器減1;當計數器小於等於0時,對象就可以被回收了。
  引用計數算法簡單高效,但它最大的問題是無法解決對象之間的循環引用問題。比如以下代碼:

A a =new A();
B b =new B();
a.x=b;
b.x=a;
a=null;
b=null;

循環引用示意圖
  如上圖右半部分,“A的實例”持有“B的實例”的引用,“B的實例”持有“A的實例”的引用。但是沒有其他任何地方引用這兩個實例。如果採用引用計數法,它們的引用計數器都不爲0,垃圾回收器一直都不會回收它們;但實際上系統中並不需要再次使用這兩個對象,這勢必造成內存泄露——佔着茅坑不拉屎。假如系統中有大量的這種循環引用的對象存在,並且每個這樣的對象佔用的內存都較大,那麼系統很快就會因爲內存溢出而崩潰。

可達性分析算法

  Java虛擬機採用可達性分析算法來判定對象的存活與否。它應用圖論的思想,堆中的每個對象實例都是圖的一個頂點,一個對象對另一個對象的引用是一條有向邊。在圖的所有頂點中,以一系列稱爲“GC Roots”的頂點作爲起始點,沿着有向邊向下搜索,走過的路徑成爲引用鏈(Reference Chain)。當起始點集合到某個頂點不可達,即GC Roots到某個對象不存任何一條引用鏈時,則證明此對象是不可用的,可以回收。如下圖右半部分,可達性分析算法可以解決對象間循環引用的問題。
這裏寫圖片描述
  Java中可作爲GC Roots的對象包括以下幾種:

  1. 棧中引用的對象
  2. 方法區中靜態熟悉引用的對象
  3. 方法區中常量引用的對象
  4. 本地方法棧中JNI引用的對象

  到這裏,我們已經可以簡單解釋內存泄露了。原本應該正常回收的對象,如上圖右半部分,如果因爲某種原因,變成上圖左半部分一樣,變成GC Roots可達了,那麼這些對象就不會被及時回收,造成資源浪費,是爲內存泄露。好比是小明用過的廁所,本來應該把門打開,其他人可以接着使用,但是小明不再繼續使用,卻把門鎖了,別人用不了。小明是GC Roots或者和GC Roots連通的對象,廁所是應該回收的對象所佔的內存資源。
  實際的可達性分析算法比以上分析複雜地多,需要結合Java的強、軟、弱、虛四種引用來解釋。內存泄露的防治也離不開軟引用和弱引用的使用。這個話題將在其他博文中詳細闡述。

方法區的回收

  方法區的回收極少被人提及,更不會像堆內存的回收一樣被大衆討論,但它確實是客觀存在。回看方法區存放的內容:類信息、常量、靜態變量、JIT編譯後的代碼數據等,後兩者的生命週期貫穿整個應用程序,因此方法區內存的回收內容主要是廢棄常量和無用的類。

廢棄常量

  回收廢棄常量與回收堆中的對象非常類似,不展開論述。

無用的類

  回收無用的類即類的卸載,對應於類的加載。滿足以下條件的類纔可算作無用的類:

  1. 該類所有實例都已被回收,即Java堆中不存在該類的任何實例。
  2. 加載該類的ClassLoader已經被回收。
  3. 該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

垃圾回收算法

  以上只談到需要回收的內存區域(堆和方法區)中如何判定一個對象可以回收,即內存中哪些是垃圾(Garbage),並未涉及如何進行垃圾回收(Garbage Collection,GC)。這部分內容非常類似《操作系統》或《計算機組成原理》中的存儲單元的分配、尋址、清除、替換等操作。不外乎是幾種常見的算法,由易到難,最終的實現因廠家而異。一般是難易搭配,集各種算法的有點於一身,但又有所折衷,以實現某幾大因素(如時間、空間)的平衡。

標記 - 清除法

  • 先標記所有需要回收的對象,然後統一清除。
  • 優點:算法簡單
  • 缺點:
  • 時間上:標記和清除的效率都不高
  • 空間上:標記清除後會產生大量不連續的內存碎片

複製算法

  • 將內存分爲大小相等的兩塊,每次只使用其中的一塊,用完後將還存活的對象複製到另外一塊,然後把使用過的那塊一次清理掉。
  • 優點:每次回收整個半區,算法簡單;時間上快速高效,空間上不會產生碎片。
  • 缺點:
    • 空間上,內存容量減半,利用率低。
    • 對象存活率高時複製次數增加,效率降低。

標記 - 整理法

  • 標記 - 整理 - 清除:先標記所有需要回收的對象,將所有存活對象移到一端,然後清除端邊界以外的內存。
  • 優缺點都類似標記 - 清除法,外加移動對象的開銷。

分代回收算法

  • 根據對象存活週期講內存劃分新生代、老年代等幾塊,根據不同年代的特點採用適當的算法。
  • 新生代對象存活率低,採用複製算法老年代對象存活率高,採用標記 - 清理算法或者標記 - 整理算法。

垃圾回收器種類

  計算機中常把完成某類功能的程序叫做某某器,英語中就是XXX-er。比如完成編譯功能的程序叫編譯器Compiler,完成調試功能的程序叫調試器Debugger,以及此處完成垃圾回收功能的Garbage Collector。

Serial

  • 最基本、歷史最悠久
  • 單線程收集器
  • 使用一個CPU或一條線程進行垃圾回收
  • 它進行垃圾回收時,必須暫停其他所有工作線程,Stop The World,直到本次回收結束。
  • Stop The World,保潔阿姨在幫你打掃辦公室時,你需要暫停一切工作離開房間,並且不繼續亂扔垃圾,直到打掃結束。

ParNew

  • Serial回收器的多線程版

Parallel Scavenge

  • 新生代收集器,多線程
  • 高吞吐量,高CPU利用率

Serial Old

  • Serial回收器的老年代版本,單線程。

Parallel Old

  • Parallel Scavenge的老年代版本,多線程。

CMS

  • 回收停頓時間最短,併發

G1

  • 較新較牛逼

總結

  Java內存劃分爲棧、堆、方法區等區域,其中棧保存的是方法的局部變量,隨方法起隨方法滅,不需要GC;堆保存所有對象的實例和數組,是GC和泄露的重點區;方法區保存的是類信息、常量、靜態變量等靜態信息,也需要GC。
  堆內存的回收中,判斷對象存活的算法有引用計數算法和可達性分析算法,引用計數算法無法解決對象間循環引用的問題,虛擬機通常採用可達性分析算法。
  常見的垃圾回收算法有:標記 - 清除法、複製算法、標記 - 整理法、分代回收算法。常見的垃圾回收器種類有:Serial、ParNew、Parallel Scavenge等。
  以下內容請聽下回分解:

  • Java強、軟、弱、虛四種引用詳解
  • 內存泄露的定義、與內存溢出的區別和聯繫、內存泄露產生的原因
  • Java和Android常見的內存泄露的情景和解決方案
  • Android內存泄露檢測
  • 防止內存泄露和內存優化的最佳實踐等等
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章