JAVA JVM詳解

一. JVM

JVM是Java Virtual Machine(Java虛擬機)的縮寫,也就是指的JVM虛擬機,是一種用于計算設(shè)備的規(guī)范,它是一個虛構(gòu)出來的計算機,是通過在實際的計算機上仿真模擬各種計算機功能來實現(xiàn)的。
總所周知,java語言是跨平臺的,而JVM是java跨平臺的關(guān)鍵之所在。JVM上執(zhí)行java字節(jié)碼,執(zhí)行時這些字節(jié)碼可以解釋成具體平臺的機器碼,因此java擁有“一次編譯,處處運行”這一跨平臺能力。

二、JRE、JDK和JVM的關(guān)系

JRE(Java Runtime Environment, Java運行環(huán)境)是Java平臺,所有的程序都要在JRE下才能夠運行。包括JVM和Java核心類庫和支持文件。

JDK(Java Development Kit,Java開發(fā)工具包)是用來編譯、調(diào)試Java程序的開發(fā)工具包。包括Java工具(javac/java/jdb等)和Java基礎(chǔ)的類庫(java API )。

JVM(Java Virtual Machine, Java虛擬機)是JRE的一部分。JVM主要工作是解釋自己的指令集(即字節(jié)碼)并映射到本地的CPU指令集和OS的系統(tǒng)調(diào)用。Java語言是跨平臺運行的,不同的操作系統(tǒng)會有不同的JVM映射規(guī)則,使之與操作系統(tǒng)無關(guān),完成跨平臺性。

有兩個概念和JVM息息相關(guān)并且很容易搞混,那就是JRE和JDK。其中JRE(JavaRuntimeEnvironment,Java運行環(huán)境),指的是Java平臺。所有的Java 程序都要在JRE下才能運行。普通用戶運行已開發(fā)好的java程序,只要安裝JRE即可。而JDK(JavaDevelopmentKit)是程序開發(fā)者用來編譯、調(diào)試java程序用的開發(fā)工具包。JDK工具包里面的工具也是Java寫的程序,因此也需要JRE才能運行。為了保持JDK的獨立性和完整性,在JDK的安裝過程中,JRE也是安裝的一部分。所以,在JDK的安裝目錄下有一個名為jre的目錄,用于存放JRE文件。而JVM是JRE的一部分。JVM有自己完善的硬件架構(gòu),如處理器、堆棧、寄存器等,還具有相應(yīng)的指令系統(tǒng)。Java語言最重要的特點就是跨平臺運行。使用JVM就是為了支持與操作系統(tǒng)無關(guān),實現(xiàn)跨平臺。使用JDK(調(diào)用JAVA API)開發(fā)JAVA程序后,通過JDK中的編譯程序(javac)將Java程序編譯為Java字節(jié)碼,在JRE上運行這些字節(jié)碼,JVM會解析并映射到真實操作系統(tǒng)的CPU指令集和OS的系統(tǒng)調(diào)用。

從上面我們可以看出java運行主要分幾個步驟:

  • 1、java源碼編譯。
  • 2、類加載。
  • 3、類執(zhí)行。

三. java源碼編譯

所謂”編譯“,通俗來講就是把我們寫的代碼“翻譯“成機器可以讀懂的機器碼。Java 技術(shù)中的編譯器可以分為如下兩類:

  • 前端編譯器:把 *.java 文件轉(zhuǎn)變?yōu)?*.class 文件的過程。比如 JDK 的 Javac。

  • 后端編譯器(兩類):

    1. 即時編譯器:Just In Time Compiler,常稱 JIT 編譯器,在「運行期」把字節(jié)碼轉(zhuǎn)變?yōu)楸镜貦C器碼的過程。比如 HotSpot VM 的 C1、C2 編譯器,Graal 編譯器。

    2.提前編譯器:Ahead Of Time Compiler,常稱 AOT 編譯器,直接把程序編譯成與目標(biāo)機器指令集相關(guān)的二進制代碼的過程。比如 JDK 的 Jaotc,GNU Compiler for the Java。

我們可以把將.java文件編譯成.class的編譯過程稱之為前端編譯。把將.class文件翻譯成機器指令的編譯過程稱之為后端編譯。

Javac編譯器對代碼的運行效率幾乎沒做什么優(yōu)化,虛擬機設(shè)計者把對代碼性能的優(yōu)化集中到了后端的JIT編譯器中。之所以這樣設(shè)計,因為Class文件擁有虛擬機規(guī)范嚴(yán)格定義的通用格式,只要符合Class文件格式,就可以被虛擬機正確加載,因此不只是Java語言,其他如JRuby、Groovy、Kotlin等語言也可以被編譯成Class文件。但不同語言使用的前端編譯器(將源碼文件編譯成Class文件)可能是不同的,故將優(yōu)化過程放到即時編譯器過程,可以讓不同語言的字節(jié)碼都能享受到性能優(yōu)化的好處。Javac編譯器本身是由Java語言編寫的,Javac編譯器針對程序編碼過程做了很多優(yōu)化措施,目的是改善程序員的編碼風(fēng)格和提高編碼效率。

1、前端編譯

把Java源碼文件(.java)編譯成Class文件(.class)的過程;也即把滿足Java語言規(guī)范的程序轉(zhuǎn)化為滿足JVM規(guī)范所要求格式的能力,稱為前端編譯。前端編譯階段中,最重要的一個編譯器就是javac 編譯器, 在命令行執(zhí)行javac命令,其實本質(zhì)是運行了javac.exe這個應(yīng)用。Android工程師可能對于 Gradle構(gòu)建過程更為熟悉,構(gòu)建過程中有一個Task:compileDebugJavaWithJavac,其實也用到了javac 編譯器,編譯中間產(chǎn)物路徑在build/intermediates/javac/debug/classes。

優(yōu)點:

  1. 這階段的優(yōu)化是指程序編碼方面的;
  2. 許多Java語法新特性("語法糖":泛型、內(nèi)部類等等),是靠前端編譯器實現(xiàn)的。
  3. 編譯成的Class文件可以直接給JVM解釋器解釋執(zhí)行,省去編譯時間,加快啟動速度;

缺點:

  1. 對代碼運行效率幾乎沒有任何優(yōu)化措施;
  2. 解釋執(zhí)行效率較低,所以需要結(jié)合下面的JIT編譯;

Javac編譯的基本流程

  • 準(zhǔn)備過程
    初始化插入式注解處理器。

  • 解析與填充符號表

    1. 語法、詞法分析
      詞法分析將源代碼的字符轉(zhuǎn)成標(biāo)記(Token)集合,單個字符是程序編寫的最小單位,而標(biāo)記則是編譯過程的最小單位。如“int a = b + 2”這句代碼可拆分為int、a、=、b、+、2共6個標(biāo)記。
      語法分析是根據(jù)Token序列構(gòu)造抽象語法樹(AST,Abstract Syntax Tree)的過程。AST是一種用來描述程序代碼語法結(jié)構(gòu)的樹形表示形式,語法樹的每一個節(jié)點都代表著程序代碼中的一個語法結(jié)構(gòu),如包、類型、修飾符、運算符、接口、返回值、代碼注釋等。抽象語法樹建立之后,編譯器基本不會再對源碼文件進行操作了,后續(xù)的操作都建立在抽象語法樹之上。
    2. 填充符號表
      符號表(Symbol Table)是由一組符號地址和符號信息構(gòu)成的表格,其中保存的信息在編譯的不同階段都要用到。以下是符號表的兩個應(yīng)用場景:
      1. 在語義分析中,符號表登記的內(nèi)容將用于語義檢查和產(chǎn)生中間代碼。
      2. 在目標(biāo)代碼生成階段,當(dāng)對符號名進行地址分配時,符號表是地址分配的依據(jù)。
  • 注解處理器

    1. JDK1.5之后,Java語言提供了對注解的支持。
    2. JDK1.6中提供了一組插入式注解處理器的標(biāo)準(zhǔn)API,支持在編譯期間對注解進行處理。
    3. 注解處理器可將其看做編譯器的插件,在這些插件里面,可以讀取、修改、添加抽象語法樹中的任意元素,如果這些插件在處理注解期間對語法樹進行了修改,編譯器將回到解析及填充符號表的過程重新處理,直到所有插入式注解處理器都沒有再對語法樹進行修改為止。
    4. 有了編譯器注解處理的標(biāo)準(zhǔn)API支持,我們的代碼才有可能干涉編譯器的行為。
  • 語義分析
    語義分析的任務(wù)是對結(jié)構(gòu)上正確的源程序進行上下文有關(guān)性質(zhì)的審查,因為抽象語法樹雖然能表示一個結(jié)構(gòu)正確的源程序的抽象,但無法保證源程序是符合邏輯的。

  • 標(biāo)注檢查
    標(biāo)注檢查步驟檢查的內(nèi)容如變量使用前是否已經(jīng)被聲明、變量與賦值之間的數(shù)據(jù)類型是否匹配等。

  • 數(shù)據(jù)及控制流分析
    數(shù)據(jù)及控制流分析是對程序上下文邏輯更進一步的驗證,可以檢查出諸如程序局部變量是否在使用前有賦值、方法的每條路徑是否都有返回值、是否所有的受檢異常都被正確處理等。

  • 解語法糖
    所謂語法糖,指在計算機語言中添加某種語法,只是為了更方便程序員使用,如提高編碼效率或減少出錯,但對語言功能沒有影響。
    所謂解語法糖(desugar),是指在編譯階段將糖衣語法還原回簡單的基礎(chǔ)語法結(jié)構(gòu),因為虛擬機運行時不支持這些語法。

    1. Java中常見的語法糖有:
      1. 泛型(JDK 1.5添加)——Java中的泛型其實是偽泛型,編譯后就會被替換為原生類型了,并在相應(yīng)的地方插入了強制轉(zhuǎn)型代碼。因此Java中的泛型實現(xiàn)方法也被稱為類型擦除。
      2. 自動裝箱、拆箱——編譯后被轉(zhuǎn)化成了對應(yīng)的包裝和還原方法。
      3. 循環(huán)遍歷——編譯后代碼被轉(zhuǎn)成了迭代器實現(xiàn),這也是被遍歷的類需要實現(xiàn)Iterable接口的原因。
      4. 變長參數(shù)——編譯后實際上被轉(zhuǎn)成數(shù)組類型的參數(shù)。
    2. Java中常見的其他語法糖:
      1. 其他語法糖還有內(nèi)部類、枚舉類、斷言語句、對枚舉和字符串的switch支持(JDK1.7)等,可以通過跟蹤Javac源碼、反編譯Class文件等方式了解它們的實現(xiàn)本質(zhì)。
  • 字節(jié)碼生成
    字節(jié)碼生成是Javac編譯過程的最后一個階段,字節(jié)碼生成階段不僅僅是把前面各個步驟生成的信息(AST、符號表)轉(zhuǎn)化成字節(jié)碼寫到磁盤中,編譯器還進行了少量的代碼添加和轉(zhuǎn)換工作。完成了了對語法樹的遍歷和調(diào)整之后,就會把填充了所有所需信息的符號表交給com.sun.tools.javac.jvm.ClassWriter類,由這個類的writeClass()方法輸出字節(jié)碼,生成最終的Class文件,到此Javac的編譯過程結(jié)束。

Javac前端編譯器
Oracle javac、Eclipse JDT中的增量式編譯器(ECJ)等。

2、即時(JIT)編譯

根據(jù)前面的內(nèi)容,我們知道編譯前端的核心編譯產(chǎn)物是:Class 文件。但是對于CPU來說,它是不認(rèn)得字節(jié)碼的。每種CPU只能“讀懂”自身支持的機器語言或者本地代碼(native code)。因此,Java 虛擬機在執(zhí)行字節(jié)碼時,需要將字節(jié)碼翻譯為當(dāng)前平臺的本地代碼,可以分為:解釋執(zhí)行 & 編譯執(zhí)行。

  • 解釋執(zhí)行
    解釋執(zhí)行,就像python一樣,代碼運行到哪里,就把代碼解釋到哪里。這么做的優(yōu)點和缺點都很明顯。
    • 解釋執(zhí)行優(yōu)點

      1. 方便更新。代碼可以在程序執(zhí)行的過程中修改.
      2. 啟動快。拿到代碼就可以跑,沒有其他多余操作。
      3. 平臺無關(guān)。所有操作都基于jvm,全平臺通用。
    • 解釋執(zhí)行缺點

      1. 平臺效率低。由于程序執(zhí)行性能只依賴jvm,導(dǎo)致不同平臺特有的優(yōu)化無法發(fā)揮。
      2. 代碼效率低。無法對代碼動態(tài)優(yōu)化,只能拿到什么執(zhí)行什么。

JVM 通過字節(jié)碼解釋器將其翻譯成對應(yīng)的機器指令,逐條讀入,逐條解釋翻譯。很顯然,經(jīng)過解釋執(zhí)行,其執(zhí)行速度必然會比可執(zhí)行的二進制字節(jié)碼程序慢很多。這就是傳統(tǒng)的JVM的解釋器(Interpreter)的功能。為了解決這種效率問題,引入了 JIT 技術(shù)。JIT 技術(shù)指JAVA程序還是通過解釋器進行解釋執(zhí)行,當(dāng)JVM發(fā)現(xiàn)某個方法或代碼塊運行特別頻繁的時候,就會認(rèn)為這是“熱點代碼”(Hot Spot Code)。然后JIT會把部分“熱點代碼”翻譯成本地機器相關(guān)的機器碼,并進行優(yōu)化,然后再把翻譯后的機器碼緩存起來,以備下次使用。

通過Java虛擬機(JVM)內(nèi)置的即時編譯器(Just In Time Compiler,JIT編譯器);在運行時把Class文件字節(jié)碼編譯成本地機器碼的過程;
優(yōu)點:

  1. 通過在運行時收集監(jiān)控信息,把"熱點代碼"(Hot Spot Code)編譯成與本地平臺相關(guān)的機器碼,并進行各種層次的優(yōu)化;
  2. 可以大大提高執(zhí)行效率;

缺點:

  1. 收集監(jiān)控信息影響程序運行;
  2. 編譯過程占用程序運行時間(如使得啟動速度變慢);
  3. 編譯機器碼占用內(nèi)存;
  4. JIT編譯器:HotSpot虛擬機的C1、C2編譯器等;

那么又一個問題出現(xiàn)了,既然實時編譯慢,那為什么不將代碼全部進行JIT預(yù)編譯后再扔到機器上去跑呢,這樣就沒有這些問題了,只是預(yù)編譯時間長而已。這個思路其實就是c和c++的做法,直接在不同機器上編譯出不同優(yōu)化方向的代碼,但是這樣導(dǎo)致了編譯后的代碼無法跨平臺執(zhí)行,而jvm的最大特性就是跨平臺,所以這是不可行的。

注意,JIT編譯速度及編譯結(jié)果的優(yōu)劣,是衡量一個JVM性能的很重要指標(biāo);所以對程序運行性能優(yōu)化集中到這個階段;也就是說可以對這個階段進行JVM調(diào)優(yōu);

3、靜態(tài)提前編譯(Ahead Of Time,AOT編譯)

程序運行前,直接把Java源碼文件(.java)編譯成本地機器碼的過程;
優(yōu)點:

  1. 編譯不占用運行時間,可以做一些較耗時的優(yōu)化,并可加快程序啟動;
  2. 把編譯的本地機器碼保存磁盤,不占用內(nèi)存,并可多次使用;

缺點:

  1. 因為Java語言的動態(tài)性(如反射)帶來了額外的復(fù)雜性,影響了靜態(tài)編譯代碼的質(zhì)量;
  2. 一般靜態(tài)編譯不如JIT編譯的質(zhì)量,這種方式用得比較少;
  3. 犧牲Java的一致性

靜態(tài)提前編譯器(AOT編譯器):JAOTC、GCJ、Excelsior JET、ART (Android Runtime)等;

4、前端編譯+JIT編譯

到這里,我們知道目前Java體系中主要還是采用前端編譯+JIT編譯的方式,如JDK中的HotSpot虛擬機。
前端編譯+JIT編譯方式的運作過程大體如下:

  1. 首先通過前端編譯把符合Java語言規(guī)范的程序代碼轉(zhuǎn)化為滿足JVM規(guī)范所要求Class格式;
  2. 然后程序啟動時Class格式文件發(fā)揮作用,解釋執(zhí)行,省去編譯時間,加快啟動速度;
  3. 針對Class解釋執(zhí)行效率低的問題,在運行中收集性能監(jiān)控信息,得知"熱點代碼";
  4. JIT逐漸發(fā)揮作用,把越來越多的熱點代碼"編譯優(yōu)化成本地代碼,提高執(zhí)行效率;

四. 類加載機制

".java"文件經(jīng)過Java編譯器編譯成拓展名為".class"的文件,".class"文件中保存著Java代碼經(jīng)轉(zhuǎn)換后的虛擬機指令,當(dāng)需要使用某個類時,虛擬機將會加載它的".class"文件,并創(chuàng)建對應(yīng)的class對象,將class文件加載到虛擬機的內(nèi)存,這個過程稱為類加載。舉個通俗點的例子來說,JVM在執(zhí)行某段代碼時,遇到了class A, 然而此時內(nèi)存中并沒有class A的相關(guān)信息,于是JVM就會到相應(yīng)的class文件中去尋找class A的類信息,并加載進內(nèi)存中,這就是我們所說的類加載過程。

由此可見,JVM不是一開始就把所有的類都加載進內(nèi)存中,而是只有第一次遇到某個需要運行的類時才會加載,且只加載一次。

從類被加載到虛擬機內(nèi)存中開始,到卸御出內(nèi)存為止,它的整個生命周期分為7個階段:加載(Loading)、驗證(Verification)、準(zhǔn)備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸載(Unloading)。其中驗證、準(zhǔn)備、解析三個部分統(tǒng)稱為連接。

1.(裝載) 加載

類的裝載指的是將類的.class文件中的二進制數(shù)據(jù)讀入到內(nèi)存中,將其放在運行時數(shù)據(jù)區(qū)的方法區(qū)內(nèi),然后在堆區(qū)創(chuàng)建一個java.lang.Class對象,用來封裝類在方法區(qū)內(nèi)的數(shù)據(jù)結(jié)構(gòu)。類的加載的最終產(chǎn)品是位于堆區(qū)中的Class對象,Class對象封裝了類在方法區(qū)內(nèi)的數(shù)據(jù)結(jié)構(gòu),并且向Java程序員提供了訪問方法區(qū)內(nèi)的數(shù)據(jù)結(jié)構(gòu)的接口。

類加載器并不需要等到某個類被“首次主動使用”時再加載它,JVM規(guī)范允許類加載器在預(yù)料某個類將要被使用時就預(yù)先加載它,如果在預(yù)先加載的過程中遇到了.class文件缺失或存在錯誤,類加載器必須在程序首次主動使用該類時才報告錯誤(LinkageError錯誤)如果這個類一直沒有被程序主動使用,那么類加載器就不會報告錯誤。

相對于類加載的其他階段而言,加載階段(準(zhǔn)確地說,是加載階段獲取類的二進制字節(jié)流的動作)是可控性最強的階段,因為開發(fā)人員既可以使用系統(tǒng)提供的類加載器來完成加載,也可以自定義自己的類加載器來完成加載。

加載階段完成后,虛擬機外部的二進制字節(jié)流就按照虛擬機所需的格式存儲在方法區(qū)之中,而且在Java堆中也創(chuàng)建一個java.lang.Class類的對象,這樣便可以通過該對象訪問方法區(qū)中的這些數(shù)據(jù)。

加載.class文件的來源方式:

  • 從本地系統(tǒng)中直接加載
  • 通過網(wǎng)絡(luò)下載.class文件
  • 從zip,jar等歸檔文件中加載.class文件
  • 從專有數(shù)據(jù)庫中提取.class文件
  • 將Java源文件動態(tài)編譯為.class文件
2. 連接
  • 驗證
    驗證的目的是為了確保Class文件中的字節(jié)流包含的信息符合當(dāng)前虛擬機的要求,而且不會危害虛擬機自身的安全。不同的虛擬機對類驗證的實現(xiàn)可能會有所不同,但大致都會完成以下四個階段的驗證:文件格式的驗證、元數(shù)據(jù)的驗證、字節(jié)碼驗證和符號引用驗證。

  • 準(zhǔn)備
    為類的靜態(tài)變量分配內(nèi)存,并將其初始化為默認(rèn)值,這些內(nèi)存都將在方法區(qū)中分配。

    1. 這時候進行內(nèi)存分配的僅包括類變量(static),而不包括實例變量,實例變量會在對象實例化時隨著對象一塊分配在Java堆中。
    2. 這里所設(shè)置的初始值通常情況下是數(shù)據(jù)類型默認(rèn)的零值(如0、0L、null、false等),而不是被在Java代碼中被顯式地賦予的值。

    為類變量(即static修飾的字段變量)分配內(nèi)存并且設(shè)置該類變量的初始值,這里不包含用final修飾的static,因為final在編譯的時候就會分配了,注意這里不會為實例變量分配初始化,類變量會分配在方法區(qū)中,而實例變量是會隨著對象一起分配到Java堆中。
    例如:

      public String firstName = "蘇";
      public static String middleName = "東";
      public static final String lastName = "坡";
    

    firstName 不會被分配內(nèi)存,而 middleName 會;但 middleName 的初始值不是“東”而是 null。
    需要注意的是,static final 修飾的變量被稱作為常量,和類變量不同。常量一旦賦值就不會改變了,所以 lastName 在準(zhǔn)備階段的值為“坡”而不是 null。

  • 解析
    解析階段是虛擬機將常量池內(nèi)的符號引用替換為直接引用的過程。符號引用就是class文件中的:

    • CONSTANT_Class_info
    • CONSTANT_Field_info
    • CONSTANT_Method_info 等類型的常量。

    在Java中,一個java類將會編譯成一個class文件。在編譯時,java類并不知道所引用的類的實際地址,因此只能使用符號引用來代替。比如org.simple.People類引用了org.simple.Language類,在編譯時People類并不知道Language類的實際內(nèi)存地址,因此只能使用符號org.simple.Language(假設(shè)是這個,當(dāng)然實際中是由類似于CONSTANT_Class_info的常量來表示的)來表示Language類的地址。

3. 初始化

類初始化階段是類加載過程的最后一步,前面的類加載過程中,除了加載(Loading)階段用戶應(yīng)用程序可以通過自定義類加載器參與之外,其余動作完全由虛擬機主導(dǎo)和控制。類的初始化過程,簡單地說就是執(zhí)行類的<clinit>()方法的過程。
JVM初始化步驟:

  1. 假如這個類還沒有被加載和連接,則程序先加載并連接該類
  2. 假如該類的直接父類還沒有被初始化,則先初始化其直接父類
  3. 假如類中有初始化語句,則系統(tǒng)依次執(zhí)行這些初始化語句

類初始化時機:只有當(dāng)對類的主動使用的時候才會導(dǎo)致類的初始化,類的主動使用包括以下六種:

  1. 創(chuàng)建類的實例,也就是new的方式
  2. 訪問某個類或接口的靜態(tài)變量,或者對該靜態(tài)變量賦值
  3. 調(diào)用類的靜態(tài)方法
  4. 反射(如Class.forName(“com.ttx.Test”))
  5. 初始化某個類的子類,則其父類也會被初始化
  6. Java虛擬機啟動時被標(biāo)明為啟動類的類(Java Test),直接使用java.exe命令來運行某個主類
4. 使用

當(dāng) JVM 完成初始化階段之后,JVM 便開始從入口方法開始執(zhí)行用戶的程序代碼。這個使用階段也只是了解一下就可以了。

5. 卸載

當(dāng)用戶程序代碼執(zhí)行完畢后,JVM 便開始銷毀創(chuàng)建的 Class 對象,最后負(fù)責(zé)運行的 JVM 也退出內(nèi)存。這個卸載階段也只是了解一下就可以了。

6. 結(jié)束生命周期

在如下幾種情況下,Java虛擬機將結(jié)束生命周期

  • 執(zhí)行了 System.exit()方法
  • 程序正常執(zhí)行結(jié)束
  • 程序在執(zhí)行過程中遇到了異?;蝈e誤而異常終止
  • 由于操作系統(tǒng)出現(xiàn)錯誤而導(dǎo)致Java虛擬機進程終止

五. 類加載器

類加載器的任務(wù)是根據(jù)一個類的全限定名來讀取此類的二進制字節(jié)流到JVM中,然后轉(zhuǎn)換為一個與目標(biāo)類對應(yīng)的java.lang.Class對象實例,一旦一個類被加載如JVM中,同一個類就不會被再次載入了。正如一個對象有一個唯一的標(biāo)識一樣,一個載入JVM的類也有一個唯一的標(biāo)識。在Java中,一個類用其全限定類名(包括包名和類名)作為標(biāo)識;但在JVM中,一個類用其全限定類名和其類加載器作為其唯一標(biāo)識。

例如,如果在pg的包中有一個名為Person的類,被類加載器ClassLoader的實例kl負(fù)責(zé)加載,則該Person類對應(yīng)的Class對象在JVM中表示為(Person.pg.kl)。這意味著兩個類加載器加載的同名類:(Person.pg.kl)和(Person.pg.kl2)是不同的、它們所加載的類也是完全不同、互不兼容的。

總的來說,類加載器(class loader)用來加載 Java 類到 Java 虛擬機中。類加載器負(fù)責(zé)讀取 Java 字節(jié)代碼,并轉(zhuǎn)換成 java.lang.Class類的一個實例。有了Class類實例,就可以通過newInstance方法創(chuàng)建該類的對象。一般來說,默認(rèn)類加載器為當(dāng)前類的類加載器。比如A類中引用B類,A的類加載器為C,那么B的類加載器也為C。

在虛擬機提供了3種類加載器,引導(dǎo)(Bootstrap)類加載器、擴展(Extension)類加載器、系統(tǒng)(System)類加載器(也稱應(yīng)用類加載器)

1. Bootstrap ClassLoader

加載JVM自身工作需要的類,它由JVM自己實現(xiàn)。它會加載$JAVA_HOME/jre/lib下的文件

2. ExtClassLoader

它是JVM的一部分,由sun.misc.Launcher[圖片上傳失敗...(image-f2fdd2-1597653733533)]
JAVA_HOME/jre/lib/ext目錄中的文件(或由System.getProperty("java.ext.dirs")所指定的文件)。

3. AppClassLoader

應(yīng)用類加載器,我們工作中接觸最多的也是這個類加載器,它由sun.misc.Launcher$AppClassLoader實現(xiàn)。它加載由System.getProperty("java.class.path")指定目錄下的文件,也就是我們通常說的classpath路徑。

4. 雙親委派模型

如果一個類加載器收到了類加載請求,它并不會自己先去加載,而是把這個請求委托給父類的加載器去執(zhí)行,如果父類加載器還存在其父類加載器,則進一步向上委托,依次遞歸,請求最終將到達(dá)頂層的啟動類加載器,如果父類加載器可以完成類加載任務(wù),就成功返回,倘若父類加載器無法完成此加載任務(wù),子加載器才會嘗試自己去加載,這就是雙親委派模式(接下來的源碼可以看出這個流程

自定義Java類加載器
從上面源碼的分析,可以知道:實現(xiàn)自定義類加載器需要繼承ClassLoader,如果想保證自定義的類加載器符合雙親委派機制,則覆寫findClass方法;如果想打破雙親委派機制,則覆寫loadClass方法。

5. 何時出發(fā)類加載動作?

類加載的觸發(fā)可以分為隱式加載和顯示加載。

  1. 隱式加載
    隱式加載包括以下幾種情況:

    • 遇到new、getstatic、putstatic、invokestatic這4條字節(jié)碼指令時
    • 對類進行反射調(diào)用時
    • 當(dāng)初始化一個類時,如果其父類還沒有初始化,優(yōu)先加載其父類并初始化
    • 虛擬機啟動時,需指定一個包含main函數(shù)的主類,優(yōu)先加載并初始化這個主類
  2. 顯示加載
    顯示加載包含以下幾種情況:

    • 通過ClassLoader的loadClass方法
    • 通過Class.forName
    • 通過ClassLoader的findClass方法
6. 編寫自定義類加載器的意義何在?
  • 當(dāng)class文件不在ClassPath路徑下,默認(rèn)系統(tǒng)類加載器無法找到該class文件,在這種情況下我們需要實現(xiàn)一個自定義的ClassLoader來加載特定路徑下的class文件生成class對象。
  • 當(dāng)一個class文件是通過網(wǎng)絡(luò)傳輸并且可能會進行相應(yīng)的加密操作時,需要先對class文件進行相應(yīng)的解密后再加載到JVM內(nèi)存中,這種情況下也需要編寫自定義的ClassLoader并實現(xiàn)相應(yīng)的邏輯。
  • 當(dāng)需要實現(xiàn)熱部署功能時(一個class文件通過不同的類加載器產(chǎn)生不同class對象從而實現(xiàn)熱部署功能),需要實現(xiàn)自定義ClassLoader的邏輯。

六. JVM運行時數(shù)據(jù)區(qū)及內(nèi)存模型(JMM)

Java內(nèi)存模型(Java Memory Model ,JMM)就是一種符合內(nèi)存模型規(guī)范的,屏蔽了各種硬件和操作系統(tǒng)的訪問差異的,保證了Java程序在各種平臺下對內(nèi)存的訪問都能保證效果一致的機制及規(guī)范。

計算機會提前給將內(nèi)存分配給軟件,由軟件控制自己內(nèi)存區(qū)域的管理。如果軟件當(dāng)前內(nèi)存已經(jīng)被占滿,我們需要將不活躍的數(shù)據(jù)清除,然后載入新的數(shù)據(jù),內(nèi)存都是可以重復(fù)被使用的。JVM也是計算機內(nèi)存中的一個程序,所以計算機會分配一定的內(nèi)存給JVM。以堆內(nèi)存為例:Xmx-最多分配內(nèi)存的大小2048M Xms-最少分配內(nèi)存的大小512M 。


首先Java源代碼文件(.java后綴)會被Java編譯器編譯為字節(jié)碼文件(.class后綴),然后由JVM中的類加載器加載各個類的字節(jié)碼文件,加載完畢之后,交由JVM執(zhí)行引擎執(zhí)行。在整個程序執(zhí)行過程中,JVM會用一段空間來存儲程序執(zhí)行期間需要用到的數(shù)據(jù)和相關(guān)信息,這段空間一般被稱作為Runtime Data Area(運行時數(shù)據(jù)區(qū)),也就是我們常說的JVM內(nèi)存。因此,在Java中我們常常說到的內(nèi)存管理就是針對這段空間進行管理(如何分配和回收內(nèi)存空間)。

Java軟件在運行時JVM會運行很多的類,但是計算機給我們分配的內(nèi)存又有一定的限制。所以JVM也需要管理class占用空間的大小或者通過class生成對象占用空間的大小。比如當(dāng)前JVM的大小是4G 我們不可能時時刻刻對4G的空間進行遍歷或者資源的回收J(rèn)VM為了方便管理對象占用的內(nèi)存空間,于是將內(nèi)存運行時數(shù)據(jù)區(qū)進行劃分。
線程獨享:

  • 程序計數(shù)器
  • 虛擬機棧
  • 本地方法棧

線程共享:

  • 堆內(nèi)存
  • 方法區(qū)
  • 堆外內(nèi)存( metadata元數(shù)據(jù)區(qū))
1. PC寄存器(程序計數(shù)器)

由于JVM同時可以處理多個線程所以就涉及到一些線程調(diào)度,當(dāng)cpu暫停運行線程A把時間片讓給線程B的時候我們需要保存線程A被暫停執(zhí)行前的一些現(xiàn)場狀態(tài),需要記錄當(dāng)前執(zhí)行到那一行字節(jié)碼了,所以PC寄存器會實時記錄當(dāng)前線程執(zhí)行的代碼行數(shù)。

虛擬機棧

Java虛擬機棧(Java Virtual Machine Stacks)是線程私有的,其生命周期和線程同步,隨著線程的啟動而創(chuàng)建,隨線程的結(jié)束而銷毀。Java虛擬機棧和線程同時創(chuàng)建,用于存儲棧幀。每個方法在執(zhí)行時都會創(chuàng)建一個棧幀(Stack Frame),用于存儲局部變量表、操作數(shù)棧、動態(tài)鏈接、方法出口等信息。每一個方法從調(diào)用直到執(zhí)行完成的過程就對應(yīng)著一個棧幀在虛擬機棧中從入棧到出棧的過程。此區(qū)域有兩個異常:當(dāng)棧深度超過虛擬機的規(guī)定時,StackOverFlowError;當(dāng)擴展時無法申請到足夠的內(nèi)存,OutOfMemeryError。

棧幀(Stack Frame)

每一個方法從調(diào)用到方法返回(結(jié)束)都對應(yīng)著一個棧幀入棧出棧的過程(棧幀隨著方法調(diào)用而創(chuàng)建,隨著方法結(jié)束而銷毀)。最頂部的棧幀稱為當(dāng)前棧幀,當(dāng)前棧幀所關(guān)聯(lián)的方法稱為當(dāng)前方法,定義當(dāng)前方法的類稱為當(dāng)前類,該線程中,虛擬機有且也只會對當(dāng)前棧幀進行操作,如果當(dāng)前方法調(diào)用了其他方法,或者當(dāng)前方法執(zhí)行結(jié)束,那這個方法的棧幀就不再是當(dāng)前棧幀了。調(diào)用新的方法時,新的棧幀也會隨之創(chuàng)建。并且隨著程序控制權(quán)轉(zhuǎn)移到新方法,新的棧幀成為了當(dāng)前棧幀。方法返回之際,原棧幀會返回方法的執(zhí)行結(jié)果給之前的棧幀(返回給方法調(diào)用者),隨后虛擬機將會丟棄此棧幀。在編譯代碼時,棧幀需要多大的局部變量表,多深的操作數(shù)棧都可以完全確定的,并寫入到Class 文件的方法表的 Code 屬性中。

  • 局部變量表
    是一組變量的存儲空間,用于存放 方法參數(shù) 和 局部變量。在Class 文件的方法表的 Code 屬性的 max_locals 指定了該方法所需局部變量表的最大容量。虛擬機通過索引定位法的方式使用局部變量表,索引值的范圍是從0到Slot的最大數(shù)量。在方法執(zhí)行時,特別是執(zhí)行實例方法時,那么實例變量表的第0位索引默認(rèn)是方法所屬的實例對象的引用“this”對象,接著是1到Slot參數(shù)變量到方法內(nèi)部的局部變量。局部變量表的基本單位為變量槽(Variable Slot),Java虛擬機規(guī)范并沒有定義一個槽所應(yīng)該占用內(nèi)存空間的大小,但是規(guī)定了一個槽應(yīng)該可以存放一個32位以內(nèi)的數(shù)據(jù)類型。如果Slot是32位的,則遇到一個64位數(shù)據(jù)類型的變量(如long或double型),則會連續(xù)使用兩個連續(xù)的Slot來存儲。

  • 操作數(shù)棧
    操作數(shù)棧,主要用于保存計算過程的中間結(jié)果,同時作為計算過程中變量臨時的存儲空間。也常稱為操作棧,它是一個后入先出棧(LIFO)。同局部變量表一樣,操作數(shù)棧的最大深度也在編譯的時候?qū)懭氲椒椒ǖ腃ode屬性的max_stacks數(shù)據(jù)項中。舉例來說,在JVM中 執(zhí)行 a = b + c 的字節(jié)碼執(zhí)行過程中操作數(shù)棧以及局部變量表的變化如下圖所示。局部變量表中存儲著a、b、c 三個局部變量,首先將b和c分別入棧。


    將棧頂?shù)膬蓚€數(shù)出棧執(zhí)行加法操作,并將結(jié)果保存至棧頂,之后將棧頂?shù)臄?shù)出棧賦值給a

  • 動態(tài)連接
    動態(tài)鏈接主要就是指向運行時常量池的方法引用。因為 Java 是在運行期間動態(tài)鏈接的,所以為了支持動態(tài)鏈接,需要將方法區(qū)里面的符號引用轉(zhuǎn)為直接引用(即:給出地址),這就叫動態(tài)鏈接。

  • 方法返回地址
    存放調(diào)用該方法的PC寄存器的值。一個方法的結(jié)束,有兩種方式:正常執(zhí)行完成,出現(xiàn)未處理的異常,非正常退出。方法執(zhí)行完以后,根據(jù)這個值決定返回到哪里去。

2.本地方法棧

JVM運行native方法準(zhǔn)備的空間,由于很多native方法都是用C語言實現(xiàn)的,所以通常又叫C棧,它與Java虛擬機棧實現(xiàn)的功能類似,只不過本地方法棧描述本地方法運行過程的內(nèi)存模型。與虛擬機棧的區(qū)別是,虛擬機棧是為執(zhí)行Java方法服務(wù),而本地方法棧是為執(zhí)行Native方法服務(wù),同樣這個區(qū)域也會拋出StackOverFlowError、OutOfMemeryError。

3.堆內(nèi)存

堆內(nèi)存理論上是JVM中占用內(nèi)存最大的一塊區(qū)域,里面存放了java創(chuàng)建的各種引用數(shù)據(jù)類型(幾乎所有的對象、數(shù)組都在這個內(nèi)存區(qū)域分配)。堆內(nèi)存被所有線程共享,虛擬機啟動時就會創(chuàng)建。堆內(nèi)存中的數(shù)據(jù)經(jīng)常會被回收,每次GC的垃圾占總量的90%以上,因此堆是垃圾收集器管理的主要區(qū)域。假設(shè)本次堆內(nèi)存大小為4G,為了找出垃圾對象,所花費的時間是比較長的,堆內(nèi)存為了更好的管理對象,又將堆內(nèi)存重新進行了區(qū)域的劃分:分為新生代(Young),老年代(Old), 新生代又被劃分為三個區(qū)域Eden、From Survivor, To Survivor。當(dāng)堆中沒有足夠的內(nèi)存完成實例分配且無法擴展時,拋出OutOfMemoryError。

新生代(Young)

所有的對象創(chuàng)建都是在新生區(qū)創(chuàng)建的,每當(dāng)JVM進行一次GC,新生代里面的對象的標(biāo)識就會進行累加+1,如果累計超過15次GC都沒有被回收掉,說明這個對象不容易被回收,將被移入老年代。如果新生區(qū)太小,會導(dǎo)致每次垃圾回收特別頻繁。于是為了更好的管理新生區(qū),將新生區(qū)進行區(qū)域的劃分Eden、From Survivor, To Survivor三個區(qū)域的比例為8:1:1。

當(dāng)對象在 Eden 創(chuàng)建后,在經(jīng)過一次 GC 后,如果對象還存活,并且能夠被另外一塊 Survivor 區(qū)域(假設(shè)為from 區(qū)域)所容納,則使用復(fù)制算法將這些仍然還存活的對象復(fù)制到另外一塊 Survivor 區(qū)域 ( 即 to 區(qū)域 ) 中,并且將這些對象的年齡設(shè)置為1,以后對象在 Survivor 區(qū)每熬過一次 Minor GC,就將對象的年齡 + 1,當(dāng)對象的年齡達(dá)到某個值時 ( 默認(rèn)是 15 歲,可以通過參數(shù) -XX:MaxTenuringThreshold 來設(shè)定 ),這些對象就會成為老年代。然后清理所使用過的 Eden 以及 Survivor 區(qū)域 ( 即 from 區(qū)域 )。但這也不是一定的,對于一些較大的對象 ( 即需要分配一塊較大的連續(xù)內(nèi)存空間 ) 新生代放不下,則是直接進入到老年代,JVM 認(rèn)為,一般大對象的存活時間一般比較久遠(yuǎn)。

From Survivor區(qū)域與To Survivor區(qū)域是交替切換空間,在同一時間內(nèi)兩者中只有一個不為空。

老年代(Old)

年老代里存放的都是存活時間較久的,大小較大的對象,因此年老代使用標(biāo)記整理算法。當(dāng)年老代容量滿的時候,會觸發(fā)一次Major GC(full GC),回收年老代和年輕代中不再被使用的對象資源。老年區(qū)的GC不是很頻繁,只有進行full GC的時候才會操作老年區(qū)。

3.方法區(qū)

方法區(qū)也是線程共享,在虛擬機啟動時創(chuàng)建。
用于存儲虛擬機加載的類信息、常量、靜態(tài)變量、即時編譯器編譯后的代碼等數(shù)據(jù)。
此區(qū)域包含運行時常量池(Runtime Constant Pool)。
當(dāng)方法區(qū)無法滿足內(nèi)存分配需求時,將拋出OutOfMemoryError。

對象的創(chuàng)建(HotSpot)
通過new關(guān)鍵字創(chuàng)建(或克隆、反序列化)
(1.) 檢查指令的參數(shù)(即工作中我們New的對象),能否在常量池中找到它的符號引用。
(2.) 如果存在,檢查符號引用代表的類是否被加載、解析、初始化過。如果沒有則執(zhí)行類的加載。
(3.) 加載通過后,虛擬機將為新生對象分配內(nèi)存。(所需內(nèi)存大小在類加載完成后便可確定)

七. 垃圾回收機制

大家都知道JVM的內(nèi)存結(jié)構(gòu)包括五大區(qū)域:程序計數(shù)器、虛擬機棧、本地方法棧、堆區(qū)、方法區(qū)。其中程序計數(shù)器、虛擬機棧、本地方法棧3個區(qū)域隨線程而生、隨線程而滅,因此這幾個區(qū)域的內(nèi)存分配和回收都具備確定性,就不需要過多考慮回收的問題,因為方法結(jié)束或者線程結(jié)束時,內(nèi)存自然就跟隨著回收了。而Java堆區(qū)和方法區(qū)則不一樣,這部分內(nèi)存的分配和回收是動態(tài)的,正是垃圾收集器所需關(guān)注的部分。

1. 判斷對象是否存活的算法

Java堆中存放著幾乎所有的對象實例,垃圾回收器在堆進行垃圾回收前,首先要判斷這些對象那些還存活,那些已經(jīng)“死去”。判斷對象是否已“死”有如下幾種算法:

(1)引用計數(shù)法
給每個對象添加一個計數(shù)器,當(dāng)有地方引用該對象時計數(shù)器加1,當(dāng)引用失效時計數(shù)器減1。用對象計數(shù)器是否為0來判斷對象是否可被回收。缺點:無法解決循環(huán)引用的問題。

優(yōu)點:引用計數(shù)收集器執(zhí)行簡單,判定效率高,交織在程序運行中。對程序不被長時間打斷的實時環(huán)境比較有利(OC的內(nèi)存管理使用該算法)。

缺點:無法檢測出循環(huán)引用。如父對象有一個對子對象的引用,子對象反過來引用父對象。這樣,他們的引用計數(shù)永遠(yuǎn)不可能為0。同時,引用計數(shù)器增加了程序執(zhí)行的開銷。

(2)可達(dá)性分析算法
可達(dá)性分析算法是從離散數(shù)學(xué)中的圖論引入的,程序把所有的引用關(guān)系看作一張圖,從一個節(jié)點GC ROOT開始,尋找對應(yīng)的引用節(jié)點,找到這個節(jié)點以后,繼續(xù)尋找這個節(jié)點的引用節(jié)點,當(dāng)所有的引用節(jié)點尋找完畢之后,剩余的節(jié)點則被認(rèn)為是沒有被引用到的節(jié)點,即無用的節(jié)點,無用的節(jié)點將會被判定為是可回收的對象。

2. 常用的垃圾回收算法

(1)標(biāo)記-清除算法

“標(biāo)記-清除”算法是最基礎(chǔ)的收集算法。算法分為標(biāo)記和清除兩個階段:首先標(biāo)記出所有需要回收的對象,在標(biāo)記完成后統(tǒng)一回收所有被標(biāo)記的對象。后續(xù)的收集算法都是基于這種思路并對其不足加以改進而已。
“標(biāo)記-清除”算法的不足主要有兩個:
效率問題:標(biāo)記和清除這兩個過程的效率都不高
空間問題:標(biāo)記清除后會產(chǎn)生大量不連續(xù)的內(nèi)存碎片,空間碎片太多可能會導(dǎo)致以后在程序運行中需要分配較大對象時,無法找到足夠連續(xù)內(nèi)存而不得不提前觸發(fā)另一次垃圾收集。

(2)復(fù)制算法(新生代回收算法)
“復(fù)制”算法是為了解決“標(biāo)記-清除”的效率問題。它將可用內(nèi)存按容量劃分為大小相等的兩塊,每次只使用其中一塊。當(dāng)這塊內(nèi)存需要進行垃圾回收時,會將此區(qū)域還存活著的對象復(fù)制到另一塊上面,然后再把已經(jīng)使用過的內(nèi)存區(qū)域一次清理掉。這樣做的好處是每次都是對整個半?yún)^(qū)進行內(nèi)存回收,內(nèi)存分配時也就不需要考慮內(nèi)存碎片等的復(fù)雜情況,只需要移動堆頂指針,按順序分配即可。此算法實現(xiàn)簡單,運行高效。

(3)分代收集算法
當(dāng)前JVM垃圾收集都采用的是"分代收集(Generational Collection)"算法,這個算法并沒有新思想,只是根據(jù)對象存活周期的不同將內(nèi)存劃分為幾塊。
一般是把Java堆分為新生代和老年代。在新生代中,每次垃圾回收都有大批對象死去,只有少量存活,因此我們采用復(fù)制算法;而老年代中對象存活率高、沒有額外空間對它進行分配擔(dān)保,就必須采用"標(biāo)記-清理"或者"標(biāo)記-整理"算法。

八. JVM的生命周期

JVM實例對應(yīng)了一個獨立運行的java程序它是進程級別

  • 啟動。啟動一個Java程序時,一個JVM實例就產(chǎn)生了,任何一個擁有public static void
    main(String[] args)函數(shù)的class都可以作為JVM實例運行的起點。

  • 運行。main()作為該程序初始線程的起點,任何其他線程均由該線程啟動。JVM內(nèi)部有兩種線程:守護線程和非守護線程,main()屬于非守護線程,守護線程通常由JVM自己使用,java程序也可以表明自己創(chuàng)建的線程是守護線程。

  • 消亡。當(dāng)程序中的所有非守護線程都終止時,JVM才退出;若安全管理器允許,程序也可以使用Runtime類或者System.exit()來退出。

參考資料
《深入理解Java虛擬機》
Java代碼到底是如何編譯成機器指令的
Java編譯方式總結(jié):前端編譯、JIT編譯、AOT編譯
AOT上手

jvm之后端編譯與優(yōu)化
Java的編譯原理!
JVM筆記-后端編譯與優(yōu)化

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容