Java記憶體模型,垃圾回收機制,常用記憶體命令及工具

Chaexsy發表於2019-04-06

1. Java簡介

JAVA 語言是一門非常純粹的物件導向程式語言, 它吸收了 C++ 語言的各種優點, 又摒棄了 C++ 裡難以理解的多繼承、指標等概念, 因此 JAVA 語言具有功能強大和簡單易用兩個特徵。

Sun 公司在 1995年年初發布了 JAVA 語言,並在 1996年年初發布了 JDK 1.0,這個版本包括兩部分:

  • 執行環境(即 JRE, Java Runtime Environment)
  • 開發環境(即JDK, Java Development Kit)。

JDK包含了JRE,同時還包含了編譯java原始碼的編譯器javac,還包含了很多java程式除錯和分析的工具:jconsole,jvisualvm等工具軟體,還包含了java程式編寫所需的文件和demo例子程式。

image

1.1 Java版本時間線

image

1.2 參考資料

作者Daniel-廣:Java系列筆記(3) - Java 記憶體區域和GC機制(www.cnblogs.com/zhguang/p/3…

2. Java記憶體區域

如圖所示,Java虛擬機器所管理的記憶體主要包含以下幾個執行時資料區域:

  1. 程式計數器
  2. Java虛擬機器棧
  3. 本地方法棧
  4. Java堆
  5. 方法區(非堆)
  6. 執行時常量池
  7. 直接記憶體

image

2.1 程式計數器(Program Counter Register)

程式計數器是一塊較小的記憶體空間,它可以看做是當前執行緒執行的位元組碼的行號指示器。

每個執行緒都需要一個獨立的程式計數器,各條執行緒之間計數器互不影響,獨立儲存,為“執行緒私有”,是唯一一個在Java虛擬機器規範中沒有規定任何OutOfMemoryError情況的區域。

情景:

  • 當執行緒正在執行一個java方法,則這個計數器記錄的是正在執行的虛擬機器位元組碼指令的地址
  • 正在執行的是Native方法,則這個計數器值為空

2.2 Java虛擬機器棧(Java Virtual Machine Stacks)

與程式計數器一樣,Java虛擬機器棧也是執行緒私有的,它的生命週期與執行緒相同。

通常人們把Java記憶體分為堆記憶體和棧記憶體,這種分法比較粗糙,實際的記憶體區域遠比這種複雜,而這裡所說的棧就是我們的虛擬機器棧。

每個方法在執行時都會建立一個棧幀,用於儲存區域性變數表、運算元棧、動態連結、方法出口等資訊。

區域性變數表存放基本資料型別、物件應用型別和returnAddress型別。

2.3 本地方法棧(Native Method Stack)

本地方法棧(Native Method Stack)與虛擬機器棧的作用非常類似,區別在於虛擬機器棧為虛擬機器執行Java方法服務,本地方法棧則為Native方法提供服務。由於兩者的區別不大,有的虛擬機器甚至把兩者合二為一。

下描繪了這種情況,就是當一個執行緒呼叫一個本地方法時,本地方法又回撥虛擬機器中的另一個Java方法。這幅圖展示了java虛擬機器內部執行緒執行的全景圖。

一個執行緒可能在整個生命週期中都執行Java方法,操作他的Java棧;或者他可能毫無障礙地在Java棧和本地方法棧之間跳轉。

image

上圖所示,該執行緒首先呼叫了兩個Java方法,而第二個Java方法又呼叫了一個本地方法,這樣導致虛擬機器使用了一個本地方法棧。

圖中的本地方法棧顯示為 一個連續的記憶體空間。

假設這是一個C語言棧,期間有兩個C函式,他們都以包圍在虛線中的灰色塊表示。

第一個C函式被第二個Java方法當做本地方法呼叫,而這個C函式又呼叫了第二個C函式。之後第二個C函式被第二個Java方法當做本地方法呼叫,而這個C函式又呼叫了第二個C函式。之後第二個C函式又通過 本地方法介面回撥了一個Java方法(第三個Java方法)。最終這個Java方法又呼叫了一個Java方法(他成為圖中的當前方法)。

2.4 Java堆(Java Heap)

Java堆是Java虛擬機器所管理的記憶體中最大的一塊,此記憶體用於存放物件例項,幾乎所有的物件例項都會在這裡分配記憶體。

Java堆是垃圾收集器管理的主要區域,因此很多時候也會被稱作“GC堆”,可分為新生代、老年代,再細可分為Eden區、Form Survivor區、To Survivor區等。

可通過控制-Xmx和Xms引數來控制大小。

2.5 方法區(Method Area)

方法區與Java堆一樣, 是各個執行緒共享的記憶體區域,方法區用於存放Class的相關資訊,如:類名,訪問修飾符,常量池,字元描述,方法描述等。

這個區域的記憶體回收目標主要是針對常量池的回收和對型別的解除安裝,為了與堆記憶體區分開來,也被稱為非堆。

2.6 執行時常量池(Runtime Constant Pool)

它是方法區的一部分。class檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊就是常量池,存放編譯期生成的各種字面量和符號引用,這部分內容會在類載入後進入方法區的執行時常量池中存放。

2.7 直接記憶體(Direct Memory)

並不是虛擬機器執行時資料區的一部分,也不是Java虛擬機器規範中定義的記憶體區域,但是這部分記憶體也會被頻繁的使用。

情景:

在JDK1.4中加入的NIO類,引入了基於通道(Channel)與緩衝區(Buffer)的I/O方式,他可以使用Native函式庫直接分配堆外記憶體,然後通過一個儲存在Java堆中的DirectByteBuffer物件作為這塊記憶體的引用進行操作,從而避免了在Java堆和Native堆中來回複製資料的操作,提高了效率。

3. 程式舉例

3.1 Java堆記憶體溢位

Java堆用於儲存物件例項,只要不斷地建立物件就可以使它報出OutOfMemory異常。

public class HeapOOM {

    static class OOMObject{}

    public static void main(String[] args) {
        List<OOMObject> list = new LinkedList<OOMObject>();
        while (true) {
            list.add(new OOMObject());
        }
    }
}
複製程式碼

當出現Java堆記憶體溢位時,異常堆疊資訊“java.lang.OutOfMemoryError”會跟著進一步提示“java heap space”。

image

3.2 棧記憶體溢位

什麼時候會讓 Java Method Stack 棧溢位啊?棧的基本特點就是 FILO(First In Last Out),如果 in 的太多而 out 的太少,就好 overflow 了。而 Java Method Stack 的功能就是儲存每一次函式呼叫時的“現場”,即為入棧,函式返回就對應著出棧,所以函式呼叫的深度越大,棧就變得越大,足夠大的時候就會溢位。所以模擬 Java Method Stack 溢位,只要不斷遞迴呼叫某一函式就可以。

public class JavaVMStackSOF {

    private int stackLength = 1;
    
    public void stackLeak(){
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try{
	        oom.stackLeak();
        } catch(Throwable e){
	        System.out.println("stack length:"+oom.stackLength);
	         e.printStackTrace();
        }
    }
}

複製程式碼

執行結果如下圖:

image

3.3 方法區溢位

方法區用於存放Class的相關資訊,如:類名,訪問修飾符,常量池,字元描述,方法描述等。

對於這個區域的測試,基本思路是執行時產生大量的類去填滿方法區,直到溢位。

每個類都設定了一個定時器,目的是讓類處於執行狀態,否則類new 出來沒有作用很快就會被垃圾回收器回收,方法區就不會溢位了。

這個例子由於建立了大量定時器,有時候會因此執行緒過多拋異常,並不是每次都會方法區溢位,大家可以想想有沒有其他更好的方法,一起交流。

package com.sigar.practice.jvm;


import java.util.Timer;
import java.util.TimerTask;

/**
 * 測試方法區溢位
 */
public class JavaMethodAreaOOM {

    public static void main(String[] args) {
        while(true){
            Car car = new Car(1, "VOLVO", 5, 1, 1, "S90");
            car.run();
        }
    }
}

class Car{
    private int type;
    private String company;
    private int engine;
    private int framework;
    private int color;
    private String model;

    private String address = "銀河系左下角的懸臂上的閃耀太陽系中離太陽第3近的最美麗的星球地球北半球中華人民共和國浙江省杭州市大江東開發區積極極極極極極極極極極極極極極極極極極極極極極極極極極極極極極極極極極極極極極極極極極極極汽車廠";

    private Timer timer;

    public Car(int type, String company, int engine, int framework, int color, String model){
        this.type = type;
        this.company = company;
        this.engine = engine;
        this.framework = framework;
        this.color = color;
        this.model = model;

        this.timer = new Timer();
        //延遲1000ms執行程式
        this.timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("Running..." + this.scheduledExecutionTime());
            }
        }, 0,2000);
    }

    public void run(){

    }

    public int getType() {
        return type;
    }

    public void setType(int type) {
        this.type = type;
    }

    public String getCompany() {
        return company;
    }

    public void setCompany(String company) {
        this.company = company;
    }

    public int getEngine() {
        return engine;
    }

    public void setEngine(int engine) {
        this.engine = engine;
    }

    public int getFramework() {
        return framework;
    }

    public void setFramework(int framework) {
        this.framework = framework;
    }

    public int getColor() {
        return color;
    }

    public void setColor(int color) {
        this.color = color;
    }

    public String getModel() {
        return model;
    }

    public void setModel(String model) {
        this.model = model;
    }
}


複製程式碼

執行結果:

image

4. Java的垃圾收集器

Java相對於C++等其他語言,有一個很大的區別就是記憶體的使用和回收。Java不需要解構函式等記憶體回收方法,基本完全由垃圾收集器來進行記憶體回收。

4.1 如何判斷物件已死

堆是垃圾回收演算法的主要工作地點,其他的區域(例如方法區),總會因為各種原因,導致垃圾回收演算法的效率和價值不大。 判斷物件已經已經死亡是垃圾回收演算法主要解決的問題,也出現了很多不同的思路。

以下是兩種最重要的垃圾回收演算法。

4.1.1 垃圾回收演算法 - 引用計數演算法

引用計數演算法的實現很簡單,判斷效率也很高,在大部分情況下是一個很不錯的演算法。思路如下:

給一個物件新增一個引用計數器,每當有一個地方引用它,計數器就加1;當引用失效時,計數器就減1。當計數器為0時,就代表該物件已經失效,就可以被回收。

該演算法有一個很嚴重的問題,就是它無法解決物件間互相引用的情況,如A中有一個B型別的屬性,而B中又有A型別的屬性,這就導致這兩個物件的引用計數器永遠不會為0,從而永遠無法被垃圾收集器回收。

4.1.2 垃圾回收演算法 - 可達性分析演算法

在主流的商用程式語言的主流實現中,都是通過該演算法來判斷物件是否存活。基本思想如下:

通過一系列被稱為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋走過的路徑被稱為引用鏈,當一個物件到GC Roots沒有任何引用鏈相連時,該物件是不可用的。

image

4.2 引用

無論是引用計數法還是可達性分析演算法,都離不開“引用” 的概念。

在JDK1.2之後,Java對引用的概念進行了補充,將引用分為強引用、軟引用、弱引用、虛引用四種,由強到弱以此如下:

  • 強引用:類似於 Object obj = new Object();只要強引用還在,垃圾收集器就不會回收掉該物件;
  • 軟引用:用來描述一下還有用但非必要的物件,在系統將要發生記憶體溢位異常之前,會把這些物件列進回收範圍中進行第二次回收,如果此後還是沒有足夠的記憶體,才會丟擲記憶體溢位異常;
  • 弱引用:被弱引用關聯的物件只能生存到下一次垃圾收集器之前。無論當前記憶體是否足夠,都會將它回收掉;
  • 虛引用:一個物件是否有虛引用的存在,完全不會對其生存時間產生影響,也無法通過虛引用來取得一個物件例項。它存在的唯一目的就是這個物件被收集器回收時能收到一個系統通知。

4.3 finalize關鍵字

當物件沒有引用到達時,並不會直接把物件回收,而是讓它們暫時處於“緩刑”階段。

可達性回收演算法會進行一次篩選,篩選的條件就是該物件是否有必要呼叫它的finalize方法,因此finalize方法則是物件拯救自己的唯一機會(只需要在方法中把物件的引用重新連上即可)。

如果回收演算法認為已經沒有必要執行該方法,則會將該物件回收掉。

4.4 垃圾收集演算法

在判斷完了物件已經死亡之後,就該把物件記憶體回收掉了,那又有哪些垃圾收集的演算法呢?

4.4.3 垃圾收集演算法 - 標記清除演算法

它的做法是當堆中的有效記憶體空間(available memory)被耗盡的時候,就會停止整個程式(也被成為stop the world),然後進行兩項工作,第一項則是標記,第二項則是清除。

下面LZ具體解釋一下標記和清除分別都會做些什麼。

標記:標記的過程其實就是,遍歷所有的GC Roots,然後將所有GC Roots可達的物件標記為存活的物件。

清除:清除的過程將遍歷堆中所有的物件,將沒有標記的物件全部清除掉。

其實這兩個步驟並不是特別複雜,也很容易理解。用通俗的話解釋一下標記/清除演算法,就是當程式執行期間,若可以使用的記憶體被耗盡的時候,GC執行緒就會被觸發並將程式暫停,隨後將依舊存活的物件標記一遍,最終再將堆中所有沒被標記的物件全部清除掉,接下來便讓程式恢復執行。

image

如上圖所示,該演算法有兩個缺陷,一個是標記和清除的效率都不高,二是清除完成之後會產生大量的記憶體碎片,導致以後的程式在生成較大的物件時,無法找到足夠的連續記憶體而不得不提前出發另一次垃圾收集動作

4.4.3 垃圾收集演算法 - 複製演算法

為了解決效率問題,複製演算法出現了。

image

將記憶體分為兩塊,當一塊滿了之後,把這一塊中還存活的物件拷貝到另一塊中。這就解決了記憶體碎片的問題,也提高了效率,但是代價就是犧牲了一半的記憶體,有點太大了

4.4.4 垃圾收集演算法 - 標記整理演算法

複製演算法中,如果存活的物件很多,那就需要複製很多次,效率也會隨之降低,標記-整理演算法因此出現了。

image

說白了就是把存活的物件往記憶體的一段移動,然後清理掉端邊界以外的記憶體。

4.4.5 垃圾收集演算法 - 分代收集演算法

並不是什麼新的演算法,而是根據物件的存活週期,將記憶體分塊,使用上述的幾種演算法進行回收。

前面說到了將堆分成Eden區、From Survivor區和To Survivor區,就是這個思想的體現。

大多數情況下,物件在新生代Eden去中分配。當Eden區沒有足夠的空間進行分配時,虛擬機器會執行一次Minor GC。

5. 堆記憶體分配和回收機制

Java的記憶體分配和回收主要發生在堆上,所以我們只說堆記憶體。

Java記憶體分配和回收的機制概括的說,就是:分代分配,分代回收。

物件將根據存活的時間被分為:年輕代(Young Generation)、年老代(Old Generation)、永久代(Permanent Generation,也就是方法區)。

如下圖(來源於《成為JavaGC專家part I》,www.importnew.com/1993.html):

image

5.1 年輕代(Young Generation)

物件被建立時,記憶體的分配首先發生在年輕代(大物件可以直接被建立在年老代),大部分的物件在建立後很快就不再使用,因此很快變得不可達,於是被年輕代的GC機制清理掉(IBM的研究表明,98%的物件都是很快消亡的),

這個GC機制被稱為Minor GC或叫Young GC。注意,Minor GC並不代表年輕代記憶體不足,它事實上只表示在Eden區上的GC。

年輕代上的記憶體分配是這樣的,年輕代可以分為3個區域:

  • Eden區(伊甸園,亞當和夏娃偷吃禁果生娃娃的地方,用來表示記憶體首次分配的區域,再貼切不過)
  • 兩個存活區(Survivor 0 、Survivor 1)。

記憶體分配過程為(來源於《成為JavaGC專家part I》,www.importnew.com/1993.html):

image

  1. 絕大多數剛建立的物件會被分配在Eden區,其中的大多數物件很快就會消亡。Eden區是連續的記憶體空間,因此在其上分配記憶體極快;
  2. 最初一次,當Eden區滿的時候,執行Minor GC,將消亡的物件清理掉,並將剩餘的物件複製到一個存活區Survivor0(此時,Survivor1是空白的,兩個Survivor總有一個是空白的);
  3. 下次Eden區滿了,再執行一次Minor GC,將消亡的物件清理掉,將存活的物件複製到Survivor1中,然後清空Eden區;
  4. 將Survivor0中消亡的物件清理掉,將其中可以晉級的物件晉級到Old區,將存活的物件也複製到Survivor1區,然後清空Survivor0區;
  5. 當兩個存活區切換了幾次(HotSpot虛擬機器預設15次,用-XX:MaxTenuringThreshold控制,大於該值進入老年代,但這只是個最大值,並不代表一定是這個值)之後,仍然存活的物件(其實只有一小部分,比如,我們自己定義的物件),將被複制到老年代。

從上面的過程可以看出,Eden區是連續的空間,且Survivor總有一個為空。

經過一次GC和複製,一個Survivor中儲存著當前還活著的物件,而Eden區和另一個Survivor區的內容都不再需要了,可以直接清空,到下一次GC時,兩個Survivor的角色再互換。

因此,這種方式分配記憶體和清理記憶體的效率都極高,這種垃圾回收的方式就是著名的“停止-複製(Stop-and-copy)”清理法(將Eden區和一個Survivor中仍然存活的物件拷貝到另一個Survivor中),這不代表著停止複製清理法很高效,其實,它也只在這種情況下高效,如果在老年代採用停止複製,則挺悲劇的。

5.2 年老代(Old Generation)

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

可以使用-XX:+UseAdaptiveSizePolicy開關來控制是否採用動態控制策略,如果動態控制,則動態調整Java堆中各個區域的大小以及進入老年代的年齡。   如果物件比較大(比如長字串或大陣列),Young空間不足,則大物件會直接分配到老年代上(大物件可能觸發提前GC,應少用,更應避免使用短命的大物件)。

用-XX:PretenureSizeThreshold來控制直接升入老年代的物件大小,大於這個值的物件會直接分配在老年代上。

可能存在年老代物件引用新生代物件的情況,如果需要執行Young GC,則可能需要查詢整個老年代以確定是否可以清理回收,這顯然是低效的。解決的方法是,年老代中維護一個512 byte的塊——”card table“,所有老年代物件引用新生代物件的記錄都記錄在這裡。Young GC時,只要查這裡即可,不用再去查全部老年代,因此效能大大提高。

5.3 方法區(永久代):

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

  • 類的所有例項都已經被回收
  • 載入類的ClassLoader已經被回收
  • 類物件的Class物件沒有被引用(即沒有通過反射引用該類的地方)

6. 常用記憶體命令及工具

jps		與UNIX命令類的ps命令類似,可以列出正在執行的虛擬機器程式
jstat		監視虛擬機器各種執行狀態資訊
jmap		記憶體映像工具
jstack		Java堆疊跟蹤工具
jconsole	Java監視與管理控制檯
…
複製程式碼

6.1 常用命令 - jmap

命令格式:jmap [option] vmid

option的可選引數有:

-dump 生成Java堆轉儲快照。格式為:-dump:[live,] format=b,file=<filename>,其中live子  引數說明是否只dump出存活的物件,如 jmap –dump:format=b,file=C:\heap.dum 3500
-finalizerinfo 顯示在F-Queue中等待Finalizer執行緒執行finalize方法的物件
-heap 顯示Java堆詳細資訊
-histo 顯示堆中物件統計資訊
-permstat 以ClassLoader為統計口徑顯示永久帶記憶體狀態
-F 當虛擬機器程式對-dump選項沒有響應時,可使用這個選項強制生成dump快照

可以使用memoryAnalyzer對記憶體物件進行分析

複製程式碼

6.2 常用工具 - jconsole

雙擊開啟jdk/bin目錄下的jconsole.exe就可以啟動它

image

相關文章