最全的 JVM 面試知識點(一):運行時數據區

本系列文章講解 面試中常見的 JVM 問題。這些問題之因此常見,是由於很基礎,對於一個有點逼格的程序猿來講, JVM 的相關特性和原理在工做也須要熟知。筆者也在面試的過程當中屢屢受挫,屢敗屢戰,總結一些常見知識點,這些知識點既能夠應付面試,也能夠幫助讀者深刻了解 JVM 提供大綱。html

在用 C 之類的編程語言時,程序員須要本身手動分配和釋放內存。而 Java 不同,它有垃圾回收器,釋放內存由回收器負責。java

Java 虛擬機在執行 Java 程序的過程當中會把它管理的內存劃分紅若干個不一樣的數據區域。那咱們來簡單看一下 Java 程序具體執行的過程:程序員

圖片來自 https://www.cnblogs.com/dolphin0520/p/3613043.html
圖片來自https://www.cnblogs.com/dolphin0520/p/3613043.html

首先 Java 源代碼文件(.java 後綴)會被 Java 編譯器編譯爲字節碼文件(.class 後綴),而後由 JVM 中的類加載器加載各個類的字節碼文件,加載完畢以後,交由 JVM 執行引擎執行。在整個程序執行過程當中,JVM 會用一段空間來存儲程序執行期間須要用到的數據和相關信息,這段空間通常被稱做爲 Runtime Data Area(運行時數據區),也就是咱們常說的 JVM 內存。所以,在 Java 中咱們經常說到的內存管理就是針對這段空間進行管理(如何分配和回收內存空間)。面試

本文的主要內容:算法

  • JVM 內存劃分
    • 方法區
    • 運行時常量池
    • Java 虛擬機棧
    • 本地方法棧
    • 程序計數器
    • 棧與堆
  • 直接內存
    • 堆外內存垃圾回收機制
  • JVM 類加載
    • 類的加載過程
    • JVM 預約義的類加載器
    • 雙親委派模式
      • 雙親委派機制
      • 雙親委派做用
    • 對象的建立
      • 對象的內存佈局
    • 對象的訪問定位

JVM 內存劃分

運行時數據區分爲線程私有和共享數據區兩大類。其中線程私有的數據區包含程序計數器、虛擬機棧、本地方法區,全部線程共享的數據區包含 Java 堆、方法區,在方法區內有一個常量池。編程

下面咱們依次介紹這些數據區。數組

堆用於存放對象實例,全部的對象和數組都要在堆上分配。是 JVM 所管理的內存中最大的一塊區域。Java 堆是全部線程共享的一塊內存區域,在虛擬機啓動時建立。此內存區域的惟一目的就是存放對象實例,幾乎全部的對象實例以及數組都在這裏分配內存。Java 堆是垃圾收集器管理的主要區域,所以也被稱做 GC 堆(Garbage Collected Heap).從垃圾回收的角度,因爲如今收集器基本都採用分代垃圾收集算法,因此 Java 堆還能夠細分爲:新生代和老年代。新生代具體劃分有:Eden 空間、From Survivor、To Survivor 空間等,進一步劃分的目的是更好地回收內存,或者更快地分配內存。緩存

方法區

方法區與 Java 堆同樣,是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類信息、常量、靜態變量、即編譯器編譯後的代碼等數據。
HotSpot 虛擬機中方法區也常被稱爲永久代,本質上二者並不等價。僅僅是由於 HotSpot 虛擬機設計團隊用永久代來實現方法區而已,這樣 HotSpot 虛擬機的垃圾收集器就能夠像管理 Java 堆同樣管理這部份內存了。可是這並非一個好主意,由於這樣更容易遇到內存溢出問題。相對而言,垃圾收集行爲在這個區域是較少出現的,但並不是數據進入方法區後就永久存在了。安全

運行時常量池

運行時常量池是方法區的一部分。Class文件中除了有類的版本、字段、方法、接口等描述信息外,還有常量池信息(用於存放編譯期生成的各類字面量和符號引用)微信

Java虛擬機棧

Java 虛擬機棧是線程私有的,它的生命週期和線程相同,描述的是 Java 方法執行的內存模型。 Java 內存能夠粗糙的區分爲堆內存(Heap)和棧內存(Stack),其中棧就是如今說的虛擬機棧,或者說是虛擬機棧中局部變量表部分。存儲局部變量表、操做數棧、動態連接和方法出口等信息。 局部變量表主要存放了編譯器可知的各類數據類型、對象引用。

本地方法棧

和虛擬機棧所發揮的做用很是類似,區別是: 虛擬機棧爲虛擬機執行 Java 方法 (也就是字節碼)服務,而本地方法棧則爲虛擬機使用到的 Native 方法服務。 一個 Native Method 就是一個 Java 程序調用非 Java 代碼的接口。在定義一個 Native method 時,並不提供實現體(有些像定義一個java interface),由於其實現體是由非 Java 語言在外面實現的。標識符native能夠與全部其它的 Java 標識符連用,可是 abstract 除外。

咱們知道,當一個類第一次被使用到時,這個類的字節碼會被加載到內存,而且只會回載一次。在這個被加載的字節碼的入口維持着一個該類全部方法描述符的 list,這些方法描述符包含這樣一些信息:方法代碼存於何處,它有哪些參數,方法的描述符(public 等)等等。

若是一個方法描述符內有 native,這個描述符塊將有一個指向該方法的實現的指針。這些實如今一些 DLL 文件內,可是它們會被操做系統加載到 Java 程序的地址空間。當一個帶有本地方法的類被加載時,其相關的 DLL 並未被加載,所以指向方法實現的指針並不會被設置。當本地方法被調用以前,這些 DLL 纔會被加載,這是經過調用 java.system.loadLibrary() 實現的。

須要提示的是,使用本地方法是有開銷的,它喪失了 Java 的不少好處。若是別無選擇,咱們能夠選擇使用本地方法。

程序計數器

程序計數器是一塊較小的內存空間,能夠看做是當前線程所執行的字節碼的行號指示器。字節碼解釋器工做時經過改變這個計數器的值來選取下一條須要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等功能都須要依賴這個計數器來完。 另外,爲了線程切換後能恢復到正確的執行位置,每條線程都須要有一個獨立的程序計數器,各線程之間計數器互不影響,獨立存儲,咱們稱這類內存區域爲「線程私有」的內存。

棧與堆

棧解決程序的運行問題,即程序如何執行,或者說如何處理數據;堆解決的是數據存儲的問題,即數據怎麼放、放在哪兒。
在 Java 中一個線程就會相應有一個線程棧與之對應,這點很容易理解,由於不一樣的線程執行邏輯有所不一樣,所以須要一個獨立的線程棧。而堆則是全部線程共享的。棧由於是運行單位,所以裏面存儲的信息都是跟當前線程(或程序)相關信息的。包括局部變量、程序運行狀態、方法返回值等等;而堆只負責存儲對象信息。
Java 的堆是一個運行時數據區,類的(對象從中分配空間。這些對象經過 new、newarray、anewarray 和 multianewarray 等指令創建,它們不須要程序代碼來顯式的釋放。堆是由垃圾回收來負責的,堆的優點是能夠動態地分配內存大小,生存期也沒必要事先告訴編譯器,由於它是在運行時 動態分配內存的,Java 的垃圾收集器會自動收走這些再也不使用的數據。但缺點是,因爲要在運行時動態分配內存,存取速度較慢。棧的優點是,存取速度比堆要快,僅次於寄存器,棧數據能夠共享。但缺點是,存在棧中的數據大小與生存期必須是肯定的,缺少靈活性。棧中主要存放一些基本類 型的變量(int, short, long, byte, float, double, boolean, char)和對象句柄。

直接內存

在 Java 中當咱們要對數據進行更底層的操做時,通常是操做數據的字節(byte)形式,這時常常會用到 ByteBuffer 這樣一個類。ByteBuffer 提供了兩種靜態實例方式:

public static ByteBuffer allocate(int capacity) public static ByteBuffer allocateDirect(int capacity) 複製代碼

爲何要提供兩種方式呢?這與 Java 的內存使用機制有關。ByteBuffer 有兩種,一種是 heap ByteBuffer,該類對象分配在 JVM 的堆內存裏面,直接由 Java 虛擬機負責垃圾回收;一種是 direct ByteBuffer 是經過 JNI 在虛擬機外內存中分配的。JDK1.4 中新加入的 NIO(New Input/Output) 類,引入了一種基於通道(Channel) 與緩存區(Buffer) 的 I/O 方式,它能夠直接使用 Native 函數庫直接分配堆外內存,而後經過一個存儲在 Java 堆中的 DirectByteBuffer 對象做爲這塊內存的引用進行操做。這樣就能在一些場景中顯著提升性能,由於避免了在 Java 堆和 Native 堆之間來回複製數據。本機直接內存的分配不會收到 Java 堆的限制,可是,既然是內存就會受到本機總內存大小以及處理器尋址空間的限制。經過 Jmap 沒法查看該快內存的使用狀況。只能經過 top 來看它的內存使用狀況。

直接內存並非虛擬機運行時數據區的一部分,也不是虛擬機規範中定義的內存區域,可是這部份內存也被頻繁地使用。並且也可能致使 OutOfMemoryError 異常出現。 DirectMemory 容量能夠經過 -XX:MaxDirectMemorySize 指定,若是不指定,則默認爲與 Java 堆的最大值。

堆外內存垃圾回收機制

direct ByteBuffer 經過 full gc 來回收內存,direct ByteBuffer 會本身檢測狀況而調用 system.gc(),可是若是參數中使用了 -DisableExplicitGC 那麼就沒法回收該快內存了,-XX:+DisableExplicitGC 標誌自動將 System.gc() 調用轉換成一個空操做,就是應用中調用 System.gc()會變成一個空操做,所以須要咱們手動來回收內存了。

@Test
    public void testGcDirectBuffer() throws NoSuchFieldException, IllegalAccessException {
        ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
        Field cleanerField = buffer.getClass().getDeclaredField("cleaner");
        cleanerField.setAccessible(true);
        Cleaner cleaner = (Cleaner) cleanerField.get(buffer);
        cleaner.clean();
    }
複製代碼

除此以外,CMS GC 也會回收 Direct ByteBuffer 的內存,CMS 主要是針對老年代空間的垃圾回收。

JVM 類加載

在 Java 中,類型的加載、鏈接和初始化過程都在程序運行期間完成的,這種策略雖然會使類加載時增長一些性能開銷,可是提供了高度的靈活性,Java 天生能夠動態擴展的語言就是依賴於運行期動態加載和動態鏈接的特色實現的。

虛擬機把描述類的數據從 Class 文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終造成能夠被虛擬機直接使用的 Java 類型,這就是 Java 虛擬機的類加載機制。Class 文件是一串二進制的字節流。實際上,每一個 Class 文件都有可能表明着 Java 語言中的一個類或者接口。

類的加載過程

類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括:加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段。其中準備、驗證、解析3個部分統稱爲鏈接(Linking)。

  1. 加載
    查找並加載類的二進制數據。 加載是類加載過程的第一個階段,虛擬機在這一階段須要完成如下三件事情:

    • 經過類的全限定名來獲取其定義的二進制字節流;
    • 將字節流所表明的靜態存儲結構轉化爲方法區的運行時數據結構;
    • 在 Java 堆中生成一個表明這個類的 java.lang.Class 對象,做爲對方法區中這些數據的訪問入口。
  2. 驗證
    確保被加載的類的正確性。 這一階段是確保 Class 文件的字節流中包含的信息符合當前虛擬機的規範,而且不會損害虛擬機自身的安全。包含了四個驗證動做:文件格式驗證,元數據驗證,字節碼驗證,符號引用驗證。

    • 文件格式檢驗
      檢驗字節流是否符合Class文件格式的規範,而且能被當前版本的虛擬機處理。檢驗可能包含下列幾種:是否以魔數開頭、主次版本號是否在虛擬機的處理範圍以內,常量池中的常量是否不被支持、文件是否被刪除或附加什麼信息等等。 只有經過文件格式檢驗的二進制字節流才能進入內存的方法區進行存儲,因此後面的3個檢驗階段都是基於方法區的存儲結構進行的,不會在操做字節流。
    • 元數據檢驗
      對字節碼描述的信息進行語義分析,以保證其描述的內容符合Java語言規範的要求。 驗證點包括:是否有父類(除了object)、父類是否繼承了不可被繼承的類(被final修飾的類)、若是這個類不是抽象類,是否實現了其父類或接口之中要求實現的全部方法、類中的方法和字段是否與父類產生矛盾(覆蓋了父類的final字段、出現不合規矩的方法重載等)。 元數據檢驗主要是對類的元數據信息進行語義校驗,保證不符合Java語言規範的元數據信息不存在。
    • 字節碼檢驗
      經過數據流和控制流分析,肯定程序語義是合法、符合邏輯的。第二階段是對元數據信息中的數據類型作了檢驗,這一階段是對類的方法體進行校驗分析,保證被校驗類的方法在運行時不會作出危害虛擬機安全的事情。
      檢驗點包括:保證任意時刻操做數棧的數據類型與指令代碼序列都能配合工做、保證指令跳轉不會跳轉到方法體以外的地方、保證方法體內的類型轉換都是有效的。 事實上,即使是通過字節碼檢驗後的方法體也不必定是安全的。
    • 符號引用檢驗
      最後一個檢驗發生在虛擬機將符號引用轉化爲直接引用時,這個轉化動做將在鏈接的第三階段–解析階段中發生的。符號引用檢驗能夠看做是對類自身之外(常量池中的各類符號引用)的信息進行匹配性校驗。 校驗點:符號引用中經過字符串描述的全限定名是否能找到對應的類、在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段、符號引用中的類、字段、方法的訪問權限是否能讓當前類訪問到等。 符號引用檢驗的目的是確保解析動做的正常執行,若是沒法經過符號引用檢驗,將會拋出 java.lang.IncompatibleClassChangeError 異常的子類,如 IllegalAccessErrorNoSuchfiledErrorNoSuchMethodError 等。
  3. 準備
    爲類的靜態變量分配內存,並將其初始化爲默認值。 準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些內存都將在方法區中分配。

  4. 解析
    把類中的符號引用轉換爲直接引用。 解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程,解析動做主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符 7 類符號引用進行。

  5. 初始化
    類變量進行初始化 爲類的靜態變量賦予正確的初始值,JVM 負責對類進行初始化,主要對類變量進行初始化。

JVM 預約義的類加載器

  • 啓動(Bootstrap)類加載器
    引導類裝入器是用本地代碼實現的類裝入器,它負責將 < JavaRuntimeHome >/lib 下面的類庫加載到內存中。因爲引導類加載器涉及到虛擬機本地實現細節,開發者沒法直接獲取到啓動類加載器的引用。
  • 標準擴展(Extension)類加載器
    擴展類加載器,負責將 < Java_Runtime_Home >/lib/ext 或者由系統變量 java.ext.dir 指定位置中的類庫加載到內存中。開發者能夠直接使用標準擴展類加載器。
  • 應用程序類加載器(Application)
    應用程序類加載器(Application ClassLoader):負責加載用戶路徑(classpath)上的類庫。

除此以外,還有用戶自定義類加載器,是 java.lang.ClassLoader 的子類。在程序運行期間,經過java.lang.ClassLoader 的子類動態加載 class 文件,體現 Java 動態實時類裝入特性.

雙親委派模式

雙親委派模型的工做流程是:若是一個類加載器收到了類加載的請求,它首先不會本身去加載這個類,而是把請求委託給父加載器去完成,依次向上。所以,全部的類加載請求最終都應該被傳遞到頂層的啓動類加載器中,只有當父加載器沒有找到所需的類時,子加載器纔會嘗試去加載該類。

雙親委派機制
  1. 當 AppClassLoader 加載一個 class 時,它首先不會本身去嘗試加載這個類,而是把類加載請求委派給父類加載器 ExtClassLoader 去完成。
  2. 當 ExtClassLoader 加載一個 class 時,它首先也不會本身去嘗試加載這個類,而是把類加載請求委派給 BootStrapClassLoader 去完成。
  3. 若是 BootStrapClassLoader 加載失敗,會使用 ExtClassLoader 來嘗試加載;
  4. 若 ExtClassLoader 也加載失敗,則會使用 AppClassLoader 來加載,若是 AppClassLoader 也加載失敗,則會報出異常 ClassNotFoundException。
雙親委派做用

經過帶有優先級的層級關係能夠避免類的重複加載; 保證 Java 程序安全穩定運行,Java 核心 API 定義類型不會被隨意替換。

對象的建立

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

在類加載檢查經過後,接下來虛擬機將爲新生對象分配內存。對象所需的內存大小在類加載完成後即可肯定,爲對象分配空間的任務等同於把一塊肯定大小的內存從Java堆中劃分出來。分配方式有 「指針碰撞」 和 「空閒列表」 兩種:

  • 指針碰撞 把指針向空閒對象移動與對象佔用內存大小相等的距離。
  • 空閒列表 虛擬機維護一個列表,記錄可用的內存塊,分配給對象列表中一塊足夠大的內存空間。

選擇那種分配方式由Java堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。虛擬機採用CAS配上失敗重試的方式保證更新操做的原子性。

對象的內存佈局

在 Hotspot 虛擬機中,對象在內存中的佈局能夠分爲3塊區域:對象頭、實例數據和對齊填充。

  • 對象頭,Hotspot 虛擬機中的對象頭包括兩部分信息,第一部分用於存儲對象自身的自身運行時數據(哈希嗎、GC 分代年齡、鎖狀態標誌等等);另外一部分是類型指針,即對象指向它的類元數據的指針,虛擬機經過這個指針來肯定這個對象是那個類的實例。

  • 實例數據,是對象真正存儲的有效信息,也是在程序中所定義的各類類型的字段內容。

  • 對齊填充部分,不是必然存在的,也沒有什麼特別的含義,僅僅起佔位做用。 由於Hotspot虛擬機的自動內存管理系統要求對象起始地址必須是8字節的整數倍,換句話說就是對象的大小必須是8字節的整數倍。而對象頭部分正好是8字節的倍數(1倍或2倍),所以,當對象實例數據部分沒有對齊時,就須要經過對齊填充來補全。

對象的訪問定位

創建對象就是爲了使用對象,咱們的Java程序經過棧上的reference數據來操做堆上的具體對象。對象的訪問方式由虛擬機實現而定,目前主流的訪問方式有句柄和直接指針兩種:

  • 使用句柄,那麼 Java 堆中將會劃分出一塊內存來做爲句柄池,reference 中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自的具體地址信息;
  • 直接指針訪問,那麼 Java 堆對象的佈局中就必須考慮如何防止訪問類型數據的相關信息,reference 中存儲的直接就是對象的地址。

這兩種對象訪問方式各有優點。使用句柄來訪問的最大好處是 reference 中存儲的是穩定的句柄地址,在對象被移動時只會改變句柄中的實例數據指針。而 reference 自己不須要修改。使用直接指針訪問方式最大的好處就是速度快,它節省了一次指針定位的時間開銷。

小結

本文主要講了 JVM 中運行時數據區的劃分以及類加載機制。JVM 中的對象建立以後,如何回收無用的對象呢?JVM 的垃圾回收算法和多種垃圾收集器是怎麼樣的呢?下篇文章將會具體講解。

訂閱最新文章,歡迎關注個人公衆號

微信公衆號
相關文章
相關標籤/搜索