一天吃透JVM面試八股文

程式設計師大彬發表於2023-04-26

什麼是JVM?

JVM,全稱Java Virtual Machine(Java虛擬機器),是透過在實際的計算機上模擬模擬各種計算機功能來實現的。由一套位元組碼指令集、一組暫存器、一個棧、一個垃圾回收堆和一個儲存方法域等組成。JVM遮蔽了與作業系統平臺相關的資訊,使得Java程式只需要生成在Java虛擬機器上執行的目的碼(位元組碼),就可在多種平臺上不加修改的執行,這也是Java能夠“一次編譯,到處執行的”原因。

講一下JVM記憶體結構?

JVM記憶體結構分為5大區域,程式計數器虛擬機器棧本地方法棧方法區最全面的Java面試網站

程式計數器

執行緒私有的,作為當前執行緒的行號指示器,用於記錄當前虛擬機器正在執行的執行緒指令地址。程式計數器主要有兩個作用:

  1. 當前執行緒所執行的位元組碼的行號指示器,透過它實現程式碼的流程控制,如:順序執行、選擇、迴圈、異常處理。
  2. 在多執行緒的情況下,程式計數器用於記錄當前執行緒執行的位置,當執行緒被切換回來的時候能夠知道它上次執行的位置。

程式計數器是唯一一個不會出現 OutOfMemoryError 的記憶體區域,它的生命週期隨著執行緒的建立而建立,隨著執行緒的結束而死亡。

本文已經收錄到Github倉庫,該倉庫包含計算機基礎、Java基礎、多執行緒、JVM、資料庫、Redis、Spring、Mybatis、SpringMVC、SpringBoot、分散式、微服務、設計模式、架構、校招社招分享等核心知識點,歡迎star~

Github地址

如果訪問不了Github,可以訪問gitee地址。

gitee地址

虛擬機器棧

Java 虛擬機器棧是由一個個棧幀組成,而每個棧幀中都擁有:區域性變數表運算元棧動態連結方法出口資訊。每一次函式呼叫都會有一個對應的棧幀被壓入虛擬機器棧,每一個函式呼叫結束後,都會有一個棧幀被彈出。

區域性變數表是用於存放方法引數和方法內的區域性變數。

每個棧幀都包含一個指向執行時常量池中該棧所屬方法的符號引用,在方法呼叫過程中,會進行動態連結,將這個符號引用轉化為直接引用。

  • 部分符號引用在類載入階段的時候就轉化為直接引用,這種轉化就是靜態連結
  • 部分符號引用在執行期間轉化為直接引用,這種轉化就是動態連結

Java 虛擬機器棧也是執行緒私有的,每個執行緒都有各自的 Java 虛擬機器棧,而且隨著執行緒的建立而建立,隨著執行緒的死亡而死亡。Java 虛擬機器棧會出現兩種錯誤:StackOverFlowErrorOutOfMemoryError

可以透過 -Xss 引數來指定每個執行緒的虛擬機器棧記憶體大小:

java -Xss2M

本地方法棧

虛擬機器棧為虛擬機器執行 Java 方法服務,而本地方法棧則為虛擬機器使用到的 Native 方法服務。Native 方法一般是用其它語言(C、C++等)編寫的。

本地方法被執行的時候,在本地方法棧也會建立一個棧幀,用於存放該本地方法的區域性變數表、運算元棧、動態連結、出口資訊。

堆用於存放物件例項,是垃圾收集器管理的主要區域,因此也被稱作GC堆。堆可以細分為:新生代(Eden空間、From SurvivorTo Survivor空間)和老年代。

透過 -Xms設定程式啟動時佔用記憶體大小,透過-Xmx設定程式執行期間最大可佔用的記憶體大小。如果程式執行需要佔用更多的記憶體,超出了這個設定值,就會丟擲OutOfMemory異常。

java -Xms1M -Xmx2M

1.方法區

方法區與 Java 堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。

對方法區進行垃圾回收的主要目標是對常量池的回收和對類的解除安裝

2.永久代

方法區是 JVM 的規範,而永久代PermGen是方法區的一種實現方式,並且只有 HotSpot 有永久代。對於其他型別的虛擬機器,如JRockit沒有永久代。由於方法區主要儲存類的相關資訊,所以對於動態生成類的場景比較容易出現永久代的記憶體溢位。

3.元空間

JDK 1.8 的時候,HotSpot的永久代被徹底移除了,使用元空間替代。元空間的本質和永久代類似,都是對JVM規範中方法區的實現。兩者最大的區別在於:元空間並不在虛擬機器中,而是使用直接記憶體。

為什麼要將永久代替換為元空間呢?

永久代記憶體受限於 JVM 可用記憶體,而元空間使用的是直接記憶體,受本機可用記憶體的限制,雖然元空間仍舊可能溢位,但是相比永久代記憶體溢位的機率更小。

執行時常量池

執行時常量池是方法區的一部分,在類載入之後,會將編譯器生成的各種字面量和符號引號放到執行時常量池。在執行期間動態生成的常量,如 String 類的 intern()方法,也會被放入執行時常量池。

直接記憶體

直接記憶體並不是虛擬機器執行時資料區的一部分,也不是虛擬機器規範中定義的記憶體區域,但是這部分記憶體也被頻繁地使用。而且也可能導致 OutOfMemoryError 錯誤出現。

NIO的Buffer提供了DirectBuffer,可以直接訪問系統實體記憶體,避免堆內記憶體到堆外記憶體的資料複製操作,提高效率。DirectBuffer直接分配在實體記憶體中,並不佔用堆空間,其可申請的最大記憶體受作業系統限制,不受最大堆記憶體的限制。

直接記憶體的讀寫操作比堆記憶體快,可以提升程式I/O操作的效能。通常在I/O通訊過程中,會存在堆內記憶體到堆外記憶體的資料複製操作,對於需要頻繁進行記憶體間資料複製且生命週期較短的暫存資料,都建議儲存到直接記憶體。

好東西應該要分享出來!我把自己學習計算機多年以來的書籍分享出來了,彙總到一個計算機經典程式設計書籍倉庫了,一共300多本,包括C語言、C++、Java、Python、前端、資料庫、作業系統、計算機網路、資料結構和演算法、機器學習、程式設計人生等,可以star一下,下次找書直接在上面搜尋,倉庫持續更新中~

Github地址

Java物件的定位方式

Java 程式透過棧上的 reference 資料來操作堆上的具體物件。物件的訪問方式由虛擬機器實現而定,目前主流的訪問方式有使用控制程式碼和直接指標兩種:

  • 如果使用控制程式碼的話,那麼 Java 堆中將會劃分出一塊記憶體來作為控制程式碼池,reference 中儲存的就是物件的控制程式碼地址,而控制程式碼中包含了物件例項資料與型別資料各自的具體地址資訊。使用控制程式碼來訪問的最大好處是 reference 中儲存的是穩定的控制程式碼地址,在物件被移動時只會改變控制程式碼中的例項資料指標,而 reference 本身不需要修改。
  • 直接指標。reference 中儲存的直接就是物件的地址。物件包含到物件型別資料的指標,透過這個指標可以訪問物件型別資料。使用直接指標訪問方式最大的好處就是訪問物件速度快,它節省了一次指標定位的時間開銷,虛擬機器hotspot主要是使用直接指標來訪問物件。

說一下堆疊的區別?

  1. 堆的實體地址分配是不連續的,效能較慢;棧的實體地址分配是連續的,效能相對較快。
  2. 堆存放的是物件的例項和陣列;棧存放的是區域性變數,運算元棧,返回結果等。
  3. 堆是執行緒共享的;棧是執行緒私有的。

什麼情況下會發生棧溢位?

  • 當執行緒請求的棧深度超過了虛擬機器允許的最大深度時,會丟擲StackOverFlowError異常。這種情況通常是因為方法遞迴沒終止條件。
  • 新建執行緒的時候沒有足夠的記憶體去建立對應的虛擬機器棧,虛擬機器會丟擲OutOfMemoryError異常。比如執行緒啟動過多就會出現這種情況。

類檔案結構

Class 檔案結構如下:

ClassFile {
    u4             magic; //類檔案的標誌
    u2             minor_version;//小版本號
    u2             major_version;//大版本號
    u2             constant_pool_count;//常量池的數量
    cp_info        constant_pool[constant_pool_count-1];//常量池
    u2             access_flags;//類的訪問標記
    u2             this_class;//當前類的索引
    u2             super_class;//父類
    u2             interfaces_count;//介面
    u2             interfaces[interfaces_count];//一個類可以實現多個介面
    u2             fields_count;//欄位屬性
    field_info     fields[fields_count];//一個類會可以有個欄位
    u2             methods_count;//方法數量
    method_info    methods[methods_count];//一個類可以有個多個方法
    u2             attributes_count;//此類的屬性表中的屬性數
    attribute_info attributes[attributes_count];//屬性表集合
}

主要引數如下:

魔數class檔案標誌。

檔案版本:高版本的 Java 虛擬機器可以執行低版本編譯器生成的類檔案,但是低版本的 Java 虛擬機器不能執行高版本編譯器生成的類檔案。

常量池:存放字面量和符號引用。字面量類似於 Java 的常量,如字串,宣告為final的常量值等。符號引用包含三類:類和介面的全限定名,方法的名稱和描述符,欄位的名稱和描述符。

訪問標誌:識別類或者介面的訪問資訊,比如這個Class是類還是介面,是否為 public 或者 abstract 型別等等。

當前類的索引:類索引用於確定這個類的全限定名。

什麼是類載入?類載入的過程?

類的載入指的是將類的class檔案中的二進位制資料讀入到記憶體中,將其放在執行時資料區的方法區內,然後在堆區建立一個此類的物件,透過這個物件可以訪問到方法區對應的類資訊。

載入

  1. 透過類的全限定名獲取定義此類的二進位制位元組流
  2. 將位元組流所代表的靜態儲存結構轉換為方法區的執行時資料結構
  3. 在記憶體中生成一個代表該類的Class物件,作為方法區類資訊的訪問入口

驗證

確保Class檔案的位元組流中包含的資訊符合虛擬機器規範,保證在執行後不會危害虛擬機器自身的安全。主要包括四種驗證:檔案格式驗證,後設資料驗證,位元組碼驗證,符號引用驗證

準備

為類變數分配記憶體並設定類變數初始值的階段。

解析

虛擬機器將常量池內的符號引用替換為直接引用的過程。符號引用用於描述目標,直接引用直接指向目標的地址。

初始化

開始執行類中定義的Java程式碼,初始化階段是呼叫類構造器的過程。

什麼是雙親委派模型?

一個類載入器收到一個類的載入請求時,它首先不會自己嘗試去載入它,而是把這個請求委派給父類載入器去完成,這樣層層委派,因此所有的載入請求最終都會傳送到頂層的啟動類載入器中,只有當父類載入器反饋自己無法完成這個載入請求時,子載入器才會嘗試自己去載入。

雙親委派模型的具體實現程式碼在 java.lang.ClassLoader中,此類的 loadClass() 方法執行過程如下:先檢查類是否已經載入過,如果沒有則讓父類載入器去載入。當父類載入器載入失敗時丟擲 ClassNotFoundException,此時嘗試自己去載入。原始碼如下:

public abstract class ClassLoader {
    // The parent class loader for delegation
    private final ClassLoader parent;

    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }

    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    c = findClass(name);
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

    protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }
}

為什麼需要雙親委派模型?

雙親委派模型的好處:可以防止記憶體中出現多份同樣的位元組碼。如果沒有雙親委派模型而是由各個類載入器自行載入的話,如果使用者編寫了一個java.lang.Object的同名類並放在ClassPath中,多個類載入器都去載入這個類到記憶體中,系統中將會出現多個不同的Object類,那麼類之間的比較結果及類的唯一性將無法保證。

什麼是類載入器,類載入器有哪些?

  • 實現透過類的全限定名獲取該類的二進位制位元組流的程式碼塊叫做類載入器。

    主要有一下四種類載入器:

    • 啟動類載入器:用來載入 Java 核心類庫,無法被 Java 程式直接引用。
    • 擴充套件類載入器:它用來載入 Java 的擴充套件庫。Java 虛擬機器的實現會提供一個擴充套件庫目錄。該類載入器在此目錄裡面查詢並載入 Java 類。
    • 系統類載入器:它根據應用的類路徑來載入 Java 類。可透過ClassLoader.getSystemClassLoader()獲取它。
    • 自定義類載入器:透過繼承java.lang.ClassLoader類的方式實現。

類的例項化順序?

  1. 父類中的static程式碼塊,當前類的static程式碼塊
  2. 父類的普通程式碼塊
  3. 父類的建構函式
  4. 當前類普通程式碼塊
  5. 當前類的建構函式

如何判斷一個物件是否存活?

對堆垃圾回收前的第一步就是要判斷那些物件已經死亡(即不再被任何途徑引用的物件)。判斷物件是否存活有兩種方法:引用計數法和可達性分析。

引用計數法

給物件中新增一個引用計數器,每當有一個地方引用它,計數器就加 1;當引用失效,計數器就減 1;任何時候計數器為 0 的物件就是不可能再被使用的。

這種方法很難解決物件之間相互迴圈引用的問題。比如下面的程式碼,obj1obj2 互相引用,這種情況下,引用計數器的值都是1,不會被垃圾回收。

public class ReferenceCount {
    Object instance = null;
    public static void main(String[] args) {
        ReferenceCount obj1 = new ReferenceCount();
        ReferenceCount obj2 = new ReferenceCount();
        obj1.instance = obj2;
        obj2.instance = obj1;
        obj1 = null;
        obj2 = null;
    }
}

可達性分析

透過GC Root物件為起點,從這些節點向下搜尋,搜尋所走過的路徑叫引用鏈,當一個物件到GC Root沒有任何的引用鏈相連時,說明這個物件是不可用的。

可作為GC Roots的物件有哪些?

  1. 虛擬機器棧中引用的物件
  2. 本地方法棧中Native方法引用的物件
  3. 方法區中類靜態屬性引用的物件
  4. 方法區中常量引用的物件

什麼情況下類會被解除安裝?

需要同時滿足以下 3 個條件類才可能會被解除安裝 :

  • 該類所有的例項都已經被回收。
  • 載入該類的類載入器已經被回收。
  • 該類對應的 java.lang.Class 物件沒有在任何地方被引用,無法在任何地方透過反射訪問該類的方法。

虛擬機器可以對滿足上述 3 個條件的類進行回收,但不一定會進行回收。

強引用、軟引用、弱引用、虛引用是什麼,有什麼區別?

強引用:在程式中普遍存在的引用賦值,類似Object obj = new Object()這種引用關係。只要強引用關係還存在,垃圾收集器就永遠不會回收掉被引用的物件。

軟引用:如果記憶體空間足夠,垃圾回收器就不會回收它,如果記憶體空間不足了,就會回收這些物件的記憶體。

//軟引用
SoftReference<String> softRef = new SoftReference<String>(str);

弱引用:在進行垃圾回收時,不管當前記憶體空間足夠與否,都會回收只具有弱引用的物件。

//弱引用
WeakReference<String> weakRef = new WeakReference<String>(str);

虛引用:虛引用並不會決定物件的生命週期。如果一個物件僅持有虛引用,那麼它就和沒有任何引用一樣,在任何時候都可能被垃圾回收。虛引用主要是為了能在物件被收集器回收時收到一個系統通知

GC是什麼?為什麼要GC?

GC(Garbage Collection),垃圾回收,是Java與C++的主要區別之一。作為Java開發者,一般不需要專門編寫記憶體回收和垃圾清理程式碼。這是因為在Java虛擬機器中,存在自動記憶體管理和垃圾清理機制。對JVM中的記憶體進行標記,並確定哪些記憶體需要回收,根據一定的回收策略,自動的回收記憶體,保證JVM中的記憶體空間,防止出現記憶體洩露和溢位問題。

Minor GC 和 Full GC的區別?

  • Minor GC:回收新生代,因為新生代物件存活時間很短,因此 Minor GC會頻繁執行,執行的速度一般也會比較快。
  • Full GC:回收老年代和新生代,老年代的物件存活時間長,因此 Full GC 很少執行,執行速度會比 Minor GC 慢很多。

記憶體的分配策略?

物件優先在 Eden 分配

大多數情況下,物件在新生代 Eden 上分配,當 Eden 空間不夠時,觸發 Minor GC

大物件直接進入老年代

大物件是指需要連續記憶體空間的物件,最典型的大物件有長字串和大陣列。可以設定JVM引數 -XX:PretenureSizeThreshold,大於此值的物件直接在老年代分配。

長期存活的物件進入老年代

透過引數 -XX:MaxTenuringThreshold 可以設定物件進入老年代的年齡閾值。物件在Survivor區每經過一次 Minor GC,年齡就增加 1 歲,當它的年齡增加到一定程度,就會被晉升到老年代中。

動態物件年齡判定

並非物件的年齡必須達到 MaxTenuringThreshold 才能晉升老年代,如果在 Survivor 中相同年齡所有物件大小的總和大於 Survivor 空間的一半,則年齡大於或等於該年齡的物件可以直接進入老年代,無需達到 MaxTenuringThreshold 年齡閾值。

空間分配擔保

在發生 Minor GC 之前,虛擬機器先檢查老年代最大可用的連續空間是否大於新生代所有物件總空間,如果條件成立的話,那麼 Minor GC 是安全的。如果不成立的話虛擬機器會檢視 HandlePromotionFailure 的值是否允許擔保失敗。如果允許,那麼就會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代物件的平均大小,如果大於,將嘗試著進行一次 Minor GC;如果小於,或者 HandlePromotionFailure 的值為不允許擔保失敗,那麼就要進行一次 Full GC

Full GC 的觸發條件?

對於 Minor GC,其觸發條件比較簡單,當 Eden 空間滿時,就將觸發一次 Minor GC。而 Full GC 觸發條件相對複雜,有以下情況會發生 full GC:

呼叫 System.gc()

只是建議虛擬機器執行 Full GC,但是虛擬機器不一定真正去執行。不建議使用這種方式,而是讓虛擬機器管理記憶體。

老年代空間不足

老年代空間不足的常見場景為前文所講的大物件直接進入老年代、長期存活的物件進入老年代等。為了避免以上原因引起的 Full GC,應當儘量不要建立過大的物件以及陣列、注意編碼規範避免記憶體洩露。除此之外,可以透過 -Xmn 引數調大新生代的大小,讓物件儘量在新生代被回收掉,不進入老年代。還可以透過 -XX:MaxTenuringThreshold 調大物件進入老年代的年齡,讓物件在新生代多存活一段時間。

空間分配擔保失敗

使用複製演算法的 Minor GC 需要老年代的記憶體空間作擔保,如果擔保失敗會執行一次 Full GC。

JDK 1.7 及以前的永久代空間不足

在 JDK 1.7 及以前,HotSpot 虛擬機器中的方法區是用永久代實現的,永久代中存放的為一些 Class 的資訊、常量、靜態變數等資料。當系統中要載入的類、反射的類和呼叫的方法較多時,永久代可能會被佔滿,在未配置為採用 CMS GC 的情況下也會執行 Full GC。如果經過 Full GC 仍然回收不了,那麼虛擬機器會丟擲 java.lang.OutOfMemoryError

垃圾回收演算法有哪些?

垃圾回收演算法有四種,分別是標記清除法、標記整理法、複製演算法、分代收集演算法

標記清除演算法

首先利用可達性去遍歷記憶體,把存活物件和垃圾物件進行標記。標記結束後統一將所有標記的物件回收掉。這種垃圾回收演算法效率較低,並且會產生大量不連續的空間碎片

複製清除演算法

半區複製,用於新生代垃圾回收。將記憶體分為大小相同的兩塊,每次使用其中的一塊。當這一塊的記憶體使用完後,就將還存活的物件複製到另一塊去,然後再把使用的空間一次清理掉。

特點:實現簡單,執行高效,但可用記憶體縮小為了原來的一半,浪費空間。

標記整理演算法

根據老年代的特點提出的一種標記演算法,標記過程仍然與標記-清除演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉邊界以外的記憶體。

分類收集演算法

根據各個年代的特點採用最適當的收集演算法。

一般將堆分為新生代和老年代。

  • 新生代使用複製演算法
  • 老年代使用標記清除演算法或者標記整理演算法

在新生代中,每次垃圾收集時都有大批物件死去,只有少量存活,使用複製演算法比較合適,只需要付出少量存活物件的複製成本就可以完成收集。老年代物件存活率高,適合使用標記-清理或者標記-整理演算法進行垃圾回收。

有哪些垃圾回收器?

垃圾回收器主要分為以下幾種:Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1

這7種垃圾收集器的特點:

收集器序列、並行or併發新生代/老年代演算法目標適用場景
Serial序列新生代複製演算法響應速度優先單CPU環境下的Client模式
ParNew並行新生代複製演算法響應速度優先多CPU環境時在Server模式下與CMS配合
Parallel Scavenge並行新生代複製演算法吞吐量優先在後臺運算而不需要太多互動的任務
Serial Old序列老年代標記-整理響應速度優先單CPU環境下的Client模式、CMS的後備預案
Parallel Old並行老年代標記-整理吞吐量優先在後臺運算而不需要太多互動的任務
CMS併發老年代標記-清除響應速度優先集中在網際網路站或B/S系統服務端上的Java應用
G1併發both標記-整理+複製演算法響應速度優先面向服務端應用,將來替換CMS

Serial 收集器

單執行緒收集器,使用一個垃圾收集執行緒去進行垃圾回收,在進行垃圾回收的時候必須暫停其他所有的工作執行緒( Stop The World ),直到它收集結束。

特點:簡單高效;記憶體消耗小;沒有執行緒互動的開銷,單執行緒收集效率高;需暫停所有的工作執行緒,使用者體驗不好。

ParNew 收集器

Serial收集器的多執行緒版本,除了使用多執行緒進行垃圾收集外,其他行為、引數與 Serial 收集器基本一致。

Parallel Scavenge 收集器

新生代收集器,基於複製清除演算法實現的收集器。特點是吞吐量優先,能夠並行收集的多執行緒收集器,允許多個垃圾回收執行緒同時執行,降低垃圾收集時間,提高吞吐量。所謂吞吐量就是 CPU 中用於執行使用者程式碼的時間與 CPU 總消耗時間的比值(吞吐量 = 執行使用者程式碼時間 /(執行使用者程式碼時間 + 垃圾收集時間))。Parallel Scavenge 收集器關注點是吞吐量,高效率的利用 CPU 資源CMS 垃圾收集器關注點更多的是使用者執行緒的停頓時間

Parallel Scavenge收集器提供了兩個引數用於精確控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX:MaxGCPauseMillis引數以及直接設定吞吐量大小的-XX:GCTimeRatio引數。

  • -XX:MaxGCPauseMillis引數的值是一個大於0的毫秒數,收集器將盡量保證記憶體回收花費的時間不超過使用者設定值。
  • -XX:GCTimeRatio引數的值大於0小於100,即垃圾收集時間佔總時間的比率,相當於吞吐量的倒數。

Serial Old 收集器

Serial 收集器的老年代版本,單執行緒收集器,使用標記整理演算法

Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本。多執行緒垃圾收集,使用標記整理演算法

CMS 收集器

Concurrent Mark Sweep ,併發標記清除,追求獲取最短停頓時間,實現了讓垃圾收集執行緒與使用者執行緒基本上同時工作

CMS 垃圾回收基於標記清除演算法實現,整個過程分為四個步驟:

  • 初始標記: 暫停所有使用者執行緒(Stop The World),記錄直接與 GC Roots 直接相連的物件 。
  • 併發標記:從GC Roots開始對堆中物件進行可達性分析,找出存活物件,耗時較長,但是不需要停頓使用者執行緒。
  • 重新標記: 在併發標記期間物件的引用關係可能會變化,需要重新進行標記。此階段也會暫停所有使用者執行緒。
  • 併發清除:清除標記物件,這個階段也是可以與使用者執行緒同時併發的。

在整個過程中,耗時最長的是併發標記和併發清除階段,這兩個階段垃圾收集執行緒都可以與使用者執行緒一起工作,所以從總體上來說,CMS收集器的記憶體回收過程是與使用者執行緒一起併發執行的。

優點:併發收集,停頓時間短。

缺點

  • 標記清除演算法導致收集結束有大量空間碎片
  • 產生浮動垃圾,在併發清理階段使用者執行緒還在執行,會不斷有新的垃圾產生,這一部分垃圾出現在標記過程之後,CMS無法在當次收集中回收它們,只好等到下一次垃圾回收再處理;

G1收集器

G1垃圾收集器的目標是在不同應用場景中追求高吞吐量和低停頓之間的最佳平衡

G1將整個堆分成相同大小的分割槽(Region),有四種不同型別的分割槽:Eden、Survivor、Old和Humongous。分割槽的大小取值範圍為 1M 到 32M,都是2的冪次方。分割槽大小可以透過-XX:G1HeapRegionSize引數指定。Humongous區域用於儲存大物件。G1規定只要大小超過了一個分割槽容量一半的物件就認為是大物件。

G1 收集器對各個分割槽回收所獲得的空間大小和回收所需時間的經驗值進行排序,得到一個優先順序列表,每次根據使用者設定的最大回收停頓時間,優先回收價值最大的分割槽。

特點:可以由使用者指定期望的垃圾收集停頓時間。

G1 收集器的回收過程分為以下幾個步驟:

  • 初始標記。暫停所有其他執行緒,記錄直接與 GC Roots 直接相連的物件,耗時較短 。
  • 併發標記。從GC Roots開始對堆中物件進行可達性分析,找出要回收的物件,耗時較長,不過可以和使用者程式併發執行。
  • 最終標記。需對其他執行緒做短暫的暫停,用於處理併發標記階段物件引用出現變動的區域。
  • 篩選回收。對各個分割槽的回收價值和成本進行排序,根據使用者所期望的停頓時間來制定回收計劃,然後把決定回收的分割槽的存活物件複製到空的分割槽中,再清理掉整個舊的分割槽的全部空間。這裡的操作涉及存活物件的移動,會暫停使用者執行緒,由多條收集器執行緒並行完成。

常用的 JVM 調優的命令都有哪些?

jps:列出本機所有 Java 程式的程式號

常用引數如下:

  • -m 輸出main方法的引數
  • -l 輸出完全的包名和應用主類名
  • -v 輸出JVM引數
jps -lvm
//output
//4124 com.zzx.Application -javaagent:E:\IDEA2019\lib\idea_rt.jar=10291:E:\IDEA2019\bin -Dfile.encoding=UTF-8

jstack:檢視某個 Java 程式內的執行緒堆疊資訊。使用引數-l可以列印額外的鎖資訊,發生死鎖時可以使用jstack -l pid觀察鎖持有情況。

jstack -l 4124 | more

輸出結果如下:

"http-nio-8001-exec-10" #40 daemon prio=5 os_prio=0 tid=0x000000002542f000 nid=0x4028 waiting on condition [0x000000002cc9e000]
   java.lang.Thread.State: WAITING (parking)
        at sun.misc.Unsafe.park(Native Method)
        - parking to wait for  <0x000000077420d7e8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
        at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
        at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039)
        at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442)
        at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:103)
        at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:31)
        at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074)
        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134)
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
        at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
        at java.lang.Thread.run(Thread.java:748)

   Locked ownable synchronizers:
        - None

WAITING (parking)指執行緒處於掛起中,在等待某個條件發生,來把自己喚醒。

jstat:用於檢視虛擬機器各種執行狀態資訊(類裝載、記憶體、垃圾收集等執行資料)。使用引數-gcuitl可以檢視垃圾回收的統計資訊。

jstat -gcutil 4124
  S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT
  0.00   0.00  67.21  19.20  96.36  94.96     10    0.084     3    0.191    0.275

引數說明:

  • S0Survivor0區當前使用比例
  • S1Survivor1區當前使用比例
  • EEden區使用比例
  • O:老年代使用比例
  • M:後設資料區使用比例
  • CCS:壓縮使用比例
  • YGC:年輕代垃圾回收次數
  • FGC:老年代垃圾回收次數
  • FGCT:老年代垃圾回收消耗時間
  • GCT:垃圾回收消耗總時間

jmap:檢視堆記憶體快照。透過jmap命令可以獲得執行中的堆記憶體的快照,從而可以對堆記憶體進行離線分析。

查詢程式4124的堆記憶體快照,輸出結果如下:

>jmap -heap 4124
Attaching to process ID 4124, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.221-b11

using thread-local object allocation.
Parallel GC with 6 thread(s)

Heap Configuration:
   MinHeapFreeRatio         = 0
   MaxHeapFreeRatio         = 100
   MaxHeapSize              = 4238344192 (4042.0MB)
   NewSize                  = 88604672 (84.5MB)
   MaxNewSize               = 1412431872 (1347.0MB)
   OldSize                  = 177733632 (169.5MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 327155712 (312.0MB)
   used     = 223702392 (213.33922576904297MB)
   free     = 103453320 (98.66077423095703MB)
   68.37795697725736% used
From Space:
   capacity = 21495808 (20.5MB)
   used     = 0 (0.0MB)
   free     = 21495808 (20.5MB)
   0.0% used
To Space:
   capacity = 23068672 (22.0MB)
   used     = 0 (0.0MB)
   free     = 23068672 (22.0MB)
   0.0% used
PS Old Generation
   capacity = 217579520 (207.5MB)
   used     = 41781472 (39.845916748046875MB)
   free     = 175798048 (167.65408325195312MB)
   19.20285144484187% used

27776 interned Strings occupying 3262336 bytes.

jinfojinfo -flags 1。檢視當前的應用JVM引數配置。

Attaching to process ID 1, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.111-b14
Non-default VM flags: -XX:CICompilerCount=2 -XX:InitialHeapSize=31457280 -XX:MaxHeapSize=480247808 -XX:MaxNewSize=160038912 -XX:MinHeapDeltaBytes=196608 -XX:NewSize=10485760 -XX:OldSize=20971520 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops
Command line:

檢視所有引數java -XX:+PrintFlagsFinal -version。用於檢視最終值,初始值可能被修改掉(檢視初始值可以使用java -XX:+PrintFlagsInitial)。

[Global flags]
    uintx AdaptiveSizeDecrementScaleFactor          = 4                                   {product}
    uintx AdaptiveSizeMajorGCDecayTimeScale         = 10                                  {product}
    uintx AdaptiveSizePausePolicy                   = 0                                   {product}
    uintx AdaptiveSizePolicyCollectionCostMargin    = 50                                  {product}
    uintx AdaptiveSizePolicyInitializingSteps       = 20                                  {product}
    uintx AdaptiveSizePolicyOutputInterval          = 0                                   {product}
    uintx AdaptiveSizePolicyWeight                  = 10                                  {product}
    uintx AdaptiveSizeThroughPutPolicy              = 0                                   {product}
    uintx AdaptiveTimeWeight                        = 25                                  {product}
     bool AdjustConcurrency                         = false                               {product}
     bool AggressiveOpts                            = false                               {product}
     ....

物件頭瞭解嗎?

Java 記憶體中的物件由以下三部分組成:物件頭例項資料對齊填充位元組

而物件頭由以下三部分組成:mark word指向類資訊的指標陣列長度(陣列才有)。

mark word包含:物件的雜湊碼、分代年齡和鎖標誌位。

物件的例項資料就是 Java 物件的屬性和值。

對齊填充位元組:因為JVM要求物件佔的記憶體大小是 8bit 的倍數,因此後面有幾個位元組用於把物件的大小補齊至 8bit 的倍數。

記憶體對齊的主要作用是:

  1. 平臺原因:不是所有的硬體平臺都能訪問任意地址上的任意資料的;某些硬體平臺只能在某些地址處取某些特定型別的資料,否則丟擲硬體異常。
  2. 效能原因:經過記憶體對齊後,CPU的記憶體訪問速度大大提升。

Object o = new Object()佔用多少個位元組?

答案是16個位元組。

首先先分析物件的記憶體佈局。

在 JVM 中,Java物件儲存在堆中時,由以下三部分組成:

物件頭(Object Header):包括關於堆物件的佈局、型別、GC狀態、同步狀態和標識雜湊碼的基本資訊。由兩個詞mark wordclasspointer組成,如果是陣列物件的話,還會有一個length field

  • mark word:通常是一組位域,用於儲存物件自身的執行時資料,如hashCode、GC分代年齡、鎖同步資訊等等。佔用64個位元(64位系統),8個位元組。
  • classpointer:類指標,是物件指向它的類後設資料的指標,虛擬機器透過這個指標來確定這個物件是哪個類的例項。佔用64個位元(64位系統),8個位元組。開啟壓縮類指標後,佔用32個位元,4個位元組。

例項資料(Instance Data):儲存了程式碼中定義的各種欄位的內容,包括從父類繼承下來的欄位和子類中定義的欄位。如果物件無屬性欄位,則這裡就不會有資料。根據欄位型別的不同佔不同的位元組,例如boolean型別佔1個位元組,int型別佔4個位元組等等。為了提高儲存空間的利用率,這部分資料的儲存順序會受到虛擬機器分配策略引數和欄位在Java原始碼中定義順序的影響。

對齊填充(Padding):物件可以有對齊資料也可以沒有。預設情況下,Java虛擬機器堆中物件的起始地址需要對齊至8的整數倍。如果一個物件的物件頭和例項資料佔用的總大小不到8位元組的整數倍,則以此來填充物件大小至8位元組的整數倍。

為什麼要對齊填充?欄位記憶體對齊的其中一個原因,是讓欄位只出現在同一CPU的快取行中。如果欄位不是對齊的,那麼就有可能出現跨快取行的欄位。也就是說,該欄位的讀取可能需要替換兩個快取行,而該欄位的儲存也會同時汙染兩個快取行。這兩種情況對程式的執行效率而言都是不利的。其實對其填充的最終目的是為了計算機高效定址

經過上面的分析之後,就可以知道Object o = new Object()具體佔用多少記憶體了(以64位系統為例)。

  • 在開啟指標壓縮的情況下,markword佔用8位元組,classpointer佔用4位元組,Instance data無資料,總共是12位元組,由於物件需要為8的整數倍,Padding會補充4個位元組,總共佔用16位元組。
  • 在沒有開啟指標壓縮的情況下,markword佔用8位元組,classpointer佔用8位元組,Instance data無資料,也是佔用16位元組。

main方法執行過程

以下是示例程式碼:

public class Application {
    public static void main(String[] args) {
        Person p = new Person("大彬");
        p.getName();
    }
}

class Person {
    public String name;

    public Person(String name) {
        this.name = name;
    }

    public String getName() {
        return this.name;
    }
}

執行main方法的過程如下:

  1. 編譯Application.java後得到 Application.class 後,執行這個class檔案,系統會啟動一個 JVM 程式,從類路徑中找到一個名為 Application.class 的二進位制檔案,將 Application 類資訊載入到執行時資料區的方法區內,這個過程叫做類的載入。
  2. JVM 找到 Application 的主程式入口,執行main方法。
  3. main方法的第一條語句為 Person p = new Person("大彬") ,就是讓 JVM 建立一個Person物件,但是這個時候方法區中是沒有 Person 類的資訊的,所以 JVM 馬上載入 Person 類,把 Person 類的資訊放到方法區中。
  4. 載入完 Person 類後,JVM 在堆中分配記憶體給 Person 物件,然後呼叫建構函式初始化 Person 物件,這個 Person 物件持有指向方法區中的 Person 類的型別資訊的引用。
  5. 執行p.getName()時,JVM 根據 p 的引用找到 p 所指向的物件,然後根據此物件持有的引用定位到方法區中 Person 類的型別資訊的方法表,獲得 getName() 的位元組碼地址。
  6. 執行getName()方法。

物件建立過程

  1. 類載入檢查:當虛擬機器遇到一條 new 指令時,首先檢查是否能在常量池中定位到這個類的符號引用,並且檢查這個符號引用代表的類是否已被載入過、解析和初始化過。如果沒有,那先執行類載入。
  2. 分配記憶體:在類載入檢查透過後,接下來虛擬機器將為物件例項分配記憶體。
  3. 初始化。分配到的記憶體空間都初始化為零值,透過這個操作保證了物件的欄位可以不賦初始值就直接使用,程式能訪問到這些欄位的資料型別所對應的零值。
  4. 設定物件頭Hotspot 虛擬機器的物件頭包括:儲存物件自身的執行時資料(雜湊碼、分代年齡、鎖標誌等等)、型別指標和資料長度(陣列物件才有),型別指標就是物件指向它的類資訊的指標,虛擬機器透過這個指標來確定這個物件是哪個類的例項。
  5. 按照Java程式碼進行初始化

如何排查 OOM 的問題?

線上JVM必須配置-XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath=/tmp/heapdump.hprof,當OOM發生時自動 dump 堆記憶體資訊到指定目錄

排查 OOM 的方法如下:

  • 檢視伺服器執行日誌日誌,捕捉到記憶體溢位異常
  • jstat 檢視監控JVM的記憶體和GC情況,評估問題大概出在什麼區域
  • 使用MAT工具載入dump檔案,分析大物件的佔用情況

什麼是記憶體溢位和記憶體洩露?

記憶體溢位指的是程式申請記憶體時,沒有足夠的記憶體供申請者使用,比如給了你一塊儲存int型別資料的儲存空間,但是你卻儲存long型別的資料,那麼結果就是記憶體不夠用,此時就會報錯OOM,即記憶體溢位。

記憶體洩露是指程式中間動態分配了記憶體,但在程式結束時沒有釋放這部分記憶體,從而造成那部分記憶體不可用的情況。這種情況重啟計算機可以解決,但也有可能再次發生記憶體洩露。記憶體洩露和硬體沒有關係,它是由軟體設計缺陷引起的。

像IO操作或者網路連線等,在使用完成之後沒有呼叫close()方法將其連線關閉,那麼它們佔用的記憶體是不會自動被GC回收的,此時就會產生記憶體洩露。

比如運算元據庫時,透過SessionFactory獲取一個session:

Session session=sessionFactory.openSession();

完成後我們必須呼叫session.close()方法關閉,否則就會產生記憶體洩露,因為sessionFactory這個長生命週期物件一直持有session這個短生命週期物件的引用。

那兩者有什麼不同呢?

記憶體洩露可以透過完善程式碼來避免,記憶體溢位可以透過調整配置來減少發生頻率,但無法徹底避免。

如何避免記憶體洩露和溢位呢?

  1. 儘早釋放無用物件的引用。比如使用臨時變數的時候,讓引用變數在退出活動域後自動設定為null,暗示垃圾收集器來收集該物件,防止發生記憶體洩露。
  2. 儘量少用靜態變數。因為靜態變數是全域性的,GC不會回收。
  3. 避免集中建立物件尤其是大物件,如果可以的話儘量使用流操作。
  4. 儘量運用池化技術(資料庫連線池等)以提高系統效能。
  5. 避免在迴圈中建立過多物件

參考資料

  • 周志明. 深入理解 Java 虛擬機器 [M]. 機械工業出版社

相關文章