JVM

CH_song發表於2024-11-05

jvm

JVM 全稱是 Java Virtual Machine,中文譯名 Java虛擬機器。JVM 本質上是一個執行在計算機上的程式,他的職責是執行Java位元組碼檔案。

image-20241014105033008

分為三個步驟:

1、編寫Java原始碼檔案。

2、使用Java編譯器(javac命令)將原始碼編譯成Java位元組碼檔案。

3、使用Java虛擬機器載入並執行Java位元組碼檔案,此時會啟動一個新的程序。

JVM的功能

JVM例項的建立:每次執行Java程式時,都會啟動一個新的JVM例項(在大多數情況下)。這個例項負責執行當前Java程式的位元組碼。JVM的啟動和初始化是自動完成的,無需程式設計師手動干預。

資源分配:JVM例項在啟動時,會向作業系統請求必要的資源(如記憶體空間),並在其內部進行資源分配和管理。

環境初始化:JVM例項會初始化其內部環境,包括設定堆大小、棧大小、方法區大小等,以及載入必要的系統類和庫。

Java虛擬機器規範

JVM(Java Virtual Machine,Java虛擬機器)規範是Oracle公司(及其前身Sun Microsystems)制定的一系列規範,用於指導JVM的設計和實現。這些規範定義了JVM如何載入、執行Java位元組碼,管理記憶體,處理垃圾回收,以及與其他語言和環境互動的方式。

Oracle HotSpot JVM‌:由Oracle公司開發,是目前最常用的JVM之一,也是Java官方推薦的JVM之一。

OpenJDK JVM‌:由Oracle公司主導的開源JVM專案,是Java官方的參考實現之一。

IBM J9 JVM‌:由IBM公司開發的JVM,具有高效能和低記憶體佔用等優點。

Azul Zing JVM‌:由Azul Systems公司開發的JVM,專注於高效能、低延遲和可預測性的最佳化。

JRockit JVM‌:由BEA Systems公司開發的JVM,具有高效能和低記憶體佔用等優點,目前已被Oracle公司收購。

Excelsior JET JVM‌:由Excelsior公司開發的JVM,可以將Java程式編譯成本地機器程式碼,提高程式的執行效率。

位元組碼檔案的組成

  • 基礎資訊:魔數、位元組碼檔案對應的Java版本號、訪問標識(public final等等)、父類和介面資訊
  • 常量池:儲存了字串常量、類或介面名、欄位名,主要在位元組碼指令中使用
  • 欄位: 當前類或介面宣告的欄位資訊
  • 方法: 當前類或介面宣告的方法資訊,核心內容為方法的位元組碼指令
  • 屬性: 類的屬性,比如原始碼的檔名、內部類的列表等

Java虛擬機器的組成

  • 類載入器(Class Loader):負責載入類檔案到JVM中。
  • 執行時資料區(Runtime Data Areas)
    • 方法區(Method Area):它儲存了已被虛擬機器載入的類資訊、方法資訊、欄位資訊、常量(final修飾)、靜態變數、即時編譯器編譯後的程式碼快取等。
    • 堆(Heap):Java堆是Java虛擬機器所管理的記憶體中最大的一塊,是所有執行緒共享的一塊記憶體區域,幾乎所有的物件例項都在這裡分配記憶體。
    • 棧(Stack):每個執行緒都有自己的棧,用於儲存區域性變數和部分計算過程的結果。
    • 程式計數器(Program Counter Register):是一塊較小的記憶體空間,可以看作是當前執行緒所執行的位元組碼的行號指示器。
    • 本地方法棧(Native Method Stacks):與虛擬機器棧所發揮的作用非常相似,其區別不過是虛擬機器棧為虛擬機器執行Java方法(也就是位元組碼)服務,而本地方法棧則為虛擬機器使用到的Native方法服務。
  • 執行引擎(Execution Engine):負責執行位元組碼,或者將位元組碼轉換為機器碼來執行。
  • 本地介面(Native Interface):允許Java程式碼呼叫原生代碼(通常是C或C++編寫的庫)。

image-20241014145633526

JVM的優勢

  • 平臺無關性(跨平臺):由於Java程式執行在JVM上,而JVM可以在任何作業系統上執行,因此Java程式具有跨平臺的特性。
  • 安全性:JVM提供了安全機制來防止惡意程式碼的攻擊。
  • 自動記憶體管理:JVM負責自動管理記憶體的分配和回收,減輕了程式設計師的負擔。
  • 多執行緒支援:JVM內建了對多執行緒的支援,簡化了多執行緒程式設計的複雜性。

一、類載入器子系統

類載入器的作用

負責載入class檔案到JVM,然後建立一個與之對應的Class物件(該物件中包含該類的資訊,方法資訊,欄位欄位等),儲存在方法區。

image-20240701195251613

解釋說明:

1、透過類載入器將User.class位元組碼檔案載入到 JVM 中,然後建立一個與之對應的Class物件。

2、User位元組碼檔案一旦加入到 JVM 以後,那麼此時就可以使用其建立對應的例項物件。

3、可以透過呼叫例項物件的getClass方法獲取位元組碼檔案物件。

4、可以呼叫位元組碼檔案物件的getClassLoader方法獲取載入該類所對應的類載入器。

類的生命週期

  • 載入(Loading):JVM負責載入Java類檔案(.class)到其內部的記憶體中,就是方法區。
  • 連結(Linking)
    • 驗證(Verification):檢查載入的類檔案是否符合Java語言規範及JVM規範。
    • 準備(Preparation):為類變數分配記憶體並設定預設的初始值(注意,這裡的初始值不是程式碼中設定的值,而是如0、null等預設值)。
    • 解析(Resolution):將類的符號引用替換為直接引用(例如,將類名、方法名等符號替換為它們在記憶體中的地址)。
  • 初始化(Initialization):根據程式設計師在Java程式碼中編寫的初始化程式碼(如靜態程式碼塊、靜態變數初始化等)來初始化類的變數。
  • 執行(Execution):JVM中的Java直譯器(JIT編譯器)負責將位元組碼轉換成平臺相關的機器碼並執行。

image-20241014125625905

 public class MyClass {
    static int staticVariable = 10;
    static {
        System.out.println("靜態程式碼塊執行");
    }
 
    public static void main(String[] args) {
        System.out.println("靜態變數的值: " + staticVariable);
    }
}

當JVM啟動並執行這個main方法時,MyClass類將經歷以下階段:

  1. 載入:JVM尋找並載入MyClass的位元組碼檔案。
  2. 連結:
    • 驗證:檢查MyClass是否符合JVM的要求。
    • 準備:為staticVariable分配記憶體,並設定預設值0。
    • 解析:如果有依賴其他類或方法等,則解析這些類或方法的符號引用。
  3. 初始化:為staticVariable賦值10,然後執行靜態程式碼塊。

總結

類載入:將某個class檔案以二進位制流的方式載入到jvm記憶體中,並且建立與之對應的Class物件儲存到jvm的方法區中

將來:例項化一個物件(new User()) 就需要使用方法區中的Class進行例項物件的建立,例項物件的建立通常就是在jvm中的堆中

1. 載入階段

主要將某個clss類安全的載入到jvm中,這個階段需要使用特點的類載入器

2. 連線階段

  • 驗證,驗證內容是否滿足《Java虛擬機器規範》。
  • 準備,給靜態變數賦初值。
  • 解析,將常量池中的符號引用替換成指向記憶體的直接引用。

驗證

驗證的主要目的是檢測Java位元組碼檔案是否遵守了《Java虛擬機器規範》中的約束。這個階段一般不需要程式設計師參與。主要包含如下四部分,具體詳見《Java虛擬機器規範》:

1、檔案格式驗證,比如檔案是否以0xCAFEBABE開頭,主次版本號是否滿足當前Java虛擬機器版本要求。

img

2、元資訊驗證,例如類必須有父類(super不能為空)。

3、驗證程式執行指令的語義,比如方法內的指令執行中跳轉到不正確的位置。

4、符號引用驗證,例如是否訪問了其他類中private的方法等。

準備

準備階段為靜態變數(static)分配記憶體並設定初值,每一種基本資料型別和引用資料型別都有其初值。

資料型別 初始值
int 0
long 0L
short 0
char ‘\u0000’
byte 0
boolean false
double 0.0
引用資料型別 null

注意:

public class Student{

public static int value = 1;

}

在準備階段會為value分配記憶體並賦初值為0,在初始化階段才會將值修改為1。

public class Student{

public static final int value = 1;

}

final修飾的基本資料型別的靜態變數,準備階段直接會將程式碼中的值進行賦值。

解析

解析階段主要是將常量池中的符號引用替換為直接引用

  • 符號引用就是在位元組碼檔案中使用編號來訪問常量池中的內容
  • 每個Class物件的地址就是直接引用

3. 初始化階段

初始化階段:主要可以為static型別的屬性賦予實際的初始值,並且也會執行static靜態程式碼塊。

public class Demo1 {
    public static int value = 1;
    static {
        value = 2;
    }
}

以下幾種方式會導致類的初始化:

1.訪問一個類的靜態變數或者靜態方法,注意變數是final修飾的並且等號右邊是常量不會觸發初始化。

2.呼叫Class.forName(String className)。

3.new一個該類的物件時。

4.執行Main方法的當前類。

4. 使用

使用,將來利用該Class建立例項物件。

5. 解除安裝

解除安裝,當某個Clss不在被需要時,可以被垃圾回收器進行垃圾回收,從而釋放方法區記憶體空間。

類載入器的分類

1、啟動類載入器(Bootstrap ClassLoader):它是虛擬機器的內建類載入器。負責載入Java核心類庫,如rt.jar中的類。啟動類載入器是由C++實現的,不是一個Java類,列印該載入器時為null。

2、擴充套件/平臺類載入器(Extension/PlatformClassLoader):擴充套件類載入器負責載入Java的擴充套件類庫,位於JRE的lib/ext目錄下的jar包。

3、應用程式/系統類載入器(Application/System ClassLoader):也稱為系統類載入器,它負責載入應用程式的類,即開發者自己編寫的類。

4、自定義類載入器。

類載入器之間是存在邏輯上的繼承關係,但是不存在物理上的繼承

image-20241014162750865

public class StudentDemo01 {

    public static void main(String[] args) {

        // 獲取載入Student類所對應的類載入器
        ClassLoader classLoader = Student.class.getClassLoader();
        System.out.println(classLoader);

        // 獲取classLoader類載入器所對應的父類載入器
        ClassLoader loaderParent = classLoader.getParent();
        System.out.println(loaderParent);

        // 獲取loaderParent類載入器所對應的父類載入器
        ClassLoader parentParent = loaderParent.getParent();
        System.out.println(parentParent);       // 引導類載入器,是透過null進行表示
    }

}

類載入的機制(雙親委派)

JVM對class檔案採用的是按需載入的方式,當需要使用該類時,JVM才會將它的class檔案載入到記憶體中產生class物件。

在載入類的時候,是採用的雙親委派機制

  • 如果一個類載入器接收到了類載入的請求,它自己不會先去載入,會把這個請求委託給父類載入器去執行。
  • 如果父類還存在父類載入器,則繼續向上委託,一直委託到啟動類載入器:Bootstrap ClassLoader
  • 如果父類載入器可以完成載入任務,就返回成功結果,如果父類載入失敗,就由子類自己去嘗試載入,如果子類載入失敗就會丟擲ClassNotFoundException異常,這就是雙親委派模式
  • 補充:每個類載入器在自己的範圍內去載入某個類之前,先判斷該類是否已經被載入。

image-20241014181253386

雙親委派機制的好處:

避免重複載入,確保類的唯一性:當一個類需要被載入時,首先會委派給父類載入器進行載入。如果父類載入器能夠找到並載入該類,就不會再由子類載入器重複載入,避免了重複載入同一個類的問題。

提高安全性:雙親委派機制可以防止惡意程式碼透過自定義類載入器來替換核心類庫中的類。因為在載入核心類庫時,會優先委派給啟動類載入器進行載入,而啟動類載入器是由JVM提供的,具有較高的安全性。

指定載入類的類載入器

方式1:使用Class.forName方法,使用當前類的類載入器去載入指定的類。

方式2:獲取到類載入器,透過類載入器的loadClass方法指定某個類載入器載入。

 //獲取main方法所在的類載入器,應用程式載入器
ClassLoader classLoader = String.class.getClassLoader();
System.out.println("classLoader = " + classLoader);

//使用應用程式載入器載入指定com.chs.A
Class<?> aClass = classLoader.loadClass("com.chs.a");
System.out.println("aClass = " + aClass);

打破雙親委派機制

打破雙親委派機制歷史上有三種方式,但本質上只有第一種算是真正的打破了雙親委派機制:

  • 自定義類載入器並且重寫loadClass方法。Tomcat透過這種方式實現應用之間類隔離。
  • 執行緒上下文類載入器。利用上下文類載入器載入類,比如JDBC和JNDI等。
  • Osgi框架的類載入器。歷史上Osgi框架實現了一套新的類載入器機制,允許同級之間委託進行類的載入,目前很少使用。

二、執行時資料區

執行時資料區:是Java虛擬機器用於儲存和管理程式執行時資料的區域。

執行時資料區又可以為劃分為如下幾個部分:

image-20230615105135381

那些區域是執行緒共享的,那些是執行緒私有的?

  • 堆、方法區是各個執行緒共享的區域。一旦是多執行緒共享區域,意味著這部分可能會出現多執行緒併發安全問題。
  • 棧、本地方法棧、程式計數器各個執行緒私有區域。不會出現多執行緒併發安全問題。

程式計數器

作用:是一塊較小的記憶體空間,可以理解為是當前執行緒所執行程式的位元組碼檔案的行號指示器,儲存的是當前執行緒所執行的行號

特點:執行緒私有空間 ,唯一一個不會出現記憶體溢位的記憶體空間。

JVM棧

‌‌JVM棧的主要作用是管理‌Java程式執行時的方法呼叫引數傳遞。‌

JVM棧是Java虛擬機器為每個執行緒分配的一塊記憶體區域【執行緒私有】,用於儲存方法的區域性變數、‌運算元棧、‌動態連結、‌返回地址等資訊

每個方法在執行時都會建立一個棧幀,並將其壓入當前執行緒對應的JVM棧中。當方法執行完畢後,該棧幀會被彈出並銷燬,JVM棧也相應地回收記憶體空間。‌

public class MethodDemo {   
    public static void main(String[] args) {        
         study();    
     }

    public static void study(){
        eat();

        sleep();
    }   
    
    public static void eat(){       
         System.out.println("吃飯");   
    }    
    
    public static void sleep(){        
        System.out.println("睡覺");    
        }
  }

main方法執行時,會建立main方法的棧幀=》接下來執行study方法,會建立study方法的棧幀=》進入eat方法,建立eat方法的棧幀=》eat方法執行完之後,會彈出它的棧幀=》然後呼叫sleep方法,建立sleep方法棧幀=》最後study方法結束之後彈出棧幀,main方法結束之後彈出main的棧幀。

JVM棧幀的具體功能和結構:

  • ‌區域性變數表‌:區域性變數表的作用是在執行過程中存放所有的區域性變數,包括基本型別、物件引用等。每個執行緒的區域性變數表是獨立的,因此區域性變數是執行緒安全的。‌
  • ‌運算元棧‌:運算元棧是棧幀中虛擬機器在執行指令過程中用來存放臨時資料的一塊區域
  • ‌幀資料,幀資料主要包含動態連結、方法出口、異常表的引用

1. 區域性變數表

區域性變數表的作用是在方法執行過程中存放所有的區域性變數。區域性變數表分為兩種,一種是位元組碼檔案中的,另外一種是棧幀中的也就是儲存在記憶體中。棧幀中的區域性變數表是根據位元組碼檔案中的內容生成的。

2. 運算元棧

運算元棧是棧幀中虛擬機器在執行指令過程中用來存放中間資料的一塊區域。他是一種棧式的資料結構,如果一條指令將一個值壓入運算元棧,則後面的指令可以彈出並使用該值。

3. 幀資料

幀資料主要包含動態連結、方法出口、異常表的引用。

動態連結

當前類的位元組碼指令引用了其他類的屬性或者方法時,需要將符號引用(編號)轉換成對應的執行時常量池中的記憶體地址。動態連結就儲存了編號到執行時常量池的記憶體地址的對映關係。、

方法出口

方法出口指的是方法在正確或者異常結束時,當前棧幀會被彈出,同時程式計數器應該指向上一個棧幀中的下一條指令的地址。所以在當前棧幀中,需要儲存此方法出口的地址。

異常表

異常表存放的是程式碼中異常的處理資訊,包含了異常捕獲的生效範圍以及異常發生後跳轉到的位元組碼指令位置。

StackOverflowError

JVM棧的大小是固定的【通常為1MB】,可以透過命令列引數【-Xss】進行調整。

如果JVM棧的深度超過了預設的閾值,或者當前執行緒所需要的棧空間已經超過了剩餘的可用空間,那麼JVM就會丟擲StackOverflowError異常,從而保護了整個程式的安全性。

每個執行緒都有自己獨立的JVM棧,用於支援執行緒的併發執行。棧太小或者方法呼叫過深,都將丟擲StackOverflowError異常。

image-20241014190835295

如果我們不指定棧的大小,JVM 將建立一個具有預設大小的棧。大小取決於作業系統和計算機的體系結構。

image-20241014190936862

要修改Java虛擬機器棧的大小,可以使用虛擬機器引數 -Xss 。

  • 語法:-Xss棧大小
  • 單位:位元組(預設,必須是 1024 的倍數)、k或者K(KB)、m或者M(MB)、g或者G(GB):

操作步驟如下,不同IDEA版本的設定方式會略有不同:

1、點選修改配置Modify options

2、點選Add VM options

3、新增引數

image-20241021093736341

一般情況下,工作中即便使用了遞迴進行操作,棧的深度最多也只能到幾百,不會出現棧的溢位。所以此引數可以手動指定為-Xss256k節省記憶體。

本地方法棧

Java虛擬機器棧儲存了Java方法呼叫時的棧幀,而本地方法棧儲存的是native本地方法的棧幀。

本地方法:被native所修飾的方法

native方法的實現,並不是java實現的,而是透過c 然後呼叫作業系統底層的api。

JVM堆

Java虛擬機器堆是Java記憶體區域中一塊用來存放物件例項的區域,新建立的物件,陣列等都使用堆記憶體。

棧上的區域性變數表中,可以存放堆上物件的引用。靜態變數也可以存放堆物件的引用,透過靜態變數就可以實現物件線上程之間共享。

image-20241014192019729

在棧上透過s1s2兩個區域性變數儲存堆上兩個物件的地址,從而實現了引用關係的建立。

堆記憶體的溢位

當堆空間不足以為一個新物件開闢空間時,此時會開始gc垃圾回收去釋放一些空間,如果還不足,就只能丟擲OutOfMemory錯誤(堆記憶體溢位)

堆空間組成部分

image-20230615141641279

說明:

1、預設Java虛擬機器堆記憶體的初始大小為實體記憶體的1/64,最大是實體記憶體的1/4。

2、新生代佔整個堆記憶體的1/3、老年代佔整個堆記憶體的2/3。 1比2

3、新生代又可以細分為:伊甸區(Eden)、倖存區(from/s0、to/s1),它們之間的比例預設情況下是8:1:1。

4、執行緒共享區域,因此需要考慮執行緒安全問題。

5、會產生OOM記憶體溢位問題。

建立新物件,在堆記憶體中的分配過程:

1、建立的大部分物件,記憶體的分配都是從堆中新生代的eden區開始
2、當eden區不足以分配一個新物件時,此時垃圾回收器開始工作,對eden區和s0(from)區進行垃圾回收
3、然後將eden區和s0區存活的物件複製到s1(to)區。此時再將eden+s0清空。
4、將s0和s1的角色互換。
5、然後將新物件在eden區進行分配。
6、後續如果在eden區又不足以分配新對像,此時繼續將eden和s0進行垃圾回收,之後依然是將eden+s0存活的物件賦值到s1,然後s0和s1的角色互換
7、當youg區的一個物件經歷15次垃圾回收依然沒有將他回收掉,此時就要將這個物件移動到old區(意味著old
區通常儲存一些生命週期較長的對像)。youg區通常儲存一些生命週期較短的物件。
8、大物件會直接進入到old區,而不需要先在eden區進行分配。

注意:s0和s1這兩塊,在同一時刻,只有一塊正在被使用,另一塊一定是空閒的(空間利用率只有50%)。

年齡最多到15的物件會被移動到年老代中,沒有達到閾值的物件會被複到“To”區域。

物件在Survivor區(S)中每熬過一次Minor GC,年齡就會增加1歲,年齡最多到15的物件會被移動到年老代中。

可以透過-XX:MaxTenuringThreshold來設定年齡閾值。

堆記憶體大小設定

-XX:NewRatio=2

-XX:SurvivorRatio=8

-Xms512m

-Xmx1024m

-Xmn256m

-XX:NewRatio引數:該引數用於設定新生代和老年代的初始比例。例如,-XX:NewRatio=2表示新生代佔堆記憶體的1/3,老年代佔堆記憶體的2/3。
-XX:SurvivorRatio引數:該引數用於設定Eden區和Survivor區的初始比例。例如,-XX:SurvivorRatio=8表示Eden區佔新生代的8/10,每個Survivor區佔新生代的1/10。
設定堆的初始大小。例如,-Xms512m表示將堆的初始大小設定為512MB。
設定堆的最大大小。例如,-Xmx1024m表示將堆的最大大小設定為1GB。
設定新生代的大小。例如,-Xmn256m表示將新生代的大小設定為256MB。
通常會將 -Xms 和 -Xmx 兩個引數配置相同的值,其目的是為了能夠在 Java 垃圾回收機制清理完堆區後不需要重新分隔計算堆區的大小,從而提高效能。

建議:

Java服務端程式開發時,建議將-Xmx和-Xms設定為相同的值,這樣在程式啟動之後可使用的總記憶體就是最大記憶體,而無需向java虛擬機器再次申請,減少了申請並分配記憶體時間上的開銷,同時也不會出現記憶體過剩之後堆收縮的情況。

堆記憶體分代意義

Java的堆記憶體分代是指將不同生命週期的物件儲存在不同的堆記憶體區域中,這裡的不同的堆記憶體區域被定義為“代”。

這樣做有助於提升垃圾回收的效率,因為這樣的話就可以為不同的"代"設定不同的回收策略。

一般來說,Java中的大部分物件都是朝生夕死的,同時也有一部分物件會持久存在。如果把這兩部分物件放到一起分析和回收,效率太低。透過將不同時期的物件儲存在不同的記憶體中,使用不同的垃圾回收器,提高回收效率和效能。

JVM中的各種GC:

Minor GC(Young Generation Garbage Collection)是指對年輕代(Young Generation)進行的垃圾回收操作。

Major GC專注於回收老年代中(Tenured Generation)的垃圾物件。

Full GC(Full Garbage Collection),它是指對整個堆記憶體進行回收,包括新生代和老年代。

方法區

方法區是被所有執行緒共享,它儲存了已被虛擬機器載入的類資訊、方法資訊、欄位資訊、常量(final修飾)、靜態變數、即時編譯器編譯後的程式碼快取等。

image-20240702212653950

方法區演進

‌方法區‌是‌JVM規範中定義的一個記憶體區域。

在HotSpot虛擬機器中,JDK 7之前的方法區由永久代實現,永久代位於JVM的堆記憶體中;

JDK 8及以後,永久代被移除,元空間被引入作為替代。元空間使用本地記憶體,不再受JVM記憶體大小的限制,減少了記憶體溢位的風險。

永久代滿了會丟擲OutOfMemoryError: PermGen space;

而元空間滿了會丟擲OutOfMemoryError: Metaspace。

image-20241014194245986引數控制:

1、-XX:MetaspaceSize 設定元空間的初始大小

2、-XX:MaxMetaspaceSize 設定元空間的最大大小

變化的原因

1、元空間使用的是直接記憶體(jvm之外的本地記憶體),受本機可用記憶體的限制,降低記憶體溢位的機率。

2、可以載入更多的類。

3、提高記憶體的回收效率。

元空間的引入減少了記憶體溢位的風險,因為它可以動態地擴充套件和收縮。

永久代的回收效率較低,而元空間的回收效率較高,因為它使用本地記憶體而不是JVM記憶體。‌

三、執行引擎

執行引擎負責執行Java程式的位元組碼指令,執行引擎的結構如下所示:

image-20230615151955474

java位元組碼指令的執行方式

  1. 解釋模式
  2. 編譯模式
  3. 混合模式--hotspot虛擬機器採用的是混合模式。

直譯器(Interpreter)

作用:逐行讀取並執行位元組碼。

工作原理:

  • 直譯器讀取程式計數器(PC)指定的位元組碼指令。
  • 將位元組碼指令翻譯成相應的機器碼指令,並立即執行。
  • 執行完一條指令後,更新程式計數器以指向下一條位元組碼指令。

直譯器的優點是啟動速度快,缺點是執行效率較低,因為每次都需要逐行翻譯位元組碼。

即時編譯器(JIT Compiler)

作用:將位元組碼編譯成高效的本地機器碼,提高執行效率。

工作原理:

  • 當某些方法或程式碼段被多次執行時,JIT編譯器將這些熱點程式碼(HotSpot Code)編譯成本地機器碼。
  • 編譯後的原生代碼被快取起來,以便後續直接執行,無需再次解釋。
  • JIT編譯器還會進行各種最佳化,例如方法內聯(Inlining)、迴圈展開(Loop Unrolling)等,以進一步提高執行效能。
將被呼叫的方法的程式碼直接嵌入到呼叫者的方法中,減少方法呼叫的開銷。
迴圈展開是一種最佳化,它將迴圈體複製多次以減少迴圈開銷。

垃圾回收器(Garbage Collector)

作用:管理記憶體,自動回收不再使用的物件,防止記憶體洩漏(OOM)。

工作原理:

  • 在程式執行期間,垃圾回收器不斷地監視物件的生命週期。
  • 當檢測到某些物件不再被引用時,回收這些物件所佔用的記憶體。
  • 垃圾回收策略和演算法有多種,如標記-清除(Mark-Sweep)、複製演算法(Copying)、標記-整理(Mark-Compact)等。

垃圾物件判定

​ 要進行垃圾回收,那麼首先需要找出垃圾,如果判斷一個物件是否為垃圾呢?

  • 兩種演算法:
    • 引用計數法
    • 可達性分析演算法

引用計數法

堆中每個物件例項都有一個引用計數。當一個物件被建立時,為該物件例項分配給一個變數,該變數計數設定為1。

每個物件都有一個引用計數器,當該物件增加了一個引用,此時計數器+1,減少了一個引用,計數器-1,當一個物件的引用計數器=0時,表示他是垃圾物件,隨時可以被回收。

  • 優點:實現簡單。
  • 缺點:無法解決 迴圈引用 問題。

迴圈引用:A引用B;B引用A,但是其他物件都沒有再用的這兩個A,B物件,此時A,B這兩個物件就是垃圾物件,但是它們的引用計數器都是1,所以不能被清理。這也稱為記憶體洩漏(A和B一直佔用著記憶體空間,但是它實際屬於垃圾物件)

可達性分析演算法(根搜尋法)

首先需要篩選到一些根節點物件,根節點物件的特點就是不需要被清理的物件(有用的對像),從根節點物件開
始,依次做鏈路追蹤。

image-20241014201427329

在Java語言中,可以作為GC Roots的物件包括下面幾種:

1、虛擬機器棧中引用的物件

2、本地方法棧中引用的物件

3、方法區中類靜態屬性引用的物件

4、方法區中常量引用的物件

5、執行緒Thread物件

6、系統類載入器載入的java.lang.Class物件

垃圾回收演算法

1、找到記憶體中存活的物件

2、釋放不再存活物件的記憶體,使得程式能再次利用這部分空間

1 、標記清除

1.標記階段,將所有存活的物件進行標記。Java中使用可達性分析演算法,從GC Root開始透過引用鏈遍歷出所有存活物件。

2.清除階段,從記憶體中刪除沒有被標記也就是非存活物件。

image-20241014202240327

物件D被清理掉了

優點:速度比較快。

缺點:會產生記憶體碎片,使得連續空間少。

image-20241014202353317

2、標記整理

1.標記階段,將所有存活的物件進行標記。Java中使用可達性分析演算法,從GC Root開始透過引用鏈遍歷出所有存活物件。

2.整理階段,將存活物件移動到堆的一端。清理掉存活物件的記憶體空間。

image-20241014202958366

優點:無記憶體碎片。

缺點:效率較低。

3、複製

1.準備兩塊空間From空間和To空間,每次在物件分配階段,只能使用其中一塊空間(From空間)。

物件A首先分配在From空間

image-20241014203149544

2.在垃圾回收GC階段,將From中存活物件複製到To空間

在垃圾回收階段,如果物件A存活,就將其複製到To空間。然後將From空間直接清空。

image-20241014203206501

3.將兩塊空間的From和To名字互換

接下來將兩塊空間的名稱互換,下次依然在From空間上建立物件。

image-20241014203219365

優點:無記憶體碎片

缺點:空間利用率只有50%;如果物件的存活率較高,複製演算法的效率就比較低。

4、分代

現代優秀的垃圾回收演算法,會將上述描述的垃圾回收演算法組合進行使用,其中應用最廣的就是分代垃圾回收演算法(Generational GC)。

分代垃圾回收將整個記憶體區域劃分為年輕代和老年代:

image-20241014203637429

新生代物件的存活的時間都比較短,因此使用的是【複製演算法】;

而老年代物件存活的時間比較長那麼採用的就是【標記清除】或者【標記整理】。

選擇的虛擬機器引數如下

引數名 引數含義 示例
-Xms 設定堆的最小和初始大小,必須是1024倍數且大於1MB 比如初始大小6MB的寫法: -Xms6291456 -Xms6144k -Xms6m
-Xmx 設定最大堆的大小,必須是1024倍數且大於2MB 比如最大堆80 MB的寫法: -Xmx83886080 -Xmx81920k -Xmx80m
-Xmn 新生代的大小 新生代256 MB的寫法: -Xmn256m -Xmn262144k -Xmn268435456
-XX:SurvivorRatio 伊甸園區和倖存區的比例,預設為8 新生代1g記憶體,伊甸園區800MB,S0和S1各100MB 比例調整為4的寫法:-XX:SurvivorRatio=4
-XX:+PrintGCDetailsverbose:gc 列印GC日誌

垃圾收集器

為什麼分代GC演算法要把堆分成年輕代和老年代?首先我們要知道堆記憶體中物件的特性:

  • 系統中的大部分物件,都是建立出來之後很快就不再使用可以被回收,比如使用者獲取訂單資料,訂單資料返回給使用者之後就可以釋放了。
  • 老年代中會存放長期存活的物件,比如Spring的大部分bean物件,在程式啟動之後就不會被回收了。
  • 在虛擬機器的預設設定中,新生代大小要遠小於老年代的大小。

分代GC演算法將堆分成年輕代和老年代主要原因有:

1、可以透過調整年輕代和老年代的比例來適應不同型別的應用程式,提高記憶體的利用率和效能。

2、新生代和老年代使用不同的垃圾回收演算法,新生代一般選擇複製演算法,老年代可以選擇標記-清除和標記-整理演算法,由程式設計師來選擇靈活度較高。

3、分代的設計中允許只回收新生代(minor gc),如果能滿足物件分配的要求就不需要對整個堆進行回收(full gc),STW時間就會減少。

常見的垃圾收集器彙總

image-20241014204327641

上面的 serial , parnew , Paraller Scavenge 是新生代的垃圾回收器;

下面的 CMS , Serial Old ,Paralle Old是老年代的垃圾收集器 ;

G1垃圾收集器可以作用於新生代和老年代; 連線表示垃圾收集器可以搭配使用。

1、Serial/Serial Old

Serial是一個單執行緒的垃圾收集器。

"Stop The World(STW)",它進行垃圾收集時,必須暫停其他所有的工作執行緒,直到它收集結束。在使用者不可見的情況下把使用者正常工作的執行緒全部停掉。

新生代(Serial)複製演算法 老年代(Serial Old)標記整理演算法

引數控制: -XX:+UseSerialGC 年輕代和老年代都用序列收集器

image-20241014204757892

只有一個gc執行緒負責垃圾回收,並且會stop the world(STW,也就是一旦gc開始工作,則會將使用者的工作執行緒暫停下來)

優點:由於會SW,垃圾清理的效果比較理想

缺點:會讓使用者的工作執行緒暫停,增勖加了客戶端的等待時間。由於單執行緒,清理的效率較低。

使用場景:多用於桌面應用(記憶體佔用較小的應用),Client端的垃圾回收器。

2、ParNew

ParNew 是 Serial 的 **多執行緒 **版本,除了使用多執行緒進行垃圾收集之外,其餘行為與Serial收集器完全一樣。

引數控制:

  • -XX:+UseParNewGC , 年輕代使用ParNew,老年代使用 Serial Old

  • -XX:ParallelGCThreads={value} ,控制gc執行緒數量

image-20241014205054944

優點:由於會SW,垃圾清理的效果比較理想

缺點:會讓使用者的工作執行緒暫停,增勖加了客戶端的等待時間。

3、Parallel/Scavenge

Parallel 收集器 類似 ParNew 收集器,多執行緒 並行 收集。

更關注吞吐量(吞吐量優先),是JDK8預設的垃圾收集器。

新生代 複製演算法、老年代標記整理演算法(Parallel Old是Parallel的老年代版本)。

吞吐量

http併發請求的角度,吞吐量表示處理請求的能力。
在垃圾收集器的角度下,吞吐量表示使用者工作執行緒的執行時間的比例。吞吐量越高,使用者的工作執行緒佔據的cpu時間越多。

cpu的總執行時間=工作執行緒佔據的時間+gc執行緒佔據的時間

吞吐量=工作執行緒佔據的時間/cpu的總執行時間

吞吐量  =  執行使用者程式碼時間  /(執行使用者程式碼時間 + 垃圾收集時間 = cpu總消耗時間)

例如:JVM虛擬機器總共執行了 100 分鐘,其中垃圾收集花掉 1 分鐘,那吞吐量就是 99% 。

image-20241014205940215

優點

吞吐量高,而且手動可控。為了提高吞吐量,虛擬機器會動態調整堆的引數

缺點

不能保證單次的停頓時間

應用場景:高吞吐量則可以高效率地利用 CPU 時間,儘快完成程式的運算任務,主要適合在後臺運算而不需要太多互動的任務。

引數控制

-XX:+UseParallelGC 新生代啟用Parallel GC

-XX:+UseParallelOldGC 新生代和老年代都啟用Parallel GC

-XX:ParallelGCThreads=<N> 設定用於垃圾收集的執行緒數

-XX:GCTimeRatio=<N> 設定垃圾收集時間佔程式執行時間的比例,預設為99,即1%的時間用於GC。

4、CMS收集器

CMS (Concurrent Mark Sweep 併發-標記-清除),老年代的收集器,基於“標記-清除”演算法實現。

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器(響應時間優先)。降低STW的時間

CMS收集器主要用於要求低延遲(即:提高響應速度)的網際網路專案。

更適合在互動性較強的場景下。

CMS垃圾收集的過程:

(1)初始標記--對根節點物件以及他的直接引用的物件進行標記。這個階段會有STW,但是時間不會很長。

(2)併發標記--此時gc標記執行緒就會沿著上一步的標記對像,繼續做可達性分析。於此同時,使用者的工作執行緒也在執行。不會有STW出現。

(3)重新標記--因為上一步”"併發標記”的過程中,使用者的工作執行緒也在執行(物件之間的引用關係會發生變更),所以可能會導致出現一些錯誤的標記,標記出的非垃圾物件,此時可能變成了垃圾物件:或者垃圾物件變成非垃圾物件。重新標記就是為了糾正’併發標記‘過程中出現
的一些錯誤標記。這個階段會有STW,但是時間很短,發生錯誤的標記的物件不會很多。

(4)併發清除--之前三個階段標記出的垃圾物件開始被回收,此時使用者的工作執行緒也在繼續執行,沒有STW。

image-20241014211424223

引數控制:

-XX:+UseConcMarkSweepGC 老年代開啟CMS

-XX:MaxGCPauseMillis=200 設定GC暫停等待時間,單位為毫秒

-XX:UseCMSCompactAtFullCollection 可以讓 JVM 在執行標記清除完成後再做整理,避免記憶體碎片

-XX:ConcGCThreads 併發的 GC 執行緒數

5、G1收集器

JDK9及以後的版本中,G1是預設的垃圾收集器。

G1收集器既可以應用新生代也可以應用老生代

之前的垃圾收集器的共性問題:在垃圾收集過程中,一定會發生STW,垃圾收集器的發展就是為了能夠儘量縮短STW的時間。

G1拋棄了將新生代和老年代作為整塊記憶體空間的方式,但依然保留了新生代老年代的概念,只是老年代新生代的記憶體空間不再是物理連續的了,它們都是Region的集合。

每個Region的型別並不是固定的,=》可能之前是年輕代,經過了垃圾回收之後就變成了老年代。

G1採用 “區域性收集” 設計思路 , 以Region為基本單位的記憶體佈局,將java堆劃分成一些大小相等的Region(建議不超過2048個)。

每個Region大小 = 堆總空間 / region個數。

可以透過引數-XX:G1HeapRegionSize來指定Region的大小。

關於region的型別:E、S、O、H

E型別的region組成了堆中的新生代的eden區。

S型別的region表示新生代的s區(s0,s1)

O型別的region組成了堆中的old區。

H型別的region.用於單獨儲存大物件(大對像直接進入到老年代,其實就是進入到H區),H區就是由多個連續的region組成。

image-20241014211931671

引數控制

1、-XX:+UseG1GC:表示使用G1收集器

2、-XX:G1HeapRegionSize:指定每一個Region的大小。

3、-XX:MaxGCPauseMillis:設定期望的最大GC停頓時間指標

4、-XX:ParallelGCThreads:設定並行垃圾回收的執行緒數

G1垃圾回收過程

image-20230615161238695

G1的垃圾收集的過程,分為4個階段

(1)初始標記--對根節點物件以及他的直接引用的物件進行標記。這個階段會有STW,但是時間不會很長。

(2)併發標記--此時gc標記執行緒就會沿著上一步的標記對像,繼續做可達性分析。於此同時,使用者的工作執行緒也在執行。不會有STW出現。

(3)最終標記--因為上一步”"併發標記”的過程中,使用者的工作執行緒也在執行(物件之間的引用關係會發生變更),所以可能會導致出現一些錯誤的標記,標記出的非垃圾物件,此時可能變成了垃圾物件:或者垃圾物件變成非垃圾物件。重新標記就是為了糾正’併發標記‘過程中出現
的一些錯誤標記。這個階段會有STW,但是時間很短,發生錯誤的標記的物件不會很多。

(4)篩選回收--併發篩選清除。
		將各個region的回收價值和回收成本進行排序。
		在期望的停頓時間內,選擇一部分region進行垃圾回收; 
				region-1,可回收0.5m,預期100ms   垃圾回收耗時最小的
				region-2,可回收0.6m,預期130ms
				region-2,可回收0.7m,預期150ms
		垃圾回收的過程採用的是“複製”演算法。
		region-1需要被垃圾回收,首先將存活物件複製到另一個region中,然後region-1整體被釋放。
		優點:避免記憶體碎片。
		缺點:如果存活的物件過多,複製的過程較慢。

三色標記演算法

三色標記法是基於可達性分析演算法的一種實現方式。

垃圾收集器在標記的過程,有兩種標記方式:序列標記(例如:serial,parallel)、併發標記(例如:cms、G1)。

1、序列標記,會暫停所有使用者執行緒,全面進行標記;

2、併發標記,不會暫停使用者工作執行緒。實現這種併發標記的演算法就是 ===》三色標記法

三種顏色

三色標記演算法使用的是三種顏色來區分物件的:

1、白色:本物件還沒有被標記執行緒訪問過

2、灰色:本物件已經被訪問過,但是本物件引用的其他物件還沒有被全部訪問

3、黑色:本物件已經被訪問過,並且本物件引用的其他物件也都被訪問過了

image-20241014214105919

三色標記演算法流程

(1)期初,所有對像全部是白色
(2)所有的根節點對像直接變成黑色
(3)將根節點物件的所有直接引用物件由白色標記成灰色,並灰色物件依次進入到一個佇列中
(4)依次從佇列中取出每個灰色物件,將當前灰色物件的所有直接引用物件由白色變成灰色,並把剛成為灰色的物件依放入佇列中,最後將當前灰色物件變成黑色。
(5)反覆上邊的過程…

詳細圖解

1、起初所有物件都是白色

image-20240215133901503

2、三色標記初始階段,所有GC Roots的直接引用(A、B、E)變成灰色,然後將灰色節點放入到一個佇列中,此時GC Roots變成黑色

image-20240215133959578

3、然後從灰色佇列中取出隊頭灰色物件,例如A,將他的直接引用C、D變成灰色,放入佇列,A因為已掃描完它的所有直接引用物件,所以A變成黑色

image-20240215134113730

4、繼續取出灰色物件,例如B物件,將它的直接引用F標記為灰色,放入佇列,B物件此時標記為黑色

image-20240215134210327

5、繼續從佇列中取出灰色物件E,因為E沒有直接引用其他物件,將E直接標記為黑色

image-20240215134242731

6、重複上述步驟,取出C 、D 、F 物件,他們都沒有直接引用其他物件,直接變為黑色即可。

image-20240215134320959

7、最後,G物件是白色,說明G物件是一個垃圾物件,可以被清理掉。

三色標記演算法弊端

因為併發標記的過程中,使用者執行緒也在執行,那麼物件引用關係很可能發生變化,進而就會產生常見的兩個問題:

1、浮動垃圾:標記為不是垃圾的物件,變成了垃圾。

回到如下的狀態,此時E已經被標記為黑色,表示不是垃圾,不會被清除。

image-20241014220943385

因為併發標記時,同一時刻某個使用者執行緒將GC Root2和E物件之間的關係斷開了(objRoot2.e = null;)

image-20241014221007579

很顯然,E物件變為了垃圾物件,但是由於之前被標記為黑色,就不會被當作垃圾回收,這種問題稱之為浮動垃圾。

2、漏標/錯殺,標記為垃圾物件,變成了非垃圾。

image-20241014221618386

上述弊端的解決方案

  • 對於第一個問題,即使不去處理也無所謂,大不了等下一次GC的時候再清理。

  • 第二個問題就比較嚴重,會發生空指標異常(F被錯殺),出現第二個問題必須滿足兩個條件:

    1、併發標記過程中黑色物件(A) 新增引用 到 白色物件(F)

    2、灰色物件(B) 斷開了(減少引用) 同一個白色物件(F)引用

image-20241014221719677

  • 兩種解決方案:

(1)增量更新(Incremental Update)==》CMS採用

​ 是站在A物件的角度(新增引用的物件),在賦值操作之前,加個寫屏障,用來記錄新增的引用(A.f = F)。在 重新標記 階段,將A變成灰色入隊,重新掃描一次,以保證不會漏標。

(2)原始快照(SATB, Snapshot At The Beginning)===》G1採用

​ 是站在B物件的角度(減少引用的物件),在將B.f = F 改成B.f = null 之前,寫屏障記錄下F,這個F稱之為 原始快照。在 最終標記 階段,直接將F設為黑色。可以保證F不被回收,但是可能成為浮動垃圾。

四種引用型別

強引用

Java中預設宣告的就是強引用,比如:

Object obj = new Object();    //只要obj還指向Object物件,Object物件就不會被回收
obj = null;                   //手動置null

只要強引用存在,垃圾回收器將永遠不會回收被引用的物件,哪怕記憶體不足時,直接丟擲OutOfMemoryError,不會去回收。

如果想中斷強引用與物件之間的聯絡,可以顯示的將強引用賦值為null,這樣一來,JVM就可以適時的回收物件了!

示例:

/**
 * JVM引數:-verbose:gc -Xlog:gc* -Xms10M -Xmx10M -Xmn5M
 */
public class StrongReferenceDemo01 {

    private static List<Object> list = new ArrayList<Object>() ;
    public static void main(String[] args) {

        // 建立物件
        for(int x = 0 ;  x < 10 ; x++) {
            byte[] buff = new byte[1024 * 1024 * 1];
            list.add(buff);
        }
    }
}

軟引用

記憶體夠,軟引用物件不會被回收;

記憶體不夠,軟引用物件會被回收。

/**
 * JVM引數:-verbose:gc -Xlog:gc* -Xms10M -Xmx10M -Xmn5M
 */
public class SoftReferenceDemo01 {

    private static List<Object> list = new ArrayList<>();

    public static void main(String[] args) {
        // 建立陣列物件
        for(int x = 0 ; x < 10 ; x++) {
            SoftReference<byte[]> softReference = new SoftReference<byte[]>(new byte[1024 * 1024 * 1]) ;
            list.add(softReference) ;
        }
        System.gc();  // 主動通知垃圾回收器進行垃圾回收
        for(int i=0; i < list.size(); i++){
            Object obj = ((SoftReference) list.get(i)).get();
            System.out.println(obj);
        }
    }
}

我們發現無論迴圈建立多少個軟引用物件,列印結果總是有一些為null,這裡就說明了在記憶體不足的情況下,軟引用將會被自動回收。

弱引用

無論記憶體是否足夠,只要JVM開始GC,弱引用關聯的物件都會被回收。

/**
 * JVM引數:-verbose:gc -Xlog:gc* -Xms10M -Xmx10M -Xmn5M
 */
public class WeakReferenceDemo01 {

    private static List<Object> list = new ArrayList<>();

    public static void main(String[] args) {

        // 建立陣列物件
        for(int x = 0 ; x < 10 ; x++) {
            WeakReference<byte[]> weakReference = new WeakReference<byte[]>(new byte[1024 * 1024 * 1]) ;
            list.add(weakReference) ;
        }

        System.gc();  // 主動通知垃圾回收器進行垃圾回收

        for(int i=0; i < list.size(); i++){
            Object obj = ((WeakReference) list.get(i)).get();
            System.out.println(obj);
        }
        
    }
}

列印全是null。

虛引用

如果一個物件僅持有虛引用,它就和沒有任何引用一樣,它隨時可能會被回收。

在 JDK1.2 之後,用 PhantomReference 類來表示,透過檢視這個類的原始碼,發現它只有一個建構函式和一個 get() 方法,而且它的 get() 方法僅僅是返回一個null,也就是說將永遠無法透過虛引用來獲取物件,虛引用必須要和 ReferenceQueue 引用佇列一起使用。

特點:

1、每次垃圾回收時都會被回收,主要用於監測物件是否已經從記憶體中刪除。

2、虛引用必須和引用佇列關聯使用,當垃圾回收器準備回收一個物件時,如果發現它還有虛引用,就會把這個虛引用加入到與之關聯的引用佇列中。

3、程式可以透過判斷引用佇列中是否已經加入了虛引用,來了解被引用的物件是否將要被垃圾回收。如果程式發現某個虛引用已經被加入到引用佇列,那麼就可以在所引用的物件的記憶體被回收之前採取必要的行動。

示例程式碼:

public class PhantomReferenceDemo {

    public static void main(String[] args) throws InterruptedException {

        // 建立一個引用佇列
        ReferenceQueue<Object> referenceQueue = new ReferenceQueue<Object>();
        
        // 建立一個虛引用,指向一個Object物件
        PhantomReference<Object> phantomReference=new PhantomReference<Object>(new Object(),referenceQueue);
        
        // 主動通知垃圾回收器進行垃圾回收
        System.gc();
        
        // 從引用佇列中獲取元素, 該方法是阻塞方法
        System.out.println(referenceQueue.remove()); 

    }
}

相關文章