JVM開荒-2

上回說到 .java —> .class
接着講解.class字節碼文件是如何在JVM中存放的
JVM在爲類實例或成員變量分配內存是如何分配的
先來了解一下JVM內存結構

JVM的內存結構

Java虛擬機的內存結構並不是官方說法
《Java虛擬機使用規範》中 [運行時數據區]纔是術語,但是大家通常使用JVM內存結構

根據 《Java虛擬機使用規範》中的說法,Java虛擬機的內存結構可以分爲公有和私有兩部分。
共有

所有線程都共享的部分
  • Java堆
  • 方法區
  • 常量池

私有

每個線程的私有數據
  • PC寄存器
  • java虛擬機棧
  • 本地方法棧

共有部分

  • Java堆
  • 方法區
  • 常量池

Java堆
java堆是從JVM劃分出來的一塊區域,專門用於Java實例對象的內存分配
幾乎所有的實例對象都會在這裏進行內存分配。(小對象會在棧上分配)

Java堆根據對象存活時間的不同,Java堆還被封爲年輕代、年老代兩個區域,年輕代還被進一步 分爲Eden 區、From Survivor 0、To Survivor 1 區。如下圖所示。
在這裏插入圖片描述

當有對象需要分配時,一個對象永遠優先被分配在年輕代Eden區,
等到Eden區域內存不夠時,啓動垃圾回收。

垃圾回收:
Eden區中沒有被引用的對象的內存就會被回收,
一些存貨時間較長的對象會進入到老年代

在JVM中有一個名爲:XX:MaxTenuringThreshold 的參數專門用來設置晉升到老年代所需要經歷的 GC 次數,
即在年輕代的對象經過了指定次數的 GC 後,將在下次 GC 時進入老年代

JVM堆區域劃分的原因

對象有存活時間長短的,普遍是正態分佈
如果混在一起,勢必導致內存不夠,垃圾回收頻繁

垃圾回收也不用對全部對象進行掃描,掃描老對象屬於浪費時間

另外一個值得我們思考的問題是:爲什麼默認的虛擬機配置,Eden:from :to = 8:1:1 呢?

其實這是 IBM 公司根據大量統計得出的結果。根據 IBM 公司對對象存活時間的統計,他們發現 80% 的對象存活時間都很短。於是他們將 Eden 區設置爲年輕代的 80%,這樣可以減少內存空間的浪費,提高內存空間利用率

方法區
存儲Java類字節碼數據的一塊區域
存儲了每一個類的結構信息

  • 運行時常量池
  • 字段
  • 方法數據
  • 構造方法
    可以看到常量池是放在方法區當中的,但<Java虛擬機規範>將常量池和方法區放在同一個等級上

方法區在不同版本的VM中有不同的表現形式

  • JDK1.7 HotSpot虛擬機中,方法區稱爲永久代(Permanent Space)
  • JDK1.8 MetaSpace

私有部分

Java 堆以及方法區的數據是共享的,但是有一些部分則是線程私有的。線程私有部分可以分爲:

  • PC 寄存器
  • Java 虛擬機棧
  • 本地方法棧三大部分

PC 寄存器
Program Counter寄存器,指的是保存線程當前正在執行的方法
如果這個方法不是native方法,
那麼PC寄存器就保存java虛擬機正在執行的字節碼指令地址
是native方法,保存undefined

任意時刻JVM一個線程指只會執行一個方法的代碼,稱爲當前方法地址存放在pc寄存器中

當JVM使用其他語言(C)來實現指令集解釋器時,也會使用到本地方法棧
若JVM不支持native方法,並且自己也不以來傳統棧的話 可以無需支持本地方法棧

ava 虛擬機的內存結構是學習虛擬機所必須掌握的地方,其中以 Java 堆的內存模型最爲重要,因爲線上問題很多時候都是 Java 堆出現問題。因此掌握 Java 堆的劃分以及常用參數的調整最爲關鍵。

除了上述所說的六大部分之外,其實在 Java 中還有直接內存、棧幀等數據結構。但因爲直接內存、棧幀的使用場景還比較少,所以這裏並不做介紹,以免讓初學者一時間混淆。

學到這裏,一個 Java 文件就加載到內存中了,並且 Java 類信息就會存儲在我們的方法區中。如果創建對象,那麼對象數據就會存放在 Java 堆中。如果調用方法,就會用到 PC 寄存器、Java 虛擬機棧、本地方法棧等結構。那麼面對如此之多的 Java 類,JVM 是如何決定這些類的加載順序,又是如此控制它們的加載的呢?
在這裏插入圖片描述

JVM類加載機制

JVM可以將字節碼讀取進內存,從而進行解析 運行等一系列動作,這個過程稱爲JVM類加載機制
JVM執行.class字節碼過程分爲七個階段:

  • 加載
  • 驗證
  • 準備
  • 解析
  • 初始化
  • 使用
  • 卸載

先看題目

class Grandpa
{
    static
    {
        System.out.println("爺爺在靜態代碼塊");
    }
}    
class Father extends Grandpa
{
    static
    {
        System.out.println("爸爸在靜態代碼塊");
    }

    public static int factor = 25;

    public Father()
    {
        System.out.println("我是爸爸~");
    }
}
class Son extends Father
{
    static 
    {
        System.out.println("兒子在靜態代碼塊");
    }

    public Son()
    {
        System.out.println("我是兒子~");
    }
}
public class InitializationDemo
{
    public static void main(String[] args)
    {
        System.out.println("爸爸的歲數:" + Son.factor);  //入口
    }
}

請寫出最後的輸出字符串。

正確答案是:

爺爺在靜態代碼塊
爸爸在靜態代碼塊
爸爸的歲數:25

加載

官方描述

加載階段是類加載過程的第一個階段。在這個階段,
JVM 的主要目的是將字節碼從各個位置(網絡、磁盤等)轉化爲二進制字節流加載到內存中,
接着會爲這個類在 JVM 的方法區創建
一個對應的 Class 對象,這個 Class 對象就是這個類各種數據的訪問入口。

就是把代碼數據加載到內存中

驗證

當JVM加載完Class字節碼文件並在方法去創建對應的Class對象後,
JVM便會啓動對這個字節碼流的校驗
校驗過程大致分爲:

  • JVM規範校驗
JVM會對字節流進行文件格式校驗,判斷是否符合JVM規範
能否被當前版本的JVM處理
例如:文件是否以 0x cafe bene 開頭
  • 代碼邏輯校驗
JVM會對代碼組成的數據流和控制流進行校驗
確保JVM運行該字節碼文件後不會出現致命的錯誤
例如:一個方法要求傳入int類型的參數,但是使用它的時候卻傳入了一個String類型的參數

當代碼數據被加載到內存中時,JVM會對代碼數據進行校驗,看看這份代碼是不是真的按照JVM規範去寫

準備(重點)

完成字節碼文件的校驗後,JVM便會開始爲類變量分配內存初始化。
分爲:

內存分配對象

Java變量有 [類變量] [類成員變量] 兩種
[類變量] 用static修飾的變量,其他都爲 [類成員變量]
在準備階段,JVM只會爲[類變量]分配內存,而不會爲 [類成員變量] 分配內存
[類成員變量] 分配內存需要等到初始化階段纔開始

// 例如下面的代碼在準備階段,只會爲 factor 屬性分配內存,而不會爲 website 屬性分配內存
public static int factor = 3;
public String website = "www.cnblogs.com/chanshuyi";

初始化的類型

在準備階段 JVM會爲類變量分配內存,併爲其初始化。但是這裏的初始化指的是爲變量賦予Java語言中該數據類型的零值,而不是用戶代碼裏的初始化的值

例如: sector的值將是0 而不是3

public static int sector = 3;

但是如果一個變量是常量(static final),那麼準備階段直接賦值

public static final int number = 3;

兩個語句的區別是一個有 final 關鍵字修飾,另外一個沒有。而 final 關鍵字在 Java 中代表不可改變的意思,意思就是說 number 的值一旦賦值就不會在改變了。既然一旦賦值就不會再改變,那麼就必須一開始就給其賦予用戶想要的值,因此被 final 修飾的類變量在準備階段就會被賦予想要的值。而沒有被 final 修飾的類變量,其可能在初始化階段或者運行階段發生變化,所以就沒有必要在準備階段對它賦予用戶想要的值。

解析

當通過準備階段之後,JVM針對

  • 接口、
  • 字段、
  • 類接口、
  • 接口方法、
  • 方法句柄 、
  • 調用點限定符號

這七類引用進行解析。這個階段主要任務是將其在常量池中的符號引用替換成直接其在內存中的直接引用

初始化(重點)

到了初始化,Java纔開始真正的執行。
JVM會根據語句的執行順序對類對象進行初始化
一般來說以下5種情況纔會初始化

  • 遇見 new \ getstitic \ putstatic \ invokestatic 這四條字節碼指令
如果類沒有初始化,需要先初始化
生成這4條指令的最常見的Java代碼場景是:
使用new關鍵字實例化對象的時候
讀取或設置一個類的靜態字段(被final修飾,已在編譯器把結果放入常量池的靜態字段除外)的時候
調用一個類的靜態方法
  • 使用java.lang.reflect包的方法對類進行反射調用的時候 ,如果類沒有進行過初始化,需要先觸發初始化
  • 初始化一個類的時候,其父類必須先初始化
  • 虛擬機啓動時,用戶需要指定一個執行的主類(main)
  • 當使用 JDK1.7 動態語言支持時,如果一個 java.lang.invoke.MethodHandle實例最後的解析結果 REF_getstatic,REF_putstatic,REF_invokeStatic 的方法句柄,並且這個方法句柄所對應的類沒有進行初始化,則需要先出觸發其初始化

使用

當JVM完成初始化階段之後,
從入口方法開始執行裏面的程序

卸載

code執行完,JVM開始銷燬創建的Class對象,最後負責運行JVM的程序也退出內存

JVM類加載案例

public class Book {
    public static void main(String[] args)
    {
        System.out.println("Hello ShuYi.");
    }

    Book()
    {
        System.out.println("書的構造方法");
        System.out.println("price=" + price +",amount=" + amount);
    }

    {
        System.out.println("書的普通代碼塊");
    }

    int price = 110;

    static
    {
        System.out.println("書的靜態代碼塊");
    }

    static int amount = 112;
}

執行結果

書的靜態代碼塊
Hello ShuYi.

思路分析
首先根據上面說到的觸發初始化第四種
當虛擬機啓動的時候,用戶需要指定一個main
那麼我們的代買當中只有一個構造方法,但實際上Java代碼編譯成字節碼後
是沒有構造方法的概念的 只有 [類初始化方法] [對象初始化方法]

[類初始化方法]
編譯器會按照其順序 收集類變量的賦值語句、靜態代碼塊、最終組成類初始化方法
類初始化方法一般在類初始化的時候執行

例子中的類初始化方法

    static
    {
        System.out.println("書的靜態代碼塊");
    }
    static int amount = 112;

[對象初始化方法]
編譯器會按照其出現順序,收集成員變量的賦值語句、普通代碼塊,最後收集構造函數的代碼,最終組成對象初始化方法
對象初始化方法一般在實例化類對象的時候執行

例子中對象初始化方法:

    {
        System.out.println("書的普通代碼塊");
    }
    int price = 110;
    System.out.println("書的構造方法");
    System.out.println("price=" + price +",amount=" + amount);

其實上面的這個例子其實沒有執行對象初始化方法。

因爲我們確實沒有進行 Book 類對象的實例化。如果你在 main 方法中增加 new Book() 語句,你會發現對象的初始化方法執行了!

實戰分析

class Grandpa
{
    static
    {
        System.out.println("爺爺在靜態代碼塊");
    }
}    
class Father extends Grandpa
{
    static
    {
        System.out.println("爸爸在靜態代碼塊");
    }

    public static int factor = 25;

    public Father()
    {
        System.out.println("我是爸爸~");
    }
}
class Son extends Father
{
    static 
    {
        System.out.println("兒子在靜態代碼塊");
    }

    public Son()
    {
        System.out.println("我是兒子~");
    }
}
public class InitializationDemo
{
    public static void main(String[] args)
    {
        System.out.println("爸爸的歲數:" + Son.factor);  //入口
    }
}

最終的輸出結果是:

爺爺在靜態代碼塊
爸爸在靜態代碼塊
爸爸的歲數:25

這是因爲對於靜態字段,只有直接定義這個字段的類纔會被初始化(執行靜態代碼塊)

  • 首先main Son初始化(需要父類初始化)
  • static輸出
  • 所有父類都初始化之後,Son類才能調用靜態變量

JVM垃圾回收機制

內存總是有限度的,需要一個機制來不斷地回收廢棄的內存,從而實現循環利用
JVM的內存結構有《Java虛擬機規範》規定,垃圾回收機制並沒有具體的規範約束
所以不同的虛擬機有不同的回收機制
以HotSpot爲例

判斷誰是垃圾

生活中,一個東西經常沒有被用,即可判定爲垃圾。
Java中也是如此,一個對象沒有被引用,就應該被回收。
根據這個思想,我們會想能否用引用數來判斷:

引用計數法:
	對象被引用時加一,被去引用的時候減一

存在致命的問題
	循環引用
		A -----> B    B---------->C  C------------A
		但是他們三個從來沒有被其他引用
	從垃圾的引用判斷,他們三個確實是不被其他對象引用,
	但是他們的應用計數不爲零,存在了循環引用的問題

GC Root Tracing算法

現在JVM普遍採用此方法
此方法基本思路:
通過一系列的’GC Roots’對象作爲起點,從這些節點向下搜索,不可達即爲垃圾
在這裏插入圖片描述
再Java中,可以作爲GC Roots對象的有:

  • 虛擬機棧(棧幀中的本地變量表) 中引用的對象
  • 方法區中的類靜態屬性引用的對象;
  • 方法區常量引用的對象
  • 本地方法棧中JNI(一般說的Native方法) 中引用的對象

【證】:那些可作爲GC Roots的對象

如何進行回收

  • 標記清楚法
  • 複製算法
  • 標記壓縮算法

標記清除法

分爲

  • 標記階段
  • 清除階段

標記所有GC對象引用的對象
清除未被標記的對象

問題:
空間破碎問題,
如果空間碎片太多,則會導致內存空間不連續
雖然說大對象也可以分配在碎片中 但是效率要很低

複製算法

將原有的內存空間分爲兩塊
每次只是用一塊
在垃圾回收的時候,正在使用的內存中的存活對象複製到未使用的內存塊中。
之後清除正在使用的內存塊中的所有對象
最後交換兩個內存塊的角色
缺點:將內存空間折半,極大浪費了內存空間

標記壓縮算法

標記清除算法的優化版

  • 標記階段
    從GC Root引用集合出發去標記所有引用的對象
  • 壓縮階段
    將所有存活的對象壓縮在內存空間壓縮在內存一邊,之後清理邊界外的所有對象

三種方法對比

標記清除算法雖然會產生內存碎片,但是不需要移動太多對象,適合應用在存貨對象較多的情況
複製算法雖然需要將內存空間折半,並且需要移動存活對象,但是清理後不會產生碎片 適合存活對象比較少的
標記壓縮算法,標記清除算法的優化版本,減少空間碎片。

分代思想

所謂分代算法,就是根據JVM內存的不同區域,採用不同的垃圾回收方法。
新生代裏採用的垃圾回收算法,
新生代的特點是存活對象少,適合採用複製算法。而複製算法的一種最簡單實現便是折半內存使用
實際上我們知道,在VJM新生代劃分區域中,卻不是採用等分爲兩塊內存的形式。而是:Eden區域 from區域 to區域
爲什麼要分成三塊而不是分成兩塊,IBM表明:新生代98%都是朝生夕死的,所以並不需要按照1:1的比例來劃分內存空間
所以在HotSpot虛擬機中,JVM將內存劃分爲一塊較大的Eden空間和兩塊較小的Survivor空間,大小佔比8:1:1.
回收時,將Eden和Survivor中還存活的對象一次性複製到另一塊Survivor空間上,清理掉Eden剛纔用過的空間

這種方式將均分50%的利用率提升到了90%

分區思想

將整個堆空間劃分成連續不同的小區域
每一個小區域都單獨使用,獨立回收
優點:

	可以控制一次回收多少個區間,可以較好地控制GC時間。
	

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章