【2022】JVM常見面試真題詳解

文章目錄

    • 5. JVM
        • 5.1 JVM包含哪幾部分?
        • 5.2 JVM是如何運行的?
        • 5.3 Java程序是怎么運行的?
        • 5.4 本地方法棧有什么用?
        • 5.5 沒有程序計數器會怎么樣?
        • 5.6 說一說Java的內存分布情況
        • 5.7 類存放在哪里?
        • 5.8 局部變量存放在哪里?
        • 5.9 介紹一下Java代碼的編譯過程
        • 5.10 介紹一下類加載的過程
        • 5.11 介紹一下對象的實例化過程
        • 5.12 元空間在棧內還是棧外?
        • 5.13 談談JVM的類加載器,以及雙親委派模型
        • 5.14 雙親委派機制會被破壞嗎?
        • 5.15 介紹一下Java的垃圾回收機制
        • 5.16 請介紹一下分代回收機制
        • 5.17 JVM中一次完整的GC流程是怎樣的?
        • 5.18 Full GC會導致什么?
        • 5.19 JVM什么時候觸發GC,如何減少FullGC的次數?
        • 5.20 如何確定對象是可回收的?
        • 5.21 對象如何晉升到老年代?
        • 5.22 為什么老年代不能使用標記復制?
        • 5.23 新生代為什么要分為Eden和Survivor,它們的比例是多少?
        • 5.24 為什么要設置兩個Survivor區域?
        • 5.25 說一說你對GC算法的了解。
        • 5.26 為什么新生代和老年代要采用不同的回收算法?
        • 5.27 請介紹G1垃圾收集器
        • 5.28 請介紹CMS垃圾收集器
        • 5.29 內存泄漏和內存溢出有什么區別?
        • 5.30 什么是內存泄漏,怎么解決?
        • 5.31 什么是內存溢出,怎么解決?
        • 5.32 哪些區域會OOM,怎么觸發OOM?
        • 5.32 哪些區域會OOM,怎么觸發OOM?

5. JVM

5.1 JVM包含哪幾部分?

參考答案

JVM 主要由四大部分組成:ClassLoader(類加載器),Runtime Data Area(運行時數據區,內存分區),Execution Engine(執行引擎),Native Interface(本地庫接口),下圖可以大致描述 JVM 的結構。

img

JVM 是執行 Java 程序的虛擬計算機系統,那我們來看看執行過程:首先需要準備好編譯好的 Java 字節碼文件(即class文件),計算機要運行程序需要先通過一定方式(類加載器)將 class 文件加載到內存中(運行時數據區),但是字節碼文件是JVM定義的一套指令集規范,并不能直接交給底層操作系統去執行,因此需要特定的命令解釋器(執行引擎)將字節碼翻譯成特定的操作系統指令集交給 CPU 去執行,這個過程中會需要調用到一些不同語言為 Java 提供的接口(例如驅動、地圖制作等),這就用到了本地 Native 接口(本地庫接口)。

  • ClassLoader:負責加載字節碼文件即 class 文件,class 文件在文件開頭有特定的文件標示,并且 ClassLoader 只負責class 文件的加載,至于它是否可以運行,則由 Execution Engine 決定。
  • Runtime Data Area:是存放數據的,分為五部分:Stack(虛擬機棧),Heap(堆),Method Area(方法區),PC Register(程序計數器),Native Method Stack(本地方法棧)。幾乎所有的關于 Java 內存方面的問題,都是集中在這塊。
  • Execution Engine:執行引擎,也叫 Interpreter。Class 文件被加載后,會把指令和數據信息放入內存中,Execution Engine 則負責把這些命令解釋給操作系統,即將 JVM 指令集翻譯為操作系統指令集。
  • Native Interface:負責調用本地接口的。他的作用是調用不同語言的接口給 JAVA 用,他會在 Native Method Stack 中記錄對應的本地方法,然后調用該方法時就通過 Execution Engine 加載對應的本地 lib。原本多用于一些專業領域,如JAVA驅動,地圖制作引擎等,現在關于這種本地方法接口的調用已經被類似于Socket通信,WebService等方式取代。

5.2 JVM是如何運行的?

參考答案

JVM的啟動過程分為如下四個步驟:

  1. JVM的裝入環境和配置

    java.exe負責查找JRE,并且它會按照如下的順序來選擇JRE:

    • 自己目錄下的JRE;
    • 父級目錄下的JRE;
    • 查注冊中注冊的JRE。
  2. 裝載JVM

    通過第一步找到JVM的路徑后,Java.exe通過LoadJavaVM來裝入JVM文件。LoadLibrary裝載JVM動態連接庫,然后把JVM中的到處函數JNI_CreateJavaVM和JNI_GetDefaultJavaVMIntArgs 掛接到InvocationFunction 變量的CreateJavaVM和GetDafaultJavaVMInitArgs 函數指針變量上。JVM的裝載工作完成。

  3. 初始化JVM,獲得本地調用接口

    調用InvocationFunction -> CreateJavaVM,也就是JVM中JNI_CreateJavaVM方法獲得JNIEnv結構的實例。

  4. 運行Java程序

    JVM運行Java程序的方式有兩種:jar包 與 class。

    運行jar 的時候,java.exe調用GetMainClassName函數,該函數先獲得JNIEnv實例然后調用JarFileJNIEnv類中getManifest(),從其返回的Manifest對象中取getAttrebutes(“Main-Class”)的值,即jar 包中文件:META-INF/MANIFEST.MF指定的Main-Class的主類名作為運行的主類。之后main函數會調用Java.c中LoadClass方法裝載該主類(使用JNIEnv實例的FindClass)。

    運行Class的時候,main函數直接調用Java.c中的LoadClass方法裝載該類。

5.3 Java程序是怎么運行的?

參考答案

概括來說,寫好的 Java 源代碼文件經過 Java 編譯器編譯成字節碼文件后,通過類加載器加載到內存中,才能被實例化,然后到 Java 虛擬機中解釋執行,最后通過操作系統操作 CPU 執行獲取結果。如下圖:

img

5.4 本地方法棧有什么用?

參考答案

本地方法棧(Native Method Stacks)與虛擬機棧所發揮的作用是非常相似的,其區別只是虛擬機棧為虛擬機執行Java方法(也就是字節碼)服務,而本地方法棧則是為虛擬機使用到的本地(Native)方法服務。

《Java虛擬機規范》對本地方法棧中方法使用的語言、使用方式與數據結構并沒有任何強制規定,因此具體的虛擬機可以根據需要自由實現它,甚至有的Java虛擬機(譬如Hot-Spot虛擬機)直接就把本地方法棧和虛擬機棧合二為一。與虛擬機棧一樣,本地方法棧也會在棧深度溢出或者棧擴展失敗時分別拋出StackOverflowError和OutOfMemoryError異常。

5.5 沒有程序計數器會怎么樣?

參考答案

沒有程序計數器,Java程序中的流程控制將無法得到正確的控制,多線程也無法正確的輪換。

擴展閱讀

程序計數器(Program Counter Register)是一塊較小的內存空間,它可以看作是當前線程所執行的字節碼的行號指示器。在Java虛擬機的概念模型里,字節碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的字節碼指令,它是程序控制流的指示器,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數器來完成。

由于Java虛擬機的多線程是通過線程輪流切換、分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對于多核處理器來說是一個內核)都只會執行一條線程中的指令。因此,為了線程切換后能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各條線程之間計數器互不影響,獨立存儲,我們稱這類內存區域為“線程私有”的內存。

如果線程正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址;如果正在執行的是本地(Native)方法,這個計數器值則應為空(Undefined)。此內存區域是唯一一個在《Java虛擬機規范》中沒有規定任何OutOfMemoryError情況的區域。

5.6 說一說Java的內存分布情況

參考答案

Java虛擬機在執行Java程序的過程中會把它所管理的內存劃分為若干個不同的數據區域。這些區域有各自的用途,以及創建和銷毀的時間,有的區域隨著虛擬機進程的啟動而一直存在,有些區域則是依賴用戶線程的啟動和結束而建立和銷毀。根據《Java虛擬機規范》的規定,Java虛擬機所管理的內存將會包括以下幾個運行時數據區域。

img

  1. 程序計數器

    程序計數器(Program Counter Register)是一塊較小的內存空間,它可以看作是當前線程所執行的字節碼的行號指示器。在Java虛擬機的概念模型里,字節碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的字節碼指令,它是程序控制流的指示器,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數器來完成。

    由于Java虛擬機的多線程是通過線程輪流切換、分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對于多核處理器來說是一個內核)都只會執行一條線程中的指令。因此,為了線程切換后能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各條線程之間計數器互不影響,獨立存儲,我們稱這類內存區域為“線程私有”的內存。

    如果線程正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址;如果正在執行的是本地(Native)方法,這個計數器值則應為空(Undefined)。此內存區域是唯一一個在《Java虛擬機規范》中沒有規定任何OutOfMemoryError情況的區域。

  2. Java虛擬機棧

    與程序計數器一樣,Java虛擬機棧(Java Virtual Machine Stack)也是線程私有的,它的生命周期與線程相同。虛擬機棧描述的是Java方法執行的線程內存模型:每個方法被執行的時候,Java虛擬機都會同步創建一個棧幀[插圖](Stack Frame)用于存儲局部變量表、操作數棧、動態連接、方法出口等信息。每一個方法被調用直至執行完畢的過程,就對應著一個棧幀在虛擬機棧中從入棧到出棧的過程。

    在《Java虛擬機規范》中,對這個內存區域規定了兩類異常狀況:如果線程請求的棧深度大于虛擬機所允許的深度,將拋出StackOverflowError異常;如果Java虛擬機棧容量可以動態擴展,當棧擴展時無法申請到足夠的內存會拋出OutOfMemoryError異常。

  3. 本地方法棧

    本地方法棧(Native Method Stacks)與虛擬機棧所發揮的作用是非常相似的,其區別只是虛擬機棧為虛擬機執行Java方法(也就是字節碼)服務,而本地方法棧則是為虛擬機使用到的本地(Native)方法服務。

    《Java虛擬機規范》對本地方法棧中方法使用的語言、使用方式與數據結構并沒有任何強制規定,因此具體的虛擬機可以根據需要自由實現它,甚至有的Java虛擬機(譬如Hot-Spot虛擬機)直接就把本地方法棧和虛擬機棧合二為一。與虛擬機棧一樣,本地方法棧也會在棧深度溢出或者棧擴展失敗時分別拋出StackOverflowError和OutOfMemoryError異常。

  4. Java堆

    對于Java應用程序來說,Java堆(Java Heap)是虛擬機所管理的內存中最大的一塊。Java堆是被所有線程共享的一塊內存區域,在虛擬機啟動時創建。此內存區域的唯一目的就是存放對象實例,Java世界里“幾乎”所有的對象實例都在這里分配內存。在《Java虛擬機規范》中對Java堆的描述是:“所有的對象實例以及數組都應當在堆上分配”,而這里筆者寫的“幾乎”是指從實現角度來看,隨著Java語言的發展,現在已經能看到些許跡象表明日后可能出現值類型的支持,即使只考慮現在,由于即時編譯技術的進步,尤其是逃逸分析技術的日漸強大,棧上分配、標量替換優化手段已經導致一些微妙的變化悄然發生,所以說Java對象實例都分配在堆上也漸漸變得不是那么絕對了。

    根據《Java虛擬機規范》的規定,Java堆可以處于物理上不連續的內存空間中,但在邏輯上它應該被視為連續的,這點就像我們用磁盤空間去存儲文件一樣,并不要求每個文件都連續存放。但對于大對象(典型的如數組對象),多數虛擬機實現出于實現簡單、存儲高效的考慮,很可能會要求連續的內存空間。

    Java堆既可以被實現成固定大小的,也可以是可擴展的,不過當前主流的Java虛擬機都是按照可擴展來實現的(通過參數-Xmx和-Xms設定)。如果在Java堆中沒有內存完成實例分配,并且堆也無法再擴展時,Java虛擬機將會拋出OutOfMemoryError異常。

  5. 方法區

    方法區(Method Area)與Java堆一樣,是各個線程共享的內存區域,它用于存儲已被虛擬機加載的類型信息、常量、靜態變量、即時編譯器編譯后的代碼緩存等數據。雖然《Java虛擬機規范》中把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫作“非堆”(Non-Heap),目的是與Java堆區分開來。

    根據《Java虛擬機規范》的規定,如果方法區無法滿足新的內存分配需求時,將拋出OutOfMemoryError異常。

  6. 運行時常量池

    運行時常量池(Runtime Constant Pool)是方法區的一部分。Class文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池表(Constant Pool Table),用于存放編譯期生成的各種字面量與符號引用,這部分內容將在類加載后存放到方法區的運行時常量池中。

    既然運行時常量池是方法區的一部分,自然受到方法區內存的限制,當常量池無法再申請到內存時會拋出OutOfMemoryError異常。

  7. 直接內存

    直接內存(Direct Memory)并不是虛擬機運行時數據區的一部分,也不是《Java虛擬機規范》中定義的內存區域。但是這部分內存也被頻繁地使用,而且也可能導致OutOfMemoryError異常出現。

    顯然,本機直接內存的分配不會受到Java堆大小的限制,但是,既然是內存,則肯定還是會受到本機總內存(包括物理內存、SWAP分區或者分頁文件)大小以及處理器尋址空間的限制,一般服務器管理員配置虛擬機參數時,會根據實際內存去設置-Xmx等參數信息,但經常忽略掉直接內存,使得各個內存區域總和大于物理內存限制(包括物理的和操作系統級的限制),從而導致動態擴展時出現OutOfMemoryError異常。

5.7 類存放在哪里?

參考答案

方法區(Method Area)與Java堆一樣,是各個線程共享的內存區域,它用于存儲已被虛擬機加載的類型信息、常量、靜態變量、即時編譯器編譯后的代碼緩存等數據。雖然《Java虛擬機規范》中把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫作“非堆”(Non-Heap),目的是與Java堆區分開來。

5.8 局部變量存放在哪里?

參考答案

與程序計數器一樣,Java虛擬機棧(Java Virtual Machine Stack)也是線程私有的,它的生命周期與線程相同。虛擬機棧描述的是Java方法執行的線程內存模型:每個方法被執行的時候,Java虛擬機都會同步創建一個棧幀(Stack Frame)用于存儲局部變量表、操作數棧、動態連接、方法出口等信息。每一個方法被調用直至執行完畢的過程,就對應著一個棧幀在虛擬機棧中從入棧到出棧的過程。

局部變量表存放了編譯期可知的各種Java虛擬機基本數據類型(boolean、byte、char、short、int、float、long、double)、對象引用(reference類型,它并不等同于對象本身,可能是一個指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄或者其他與此對象相關的位置)和returnAddress類型(指向了一條字節碼指令的地址)。

5.9 介紹一下Java代碼的編譯過程

參考答案

從Javac代碼的總體結構來看,編譯過程大致可以分為1個準備過程和3個處理過程,它們分別如下所示。

  1. 準備過程:初始化插入式注解處理器。

  2. 解析與填充符號表過程,包括:

    • 詞法、語法分析,將源代碼的字符流轉變為標記集合,構造出抽象語法樹。
    • 填充符號表,產生符號地址和符號信息。
  3. 插入式注解處理器的注解處理過程:

    在Javac源碼中,插入式注解處理器的初始化過程是在initPorcessAnnotations()方法中完成的,而它的執行過程則是在processAnnotations()方法中完成。這個方法會判斷是否還有新的注解處理器需要執行,如果有的話,通過JavacProcessing-Environment類的doProcessing()方法來生成一個新的JavaCompiler對象,對編譯的后續步驟進行處理。

  4. 分析與字節碼生成過程,包括:

    • 標注檢查,對語法的靜態信息進行檢查。
    • 數據流及控制流分析,對程序動態運行過程進行檢查。
    • 解語法糖,將簡化代碼編寫的語法糖還原為原有的形式。
    • 字節碼生成,將前面各個步驟所生成的信息轉化成字節碼。

上述3個處理過程里,執行插入式注解時又可能會產生新的符號,如果有新的符號產生,就必須轉回到之前的解析、填充符號表的過程中重新處理這些新符號,從總體來看,三者之間的關系與交互順序如圖所示。

img

5.10 介紹一下類加載的過程

參考答案

一個類型從被加載到虛擬機內存中開始,到卸載出內存為止,它的整個生命周期將會經歷加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)七個階段,其中驗證、準備、解析三個部分統稱為連接(Linking)。這七個階段的發生順序如下圖所示。

img

在上述七個階段中,包括了類加載的全過程,即加載、驗證、準備、解析和初始化這五個階段。

一、加載

“加載”(Loading)階段是整個“類加載”(Class Loading)過程中的一個階段,在加載階段,Java虛擬機需要完成以下三件事情:

  1. 通過一個類的全限定名來獲取定義此類的二進制字節流。
  2. 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構。
  3. 在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。

加載階段結束后,Java虛擬機外部的二進制字節流就按照虛擬機所設定的格式存儲在方法區之中了,方法區中的數據存儲格式完全由虛擬機實現自行定義,《Java虛擬機規范》未規定此區域的具體數據結構。類型數據妥善安置在方法區之后,會在Java堆內存中實例化一個java.lang.Class類的對象,這個對象將作為程序訪問方法區中的類型數據的外部接口。

二、驗證

驗證是連接階段的第一步,這一階段的目的是確保Class文件的字節流中包含的信息符合《Java虛擬機規范》的全部約束要求,保證這些信息被當作代碼運行后不會危害虛擬機自身的安全。驗證階段大致上會完成下面四個階段的檢驗動作:文件格式驗證、元數據驗證、字節碼驗證和符號引用驗證。

  1. 文件格式驗證:

    第一階段要驗證字節流是否符合Class文件格式的規范,并且能被當前版本的虛擬機處理。

  2. 元數據驗證:

    第二階段是對字節碼描述的信息進行語義分析,以保證其描述的信息符合《Java語言規范》的要求。

  3. 字節碼驗證:

    第三階段是通過數據流分析和控制流分析,確定程序語義是合法的、符合邏輯的。

  4. 符號引用驗證:

    符號引用驗證可以看作是對類自身以外(常量池中的各種符號引用)的各類信息進行匹配性校驗,通俗來說就是,該類是否缺少或者被禁止訪問它依賴的某些外部類、方法、字段等資源。

三、準備

準備階段是正式為類中定義的變量(即靜態變量,被static修飾的變量)分配內存并設置類變量初始值的階段。從概念上講,這些變量所使用的內存都應當在方法區中進行分配,但必須注意到方法區本身是一個邏輯上的區域,在JDK7及之前,HotSpot使用永久代來實現方法區時,實現是完全符合這種邏輯概念的。而在JDK 8及之后,類變量則會隨著Class對象一起存放在Java堆中,這時候“類變量在方法區”就完全是一種對邏輯概念的表述了。

四、解析

解析階段是Java虛擬機將常量池內的符號引用替換為直接引用的過程,符號引用在Class文件中以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等類型的常量出現,那解析階段中所說的直接引用與符號引用又有什么關聯呢?

符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內存布局無關,引用的目標并不一定是已經加載到虛擬機內存當中的內容。各種虛擬機實現的內存布局可以各不相同,但是它們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在《Java虛擬機規范》的Class文件格式中。

直接引用(Direct References):直接引用是可以直接指向目標的指針、相對偏移量或者是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的內存布局直接相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在虛擬機的內存中存在。

五、初始化

類的初始化階段是類加載過程的最后一個步驟,之前介紹的幾個類加載的動作里,除了在加載階段用戶應用程序可以通過自定義類加載器的方式局部參與外,其余動作都完全由Java虛擬機來主導控制。直到初始化階段,Java虛擬機才真正開始執行類中編寫的Java程序代碼,將主導權移交給應用程序。

進行準備階段時,變量已經賦過一次系統要求的初始零值,而在初始化階段,則會根據程序員通過程序編碼制定的主觀計劃去初始化類變量和其他資源。我們也可以從另外一種更直接的形式來表達:初始化階段就是執行類構造器<clinit>()方法的過程。<clinit>()并不是程序員在Java代碼中直接編寫的方法,它是Javac編譯器的自動生成物。

5.11 介紹一下對象的實例化過程

參考答案

對象實例化過程,就是執行類構造函數對應在字節碼文件中的<init>()方法(實例構造器),<init>()方法由非靜態變量、非靜態代碼塊以及對應的構造器組成。

  • <init>()方法可以重載多個,類有幾個構造器就有幾個<init>()方法;
  • <init>()方法中的代碼執行順序為:父類變量初始化、父類代碼塊、父類構造器、子類變量初始化、子類代碼塊、子類構造器。

靜態變量、靜態代碼塊、普通變量、普通代碼塊、構造器的執行順序如下圖:

img

具有父類的子類的實例化順序如下:

img

擴展閱讀

Java是一門面向對象的編程語言,Java程序運行過程中無時無刻都有對象被創建出來。在語言層面上,創建對象通常(例外:復制、反序列化)僅僅是一個new關鍵字而已,而在虛擬機中,對象(文中討論的對象限于普通Java對象,不包括數組和Class對象等)的創建又是怎樣一個過程呢?

當Java虛擬機遇到一條字節碼new指令時,首先將去檢查這個指令的參數是否能在常量池中定位到一個類的符號引用,并且檢查這個符號引用代表的類是否已被加載、解析和初始化過。如果沒有,那必須先執行相應的類加載過程。

在類加載檢查通過后,接下來虛擬機將為新生對象分配內存。對象所需內存的大小在類加載完成后便可完全確定,為對象分配空間的任務實際上便等同于把一塊確定大小的內存塊從Java堆中劃分出來。假設Java堆中內存是絕對規整的,所有被使用過的內存都被放在一邊,空閑的內存被放在另一邊,中間放著一個指針作為分界點的指示器,那所分配內存就僅僅是把那個指針向空閑空間方向挪動一段與對象大小相等的距離,這種分配方式稱為“指針碰撞”(Bump The Pointer)。但如果Java堆中的內存并不是規整的,已被使用的內存和空閑的內存相互交錯在一起,那就沒有辦法簡單地進行指針碰撞了,虛擬機就必須維護一個列表,記錄上哪些內存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,并更新列表上的記錄,這種分配方式稱為“空閑列表”(Free List)。選擇哪種分配方式由Java堆是否規整決定,而Java堆是否規整又由所采用的垃圾收集器是否帶有空間壓縮整理(Compact)的能力決定。因此,當使用Serial、ParNew等帶壓縮整理過程的收集器時,系統采用的分配算法是指針碰撞,既簡單又高效;而當使用CMS這種基于清除(Sweep)算法的收集器時,理論上就只能采用較為復雜的空閑列表來分配內存。

除如何劃分可用空間之外,還有另外一個需要考慮的問題:對象創建在虛擬機中是非常頻繁的行為,即使僅僅修改一個指針所指向的位置,在并發情況下也并不是線程安全的,可能出現正在給對象A分配內存,指針還沒來得及修改,對象B又同時使用了原來的指針來分配內存的情況。解決這個問題有兩種可選方案:一種是對分配內存空間的動作進行同步處理——實際上虛擬機是采用CAS配上失敗重試的方式保證更新操作的原子性;另外一種是把內存分配的動作按照線程劃分在不同的空間之中進行,即每個線程在Java堆中預先分配一小塊內存,稱為本地線程分配緩沖(Thread Local Allocation Buffer,TLAB),哪個線程要分配內存,就在哪個線程的本地緩沖區中分配,只有本地緩沖區用完了,分配新的緩存區時才需要同步鎖定。虛擬機是否使用TLAB,可以通過-XX:+/-UseTLAB參數來設定。

內存分配完成之后,虛擬機必須將分配到的內存空間(但不包括對象頭)都初始化為零值,如果使用了TLAB的話,這一項工作也可以提前至TLAB分配時順便進行。這步操作保證了對象的實例字段在Java代碼中可以不賦初始值就直接使用,使程序能訪問到這些字段的數據類型所對應的零值。

接下來,Java虛擬機還要對對象進行必要的設置,例如這個對象是哪個類的實例、如何才能找到類的元數據信息、對象的哈希碼(實際上對象的哈希碼會延后到真正調用Object::hashCode()方法時才計算)、對象的GC分代年齡等信息。這些信息存放在對象的對象頭(Object Header)之中。根據虛擬機當前運行狀態的不同,如是否啟用偏向鎖等,對象頭會有不同的設置方式。

在上面工作都完成之后,從虛擬機的視角來看,一個新的對象已經產生了。但是從Java程序的視角看來,對象創建才剛剛開始——構造函數,即Class文件中的<init>()方法還沒有執行,所有的字段都為默認的零值,對象需要的其他資源和狀態信息也還沒有按照預定的意圖構造好。一般來說(由字節碼流中new指令后面是否跟隨invokespecial指令所決定,Java編譯器會在遇到new關鍵字的地方同時生成這兩條字節碼指令,但如果直接通過其他方式產生的則不一定如此),new指令之后會接著執行<init>()方法,按照程序員的意愿對對象進行初始化,這樣一個真正可用的對象才算完全被構造出來。

5.12 元空間在棧內還是棧外?

參考答案

在棧外,元空間占用的是本地內存。

擴展閱讀

許多Java程序員都習慣在HotSpot虛擬機上開發、部署程序,很多人都更愿意把方法區稱呼為“永久代“,或將兩者混為一談。本質上這兩者并不是等價的,因為僅僅是當時的HotSpot虛擬機設計團隊選擇把收集器的分代設計擴展至方法區,或者說使用永久代來實現方法區而已,這樣使得HotSpot的垃圾收集器能夠像管理Java堆一樣管理這部分內存,省去專門為方法區編寫內存管理代碼的工作。但是對于其他虛擬機實現,譬如BEAJRockit、IBM J9等來說,是不存在永久代的概念的。原則上如何實現方法區屬于虛擬機實現細節,不受《Java虛擬機規范》管束,并不要求統一。

現在回頭來看,當年使用永久代來實現方法區的決定并不是一個好主意,這種設計導致了Java應用更容易遇到內存溢出的問題(永久代有-XX:MaxPermSize的上限,即使不設置也有默認大小,而J9和JRockit只要沒有觸碰到進程可用內存的上限,例如32位系統中的4GB限制,就不會出問題),而且有極少數方法(例如String::intern())會因永久代的原因而導致不同虛擬機下有不同的表現。

當Oracle收購BEA獲得了JRockit的所有權后,準備把JRockit中的優秀功能,譬如Java Mission Control管理工具,移植到HotSpot虛擬機時,但因為兩者對方法區實現的差異而面臨諸多困難。考慮到HotSpot未來的發展,在JDK 6的時候HotSpot開發團隊就有放棄永久代,逐步改為采用本地內存(Native Memory)來實現方法區的計劃了,到了JDK 7的HotSpot,已經把原本放在永久代的字符串常量池、靜態變量等移出,而到了JDK 8,終于完全廢棄了永久代的概念,改用與JRockit、J9一樣在本地內存中實現的元空間(Meta-space)來代替,把JDK 7中永久代還剩余的內容(主要是類型信息)全部移到元空間中。

5.13 談談JVM的類加載器,以及雙親委派模型

參考答案

一、類加載器

Java虛擬機設計團隊有意把類加載階段中的“通過一個類的全限定名來獲取描述該類的二進制字節流”這個動作放到Java虛擬機外部去實現,以便讓應用程序自己決定如何去獲取所需的類。實現這個動作的代碼被稱為“類加載器”(Class Loader)。

類加載器雖然只用于實現類的加載動作,但它在Java程序中起到的作用卻遠超類加載階段。對于任意一個類,都必須由加載它的類加載器和這個類本身一起共同確立其在Java虛擬機中的唯一性,每一個類加載器,都擁有一個獨立的類名稱空間。這句話可以表達得更通俗一些:比較兩個類是否“相等”,只有在這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源于同一個Class文件,被同一個Java虛擬機加載,只要加載它們的類加載器不同,那這兩個類就必定不相等。

二、雙親委派模型

自JDK1.2以來,Java一直保持著三層類加載器、雙親委派的類加載架構。對于這個時期的Java應用,絕大多數Java程序都會使用到以下3個系統提供的類加載器來進行加載。

  • 啟動類加載器(Bootstrap Class Loader):這個類加載器負責加載存放在\lib目錄,或者被-Xbootclasspath參數所指定的路徑中存放的,而且是Java虛擬機能夠識別的(按照文件名識別,如rt.jar、tools.jar,名字不符合的類庫即使放在lib目錄中也不會被加載)類庫加載到虛擬機的內存中。啟動類加載器無法被Java程序直接引用,用戶在編寫自定義類加載器時,如果需要把加載請求委派給引導類加載器去處理,那直接使用null代替即可。
  • 擴展類加載器(Extension Class Loader):這個類加載器是在類sun.misc.Launcher$ExtClassLoader中以Java代碼的形式實現的。它負責加載\lib\ext目錄中,或者被java.ext.dirs系統變量所指定的路徑中所有的類庫。根據“擴展類加載器”這個名稱,就可以推斷出這是一種Java系統類庫的擴展機制,JDK的開發團隊允許用戶將具有通用性的類庫放置在ext目錄里以擴展Java SE的功能,在JDK 9之后,這種擴展機制被模塊化帶來的天然的擴展能力所取代。由于擴展類加載器是由Java代碼實現的,開發者可以直接在程序中使用擴展類加載器來加載Class文件。
  • 應用程序類加載器(Application Class Loader):這個類加載器由sun.misc.Launcher$AppClassLoader來實現。由于應用程序類加載器是ClassLoader類中的getSystem-ClassLoader()方法的返回值,所以有些場合中也稱它為“系統類加載器”。它負責加載用戶類路徑(ClassPath)上所有的類庫,開發者同樣可以直接在代碼中使用這個類加載器。如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。

這些類加載器之間的協作關系“通常”會如下圖所示,圖中展示的各種類加載器之間的層次關系被稱為類加載器的“雙親委派模型(Parents Delegation Model)”。雙親委派模型要求除了頂層的啟動類加載器外,其余的類加載器都應有自己的父類加載器。不過這里類加載器之間的父子關系一般不是以繼承(Inheritance)的關系來實現的,而是通常使用組合(Composition)關系來復用父加載器的代碼。

img

雙親委派模型的工作過程是:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到最頂層的啟動類加載器中,只有當父加載器反饋自己無法完成這個加載請求(它的搜索范圍中沒有找到所需的類)時,子加載器才會嘗試自己去完成加載。

使用雙親委派模型來組織類加載器之間的關系,一個顯而易見的好處就是Java中的類隨著它的類加載器一起具備了一種帶有優先級的層次關系。例如類java.lang.Object,它存放在rt.jar之中,無論哪一個類加載器要加載這個類,最終都是委派給處于模型最頂端的啟動類加載器進行加載,因此Object類在程序的各種類加載器環境中都能夠保證是同一個類。反之,如果沒有使用雙親委派模型,都由各個類加載器自行去加載的話,如果用戶自己也編寫了一個名為java.lang.Object的類,并放在程序的ClassPath中,那系統中就會出現多個不同的Object類,Java類型體系中最基礎的行為也就無從保證,應用程序將會變得一片混亂。

擴展閱讀

雙親委派模型對于保證Java程序的穩定運作極為重要,但它的實現卻異常簡單,用以實現雙親委派的代碼只有短短十余行,全部集中在java.lang.ClassLoader的loadClass()方法之中。

protected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {synchronized (getClassLoadingLock(name)) {// First, check if the class has already been loadedClass<?> c = findLoadedClass(name);if (c == null) {long t0 = System.nanoTime();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.long t1 = System.nanoTime();c = findClass(name);// this is the defining class loader; record the statsPerfCounter.getParentDelegationTime().addTime(t1 - t0);PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);PerfCounter.getFindClasses().increment();}}if (resolve) {resolveClass(c);}return c;}
}

這段代碼的邏輯清晰易懂:先檢查請求加載的類型是否已經被加載過,若沒有則調用父加載器的loadClass()方法,若父加載器為空則默認使用啟動類加載器作為父加載器。假如父類加載器加載失敗,拋出ClassNotFoundException異常的話,才調用自己的findClass()方法嘗試進行加載。

5.14 雙親委派機制會被破壞嗎?

參考答案

雙親委派模型并不是一個具有強制性約束的模型,而是Java設計者推薦給開發者們的類加載器實現方式。在Java的世界中大部分的類加載器都遵循這個模型,但也有例外的情況,直到Java模塊化出現為止,雙親委派模型主要出現過3次較大規模“被破壞”的情況。

雙親委派模型的第一次“被破壞”其實發生在雙親委派模型出現之前——即JDK 1.2面世以前的“遠古”時代。由于雙親委派模型在JDK 1.2之后才被引入,但是類加載器的概念和抽象類java.lang.ClassLoader則在Java的第一個版本中就已經存在,面對已經存在的用戶自定義類加載器的代碼,Java設計者們引入雙親委派模型時不得不做出一些妥協,為了兼容這些已有代碼,無法再以技術手段避免loadClass()被子類覆蓋的可能性,只能在JDK 1.2之后的java.lang.ClassLoader中添加一個新的protected方法findClass(),并引導用戶編寫的類加載邏輯時盡可能去重寫這個方法,而不是在loadClass()中編寫代碼。雙親委派的具體邏輯就實現在這里面,按照loadClass()方法的邏輯,如果父類加載失敗,會自動調用自己的findClass()方法來完成加載,這樣既不影響用戶按照自己的意愿去加載類,又可以保證新寫出來的類加載器是符合雙親委派規則的。

雙親委派模型的第二次“被破壞”是由這個模型自身的缺陷導致的,雙親委派很好地解決了各個類加載器協作時基礎類型的一致性問題(越基礎的類由越上層的加載器進行加載),基礎類型之所以被稱為“基礎”,是因為它們總是作為被用戶代碼繼承、調用的API存在,但程序設計往往沒有絕對不變的完美規則,如果有基礎類型又要調用回用戶的代碼,那該怎么辦呢?

這并非是不可能出現的事情,一個典型的例子便是JNDI服務,JNDI現在已經是Java的標準服務,它的代碼由啟動類加載器來完成加載(在JDK 1.3時加入到rt.jar的),肯定屬于Java中很基礎的類型了。但JNDI存在的目的就是對資源進行查找和集中管理,它需要調用由其他廠商實現并部署在應用程序的ClassPath下的JNDI服務提供者接口(Service Provider Interface,SPI)的代碼,現在問題來了,啟動類加載器是絕不可能認識、加載這些代碼的,那該怎么辦?

為了解決這個困境,Java的設計團隊只好引入了一個不太優雅的設計:線程上下文類加載器(Thread Context ClassLoader)。這個類加載器可以通過java.lang.Thread類的setContext-ClassLoader()方法進行設置,如果創建線程時還未設置,它將會從父線程中繼承一個,如果在應用程序的全局范圍內都沒有設置過的話,那這個類加載器默認就是應用程序類加載器。

有了線程上下文類加載器,程序就可以做一些“舞弊”的事情了。JNDI服務使用這個線程上下文類加載器去加載所需的SPI服務代碼,這是一種父類加載器去請求子類加載器完成類加載的行為,這種行為實際上是打通了雙親委派模型的層次結構來逆向使用類加載器,已經違背了雙親委派模型的一般性原則,但也是無可奈何的事情。Java中涉及SPI的加載基本上都采用這種方式來完成,例如JNDI、JDBC、JCE、JAXB和JBI等。不過,當SPI的服務提供者多于一個的時候,代碼就只能根據具體提供者的類型來硬編碼判斷,為了消除這種極不優雅的實現方式,在JDK 6時,JDK提供了java.util.ServiceLoader類,以META-INF/services中的配置信息,輔以責任鏈模式,這才算是給SPI的加載提供了一種相對合理的解決方案。

雙親委派模型的第三次“被破壞”是由于用戶對程序動態性的追求而導致的,這里所說的“動態性”指的是一些非常“熱”門的名詞:代碼熱替換(HotSwap)、模塊熱部署(Hot Deployment)等。說白了就是希望Java應用程序能像我們的電腦外設那樣,接上鼠標、U盤,不用重啟機器就能立即使用,鼠標有問題或要升級就換個鼠標,不用關機也不用重啟。對于個人電腦來說,重啟一次其實沒有什么大不了的,但對于一些生產系統來說,關機重啟一次可能就要被列為生產事故,這種情況下熱部署就對軟件開發者,尤其是大型系統或企業級軟件開發者具有很大的吸引力。

早在2008年,在Java社區關于模塊化規范的第一場戰役里,由Sun/Oracle公司所提出的JSR-294、JSR-277規范提案就曾敗給以IBM公司主導的JSR-291(即OSGi R4.2)提案。盡管Sun/Oracle并不甘心就此失去Java模塊化的主導權,隨即又再拿出Jigsaw項目迎戰,但此時OSGi已經站穩腳跟,成為業界“事實上”的Java模塊化標準。曾經在很長一段時間內,IBM憑借著OSGi廣泛應用基礎讓Jigsaw吃盡苦頭,其影響一直持續到Jigsaw隨JDK 9面世才算告一段落。而且即使Jigsaw現在已經是Java的標準功能了,它仍需小心翼翼地避開OSGi運行期動態熱部署上的優勢,僅局限于靜態地解決模塊間封裝隔離和訪問控制的問題,現在我們先來簡單看一看OSGi是如何通過類加載器實現熱部署的。

OSGi實現模塊化熱部署的關鍵是它自定義的類加載器機制的實現,每一個程序模塊(OSGi中稱為Bundle)都有一個自己的類加載器,當需要更換一個Bundle時,就把Bundle連同類加載器一起換掉以實現代碼的熱替換。在OSGi環境下,類加載器不再雙親委派模型推薦的樹狀結構,而是進一步發展為更加復雜的網狀結構,當收到類加載請求時,OSGi將按照下面的順序進行類搜索:

  1. 將以java.*開頭的類,委派給父類加載器加載。
  2. 否則,將委派列表名單內的類,委派給父類加載器加載。
  3. 否則,將Import列表中的類,委派給Export這個類的Bundle的類加載器加載。
  4. 否則,查找當前Bundle的ClassPath,使用自己的類加載器加載。
  5. 否則,查找類是否在自己的Fragment Bundle中,如果在,則委派給Fragment Bundle的類加載器加載。
  6. 否則,查找Dynamic Import列表的Bundle,委派給對應Bundle的類加載器加載。
  7. 否則,類查找失敗。

上面的查找順序中只有開頭兩點仍然符合雙親委派模型的原則,其余的類查找都是在平級的類加載器中進行的。

5.15 介紹一下Java的垃圾回收機制

參考答案

一、哪些內存需要回收

在Java內存運行時區域的各個部分中,堆和方法區這兩個區域則有著很顯著的不確定性:一個接口的多個實現類需要的內存可能會不一樣,一個方法所執行的不同條件分支所需要的內存也可能不一樣,只有處于運行期間,我們才能知道程序究竟會創建哪些對象,創建多少個對象,這部分內存的分配和回收是動態的。垃圾收集器所關注的正是這部分內存該如何管理,我們平時所說的內存分配與回收也僅僅特指這一部分內存。

二、怎么定義垃圾

引用計數算法:

在對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加一;當引用失效時,計數器值就減一;任何時刻計數器為零的對象就是不可能再被使用的。

但是,在Java領域,至少主流的Java虛擬機里面都沒有選用引用計數算法來管理內存,主要原因是,這個看似簡單的算法有很多例外情況要考慮,必須要配合大量額外處理才能保證正確地工作,譬如單純的引用計數就很難解決對象之間相互循環引用的問題。

舉個簡單的例子:對象objA和objB都有字段instance,賦值令objA.instance=objB及objB.instance=objA,除此之外,這兩個對象再無任何引用,實際上這兩個對象已經不可能再被訪問,但是它們因為互相引用著對方,導致它們的引用計數都不為零,引用計數算法也就無法回收它們。

可達性分析算法:

當前主流的商用程序語言的內存管理子系統,都是通過可達性分析(Reachability Analysis)算法來判定對象是否存活的。這個算法的基本思路就是通過一系列稱為“GC Roots”的根對象作為起始節點集,從這些節點開始,根據引用關系向下搜索,搜索過程所走過的路徑稱為“引用鏈”(Reference Chain),如果某個對象到GC Roots間沒有任何引用鏈相連,或者用圖論的話來說就是從GC Roots到這個對象不可達時,則證明此對象是不可能再被使用的。

如下圖所示,對象object 5、object 6、object 7雖然互有關聯,但是它們到GC Roots是不可達的,因此它們將會被判定為可回收的對象。

img

在Java技術體系里面,固定可作為GC Roots的對象包括以下幾種:

  • 在虛擬機棧(棧幀中的本地變量表)中引用的對象,譬如各個線程被調用的方法堆棧中使用到的參數、局部變量、臨時變量等。
  • 在方法區中類靜態屬性引用的對象,譬如Java類的引用類型靜態變量。
  • 在方法區中常量引用的對象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法棧中JNI(即通常所說的Native方法)引用的對象。
  • Java虛擬機內部的引用,如基本數據類型對應的Class對象,一些常駐的異常對象(比如NullPointExcepiton、OutOfMemoryError)等,還有系統類加載器。
  • 所有被同步鎖(synchronized關鍵字)持有的對象。
  • 反映Java虛擬機內部情況的JMXBean、JVMTI中注冊的回調、本地代碼緩存等。

回收方法區:

方法區的垃圾收集主要回收兩部分內容:廢棄的常量和不再使用的類型。回收廢棄常量與回收Java堆中的對象非常類似。舉個常量池中字面量回收的例子,假如一個字符串“java”曾經進入常量池中,但是當前系統又沒有任何一個字符串對象的值是“java”,換句話說,已經沒有任何字符串對象引用常量池中的“java”常量,且虛擬機中也沒有其他地方引用這個字面量。如果在這時發生內存回收,而且垃圾收集器判斷確有必要的話,這個“java”常量就將會被系統清理出常量池。常量池中其他類(接口)、方法、字段的符號引用也與此類似。

判定一個常量是否“廢棄”還是相對簡單,而要判定一個類型是否屬于“不再被使用的類”的條件就比較苛刻了。需要同時滿足下面三個條件:

  • 該類所有的實例都已經被回收,也就是Java堆中不存在該類及其任何派生子類的實例。
  • 加載該類的類加載器已經被回收,這個條件除非是經過精心設計的可替換類加載器的場景,如OSGi、JSP的重加載等,否則通常是很難達成的。
  • 該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

三、怎么回收垃圾

分代收集理論:

當前商業虛擬機的垃圾收集器,大多數都遵循了“分代收集”(GenerationalCollection)的理論進行設計,分代收集名為理論,實質是一套符合大多數程序運行實際情況的經驗法則,它建立在兩個分代假說之上:

  1. 弱分代假說(Weak Generational Hypothesis):絕大多數對象都是朝生夕滅的。
  2. 強分代假說(Strong Generational Hypothesis):熬過越多次垃圾收集過程的對象就越難以消亡。

這兩個分代假說共同奠定了多款常用的垃圾收集器的一致的設計原則:收集器應該將Java堆劃分出不同的區域,然后將回收對象依據其年齡(年齡即對象熬過垃圾收集過程的次數)分配到不同的區域之中存儲。顯而易見,如果一個區域中大多數對象都是朝生夕滅,難以熬過垃圾收集過程的話,那么把它們集中放在一起,每次回收時只關注如何保留少量存活而不是去標記那些大量將要被回收的對象,就能以較低代價回收到大量的空間;如果剩下的都是難以消亡的對象,那把它們集中放在一塊,虛擬機便可以使用較低的頻率來回收這個區域,這就同時兼顧了垃圾收集的時間開銷和內存的空間有效利用。

標記-清除算法:

最早出現也是最基礎的垃圾收集算法是“標記-清除”(Mark-Sweep)算法,如它的名字一樣,算法分為“標記”和“清除”兩個階段:首先標記出所有需要回收的對象,在標記完成后,統一回收掉所有被標記的對象,也可以反過來,標記存活的對象,統一回收所有未被標記的對象。

它的主要缺點有兩個:第一個是執行效率不穩定,如果Java堆中包含大量對象,而且其中大部分是需要被回收的,這時必須進行大量標記和清除的動作,導致標記和清除兩個過程的執行效率都隨對象數量增長而降低;第二個是內存空間的碎片化問題,標記、清除之后會產生大量不連續的內存碎片,空間碎片太多可能會導致當以后在程序運行過程中需要分配較大對象時無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。標記-清除算法的執行過程如下圖所示。

img

標記-復制算法:

為了解決標記-清除算法面對大量可回收對象時執行效率低的問題,1969年Fenichel提出了一種稱為“半區復制”(Semispace Copying)的垃圾收集算法,它將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活著的對象復制到另外一塊上面,然后再把已使用過的內存空間一次清理掉。如果內存中多數對象都是存活的,這種算法將會產生大量的內存間復制的開銷,但對于多數對象都是可回收的情況,算法需要復制的就是占少數的存活對象,而且每次都是針對整個半區進行內存回收,分配內存時也就不用考慮有空間碎片的復雜情況,只要移動堆頂指針,按順序分配即可。這樣實現簡單,運行高效,不過其缺陷也顯而易見,這種復制回收算法的代價是將可用內存縮小為了原來的一半,空間浪費未免太多了一點。標記-復制算法的執行過程如下圖所示。

img

在1989年,Andrew Appel針對具備“朝生夕滅”特點的對象,提出了一種更優化的半區復制分代策略,現在稱為“Appel式回收”。Appel式回收的具體做法是把新生代分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次分配內存只使用Eden和其中一塊Survivor。發生垃圾搜集時,將Eden和Survivor中仍然存活的對象一次性復制到另外一塊Survivor空間上,然后直接清理掉Eden和已用過的那塊Survivor空間。HotSpot虛擬機默認Eden和Survivor的大小比例是8∶1,也即每次新生代中可用內存空間為整個新生代容量的90%(Eden的80%加上一個Survivor的10%),只有一個Survivor空間,即10%的新生代是會被“浪費”的。當然,98%的對象可被回收僅僅是“普通場景”下測得的數據,任何人都沒有辦法百分百保證每次回收都只有不多于10%的對象存活,因此Appel式回收還有一個充當罕見情況的“逃生門”的安全設計,當Survivor空間不足以容納一次Minor GC之后存活的對象時,就需要依賴其他內存區域(實際上大多就是老年代)進行分配擔保(Handle Promotion)。

標記-整理算法:

標記-復制算法在對象存活率較高時就要進行較多的復制操作,效率將會降低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的內存中所有對象都100%存活的極端情況,所以在老年代一般不能直接選用這種算法。

針對老年代對象的存亡特征,1974年Edward Lueders提出了另外一種有針對性的“標記-整理”(Mark-Compact)算法,其中的標記過程仍然與“標記-清除”算法一樣,但后續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向內存空間一端移動,然后直接清理掉邊界以外的內存,“標記-整理”算法的示意圖如下圖所示。

img

5.16 請介紹一下分代回收機制

參考答案

當前商業虛擬機的垃圾收集器,大多數都遵循了“分代收集”(GenerationalCollection)[插圖]的理論進行設計,分代收集名為理論,實質是一套符合大多數程序運行實際情況的經驗法則,它建立在兩個分代假說之上:

  1. 弱分代假說(Weak Generational Hypothesis):絕大多數對象都是朝生夕滅的。
  2. 強分代假說(Strong Generational Hypothesis):熬過越多次垃圾收集過程的對象就越難以消亡。

這兩個分代假說共同奠定了多款常用的垃圾收集器的一致的設計原則:收集器應該將Java堆劃分出不同的區域,然后將回收對象依據其年齡(年齡即對象熬過垃圾收集過程的次數)分配到不同的區域之中存儲。把分代收集理論具體放到現在的商用Java虛擬機里,設計者一般至少會把Java堆劃分為新生代(Young Generation)和老年代(Old Generation)兩個區域。顧名思義,在新生代中,每次垃圾收集時都發現有大批對象死去,而每次回收后存活的少量對象,將會逐步晉升到老年代中存放。

分代收集并非只是簡單劃分一下內存區域那么容易,它至少存在一個明顯的困難:對象不是孤立的,對象之間會存在跨代引用。假如要現在進行一次只局限于新生代區域內的收集,但新生代中的對象是完全有可能被老年代所引用的,為了找出該區域中的存活對象,不得不在固定的GC Roots之外,再額外遍歷整個老年代中所有對象來確保可達性分析結果的正確性,反過來也是一樣。遍歷整個老年代所有對象的方案雖然理論上可行,但無疑會為內存回收帶來很大的性能負擔。為了解決這個問題,就需要對分代收集理論添加第三條經驗法則:

  1. 跨代引用假說(Intergenerational Reference Hypothesis):跨代引用相對于同代引用來說僅占極少數。

依據這條假說,我們就不應再為了少量的跨代引用去掃描整個老年代,也不必浪費空間專門記錄每一個對象是否存在及存在哪些跨代引用,只需在新生代上建立一個全局的數據結構(稱為“記憶集”,RememberedSet),這個結構把老年代劃分成若干小塊,標識出老年代的哪一塊內存會存在跨代引用。此后當發生Minor GC時,只有包含了跨代引用的小塊內存里的對象才會被加入到GC Roots進行掃描。雖然這種方法需要在對象改變引用關系(如將自己或者某個屬性賦值)時維護記錄數據的正確性,會增加一些運行時的開銷,但比起收集時掃描整個老年代來說仍然是劃算的。

5.17 JVM中一次完整的GC流程是怎樣的?

參考答案

新創建的對象一般會被分配在新生代中,常用的新生代的垃圾回收器是 ParNew 垃圾回收器,它按照 8:1:1 將新生代分成 Eden 區,以及兩個 Survivor 區。某一時刻,我們創建的對象將 Eden 區全部擠滿,這個對象就是擠滿新生代的最后一個對象。此時,Minor GC 就觸發了。

在正式 Minor GC 前,JVM 會先檢查新生代中對象,是比老年代中剩余空間大還是小。為什么要做這樣的檢查呢?原因很簡單,假如 Minor GC 之后 Survivor 區放不下剩余對象,這些對象就要進入到老年代,所以要提前檢查老年代是不是夠用。這樣就有兩種情況:

  1. 老年代剩余空間大于新生代中的對象大小,那就直接Minor GC,GC完survivor不夠放,老年代也絕對夠放;

  2. 老年代剩余空間小于新生代中的對象大小,這個時候就要查看是否啟用了“老年代空間分配擔保規則”,具體來說就是看 -XX:-HandlePromotionFailure 參數是否設置了。

    老年代空間分配擔保規則是這樣的,如果老年代中剩余空間大小,大于歷次 Minor GC 之后剩余對象的大小,那就允許進行 Minor GC。因為從概率上來說,以前的放的下,這次的也應該放的下。那就有兩種情況:

    老年代中剩余空間大小,大于歷次Minor GC之后剩余對象的大小,進行 Minor GC;

    老年代中剩余空間大小,小于歷次Minor GC之后剩余對象的大小,進行Full GC,把老年代空出來再檢查。

開啟老年代空間分配擔保規則只能說是大概率上來說,Minor GC 剩余后的對象夠放到老年代,所以當然也會有萬一,Minor GC 后會有這樣三種情況:

  1. Minor GC 之后的對象足夠放到 Survivor 區,皆大歡喜,GC 結束;
  2. Minor GC 之后的對象不夠放到 Survivor 區,接著進入到老年代,老年代能放下,那也可以,GC 結束;
  3. Minor GC 之后的對象不夠放到 Survivor 區,老年代也放不下,那就只能 Full GC。

前面都是成功 GC 的例子,還有 3 中情況,會導致 GC 失敗,報 OOM:

  1. 緊接上一節 Full GC 之后,老年代任然放不下剩余對象,就只能 OOM;
  2. 未開啟老年代分配擔保機制,且一次 Full GC 后,老年代任然放不下剩余對象,也只能 OOM;
  3. 開啟老年代分配擔保機制,但是擔保不通過,一次 Full GC 后,老年代任然放不下剩余對象,也是能 OOM。

GC完整流程,參考下圖:

img

5.18 Full GC會導致什么?

參考答案

Full GC會“Stop The World”,即在GC期間全程暫停用戶的應用程序。

5.19 JVM什么時候觸發GC,如何減少FullGC的次數?

參考答案

當 Eden 區的空間耗盡時 Java 虛擬機便會觸發一次 Minor GC 來收集新生代的垃圾,存活下來的對象,則會被送到 Survivor 區,簡單說就是當新生代的Eden區滿的時候觸發 Minor GC。

serial GC 中,老年代內存剩余已經小于之前年輕代晉升老年代的平均大小,則進行 Full GC。而在 CMS 等并發收集器中則是每隔一段時間檢查一下老年代內存的使用量,超過一定比例時進行 Full GC 回收。

可以采用以下措施來減少Full GC的次數:

  1. 增加方法區的空間;
  2. 增加老年代的空間;
  3. 減少新生代的空間;
  4. 禁止使用System.gc()方法;
  5. 使用標記-整理算法,盡量保持較大的連續內存空間;
  6. 排查代碼中無用的大對象。

5.20 如何確定對象是可回收的?

參考答案

引用計數算法:

在對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加一;當引用失效時,計數器值就減一;任何時刻計數器為零的對象就是不可能再被使用的。

但是,在Java領域,至少主流的Java虛擬機里面都沒有選用引用計數算法來管理內存,主要原因是,這個看似簡單的算法有很多例外情況要考慮,必須要配合大量額外處理才能保證正確地工作,譬如單純的引用計數就很難解決對象之間相互循環引用的問題。

舉個簡單的例子:對象objA和objB都有字段instance,賦值令objA.instance=objB及objB.instance=objA,除此之外,這兩個對象再無任何引用,實際上這兩個對象已經不可能再被訪問,但是它們因為互相引用著對方,導致它們的引用計數都不為零,引用計數算法也就無法回收它們。

可達性分析算法:

當前主流的商用程序語言的內存管理子系統,都是通過可達性分析(Reachability Analysis)算法來判定對象是否存活的。這個算法的基本思路就是通過一系列稱為“GC Roots”的根對象作為起始節點集,從這些節點開始,根據引用關系向下搜索,搜索過程所走過的路徑稱為“引用鏈”(Reference Chain),如果某個對象到GC Roots間沒有任何引用鏈相連,或者用圖論的話來說就是從GC Roots到這個對象不可達時,則證明此對象是不可能再被使用的。

如下圖所示,對象object 5、object 6、object 7雖然互有關聯,但是它們到GC Roots是不可達的,因此它們將會被判定為可回收的對象。

img

在Java技術體系里面,固定可作為GC Roots的對象包括以下幾種:

  • 在虛擬機棧(棧幀中的本地變量表)中引用的對象,譬如各個線程被調用的方法堆棧中使用到的參數、局部變量、臨時變量等。
  • 在方法區中類靜態屬性引用的對象,譬如Java類的引用類型靜態變量。
  • 在方法區中常量引用的對象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法棧中JNI(即通常所說的Native方法)引用的對象。
  • Java虛擬機內部的引用,如基本數據類型對應的Class對象,一些常駐的異常對象(比如NullPointExcepiton、OutOfMemoryError)等,還有系統類加載器。
  • 所有被同步鎖(synchronized關鍵字)持有的對象。
  • 反映Java虛擬機內部情況的JMXBean、JVMTI中注冊的回調、本地代碼緩存等。

5.21 對象如何晉升到老年代?

參考答案

虛擬機給每個對象定義了一個對象年齡(Age)計數器,存儲在對象頭中。對象通常在Eden區里誕生,如果經過第一次MinorGC后仍然存活,并且能被Survivor容納的話,該對象會被移動到Survivor空間中,并且將其對象年齡設為1歲。對象在Survivor區中每熬過一次MinorGC,年齡就增加1歲,當它的年齡增加到一定程度(默認為15),就會被晉升到老年代中。對象晉升老年代的年齡閾值,可以通過參數-XX:MaxTenuringThreshold設置。

5.22 為什么老年代不能使用標記復制?

參考答案

因為老年代保留的對象都是難以消亡的,而標記復制算法在對象存活率較高時就要進行較多的復制操作,效率將會降低,所以在老年代一般不能直接選用這種算法。

5.23 新生代為什么要分為Eden和Survivor,它們的比例是多少?

參考答案

現在的商用Java虛擬機大多都優先采用了“標記-復制算法”去回收新生代,該算法早期采用“半區復制”的機制進行垃圾回收。它將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活著的對象復制到另外一塊上面,然后再把已使用過的內存空間一次清理掉。這樣實現簡單,運行高效,不過其缺陷也顯而易見,這種復制回收算法的代價是將可用內存縮小為了原來的一半,空間浪費未免太多了一點。

實際上,新生代中的對象有98%熬不過第一輪收集,因此并不需要按照1∶1的比例來劃分新生代的內存空間。在1989年,Andrew Appel提出了一種更優化的半區復制分代策略,現在稱為“Appel式回收”。Appel式回收的具體做法是把新生代分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次分配內存只使用Eden和其中一塊Survivor。發生垃圾搜集時,將Eden和Survivor中仍然存活的對象一次性復制到另外一塊Survivor空間上,然后直接清理掉Eden和已用過的那塊Survivor空間。

HotSpot虛擬機默認Eden和Survivor的大小比例是8∶1,也即每次新生代中可用內存空間為整個新生代容量的90%(Eden的80%加上一個Survivor的10%),只有一個Survivor空間,即10%的新生代是會被“浪費”的。

5.24 為什么要設置兩個Survivor區域?

參考答案

設置兩個 Survivor 區最大的好處就是解決內存碎片化。

我們先假設一下,Survivor 只有一個區域會怎樣。Minor GC 執行后,Eden 區被清空了,存活的對象放到了 Survivor 區,而之前 Survivor 區中的對象,可能也有一些是需要被清除的。問題來了,這時候我們怎么清除它們?在這種場景下,我們只能標記清除,而我們知道標記清除最大的問題就是內存碎片,在新生代這種經常會消亡的區域,采用標記清除必然會讓內存產生嚴重的碎片化。因為 Survivor 有 2 個區域,所以每次 Minor GC,會將之前 Eden 區和 From 區中的存活對象復制到 To 區域。第二次 Minor GC 時,From 與 To 職責兌換,這時候會將 Eden 區和 To 區中的存活對象再復制到 From 區域,以此反復。

這種機制最大的好處就是,整個過程中,永遠有一個 Survivor space 是空的,另一個非空的 Survivor space 是無碎片的。那么,Survivor 為什么不分更多塊呢?比方說分成三個、四個、五個?顯然,如果 Survivor 區再細分下去,每一塊的空間就會比較小,容易導致 Survivor 區滿,兩塊 Survivor 區可能是經過權衡之后的最佳方案。

5.25 說一說你對GC算法的了解。

參考答案

標記-清除算法:

最早出現也是最基礎的垃圾收集算法是“標記-清除”(Mark-Sweep)算法,如它的名字一樣,算法分為“標記”和“清除”兩個階段:首先標記出所有需要回收的對象,在標記完成后,統一回收掉所有被標記的對象,也可以反過來,標記存活的對象,統一回收所有未被標記的對象。

它的主要缺點有兩個:第一個是執行效率不穩定,如果Java堆中包含大量對象,而且其中大部分是需要被回收的,這時必須進行大量標記和清除的動作,導致標記和清除兩個過程的執行效率都隨對象數量增長而降低;第二個是內存空間的碎片化問題,標記、清除之后會產生大量不連續的內存碎片,空間碎片太多可能會導致當以后在程序運行過程中需要分配較大對象時無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。標記-清除算法的執行過程如下圖所示。

img

標記-復制算法:

為了解決標記-清除算法面對大量可回收對象時執行效率低的問題,1969年Fenichel提出了一種稱為“半區復制”(Semispace Copying)的垃圾收集算法,它將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活著的對象復制到另外一塊上面,然后再把已使用過的內存空間一次清理掉。如果內存中多數對象都是存活的,這種算法將會產生大量的內存間復制的開銷,但對于多數對象都是可回收的情況,算法需要復制的就是占少數的存活對象,而且每次都是針對整個半區進行內存回收,分配內存時也就不用考慮有空間碎片的復雜情況,只要移動堆頂指針,按順序分配即可。這樣實現簡單,運行高效,不過其缺陷也顯而易見,這種復制回收算法的代價是將可用內存縮小為了原來的一半,空間浪費未免太多了一點。標記-復制算法的執行過程如下圖所示。

img

在1989年,Andrew Appel針對具備“朝生夕滅”特點的對象,提出了一種更優化的半區復制分代策略,現在稱為“Appel式回收”。Appel式回收的具體做法是把新生代分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次分配內存只使用Eden和其中一塊Survivor。發生垃圾搜集時,將Eden和Survivor中仍然存活的對象一次性復制到另外一塊Survivor空間上,然后直接清理掉Eden和已用過的那塊Survivor空間。HotSpot虛擬機默認Eden和Survivor的大小比例是8∶1,也即每次新生代中可用內存空間為整個新生代容量的90%(Eden的80%加上一個Survivor的10%),只有一個Survivor空間,即10%的新生代是會被“浪費”的。當然,98%的對象可被回收僅僅是“普通場景”下測得的數據,任何人都沒有辦法百分百保證每次回收都只有不多于10%的對象存活,因此Appel式回收還有一個充當罕見情況的“逃生門”的安全設計,當Survivor空間不足以容納一次Minor GC之后存活的對象時,就需要依賴其他內存區域(實際上大多就是老年代)進行分配擔保(Handle Promotion)。

標記-整理算法:

標記-復制算法在對象存活率較高時就要進行較多的復制操作,效率將會降低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的內存中所有對象都100%存活的極端情況,所以在老年代一般不能直接選用這種算法。

針對老年代對象的存亡特征,1974年Edward Lueders提出了另外一種有針對性的“標記-整理”(Mark-Compact)算法,其中的標記過程仍然與“標記-清除”算法一樣,但后續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向內存空間一端移動,然后直接清理掉邊界以外的內存,“標記-整理”算法的示意圖如下圖所示。

img

5.26 為什么新生代和老年代要采用不同的回收算法?

參考答案

如果一個區域中大多數對象都是朝生夕滅,難以熬過垃圾收集過程的話,那么把它們集中放在一起,每次回收時只關注如何保留少量存活而不是去標記那些大量將要被回收的對象,就能以較低代價回收到大量的空間。如果剩下的都是難以消亡的對象,那把它們集中放在一塊,虛擬機便可以使用較低的頻率來回收這個區域,這就同時兼顧了垃圾收集的時間開銷和內存的空間有效利用。

5.27 請介紹G1垃圾收集器

參考答案

G1(Garbage First)是一款主要面向服務端應用的垃圾收集器,JDK 9發布之日,G1宣告取代ParallelScavenge加Parallel Old組合,成為服務端模式下的默認垃圾收集器,而CMS則淪落至被聲明為不推薦使用(Deprecate)的收集器。G1收集器是垃圾收集器技術發展歷史上的里程碑式的成果,它開創了收集器面向局部收集的設計思路和基于Region的內存布局形式。

雖然G1也仍是遵循分代收集理論設計的,但其堆內存的布局與其他收集器有非常明顯的差異:G1不再堅持固定大小以及固定數量的分代區域劃分,而是把連續的Java堆劃分為多個大小相等的獨立區域(Region),每一個Region都可以根據需要,扮演新生代的Eden空間、Survivor空間,或者老年代空間。收集器能夠對扮演不同角色的Region采用不同的策略去處理,這樣無論是新創建的對象還是已經存活了一段時間、熬過多次收集的舊對象都能獲取很好的收集效果。

Region中還有一類特殊的Humongous區域,專門用來存儲大對象。G1認為只要大小超過了一個Region容量一半的對象即可判定為大對象。每個Region的大小可以通過參數 -XX:G1HeapRegionSize 設定,取值范圍為1MB~32MB,且應為2的N次冪。而對于那些超過了整個Region容量的超級大對象,將會被存放在N個連續的Humongous Region 之中,G1的大多數行為都把 Humongous Region 作為老年代的一部分來進行看待,如下圖所示。

img

雖然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它們都是一系列區域(不需要連續)的動態集合。G1收集器之所以能建立可預測的停頓時間模型,是因為它將Region作為單次回收的最小單元,即每次收集到的內存空間都是Region大小的整數倍,這樣可以有計劃地避免在整個Java堆中進行全區域的垃圾收集。更具體的處理思路是讓G1收集器去跟蹤各個Region里面的垃圾堆積的“價值”大小,價值即回收所獲得的空間大小以及回收所需時間的經驗值,然后在后臺維護一個優先級列表,每次根據用戶設定允許的收集停頓時間(使用參數-XX:MaxGCPauseMillis指定,默認值是200毫秒),優先處理回收價值收益最大的那些Region,這也就是“Garbage First”名字的由來。這種使用Region劃分內存空間,以及具有優先級的區域回收方式,保證了G1收集器在有限的時間內獲取盡可能高的收集效率。

5.28 請介紹CMS垃圾收集器

參考答案

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。從名字上就可以看出CMS收集器是基于標記-清除算法實現的,它的運作過程分為四個步驟,包括:

  1. 初始標記(CMS initial mark);
  2. 并發標記(CMS concurrent mark);
  3. 重新標記(CMS remark);
  4. 并發清除(CMS concurrent sweep)。

其中初始標記、重新標記這兩個步驟仍然需要“Stop The World”。初始標記僅僅只是標記一下GC Roots能直接關聯到的對象,速度很快;并發標記階段就是從GC Roots的直接關聯對象開始遍歷整個對象圖的過程,這個過程耗時較長但是不需要停頓用戶線程,可以與垃圾收集線程一起并發運行;而重新標記階段則是為了修正并發標記期間,因用戶程序繼續運作而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間通常會比初始標記階段稍長一些,但也遠比并發標記階段的時間短;最后是并發清除階段,清理刪除掉標記階段判斷的已經死亡的對象,由于不需要移動存活對象,所以這個階段也是可以與用戶線程同時并發的。

由于在整個過程中耗時最長的并發標記和并發清除階段中,垃圾收集器線程都可以與用戶線程一起工作,所以從總體上來說,CMS收集器的內存回收過程是與用戶線程一起并發執行的。通過下圖可以比較清楚地看到CMS收集器的運作步驟中并發和需要停頓的階段。

img

CMS收集器還遠達不到完美的程度,它至少有以下三個明顯的缺點:

首先,CMS收集器對處理器資源非常敏感。在并發階段,它雖然不會導致用戶線程停頓,但卻會因為占用了一部分線程(或者說處理器的計算能力)而導致應用程序變慢,降低總吞吐量。

然后,由于CMS收集器無法處理“浮動垃圾”(Floating Garbage),有可能出現“Con-current Mode Failure”失敗進而導致另一次完全“Stop TheWorld”的Full GC的產生。

還有最后一個缺點,CMS是一款基于“標記-清除”算法實現的收集器,這意味著收集結束時會有大量空間碎片產生。空間碎片過多時,將會給大對象分配帶來很大麻煩,往往會出現老年代還有很多剩余空間,但就是無法找到足夠大的連續空間來分配當前對象,而不得不提前觸發一次Full GC的情況。

5.29 內存泄漏和內存溢出有什么區別?

參考答案

內存泄漏(memory leak):內存泄漏指程序運行過程中分配內存給臨時變量,用完之后卻沒有被GC回收,始終占用著內存,既不能被使用也不能分配給其他程序,于是就發生了內存泄漏。

內存溢出(out of memory):簡單地說內存溢出就是指程序運行過程中申請的內存大于系統能夠提供的內存,導致無法申請到足夠的內存,于是就發生了內存溢出。

5.30 什么是內存泄漏,怎么解決?

參考答案

內存泄漏的根本原因是長生命周期的對象持有短生命周期對象的引用,盡管短生命周期的對象已經不再需要,但由于長生命周期對象持有它的引用而導致不能被回收。以發生的方式來分類,內存泄漏可以分為4類:

  1. 常發性內存泄漏。發生內存泄漏的代碼會被多次執行到,每次被執行的時候都會導致一塊內存泄漏。
  2. 偶發性內存泄漏。發生內存泄漏的代碼只有在某些特定環境或操作過程下才會發生。常發性和偶發性是相對的。對于特定的環境,偶發性的也許就變成了常發性的。所以測試環境和測試方法對檢測內存泄漏至關重要。
  3. 一次性內存泄漏。發生內存泄漏的代碼只會被執行一次,或者由于算法上的缺陷,導致總會有一塊僅且一塊內存發生泄漏。
  4. 隱式內存泄漏。程序在運行過程中不停的分配內存,但是直到結束的時候才釋放內存。嚴格的說這里并沒有發生內存泄漏,因為最終程序釋放了所有申請的內存。但是對于一個服務器程序,需要運行幾天,幾周甚至幾個月,不及時釋放內存也可能導致最終耗盡系統的所有內存。所以,我們稱這類內存泄漏為隱式內存泄漏。

避免內存泄漏的幾點建議:

  1. 盡早釋放無用對象的引用。
  2. 避免在循環中創建對象。
  3. 使用字符串處理時避免使用String,應使用StringBuffer。
  4. 盡量少使用靜態變量,因為靜態變量存放在永久代,基本不參與垃圾回收。

5.31 什么是內存溢出,怎么解決?

參考答案

內存溢出(out of memory):簡單地說內存溢出就是指程序運行過程中申請的內存大于系統能夠提供的內存,導致無法申請到足夠的內存,于是就發生了內存溢出。

引起內存溢出的原因有很多種,常見的有以下幾種:

  1. 內存中加載的數據量過于龐大,如一次從數據庫取出過多數據;
  2. 集合類中有對對象的引用,使用完后未清空,使得JVM不能回收;
  3. 代碼中存在死循環或循環產生過多重復的對象實體;
  4. 使用的第三方軟件中的BUG;
  5. 啟動參數內存值設定的過小。

內存溢出的解決方案:

  • 第一步,修改JVM啟動參數,直接增加內存。
  • 第二步,檢查錯誤日志,查看“OutOfMemory”錯誤前是否有其它異常或錯誤。
  • 第三步,對代碼進行走查和分析,找出可能發生內存溢出的位置。
  • 第四步,使用內存查看工具動態查看內存使用情況。

5.32 哪些區域會OOM,怎么觸發OOM?

參考答案

除了程序計數器外,虛擬機內存的其他幾個運行時區域都有發生OOM異常的可能。

  1. Java堆溢出

    Java堆用于儲存對象實例,我們只要不斷地創建對象,并且保證GC Roots到對象之間有可達路徑來避免垃圾回收機制清除這些對象,那么隨著對象數量的增加,總容量觸及最大堆的容量限制后就會產生內存溢出異常。

  2. 虛擬機棧和本地方法棧溢出

    HotSpot虛擬機中并不區分虛擬機棧和本地方法棧,如果虛擬機的棧內存允許動態擴展,當擴展棧容量無法申請到足夠的內存時,將拋出OutOfMemoryError異常。

  3. 方法區和運行時常量池溢出

    方法區溢出也是一種常見的內存溢出異常,在經常運行時生成大量動態類的應用場景里,就應該特別關注這些類的回收狀況。這類場景常見的包括:程序使用了CGLib字節碼增強和動態語言、大量JSP或動態產生JSP文件的應用(JSP第一次運行時需要編譯為Java類)、基于OSGi的應用(即使是同一個類文件,被不同的加載器加載也會視為不同的類)等。

    在JDK 6或更早之前的HotSpot虛擬機中,常量池都是分配在永久代中,即常量池是方法去的一部分,所以上述問題在常量池中也同樣會出現。而HotSpot從JDK 7開始逐步“去永久代”的計劃,并在JDK 8中完全使用元空間來代替永久代,所以上述問題在JDK 8中會得到避免。

  4. 本地直接內存溢出

前是否有其它異常或錯誤。

  • 第三步,對代碼進行走查和分析,找出可能發生內存溢出的位置。
  • 第四步,使用內存查看工具動態查看內存使用情況。

5.32 哪些區域會OOM,怎么觸發OOM?

參考答案

除了程序計數器外,虛擬機內存的其他幾個運行時區域都有發生OOM異常的可能。

  1. Java堆溢出

    Java堆用于儲存對象實例,我們只要不斷地創建對象,并且保證GC Roots到對象之間有可達路徑來避免垃圾回收機制清除這些對象,那么隨著對象數量的增加,總容量觸及最大堆的容量限制后就會產生內存溢出異常。

  2. 虛擬機棧和本地方法棧溢出

    HotSpot虛擬機中并不區分虛擬機棧和本地方法棧,如果虛擬機的棧內存允許動態擴展,當擴展棧容量無法申請到足夠的內存時,將拋出OutOfMemoryError異常。

  3. 方法區和運行時常量池溢出

    方法區溢出也是一種常見的內存溢出異常,在經常運行時生成大量動態類的應用場景里,就應該特別關注這些類的回收狀況。這類場景常見的包括:程序使用了CGLib字節碼增強和動態語言、大量JSP或動態產生JSP文件的應用(JSP第一次運行時需要編譯為Java類)、基于OSGi的應用(即使是同一個類文件,被不同的加載器加載也會視為不同的類)等。

    在JDK 6或更早之前的HotSpot虛擬機中,常量池都是分配在永久代中,即常量池是方法去的一部分,所以上述問題在常量池中也同樣會出現。而HotSpot從JDK 7開始逐步“去永久代”的計劃,并在JDK 8中完全使用元空間來代替永久代,所以上述問題在JDK 8中會得到避免。

  4. 本地直接內存溢出

    直接內存(Direct Memory)的容量大小可通過-XX:MaxDirectMemorySize參數來指定,如果不去指定,則默認與Java堆最大值(由-Xmx指定)一致。如果直接通過反射獲取Unsafe實例進行內存分配,并超出了上述的限制時,將會引發OOM異常。

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/450726.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/450726.shtml
英文地址,請注明出處:http://en.pswp.cn/news/450726.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

Linux 基本操作--文件查看 (day3)

一、查看文件-----cat (詳情參考:http://blog.sina.com.cn/s/blog_52f6ead0010127xm.html) 語法結構: cat 查看方式 文件 cat -A : show all 顯示所有內容,相當于-vET [rootlocalhost tmp]# cat -A /etc/profile #注釋:查看/erx/目錄下profile文件的內容 cat -b  :對非空…

如何在面試時寫出高質量的代碼

摘要&#xff1a;有些程序員由于平時沒有養成良好的編程習慣&#xff0c;在面試時寫出的代碼質量不高&#xff0c;最終遺憾地與心儀的公司和職位失之交臂。如何在面試時能寫出高質量的代碼&#xff0c;是很多程序員關心的問題。 程序員在職業生涯中難免要接受編程面試。有些程序…

IntelliJ IDEA添加jar包

見&#xff1a;http://blog.csdn.net/a153375250/article/details/50851049 以JDBC-MySQL驅動包為例 1、在IntelliJ IDEA中打開要添加jar包的Project 2、File – Project Structure如下圖 3、選擇Moudules – 再選擇Dependencies如下圖 4、選中Moudule source – 然后點擊2處號…

Python3 與 C# 并發編程之~ 進程篇

上次說了很多Linux下進程相關知識&#xff0c;這邊不再復述&#xff0c;下面來說說Python的并發編程&#xff0c;如有錯誤歡迎提出&#xff5e; 如果遇到聽不懂的可以看上一次的文章&#xff1a;https://www.cnblogs.com/dotnetcrazy/p/9363810.html 官方文檔&#xff1a;https…

11月12號 用戶登錄輸入密碼錯誤達到指定次數后,鎖定賬戶 004

用戶表里添加兩個屬性 連續密碼輸錯次數private Integer loginFailCount;/** 登錄失敗禁用時間 */ private Date missDate; / 如果登錄錯誤次數大于5次 規定時間內禁止登錄if(dbUser.getLoginFailCount() ! null && dbUser.getLoginFailCount() > 3){if(DateUtils.…

Goobuntu:谷歌的內部桌面系統

摘要&#xff1a;大多數Linux用戶都知道Google用Linux作為它們的桌面和服務器端操作系統&#xff0c;有的人可能還知道Google選擇的是定制的Ubuntu——Goobuntu&#xff0c;但在此之前幾乎沒有Google外部人員了解他們究竟是如何使用Ubuntu的&#xff0c;8月29日&#xff0c;Tho…

Springboot 之 Hibernate自動建表(Mysql)

前些天發現了一個巨牛的人工智能學習網站&#xff0c;通俗易懂&#xff0c;風趣幽默&#xff0c;忍不住分享一下給大家。點擊跳轉到教程。 引入Maven依賴包 <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-…

Spring全家桶面試真題

文章目錄1. Spring Boot1.1 說說你對Spring Boot的理解1.2 Spring Boot Starter有什么用&#xff1f;1.3 介紹Spring Boot的啟動流程1.4 Spring Boot項目是如何導入包的&#xff1f;1.5 請描述Spring Boot自動裝配的過程1.6 說說你對Spring Boot注解的了解2. Spring2.1 請你說說…

WSDL測試webservice接口記錄

收到一個事情&#xff0c;需要對接第三方API&#xff0c;對方給了個service&#xff0c;看了一下&#xff0c;原來是webservices的。 上一次測試webervice的接口&#xff0c;還是至少八九年前的時候了&#xff0c;這種相對比較老舊的也好久不在使用。 于是&#xff0c;簡單搞了…

idea窗口下方滾動條不明顯設置

在使用idea時&#xff0c;下方的滾動條老是顯示不明顯&#xff0c;每次點擊拖拽都很費勁&#xff0c;在網上找了很多相關設置&#xff0c;最后確定了一個最好的辦法解決問題&#xff1a; Shift &#xff08;上檔&#xff09; 鼠標滾動&#xff0c;這樣就可以橫向翻滾了&#…

把握本質規律——《數學之美》作者吳軍

無論是互聯網&#xff0c;還是手機、電視&#xff0c;現代通信都遵循信息論的規律&#xff0c;整個信息論的基礎都是數學。搜索引擎、語音識別、機器翻譯也都是我們生活中離不開的技術&#xff0c;數學也是解決這些問題的最好工具。在《浪潮之巔》出版后&#xff0c;吳軍將蘊含…

Hibernate4 注解方法說明

前些天發現了一個巨牛的人工智能學習網站&#xff0c;通俗易懂&#xff0c;風趣幽默&#xff0c;忍不住分享一下給大家。點擊跳轉到教程。 1.類級別注解 Entity 映射實體類 Table 映射數句庫表 Entity(name"tableName") - 必須&#xff0c;注解將一個類聲明…

消息隊列常見面試題

文章目錄2. 消息隊列2.1 MQ有什么用&#xff1f;2.2 說一說生產者與消費者模式2.3 消息隊列如何保證順序消費&#xff1f;2.4 消息隊列如何保證消息不丟&#xff1f;2.5 消息隊列如何保證不重復消費&#xff1f;2.6 MQ處理消息失敗了怎么辦&#xff1f;2.7 請介紹消息隊列推和拉…

Mybatis 詳解--- 一級緩存、二級緩存

2019獨角獸企業重金招聘Python工程師標準>>> Mybatis 為我們提供了一級緩存和二級緩存&#xff0c;可以通過下圖來理解&#xff1a; ①、一級緩存是SqlSession級別的緩存。在操作數據庫時需要構造sqlSession對象&#xff0c;在對象中有一個數據結構&#xff08;Hash…

我的nabcd

我們組要做的軟件是一款MP3播放軟件&#xff0c;名字叫TDG音樂 N&#xff08;need需求&#xff09;&#xff0c;由于現在版權越來越被重視&#xff0c;許多播放軟件里面的大部分歌曲都是收費的&#xff0c;不想花錢又想聽可怎么辦呢&#xff0c;只能在網上找免費資源&#xff0…

【C/C++和指針】深度解析---指針與數組 【精華】

一&#xff0c;引例子 二維數組可以使用指向數組的指針代替&#xff0c;而指針數組才可以用指向指針的指針代替。 [html] view plaincopy#include<iostream> using namespace std; void main() { char *a[]{"Hello","the","World&q…

Redis常見面試題詳解

文章目錄1. Redis1.1 Redis可以用來做什么&#xff1f;1.2 Redis和傳統的關系型數據庫有什么不同&#xff1f;1.3 Redis有哪些數據類型&#xff1f;1.4 Redis是單線程的&#xff0c;為什么還能這么快&#xff1f;1.5 Redis在持久化時fork出一個子進程&#xff0c;這時已經有兩個…

IntelliJ 創建main函數、for循環、輸出語句快捷鍵

前些天發現了一個巨牛的人工智能學習網站&#xff0c;通俗易懂&#xff0c;風趣幽默&#xff0c;忍不住分享一下給大家。點擊跳轉到教程。 今天偶然發現了IntelliJ中 創建main函數的快捷鍵&#xff0c;依次還有for循環&#xff0c;System.out.println(); 在編寫代碼的時候直接…

CentOS新增用戶并授予sudo權限

2019獨角獸企業重金招聘Python工程師標準>>> 新增用戶 添加用戶useradd demo設置用戶密碼passwd demo授予sudo權限 輸入以下命令&#xff0c;編輯sudoers配置&#xff1a;visudo找到以下行root ALL(ALL) ALL增加以下內容&#xff1a;demo ALL(ALL) ALL保存后登錄dem…

跨站腳本功攻擊,xss,一個簡單的例子讓你知道什么是xss攻擊

跨站腳本功攻擊&#xff0c;xss&#xff0c;一個簡單的例子讓你知道什么是xss攻擊 一、總結 一句話總結&#xff1a;比如用戶留言功能&#xff0c;用戶留言中寫的是網頁可執行代碼&#xff0c;例如js代碼&#xff0c;然后這段代碼在可看到這段留言的不同一戶的顯示上就會執行。…