溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點(diǎn)擊 登錄注冊 即表示同意《億速云用戶服務(wù)條款》

深入淺出 Java 虛擬機(jī) 是你通往高級 Java 開發(fā)的必經(jīng)之路

發(fā)布時間:2020-08-04 15:33:07 來源:網(wǎng)絡(luò) 閱讀:3927 作者:java周某人 欄目:編程語言

深入淺出 Java 虛擬機(jī) 是你通往高級 Java 開發(fā)的必經(jīng)之路


干貨來咯

深入淺出 Java 虛擬機(jī) 是你通往高級 Java 開發(fā)的必經(jīng)之路


前言:

今天要給大家分享的是Java虛擬機(jī)的一些硬貨知識,文章不錯的話記得給我點(diǎn)給個關(guān)注哦,私信我可以獲取更多的java資料。

第一章 JVM 內(nèi)存模型

Java 虛擬機(jī)(Java Virtual Machine=JVM)的內(nèi)存空間分為五個部分,分別是:

  1. 程序計(jì)數(shù)器

  2. Java 虛擬機(jī)棧

  3. 本地方法棧

  4. 方法區(qū)。

下面對這五個區(qū)域展開深入的介紹。

1.1 程序計(jì)數(shù)器

1.1.1 什么是程序計(jì)數(shù)器?

程序計(jì)數(shù)器是一塊較小的內(nèi)存空間,可以把它看作當(dāng)前線程正在執(zhí)行的字節(jié)碼的行號指示器。也就是說,程序計(jì)數(shù)器里面記錄的是當(dāng)前線程正在執(zhí)行的那一條字節(jié)碼指令的地址。

注:但是,如果當(dāng)前線程正在執(zhí)行的是一個本地方法,那么此時程序計(jì)數(shù)器為空。

1.1.2 程序計(jì)數(shù)器的作用

程序計(jì)數(shù)器有兩個作用:

  1. 字節(jié)碼解釋器通過改變程序計(jì)數(shù)器來依次讀取指令,從而實(shí)現(xiàn)代碼的流程控制,如:順序執(zhí)行、選擇、循環(huán)、異常處理。

  2. 在多線程的情況下,程序計(jì)數(shù)器用于記錄當(dāng)前線程執(zhí)行的位置,從而當(dāng)線程被切換回來的時候能夠知道該線程上次運(yùn)行到哪兒了。

1.1.3 程序計(jì)數(shù)器的特點(diǎn)

  1. 是一塊較小的存儲空間

  2. 線程私有。每條線程都有一個程序計(jì)數(shù)器。

  3. 是唯一一個不會出現(xiàn)OutOfMemoryError的內(nèi)存區(qū)域。

  4. 生命周期隨著線程的創(chuàng)建而創(chuàng)建,隨著線程的結(jié)束而死亡。

1.2 Java虛擬機(jī)棧(JVM Stack)

1.2.1 什么是Java虛擬機(jī)棧?

Java虛擬機(jī)棧是描述Java方法運(yùn)行過程的內(nèi)存模型。

Java虛擬機(jī)棧會為每一個即將運(yùn)行的Java方法創(chuàng)建一塊叫做“棧幀”的區(qū)域,這塊區(qū)域用于存儲該方法在運(yùn)行過程中所需要的一些信息,這些信息包括:

  1. 局部變量表

  2. 存放基本數(shù)據(jù)類型變量、引用類型的變量、returnAddress類型的變量。

  3. 操作數(shù)棧

  4. 動態(tài)鏈接

  5. 方法出口信息

當(dāng)一個方法即將被運(yùn)行時,Java虛擬機(jī)棧首先會在Java虛擬機(jī)棧中為該方法創(chuàng)建一塊“棧幀”,棧幀中包含局部變量表、操作數(shù)棧、動態(tài)鏈接、方法出口信息等。當(dāng)方法在運(yùn)行過程中需要創(chuàng)建局部變量時,就將局部變量的值存入棧幀的局部變量表中。

當(dāng)這個方法執(zhí)行完畢后,這個方法所對應(yīng)的棧幀將會出棧,并釋放內(nèi)存空間。

注意:人們常說,Java的內(nèi)存空間分為“棧”和“堆”,棧中存放局部變量,堆中存放對象。

這句話不完全正確!這里的“堆”可以這么理解,但這里的“?!敝淮砹薐ava虛擬機(jī)棧中的局部變量表部分。真正的Java虛擬機(jī)棧是由一個個棧幀組成,而每個棧幀中都擁有:局部變量表、操作數(shù)棧、動態(tài)鏈接、方法出口信息。

1.2.2 Java 虛擬機(jī)棧的特點(diǎn)

(1)局部變量表的創(chuàng)建是在方法被執(zhí)行的時候,隨著棧幀的創(chuàng)建而創(chuàng)建。而且,局部變量表的大小在編譯時期就確定下來了,在創(chuàng)建的時候只需分配事先規(guī)定好的大小即可。此外,在方法運(yùn)行的過程中局部變量表的大小是不會發(fā)生改變的。

(2)Java 虛擬機(jī)棧會出現(xiàn)兩種異常:StackOverFlowError 和 OutOfMemoryError。

  • a) StackOverFlowError:

  • 若Java虛擬機(jī)棧的內(nèi)存大小不允許動態(tài)擴(kuò)展,那么當(dāng)線程請求棧的深度超過當(dāng)前Java虛擬機(jī)棧的最大深度的時候,就拋出StackOverFlowError異常。

  • b) OutOfMemoryError:

  • 若Java虛擬機(jī)棧的內(nèi)存大小允許動態(tài)擴(kuò)展,且當(dāng)線程請求棧時內(nèi)存用完了,無法再動態(tài)擴(kuò)展了,此時拋出OutOfMemoryError異常。

(3)Java虛擬機(jī)棧也是線程私有的,每個線程都有各自的Java虛擬機(jī)棧,而且隨著線程的創(chuàng)建而創(chuàng)建,隨著線程的死亡而死亡。

注:StackOverFlowError和OutOfMemoryError的異同?StackOverFlowError表示當(dāng)前線程申請的棧超過了事先定好的棧的最大深度,但內(nèi)存空間可能還有很多。而OutOfMemoryError是指當(dāng)線程申請棧時發(fā)現(xiàn)棧已經(jīng)滿了,而且內(nèi)存也全都用光了。

1.3 本地方法棧

1.3.1 什么是本地方法棧?

本地方法棧和Java虛擬機(jī)棧實(shí)現(xiàn)的功能類似,只不過本地方法區(qū)是本地方法運(yùn)行的內(nèi)存模型。

本地方法被執(zhí)行的時候,在本地方法棧也會創(chuàng)建一個棧幀,用于存放該本地方法的局部變量表、操作數(shù)棧、動態(tài)鏈接、出口信息。

方法執(zhí)行完畢后相應(yīng)的棧幀也會出棧并釋放內(nèi)存空間。

也會拋出StackOverFlowError和OutOfMemoryError異常。

1.4 堆

1.4.1 什么是堆?

堆是用來存放對象的內(nèi)存空間。

幾乎所有的對象都存儲在堆中。

1.4.2 堆的特點(diǎn)

(1)線程共享

整個 Java 虛擬機(jī)只有一個堆,所有的線程都訪問同一個堆。而程序計(jì)數(shù)器、Java 虛擬機(jī)棧、本地方法棧都是一個線程對應(yīng)一個的。

(2)在虛擬機(jī)啟動時創(chuàng)建。

(3)垃圾回收的主要場所。

(4)可以進(jìn)一步細(xì)分為:新生代、老年代。

新生代又可被分為:Eden、From Survior、To Survior。不同的區(qū)域存放具有不同生命周期的對象。這樣可以根據(jù)不同的區(qū)域使用不同的垃圾回收算法,從而更具有針對性,從而更高效。

(5)堆的大小既可以固定也可以擴(kuò)展,但主流的虛擬機(jī)堆的大小是可擴(kuò)展的,因此當(dāng)線程請求分配內(nèi)存,但堆已滿,且內(nèi)存已滿無法再擴(kuò)展時,就拋出 OutOfMemoryError。

1.5 方法區(qū)

1.5.1 什么是方法區(qū)?

Java 虛擬機(jī)規(guī)范中定義方法區(qū)是堆的一個邏輯部分。方法區(qū)中存放已經(jīng)被虛擬機(jī)加載的類信息、常量、靜態(tài)變量、即時編譯器編譯后的代碼等。

1.5.2 方法區(qū)的特點(diǎn)

  1. 線程共享

  2. 方法區(qū)是堆的一個邏輯部分,因此和堆一樣,都是線程共享的。整個虛擬機(jī)中只有一個方法區(qū)。

  3. 永久代

  4. 方法區(qū)中的信息一般需要長期存在,而且它又是堆的邏輯分區(qū),因此用堆的劃分方法,我們把方法區(qū)稱為老年代。

  5. 內(nèi)存回收效率低

  6. 方法區(qū)中的信息一般需要長期存在,回收一遍內(nèi)存之后可能只有少量信息無效。

  7. 對方法區(qū)的內(nèi)存回收的主要目標(biāo)是:對常量池的回收 和 對類型的卸載。

  8. Java虛擬機(jī)規(guī)范對方法區(qū)的要求比較寬松。

  9. 和堆一樣,允許固定大小,也允許可擴(kuò)展的大小,還允許不實(shí)現(xiàn)垃圾回收。

1.5.3 什么是運(yùn)行時常量池?

方法區(qū)中存放三種數(shù)據(jù):類信息、常量、靜態(tài)變量、即時編譯器編譯后的代碼。其中常量存儲在運(yùn)行時常量池中。

我們一般在一個類中通過public static final來聲明一個常量。這個類被編譯后便生成Class文件,這個類的所有信息都存儲在這個class文件中。

當(dāng)這個類被Java虛擬機(jī)加載后,class文件中的常量就存放在方法區(qū)的運(yùn)行時常量池中。而且在運(yùn)行期間,可以向常量池中添加新的常量。如:String類的intern()方法就能在運(yùn)行期間向常量池中添加字符串常量。

當(dāng)運(yùn)行時常量池中的某些常量沒有被對象引用,同時也沒有被變量引用,那么就需要垃圾收集器回收。

1.6 直接內(nèi)存

直接內(nèi)存是除Java虛擬機(jī)之外的內(nèi)存,但也有可能被Java使用。

在NIO中引入了一種基于通道和緩沖的IO方式。它可以通過調(diào)用本地方法直接分配Java虛擬機(jī)之外的內(nèi)存,然后通過一個存儲在Java堆中的DirectByteBuffer對象直接操作該內(nèi)存,而無需先將外面內(nèi)存中的數(shù)據(jù)復(fù)制到堆中再操作,從而提升了數(shù)據(jù)操作的效率。

直接內(nèi)存的大小不受Java虛擬機(jī)控制,但既然是內(nèi)存,當(dāng)內(nèi)存不足時就會拋出OOM異常。

1.7 綜上所述

  1. Java虛擬機(jī)的內(nèi)存模型中一共有兩個“?!?,分別是:Java虛擬機(jī)棧和本地方法棧。

  2. 兩個“棧”的功能類似,都是方法運(yùn)行過程的內(nèi)存模型。并且兩個“?!眱?nèi)部構(gòu)造相同,都是線程私有。

  3. 只不過Java虛擬機(jī)棧描述的是Java方法運(yùn)行過程的內(nèi)存模型,而本地方法棧是描述Java本地方法運(yùn)行過程的內(nèi)存模型。

  4. Java虛擬機(jī)的內(nèi)存模型中一共有兩個“堆”,一個是原本的堆,一個是方法區(qū)。方法區(qū)本質(zhì)上是屬于堆的一個邏輯部分。堆中存放對象,方法區(qū)中存放類信息、常量、靜態(tài)變量、即時編譯器編譯的代碼。

  5. 堆是Java虛擬機(jī)中最大的一塊內(nèi)存區(qū)域,也是垃圾收集器主要的工作區(qū)域。

  6. 程序計(jì)數(shù)器、Java虛擬機(jī)棧、本地方法棧是線程私有的,即每個線程都擁有各自的程序計(jì)數(shù)器、Java虛擬機(jī)棧、本地方法棧。并且他們的生命周期和所屬的線程一樣。

  7. 而堆、方法區(qū)是線程共享的,在Java虛擬機(jī)中只有一個堆、一個方法棧。并在JVM啟動的時候就創(chuàng)建,JVM停止才銷毀。


第二章 揭開Java對象創(chuàng)建的奧秘

2.1 對象的創(chuàng)建過程

當(dāng)虛擬機(jī)遇到一條含有new的指令時,會進(jìn)行一系列對象創(chuàng)建的操作:

(1)檢查常量池中是否有即將要創(chuàng)建的這個對象所屬的類的符號引用;

  • 若常量池中沒有這個類的符號引用,說明這個類還沒有被定義!拋出ClassNotFoundException;

  • 若常量池中有這個類的符號引用,則進(jìn)行下一步工作;

(2)進(jìn)而檢查這個符號引用所代表的類是否已經(jīng)被JVM加載;

  • 若該類還沒有被加載,就找該類的class文件,并加載進(jìn)方法區(qū);

  • 若該類已經(jīng)被JVM加載,則準(zhǔn)備為對象分配內(nèi)存;

(3)根據(jù)方法區(qū)中該類的信息確定該類所需的內(nèi)存大??;

一個對象所需的內(nèi)存大小是在這個對象所屬類被定義完就能確定的!且一個類所生產(chǎn)的所有對象的內(nèi)存大小是一樣的!JVM在一個類被加載進(jìn)方法區(qū)的時候就知道該類生產(chǎn)的每一個對象所需要的內(nèi)存大小。

(4)從堆中劃分一塊對應(yīng)大小的內(nèi)存空間給新的對象;分配堆中內(nèi)存有兩種方式:

  • 指針碰撞

  • 如果JVM的垃圾收集器采用復(fù)制算法或標(biāo)記-整理算法,那么堆中空閑內(nèi)存是完整的區(qū)域,并且空閑內(nèi)存和已使用內(nèi)存之間由一個指針標(biāo)記。那么當(dāng)為一個對象分配內(nèi)存時,只需移動指針即可。因此,這種在完整空閑區(qū)域上通過移動指針來分配內(nèi)存的方式就叫做“指針碰撞”。

  • 空閑列表

  • 如果JVM的垃圾收集器采用標(biāo)記-清除算法,那么堆中空閑區(qū)域和已使用區(qū)域交錯,因此需要用一張“空閑列表”來記錄堆中哪些區(qū)域是空閑區(qū)域,從而在創(chuàng)建對象的時候根據(jù)這張“空閑列表”找到空閑區(qū)域,并分配內(nèi)存。

  • 綜上所述:JVM究竟采用哪種內(nèi)存分配方法,取決于它使用了何種垃圾收集器。

(5)為對象中的成員變量賦上初始值(默認(rèn)初始化);

(6)設(shè)置對象頭中的信息;

(7)調(diào)用對象的構(gòu)造函數(shù)進(jìn)行初始化;

此時,整個對象的創(chuàng)建過程就完成了。

2.2 對象的內(nèi)存模型

一個對象從邏輯角度看,它由成員變量和成員函數(shù)構(gòu)成,從物理角度來看,對象是存儲在堆中的一串二進(jìn)制數(shù),這串二進(jìn)制數(shù)的組織結(jié)構(gòu)如下。

對象在內(nèi)存中分為三個部分:

  1. 對象頭

  2. 實(shí)例數(shù)據(jù)

  3. 對齊補(bǔ)充

2.2.1 對象頭

對象頭中記錄了對象在運(yùn)行過程中所需要使用的一些數(shù)據(jù):哈希碼、GC分代年齡、鎖狀態(tài)標(biāo)志、線程持有的鎖、偏向線程ID、偏向時間戳等。

此外,對象頭中可能還包含類型指針。通過該指針能確定這個對象所屬哪個類。

此外,如果對象是一個數(shù)組,那么對象頭中還要包含數(shù)組長度。

2.2.2 實(shí)例數(shù)據(jù)

實(shí)力數(shù)據(jù)部分就是成員變量的值,其中包含父類的成員變量和本類的成員變量。

2.2.3 對齊補(bǔ)充

用于確保對象的總長度為8字節(jié)的整數(shù)倍。

HotSpot要求對象的總長度必須是8字節(jié)的整數(shù)倍。由于對象頭一定是8字節(jié)的整數(shù)倍,但實(shí)例數(shù)據(jù)部分的長度是任意的,因此需要對齊補(bǔ)充字段確保整個對象的總長度為8的整數(shù)倍。

2.3 訪問對象的過程

我們知道,引用類型的變量中存放的是一個地址,那么根據(jù)地址類型的不同,對象有不同的訪問方式:

  1. 句柄訪問方式

  2. 堆中需要有一塊叫做“句柄池”的內(nèi)存空間,用于存放所有對象的地址和所有對象所屬類的類信息。

  3. 引用類型的變量存放的是該對象在句柄池中的地址。訪問對象時,首先需要通過引用類型的變量找到該對象的句柄,然后根據(jù)句柄中對象的地址再訪問對象。

  4. 直接指針訪問方式

  5. 引用類型的變量直接存放對象的地址,從而不需要句柄池,通過引用能夠直接訪問對象。

  6. 但對象所在的內(nèi)存空間中需要額外的策略存儲對象所屬的類信息的地址。

比較

HotSpot采用直接指針方式訪問對象,因?yàn)樗恍枰淮螌ぶ凡僮鳎瑥亩阅鼙染浔L問方式快一倍。但它需要額外的策略存儲對象在方法區(qū)中類信息的地址。


第三章 揭開 Java 對象內(nèi)存分配的秘密

Java所承諾的自動內(nèi)存管理主要是針對對象內(nèi)存的回收和對象內(nèi)存的分配。

在Java虛擬機(jī)的五塊內(nèi)存空間中,程序計(jì)數(shù)器、Java虛擬機(jī)棧、本地方法棧內(nèi)存的分配和回收都具有確定性,一半都在編譯階段就能確定下來需要分配的內(nèi)存大小,并且由于都是線程私有,因此它們的內(nèi)存空間都隨著線程的創(chuàng)建而創(chuàng)建,線程的結(jié)束而回收。也就是這三個區(qū)域的內(nèi)存分配和回收都具有確定性。

而Java虛擬機(jī)中的方法區(qū)因?yàn)槭怯脕泶鎯︻愋畔?、常?/p>

靜態(tài)變量,這些數(shù)據(jù)的變動性較小,因此不是Java內(nèi)存管理重點(diǎn)需要關(guān)注的區(qū)域。

而對于堆,所有線程共享,所有的對象都需要在堆中創(chuàng)建和回收。雖然每個對象的大小在類加載的時候就能確定,但對象的數(shù)量只有在程序運(yùn)行期間才能確定,因此堆中內(nèi)存的分配具有較大的不確定性。此外,對象的生命周期長短不一,因此需要針對不同生命周期的對象采用不同的內(nèi)存回收算法,增加了內(nèi)存回收的復(fù)雜性。

綜上所述:Java自動內(nèi)存管理最核心的功能是堆內(nèi)存中對象的分配與回收。

3.1 對象優(yōu)先在 Eden 區(qū)中分配

目前主流的垃圾收集器都會采用分代回收算法,因此需要將堆內(nèi)存分為新生代和老年代。

在新生代中為了防止內(nèi)存碎片問題,因此垃圾收集器一般都選用“復(fù)制”算法。因此,堆內(nèi)存的新生代被進(jìn)一步分為:Eden區(qū)+Survior1區(qū)+Survior2區(qū)。

每次創(chuàng)建對象時,首先會在Eden區(qū)中分配。

若Eden區(qū)已滿,則在Survior1區(qū)中分配。

若Eden區(qū)+Survior1區(qū)剩余內(nèi)存太少,導(dǎo)致對象無法放入該區(qū)域時,就會啟用“分配擔(dān)?!保瑢?dāng)前Eden區(qū)+Survior1區(qū)中的對象轉(zhuǎn)移到老年代中,然后再將新對象存入Eden區(qū)。

3.2 大對象直接進(jìn)入老年代

所謂“大對象”就是指一個占用大量連續(xù)存儲空間的對象,如數(shù)組。

當(dāng)發(fā)現(xiàn)一個大對象在Eden區(qū)+Survior1區(qū)中存不下的時候就需要分配擔(dān)保機(jī)制把當(dāng)前Eden區(qū)+Survior1區(qū)的所有對象都復(fù)制到老年代中去。

我們知道,一個大對象能夠存入Eden區(qū)+Survior1區(qū)的概率比較小,發(fā)生分配擔(dān)保的概率比較大,而分配擔(dān)保需要涉及到大量的復(fù)制,就會造成效率低下。

因此,對于大對象我們直接把他放到老年代中去,從而就能避免大量的復(fù)制操作。

那么,什么樣的對象才是“大對象”呢?

通過-XX:PretrnureSizeThreshold參數(shù)設(shè)置大對象

該參數(shù)用于設(shè)置大小超過該參數(shù)的對象被認(rèn)為是“大對象”,直接進(jìn)入老年代。

注意:該參數(shù)只對Serial和ParNew收集器有效。

3.3 生命周期較長的對象進(jìn)入老年代

老年代用于存儲生命周期較長的對象,那么我們?nèi)绾闻袛嘁粋€對象的年齡呢?

新生代中的每個對象都有一個年齡計(jì)數(shù)器,當(dāng)新生代發(fā)生一次MinorGC后,存活下來的對象的年齡就加一,當(dāng)年齡超過一定值時,就將超過該值的所有對象轉(zhuǎn)移到老年代中去。

使用-XXMaxTenuringThreshold設(shè)置新生代的最大年齡

設(shè)置該參數(shù)后,只要超過該參數(shù)的新生代對象都會被轉(zhuǎn)移到老年代中去。

3.4 相同年齡的對象內(nèi)存超過Survior內(nèi)存一半的對象進(jìn)入老年代

如果當(dāng)前新生代的Survior中,年齡相同的對象的內(nèi)存空間總和超過了Survior內(nèi)存空間的一半,那么所有年齡相同的對象和超過該年齡的對象都被轉(zhuǎn)移到老年代中去。無需等到對象的年齡超過MaxTenuringThreshold才被轉(zhuǎn)移到老年代中去。

3.5 “分配擔(dān)?!辈呗栽斀?/span>

當(dāng)垃圾收集器準(zhǔn)備要在新生代發(fā)起一次MinorGC時,首先會檢查“老年代中最大的連續(xù)空閑區(qū)域的大小 是否大于 新生代中所有對象的大小?”,也就是老年代中目前能夠?qū)⑿律兴袑ο笕垦b下?

若老年代能夠裝下新生代中所有的對象,那么此時進(jìn)行MinorGC沒有任何風(fēng)險,然后就進(jìn)行MinorGC。

若老年代無法裝下新生代中所有的對象,那么此時進(jìn)行MinorGC是有風(fēng)險的,垃圾收集器會進(jìn)行一次預(yù)測:根據(jù)以往MinorGC過后存活對象的平均數(shù)來預(yù)測這次MinorGC后存活對象的平均數(shù)。

如果以往存活對象的平均數(shù)小于當(dāng)前老年代最大的連續(xù)空閑空間,那么就進(jìn)行MinorGC,雖然此次MinorGC是有風(fēng)險的。

如果以往存活對象的平均數(shù)大于當(dāng)前老年代最大的連續(xù)空閑空間,那么就對老年代進(jìn)行一次Full GC,通過清除老年代中廢棄數(shù)據(jù)來擴(kuò)大老年代空閑空間,以便給新生代作擔(dān)保。

這個過程就是分配擔(dān)保。

注意:
  • 分配擔(dān)保是老年代為新生代作擔(dān)保;

  • 新生代中使用“復(fù)制”算法實(shí)現(xiàn)垃圾回收,老年代中使用“標(biāo)記-清除”或“標(biāo)記-整理”算法實(shí)現(xiàn)垃圾回收,只有使用“復(fù)制”算法的區(qū)域才需要分配擔(dān)保,因此新生代需要分配擔(dān)保,而老年代不需要分配擔(dān)保。


第四章 了解 Java 虛擬機(jī)的垃圾回收算法

Java虛擬機(jī)的內(nèi)存模型分為五個部分,分別是:程序計(jì)數(shù)器、Java虛擬機(jī)棧、本地方法棧、堆、方法區(qū)。

這五個區(qū)域既然是存儲空間,那么為了避免Java虛擬機(jī)在運(yùn)行期間內(nèi)存存滿的情況,就必須得有一個垃圾收集者的角色,不定期地回收一些無效內(nèi)存,以保障Java虛擬機(jī)能夠健康地持續(xù)運(yùn)行。

這個垃圾收集者就是平常我們所說的“垃圾收集器”,那么垃圾收集器在何時清掃內(nèi)存?清掃哪些數(shù)據(jù)?這就是接下來我們要解決的問題。

程序計(jì)數(shù)器、Java虛擬機(jī)棧、本地方法棧都是線程私有的,也就是每條線程都擁有這三塊區(qū)域,而且會隨著線程的創(chuàng)建而創(chuàng)建,線程的結(jié)束而銷毀。那么,垃圾收集器在何時清掃這三塊區(qū)域的問題就解決了。

此外,Java虛擬機(jī)棧、本地方法棧中的棧幀會隨著方法的開始而入棧,方法的結(jié)束而出棧,并且每個棧幀中的本地變量表都是在類被加載的時候就確定的。因此以上三個區(qū)域的垃圾收集工作具有確定性,垃圾收集器能夠清楚地知道何時清掃這三塊區(qū)域中的哪些數(shù)據(jù)。

然而,堆和方法區(qū)中的內(nèi)存清理工作就沒那么容易了。

堆和方法區(qū)所有線程共享,并且都在JVM啟動時創(chuàng)建,一直得運(yùn)行到JVM停止時。因此它們沒辦法根據(jù)線程的創(chuàng)建而創(chuàng)建、線程的結(jié)束而釋放。

堆中存放JVM運(yùn)行期間的所有對象,雖然每個對象的內(nèi)存大小在加載該對象所屬類的時候就確定了,但究竟創(chuàng)建多少個對象只有在程序運(yùn)行期間才能確定。

方法區(qū)中存放類信息、靜態(tài)成員變量、常量。類的加載是在程序運(yùn)行過程中,當(dāng)需要創(chuàng)建這個類的對象時才會加載這個類。因此,JVM究竟要加載多少個類也需要在程序運(yùn)行期間確定。

因此,堆和方法區(qū)的內(nèi)存回收具有不確定性,因此垃圾收集器在回收堆和方法區(qū)內(nèi)存的時候花了一些心思。

4.1 堆內(nèi)存的回收

4.1.1 如何判定哪些對象需要回收?

在對堆進(jìn)行對象回收之前,首先要判斷哪些是無效對象。我們知道,一個對象不被任何對象或變量引用,那么就是無效對象,需要被回收。一般有兩種判別方式:

  • 引用計(jì)數(shù)法

  • 每個對象都有一個計(jì)數(shù)器,當(dāng)這個對象被一個變量或另一個對象引用一次,該計(jì)數(shù)器加一;若該引用失效則計(jì)數(shù)器減一。當(dāng)計(jì)數(shù)器為0時,就認(rèn)為該對象是無效對象。

  • 可達(dá)性分析法

  • 所有和GC Roots直接或間接關(guān)聯(lián)的對象都是有效對象,和GC Roots沒有關(guān)聯(lián)的對象就是無效對象。

GC Roots是指:

  1. Java虛擬機(jī)棧所引用的對象(棧幀中局部變量表中引用類型的變量所引用的對象)

  2. 方法區(qū)中靜態(tài)屬性引用的對象

  3. 方法區(qū)中常量所引用的對象

  4. 本地方法棧所引用的對象

兩者對比:

引用計(jì)數(shù)法雖然簡單,但存在一個嚴(yán)重的問題,它無法解決循環(huán)引用的問題。

因此,目前主流語言均使用可達(dá)性分析方法來判斷對象是否有效。

4.1.2 回收無效對象的過程

當(dāng)JVM篩選出失效的對象之后,并不是立即清除,而是再給對象一次重生的機(jī)會,具體過程如下:

(1)判斷該對象是否覆蓋了finalize()方法

  • 若已覆蓋該方法,并該對象的finalize()方法還沒有被執(zhí)行過,那么就會將finalize()扔到F-Queue隊(duì)列中;

  • 若未覆蓋該方法,則直接釋放對象內(nèi)存。

(2)執(zhí)行F-Queue隊(duì)列中的finalize()方法

虛擬機(jī)會以較低的優(yōu)先級執(zhí)行這些finalize()方法們,也不會確保所有的finalize()方法都會執(zhí)行結(jié)束。如果finalize()方法中出現(xiàn)耗時操作,虛擬機(jī)就直接停止執(zhí)行,將該對象清除。

(3)對象重生或死亡

如果在執(zhí)行finalize()方法時,將this賦給了某一個引用,那么該對象就重生了。如果沒有,那么就會被垃圾收集器清除。

注意:強(qiáng)烈不建議使用finalize()函數(shù)進(jìn)行任何操作!如果需要釋放資源,請使用try-finally。因?yàn)閒inalize()不確定性大,開銷大,無法保證順利執(zhí)行。

4.2 方法區(qū)的內(nèi)存回收

我們知道,如果使用復(fù)制算法實(shí)現(xiàn)堆的內(nèi)存回收,堆就會被分為新生代和老年代,新生代中的對象“朝生夕死”,每次垃圾回收都會清除掉大量的對象;而老年代中的對象生命較長,每次垃圾回收只有少量的對象被清除掉。

由于方法區(qū)中存放生命周期較長的類信息、常量、靜態(tài)變量,因此方法區(qū)就像是堆的老年代,每次垃圾收集的只有少量的垃圾被清除掉。

方法區(qū)中主要清除兩種垃圾:

  1. 廢棄常量

  2. 廢棄的類

4.2.1 如何判定廢棄常量?

清除廢棄的常量和清除對象類似,只要常量池中的常量不被任何變量或?qū)ο笠?,那么這些常量就會被清除掉。

4.2.2 如何廢棄廢棄的類?

清除廢棄類的條件較為苛刻:

  1. 該類的所有對象都已被清除

  2. 該類的java.lang.Class對象沒有被任何對象或變量引用

  3. 只要一個類被虛擬機(jī)加載進(jìn)方法區(qū),那么在堆中就會有一個代表該類的對象:java.lang.Class。這個對象在類被加載進(jìn)方法區(qū)的時候創(chuàng)建,在方法區(qū)中該類被刪除時清除。

  4. 加載該類的ClassLoader已經(jīng)被回收

4.3 垃圾收集算法

現(xiàn)在我們知道了判定一個對象是無效對象、判定一個類是廢棄類、判定一個常量是廢棄常量的方法,也就是知道了垃圾收集器會清除哪些數(shù)據(jù),那么接下來介紹如何清除這些數(shù)據(jù)。

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

首先利用剛才介紹的方法判斷需要清除哪些數(shù)據(jù),并給它們做上標(biāo)記;然后清除被標(biāo)記的數(shù)據(jù)。

分析:

這種算法標(biāo)記和清除過程效率都很低,而且清除完后存在大量碎片空間,導(dǎo)致無法存儲大對象,降低了空間利用率。

4.3.2 復(fù)制算法

將內(nèi)存分成兩份,只將數(shù)據(jù)存儲在其中一塊上。當(dāng)需要回收垃圾時,也是首先標(biāo)記出廢棄的數(shù)據(jù),然后將有用的數(shù)據(jù)復(fù)制到另一塊內(nèi)存上,最后將第一塊內(nèi)存全部清除。

分析:

這種算法避免了碎片空間,但內(nèi)存被縮小了一半。

而且每次都需要將有用的數(shù)據(jù)全部復(fù)制到另一片內(nèi)存上去,效率不高。

解決空間利用率問題:

在新生代中,由于大量的對象都是“朝生夕死”,也就是一次垃圾收集后只有少量對象存活,因此我們可以將內(nèi)存劃分成三塊:Eden、Survior1、Survior2,內(nèi)存大小分別是8:1:1。分配內(nèi)存時,只使用Eden和一塊Survior1。當(dāng)發(fā)現(xiàn)Eden+Survior1的內(nèi)存即將滿時,JVM會發(fā)起一次MinorGC,清除掉廢棄的對象,并將所有存活下來的對象復(fù)制到另一塊Survior2中。那么,接下來就使用Survior2+Eden進(jìn)行內(nèi)存分配。

通過這種方式,只需要浪費(fèi)10%的內(nèi)存空間即可實(shí)現(xiàn)帶有壓縮功能的垃圾收集方法,避免了內(nèi)存碎片的問題。

但是,當(dāng)一個對象要申請內(nèi)存空間時,發(fā)現(xiàn)Eden+Survior中剩下的空間無法放置該對象,此時需要進(jìn)行Minor GC,如果MinorGC過后空閑出來的內(nèi)存空間仍然無法放置該對象,那么此時就需要將對象轉(zhuǎn)移到老年代中,這種方式叫做“分配擔(dān)?!薄?/p>

什么是分配擔(dān)保?

當(dāng)JVM準(zhǔn)備為一個對象分配內(nèi)存空間時,發(fā)現(xiàn)此時Eden+Survior中空閑的區(qū)域無法裝下該對象,那么就會觸發(fā)MinorGC,對該區(qū)域的廢棄對象進(jìn)行回收。但如果MinorGC過后只有少量對象被回收,仍然無法裝下新對象,那么此時需要將Eden+Survior中的所有對象都轉(zhuǎn)移到老年代中,然后再將新對象存入Eden區(qū)。這個過程就是“分配擔(dān)?!薄?/p>

4.3.3 標(biāo)記-整理算法

在回收垃圾前,首先將所有廢棄的對象做上標(biāo)記,然后將所有未被標(biāo)記的對象移到一邊,最后清空另一邊區(qū)域即可。

分析:

它是一種老年代的垃圾收集算法。老年代中的對象一般壽命比較長,因此每次垃圾回收會有大量對象存活,因此如果選用“復(fù)制”算法,每次需要復(fù)制大量存活的對象,會導(dǎo)致效率很低。而且,在新生代中使用“復(fù)制”算法,當(dāng)Eden+Survior中都裝不下某個對象時,可以使用老年代的內(nèi)存進(jìn)行“分配擔(dān)?!?,而如果在老年代使用該算法,那么在老年代中如果出現(xiàn)Eden+Survior裝不下某個對象時,沒有其他區(qū)域給他作分配擔(dān)保。因此,老年代中一般使用“標(biāo)記-整理”算法。

4.3.4 分代收集算法

將內(nèi)存劃分為老年代和新生代。老年代中存放壽命較長的對象,新生代中存放“朝生夕死”的對象。然后在不同的區(qū)域使用不同的垃圾收集算法。

4.4 Java中引用的種類

Java中根據(jù)生命周期的長短,將引用分為4類。

4.4.1 強(qiáng)引用

我們平時所使用的引用就是強(qiáng)引用。

A a = new A();

也就是通過關(guān)鍵字new創(chuàng)建的對象所關(guān)聯(lián)的引用就是強(qiáng)引用。

只要強(qiáng)引用存在,該對象永遠(yuǎn)也不會被回收。

4.4.2 軟引用

只有當(dāng)堆即將發(fā)生OOM異常時,JVM才會回收軟引用所指向的對象。

軟引用通過SoftReference類實(shí)現(xiàn)。

軟引用的生命周期比強(qiáng)引用短一些。

4.4.3 弱引用

只要垃圾收集器運(yùn)行,軟引用所指向的對象就會被回收。

弱引用通過WeakReference類實(shí)現(xiàn)。

弱引用的生命周期比軟引用短。

4.4.4 虛引用

虛引用也叫幽靈引用,它和沒有引用沒有區(qū)別,無法通過虛引用訪問對象的任何屬性或函數(shù)。

一個對象關(guān)聯(lián)虛引用唯一的作用就是在該對象被垃圾收集器回收之前會受到一條系統(tǒng)通知。

虛引用通過PhantomReference類來實(shí)現(xiàn)。


第五章 class 文件結(jié)構(gòu)詳解

5.1 什么是JVM的“無關(guān)性”?

Java具有平臺無關(guān)性,也就是任何操作系統(tǒng)都能運(yùn)行Java代碼。之所以能實(shí)現(xiàn)這一點(diǎn),是因?yàn)镴ava運(yùn)行在虛擬機(jī)之上,不同的操作系統(tǒng)都擁有各自的Java虛擬機(jī),因此Java能實(shí)現(xiàn)“一次編寫,處處運(yùn)行”。

而JVM不僅具有平臺無關(guān)性,還具有語言無關(guān)性。

平臺無關(guān)性是指不同操作系統(tǒng)都有各自的JVM,而語言無關(guān)性是指Java虛擬機(jī)能運(yùn)行除Java以外的代碼!

這聽起來非常驚人,但JVM對能運(yùn)行的語言是有嚴(yán)格要求的。首先來了解下Java代碼的運(yùn)行過程。

Java源代碼首先需要使用Javac編譯器編譯成class文件,然后啟動JVM執(zhí)行class文件,從而程序開始運(yùn)行。

也就是JVM只認(rèn)識class文件,它并不管何種語言生成了class文件,只要class文件符合JVM的規(guī)范就能運(yùn)行。

因此目前已經(jīng)有Scala、JRuby、Jython等語言能夠在JVM上運(yùn)行。它們有各自的語法規(guī)則,不過它們的編譯器都能將各自的源碼編譯成符合JVM規(guī)范的class文件,從而能夠借助JVM運(yùn)行它們。

5.2 縱觀Class文件結(jié)構(gòu)

class文件是二進(jìn)制文件,它的內(nèi)容具有嚴(yán)格的規(guī)范,文件中沒有任何空格,全是連續(xù)的0/1。class文件中的所有內(nèi)容被分為兩種類型:無符號數(shù) 和 表。

  • 無符號數(shù):它表示class文件中的值,這些值沒有任何類型,但有不同的長度。根據(jù)這些值長度的不同分為:u1、u2、u4、u8,分別代表1字節(jié)的無符號數(shù)、2字節(jié)的無符號數(shù)、4字節(jié)的無符號數(shù)、8字節(jié)的無符號數(shù)。

  • 表:class文件中所有數(shù)據(jù)(即無符號數(shù))要么單獨(dú)存在,要么由多個無符號數(shù)組成二維表。即class文件中的數(shù)據(jù)要么是單個值,要么是二維表。

5.2.1 class文件的組織結(jié)構(gòu)

  1. 魔數(shù)

  2. 本文件的版本信息

  3. 常量池

  4. 訪問標(biāo)志

  5. 類索引

  6. 父類索引

  7. 接口索引集合

  8. 字段表集合

  9. 方法表集合

5.3 Class文件的構(gòu)成1:魔數(shù)

class文件的頭4個字節(jié)稱為魔數(shù),用來表示這個class文件的類型。

魔數(shù)的作用就相當(dāng)于文件后綴名,只不過后綴名容易被修改,不安全,因此在class文件中標(biāo)示文件類型比較合適。

class文件的魔數(shù)是用16進(jìn)制表示的“CAFEBABE”,非常具有浪漫主義色彩,誰說程序員的情商都很低!

5.4 Class文件的構(gòu)成2:版本信息

緊接著魔數(shù)的4個字節(jié)是版本號。它表示本class中使用的是哪個版本的JDK。

在高版本的JVM上能夠運(yùn)行低版本的class文件,但在低版本的JVM上無法運(yùn)行高版本的class文件,即使該class文件中沒有用到任何高版本JDK的特性也無法運(yùn)行!

5.5 Class文件的構(gòu)成3:常量池

5.5.1 什么是常量池?

緊接著版本號之后的就是常量池。常量池中存放兩種類型的常量:

  • 字面值常量

  • 字面值常量即我們在程序中定義的字符串、被final修飾的值。

  • 符號引用

  • 符號引用就是我們定義的各種名字:

  1. 類和接口的全限定名

  2. 字段的名字 和 描述符

  3. 方法的名字 和 描述符

5.5.2 常量池的特點(diǎn)

  • 常量池長度不固定

  • 常量池的大小是不固定的,因此常量池開頭放置一個u2類型的無符號數(shù),用來存儲當(dāng)前常量池的容量。JVM根據(jù)這個值就知道常量池的頭尾來。

注:這個值是從1開始的,若為5表示池中有4個常量。

  • 常量池中的常量由而為表來表示

  • 常量池開頭有個常量池容量計(jì)數(shù)器,接下來就全是一個個常量了,只不過常量都是由一張張二維表構(gòu)成,除了記錄常量的值以外,還記錄當(dāng)前常量的相關(guān)信息。

  • 常量池是class文件的資源倉庫

  • 常量池是與本class中其它部分關(guān)聯(lián)最多的部分

  • 常量池是class文件中空間占用最大的部分之一

5.5.3 常量池中常量的類型

剛才介紹了,常量池中的常量大體上分為:字面值常量 和 符號引用。在此基礎(chǔ)上,根據(jù)常量的數(shù)據(jù)類型不同,又可以被細(xì)分為14種常量類型。這14種常量類型都有各自的二維表示結(jié)構(gòu)。每種常量類型的頭1個字節(jié)都是tag,用于表示當(dāng)前常量屬于14種類型中的哪一個。

以CONSTANT_Class_info常量為例,它的二維表示結(jié)構(gòu)如下:

CONSTANT_Class_info表:

類型名稱數(shù)量u1tag1u2name_index1

tag表示當(dāng)前常量的類型(當(dāng)前常量為CONSTANT_Class_info,因此tag的值應(yīng)為7,表示一個類或接口的全限定名);

name_index表示這個類或接口全限定名的位置。它的值表示指向常量池的第幾個常量。它會指向一個CONSTANT_Utf8_info類型的常量,它的二維表結(jié)構(gòu)如下:

CONSTANT_Utf8_info表:

類型名稱數(shù)量u1tag1u2length2u1byteslength

  • CONSTANT_Utf8_info表示字符串常量;

  • tag表示當(dāng)前常量的類型,這里應(yīng)該是1;

  • length表示這個字符串的長度;

  • bytes為這個字符串的內(nèi)容(采用縮略的UTF8編碼)

問:為什么Java中定義的類、變量名字必須小于64K?

類、接口、變量等名字都屬于符號引用,它們都存儲在常量池中。而不管哪種符號引用,它們的名字都由CONSTANT_Utf8_info類型的常量表示,這種類型的常量使用u2存儲字符串的長度。由于2字節(jié)最多能表示65535個數(shù),因此這些名字的最大長度最多只能是64K。

問:什么是UTF-8編碼?什么是縮略UTF-8編碼?

前者每個字符使用3個字節(jié)表示,而后者把128個ASKII碼用1字節(jié)表示,某些字符用2字節(jié)表示,某些字符用3字節(jié)表示。

5.6 Class文件的構(gòu)成4:訪問標(biāo)志

在常量池之后是2字節(jié)的訪問標(biāo)志。訪問標(biāo)志是用來表示這個class文件是類還是接口、是否被public修飾、是否被abstract修飾、是否被final修飾等。

由于這些標(biāo)志都由是/否表示,因此可以用0/1表示。

訪問標(biāo)志為2字節(jié),可以表示16位標(biāo)志,但JVM目前只定義了8種,未定義的直接寫0.

5.7 Class文件的構(gòu)成5:類索引、父類索引、接口索引集合

類索引、父類索引、接口索引集合是用來表示當(dāng)前class文件所表示類的名字、父類名字、接口們的名字。

它們按照順序依次排列,類索引和父類索引各自使用一個u2類型的無符號常量,這個常量指向CONSTANT_Class_info類型的常量,該常量的bytes字段記錄了本類、父類的全限定名。

由于一個類的接口可能有好多個,因此需要用一個集合來表示接口索引,它在類索引和父類索引之后。這個集合頭兩個字節(jié)表示接口索引集合的長度,接下來就是接口的名字索引。

5.8 Class文件的構(gòu)成6:字段表的集合

5.8.1 什么是字段表集合?

接下來是字段表的集合。字段表集合用于存儲本類所涉及到的成員變量,包括實(shí)例變量和類變量,但不包括方法中的局部變量。

每一個字段表只表示一個成員變量,本類中所有的成員變量構(gòu)成了字段表集合。

5.8.2 字段表結(jié)構(gòu)的定義

類型名稱數(shù)量u2access_flags1u2name_index1u2descriptor_index1u2attributes_count1attribute_infoattributesattributes_count

  • access_flags:字段的訪問標(biāo)志。在Java中,每個成員變量都有一系列的修飾符,和上述class文件的訪問標(biāo)志的作用一樣,只不過成員變量的訪問標(biāo)志與類的訪問標(biāo)志稍有區(qū)別。

  • name_index:本字段名字的索引。指向一個CONSTANT_Class_info類型的常量,這里面存儲了本字段的名字等信息。

  • descriptor_index:描述符。用于描述本字段在Java中的數(shù)據(jù)類型等信息(下面詳細(xì)介紹)。

  • attributes_count:屬性表集合的長度。

  • attributes:屬性表集合。到descriptor_index為止是字段表的固定信息,光有上述信息可能無法完整地描述一個字段,因此用屬性表集合來存放額外的信息,比如一個字段的值(下面會詳細(xì)介紹)。

5.8.3 什么是描述符?

成員變量(包括靜態(tài)成員變量和實(shí)例變量)和 方法都有各自的描述符。

對于字段而言,描述符用于描述字段的數(shù)據(jù)類型;

對于方法而言,描述符用于描述字段的數(shù)據(jù)類型、參數(shù)列表、返回值。

在描述符中,基本數(shù)據(jù)類型用大寫字母表示,對象類型用“L對象類型的全限定名”表示,數(shù)組用“[數(shù)組類型的全限定名”表示。

描述方法時,將參數(shù)根據(jù)上述規(guī)則放在()中,()右側(cè)按照上述方法放置返回值。而且,參數(shù)之間無需任何符號。

5.8.4 字段表集合的注意點(diǎn)

  1. 一個class文件的字段表集合中不能出現(xiàn)從父類/接口繼承而來字段;

  2. 一個class文件的字段表集合中可能會出現(xiàn)程序猿沒有定義的字段

  3. 如編譯器會自動地在內(nèi)部類的class文件的字段表集合中添加外部類對象的成員變量,供內(nèi)部類訪問外部類。

  4. Java中只要兩個字段名字相同就無法通過編譯。但在JVM規(guī)范中,允許兩個字段的名字相同但描述符不同的情況,并且認(rèn)為它們是兩個不同的字段。

5.9 Class文件的構(gòu)成7:方法表的集合

在class文件中,所有的方法以二維表的形式存儲,每張表來表示一個函數(shù),一個類中的所有方法構(gòu)成方法表的集合。

方法表的結(jié)構(gòu)和字段表的結(jié)構(gòu)一致,只不過訪問標(biāo)志和屬性表集合的可選項(xiàng)有所不同。

類型名稱數(shù)量u2access_flags1u2name_index1u2descriptor_index1u2attributes_count1attribute_infoattributesattributes_count

方法表的屬性表集合中有一張Code屬性表,用于存儲當(dāng)前方法經(jīng)編譯器編譯過后的字節(jié)碼指令。

方法表集合的注意點(diǎn)

  1. 如果本class沒有重寫父類的方法,那么本class文件的方法表集合中是不會出現(xiàn)父類/父接口的方法表;

  2. 本class的方法表集合可能出現(xiàn)程序猿沒有定義的方法

  3. 編譯器在編譯時會在class文件的方法表集合中加入類構(gòu)造器

  4. 和實(shí)例構(gòu)造器。

  5. 重載一個方法需要有相同的簡單名稱和不同的特征簽名。JVM的特征簽名和Java的特征簽名有所不同:

  • Java特征簽名:方法參數(shù)在常量池中的字段符號引用的集合

  • JVM特征簽名:方法參數(shù)+返回值


第六章 詳解 Java 類的加載過程

6.1 類的生命周期

一個類從加載進(jìn)內(nèi)存到卸載出內(nèi)存為止,一共經(jīng)歷7個階段:

加載——>驗(yàn)證——>準(zhǔn)備——>解析——>初始化——>使用——>卸載

其中,類加載包括5個階段:

加載——>驗(yàn)證——>準(zhǔn)備——>解析——>初始化

在類加載的過程中,以下3個過程稱為連接:

驗(yàn)證——>準(zhǔn)備——>解析

因此,JVM的類加載過程也可以概括為3個過程:

加載——>連接——>初始化

C/C++在運(yùn)行前需要完成預(yù)處理、編譯、匯編、鏈接;而在Java中,類加載(加載、連接、初始化)是在程序運(yùn)行期間完成的。

在程序運(yùn)行期間進(jìn)行類加載會稍微增加程序的開銷,但隨之會帶來更大的好處——提高程序的靈活性。Java語言的靈活性體現(xiàn)在它可以在運(yùn)行期間動態(tài)擴(kuò)展,所謂動態(tài)擴(kuò)展就是在運(yùn)行期間動態(tài)加載動態(tài)連接。

6.2 類加載的時機(jī)

6.2.1 類加載過程中每個步驟的順序

我們已經(jīng)知道,類加載的過程包括:加載、連接、初始化,連接又分為:驗(yàn)證、準(zhǔn)備、解析,所以說類加載一共分為5步:加載、驗(yàn)證、準(zhǔn)備、解析、初始化。

其中加載、驗(yàn)證、準(zhǔn)備、初始化的開始順序是依次進(jìn)行的,這些步驟開始之后的過程可能會有重疊。

而解析過程會發(fā)生在初始化過程中。

6.2.2 類加載過程中“初始化”開始的時機(jī)

JVM規(guī)范中只定義了類加載過程中初始化過程開始的時機(jī),加載、連接過程都應(yīng)該在初始化之前開始(解析除外),這些過程具體在何時開始,JVM規(guī)范并沒有定義,不同的虛擬機(jī)可以根據(jù)具體的需求自定義。

初始化開始的時機(jī):

  1. 在運(yùn)行過程中遇到如下字節(jié)碼指令時,如果類尚未初始化,那就要進(jìn)行初始化:new、getstatic、putstatic、invokestatic。這四個指令對應(yīng)的Java代碼場景是:

  • 通過new創(chuàng)建對象;

  • 讀取、設(shè)置一個類的靜態(tài)成員變量(不包括final修飾的靜態(tài)變量);

  • 調(diào)用一個類的靜態(tài)成員函數(shù)。

  1. 使用java.lang.reflect進(jìn)行反射調(diào)用的時候,如果類沒有初始化,那就需要初始化;

  2. 當(dāng)初始化一個類的時候,若其父類尚未初始化,那就先要讓其父類初始化,然后再初始化本類;

  3. 當(dāng)虛擬機(jī)啟動時,虛擬機(jī)會首先初始化帶有main方法的類,即主類;

6.2.3 主動引用 與 被動引用

JVM規(guī)范中要求在程序運(yùn)行過程中,“當(dāng)且僅當(dāng)”出現(xiàn)上述4個條件之一的情況才會初始化一個類。如果間接滿足上述初始化條件是不會初始化類的。

其中,直接滿足上述初始化條件的情況叫做主動引用;間接滿足上述初始化過程的情況叫做被動引用

那么,只有當(dāng)程序在運(yùn)行過程中滿足主動引用的時候才會初始化一個類,若滿足被動引用就不會初始化一個類。

6.2.4 被動引用的場景示例

示例一

public?class?Fu{
?public?static?String?name?=?"柴毛毛";
?static{
?System.out.println("父類被初始化!");
?}
}
public?class?Zi{
?static{
?System.out.println("子類被初始化!");
?}
}
public?static?void?main(String[]?args){
?System.out.println(Zi.name);
}

輸出結(jié)果:

父類被初始化!

柴毛毛

原因分析:

本示例看似滿足初始化時機(jī)的第一條:當(dāng)要獲取某一個類的靜態(tài)成員變量的時候如果該類尚未初始化,則對該類進(jìn)行初始化。

但由于這個靜態(tài)成員變量屬于Fu類,Zi類只是間接調(diào)用Fu類中的靜態(tài)成員變量,因此Zi類調(diào)用name屬性屬于間接引用,而Fu類調(diào)用name屬性屬于直接引用,由于JVM只初始化直接引用的類,因此只有Fu類被初始化。

示例二

public?class?A{
?public?static?void?main(String[]?args){
?Fu[]?arr?=?new?Fu[10];
?}
}

輸出結(jié)果:

并沒有輸出“父類被初始化!”

原因分析:

這個過程看似滿足初始化時機(jī)的第一條:遇到new創(chuàng)建對象時若類沒被初始化,則初始化該類。

但現(xiàn)在通過new要創(chuàng)建的是一個數(shù)組對象,而非Fu類對象,因此也屬于間接引用,不會初始化Fu類。

示例三

public?class?Fu{
?public?static?final?String?name?=?"柴毛毛";
?static{
?System.out.println("父類被初始化!");
?}
}
public?class?A{
?public?static?void?main(String[]?args){
?System.out.println(Fu.name);
?}
}

輸出結(jié)果:

柴毛毛

原因分析:

本示例看似滿足類初始化時機(jī)的第一個條件:獲取一個類靜態(tài)成員變量的時候若類尚未初始化則初始化類。

但是,F(xiàn)u類的靜態(tài)成員變量被final修飾,它已經(jīng)是一個常量。被final修飾的常量在Java代碼編譯的過程中就會被放入它被引用的class文件的常量池中(這里是A的常量池)。所以程序在運(yùn)行期間如果需要調(diào)用這個常量,直接去當(dāng)前類的常量池中取,而不需要初始化這個類。

6.2.5 接口的初始化

接口和類都需要初始化,接口和類的初始化過程基本一樣,不同點(diǎn)在于:類初始化時,如果發(fā)現(xiàn)父類尚未被初始化,則先要初始化父類,然后再初始化自己;但接口初始化時,并不要求父接口已經(jīng)全部初始化,只有程序在運(yùn)行過程中用到當(dāng)父接口中的東西時才初始化父接口。

6.3 類加載的過程

通過之前的介紹可知,類加載過程共有5個步驟,分別是:加載、驗(yàn)證、準(zhǔn)備、解析、初始化。其中,驗(yàn)證、準(zhǔn)備、解析稱為連接。下面詳細(xì)介紹這5個過程JVM所做的工作。

6.3.1 加載

注意:“加載”是“類加載”過程的第一步,千萬不要混淆。

在加載過程中,JVM主要做3件事情:

  • 通過一個類的全限定名來獲取這個類的二進(jìn)制字節(jié)流,即class文件:

  • 在程序運(yùn)行過程中,當(dāng)要訪問一個類時,若發(fā)現(xiàn)這個類尚未被加載,并滿足類初始化時機(jī)的條件時,就根據(jù)要被初始化的這個類的全限定名找到該類的二進(jìn)制字節(jié)流,開始加載過程。

  • 將二進(jìn)制字節(jié)流的存儲結(jié)構(gòu)轉(zhuǎn)化為特定的數(shù)據(jù)結(jié)構(gòu),存儲在方法區(qū)中;

  • 在內(nèi)存中創(chuàng)建一個java.lang.Class類型的對象:

  • 接下來程序在運(yùn)行過程中所有對該類的訪問都通過這個類對象,也就是這個Class類型的類對象是提供給外界訪問該類的接口。

從哪里加載?

JVM規(guī)范對于加載過程給予了較大的寬松度。一般二進(jìn)制字節(jié)流都從已經(jīng)編譯好的本地class文件中讀取,此外還可以從以下地方讀?。?/p>

  • 從壓縮包中讀取,如:Jar、War、Ear等。

  • 從其它文件中動態(tài)生成,如:從JSP文件中生成Class類。

  • 從數(shù)據(jù)庫中讀取,將二進(jìn)制字節(jié)流存儲至數(shù)據(jù)庫中,然后在加載時從數(shù)據(jù)庫中讀取。有些中間件會這么做,用來實(shí)現(xiàn)代碼在集群間分發(fā)。

  • 從網(wǎng)絡(luò)中獲取,從網(wǎng)絡(luò)中獲取二進(jìn)制字節(jié)流。典型就是Applet。

類 和 數(shù)組加載過程的區(qū)別?

數(shù)組也有類型,稱為“數(shù)組類型”。如:

String[]?str?=?new?String[10];


這個數(shù)組的數(shù)組類型是Ljava.lang.String,而String只是這個數(shù)組中元素的類型。

當(dāng)程序在運(yùn)行過程中遇到new關(guān)鍵字創(chuàng)建一個數(shù)組時,由JVM直接創(chuàng)建數(shù)組類,再由類加載器創(chuàng)建數(shù)組中的元素類。

而普通類的加載由類加載器完成。既可以使用系統(tǒng)提供的引導(dǎo)類加載器,也可以使用用戶自定義的類加載器。

加載過程的注意點(diǎn)

  1. JVM規(guī)范并未給出類在方法區(qū)中存放的數(shù)據(jù)結(jié)構(gòu)

  2. 類完成加載后,二進(jìn)制字節(jié)流就以特定的數(shù)據(jù)結(jié)構(gòu)存儲在方法區(qū)中,但存儲的數(shù)據(jù)結(jié)構(gòu)是由虛擬機(jī)自己定義的,JVM規(guī)范并沒有指定。

  3. JVM規(guī)范并沒有指定Class對象存放的位置

  4. 在二進(jìn)制字節(jié)流以特定格式存儲在方法區(qū)后,JVM會創(chuàng)建一個java.lang.Class類型的對象,作為本類的外部接口。既然是對象就應(yīng)該存放在堆內(nèi)存中,不過JVM規(guī)范并沒有給出限制,不同的虛擬機(jī)根據(jù)自己的需求存放這個對象。HotSpot將Class對象存放在方法區(qū)。

  5. 加載階段和連接階段是交叉的

  6. 通過之前的介紹可知,類加載過程中每個步驟的開始順序都有嚴(yán)格限制,但每個步驟的結(jié)束順序沒有限制。也就是說,類加載過程中,必須按照如下順序開始:

  7. 加載、連接、初始化,但結(jié)束順序無所謂,因此由于每個步驟處理時間的長短不一就會導(dǎo)致有些步驟會出現(xiàn)交叉。

6.3.2 驗(yàn)證

驗(yàn)證階段比較耗時,它非常重要但不一定必要,如果所運(yùn)行的代碼已經(jīng)被反復(fù)使用和驗(yàn)證過,那么可以使用-Xverify:none參數(shù)關(guān)閉,以縮短類加載時間。

驗(yàn)證的目的是什么?

驗(yàn)證是為了保證二進(jìn)制字節(jié)流中的信息符合虛擬機(jī)規(guī)范,并沒有安全問題。

為什么需要驗(yàn)證?

雖然Java語言是一門安全的語言,它能確保程序猿無法訪問數(shù)組邊界以外的內(nèi)存、避免讓一個對象轉(zhuǎn)換成任意類型、避免跳轉(zhuǎn)到不存在的代碼行,如果出現(xiàn)這些情況,編譯無法通過。也就是說,Java語言的安全性是通過編譯器來保證的。

但是我們知道,編譯器和虛擬機(jī)是兩個獨(dú)立的東西,虛擬機(jī)只認(rèn)二進(jìn)制字節(jié)流,它不會管所獲得的二進(jìn)制字節(jié)流是哪來的,當(dāng)然,如果是編譯器給它的,那么就相對安全,但如果是從其它途徑獲得的,那么無法確保該二進(jìn)制字節(jié)流是安全的。通過上文可知,虛擬機(jī)規(guī)范中沒有限制二進(jìn)制字節(jié)流的來源,那么任意來源的二進(jìn)制字節(jié)流虛擬機(jī)都能接受,為了防止字節(jié)流中有安全問題,因此需要驗(yàn)證!

驗(yàn)證的過程

(1)文件格式驗(yàn)證

這個階段主要驗(yàn)證輸入的二進(jìn)制字節(jié)流是否符合class文件結(jié)構(gòu)的規(guī)范。二進(jìn)制字節(jié)流只有通過了本階段的驗(yàn)證,才會被允許存入到方法區(qū)中。

本驗(yàn)證階段是基于二進(jìn)制字節(jié)流的,而后面的三個驗(yàn)證階段都是在方法區(qū)中進(jìn)行,并基于類特定的數(shù)據(jù)結(jié)構(gòu)的。

通過上文可知,加載開始前,二進(jìn)制字節(jié)流還沒進(jìn)方法區(qū),而加載完成后,二進(jìn)制字節(jié)流已經(jīng)存入方法區(qū)。而在文件格式驗(yàn)證前,二進(jìn)制字節(jié)流尚未進(jìn)入方法區(qū),文件格式驗(yàn)證通過之后才進(jìn)入方法區(qū)。也就是說,加載開始后,立即啟動了文件格式驗(yàn)證,本階段驗(yàn)證通過后,二進(jìn)制字節(jié)流被轉(zhuǎn)換成特定數(shù)據(jù)結(jié)構(gòu)存儲至方法區(qū)中,繼而開始下階段的驗(yàn)證和創(chuàng)建Class對象等操作。這個過程印證了:加載和驗(yàn)證是交叉進(jìn)行的。

(2)元數(shù)據(jù)驗(yàn)證

本階段對方法區(qū)中的字節(jié)碼描述信息進(jìn)行語義分析,確保其符合Java語法規(guī)范。

(3)字節(jié)碼驗(yàn)證

本階段是驗(yàn)證過程的最復(fù)雜的一個階段。本階段對方法體進(jìn)行語義分析,保證方法在運(yùn)行時不會出現(xiàn)危害虛擬機(jī)的事件。

(4)符號引用驗(yàn)證,本階段驗(yàn)證發(fā)生在解析階段,確保解析能正常執(zhí)行。

6.3.3 準(zhǔn)備

準(zhǔn)備階段完成兩件事情:

  1. 為已經(jīng)在方法區(qū)中的類中的靜態(tài)成員變量分配內(nèi)存

  2. 類的靜態(tài)成員變量也存儲在方法區(qū)中。

  3. 為靜態(tài)成員變量設(shè)置初始值

  4. 初始值為0、false、null等。

示例1:

public?static?String?name?=?"柴毛毛";


在準(zhǔn)備階段,JVM會在方法區(qū)中為name分配內(nèi)存空間,并賦上初始值null。

給name賦上"柴毛毛"是在初始化階段完成的。

示例2:

public?static?final?String?name?=?"柴毛毛";


被final修飾的常量如果有初始值,那么在編譯階段就會將初始值存入constantValue屬性中,在準(zhǔn)備階段就將constantValue的值賦給該字段。

6.3.3 解析

解析階段是虛擬機(jī)將常量池中的符號引用替換為直接引用的過程。

6.3.4 初始化

初始化階段就是執(zhí)行類構(gòu)造器clinit()的過程。

clinit()方法由編譯器自動產(chǎn)生,收集類中static{}代碼塊中的類變量賦值語句和類中靜態(tài)成員變量的賦值語句。在準(zhǔn)備階段,類中靜態(tài)成員變量已經(jīng)完成了默認(rèn)初始化,而在初始化階段,clinit()方法對靜態(tài)成員變量進(jìn)行顯示初始化。

初始化過程的注意點(diǎn):

  • clinit()方法中靜態(tài)成員變量的賦值順序是根據(jù)Java代碼中成員變量的出現(xiàn)的順序決定的。

  • 靜態(tài)代碼塊能訪問出現(xiàn)在靜態(tài)代碼塊之前的靜態(tài)成員變量,無法訪問出現(xiàn)在靜態(tài)代碼塊之后的成員變量。

  • 靜態(tài)代碼塊能給出現(xiàn)在靜態(tài)代碼塊之后的靜態(tài)成員變量賦值。

  • 構(gòu)造函數(shù)init()需要顯示調(diào)用父類構(gòu)造函數(shù),而類的構(gòu)造函數(shù)clinit()不需要調(diào)用父類的類構(gòu)造函數(shù),因?yàn)樘摂M機(jī)會確保子類的clinit()方法執(zhí)行前已經(jīng)執(zhí)行了父類的clinit()方法。

  • 如果一個類/接口中沒有靜態(tài)代碼塊,也沒有靜態(tài)成員變量的賦值操作,那么編譯器就不會生成clinit()方法。

  • 接口也需要通過clinit()方法為接口中定義的靜態(tài)成員變量顯示初始化。

  • 接口中不能使用靜態(tài)代碼塊。

  • 接口在執(zhí)行clinit()方法前,虛擬機(jī)不會確保其父接口的clinit()方法被執(zhí)行,只有當(dāng)父接口中的靜態(tài)成員變量被使用到時才會執(zhí)行父接口的clinit()方法。

  • 虛擬機(jī)會給clinit()方法加鎖,因此當(dāng)多條線程同時執(zhí)行某一個類的clinit()方法時,只有一個方法會被執(zhí)行,其它的方法都被阻塞。并且,只要有一個clinit()方法執(zhí)行完,其它的clinit()方法就不會再被執(zhí)行。因此,在同一個類加載器下,同一個類只會被初始化一次。

6.4 類加載器

6.4.1 類與類加載器

  • 類加載器的作用:將class文件加載進(jìn)JVM的方法區(qū),并在方法區(qū)中創(chuàng)建一個java.lang.Class對象作為外界訪問這個類的接口。

  • 類與類加載器的關(guān)系:比較兩個類是否相等,只有當(dāng)這兩個類由同一個加載器加載才有意義;否則,即使同一個class文件被不同的類加載器加載,那這兩個類必定不同,即通過類的Class對象的equals執(zhí)行的結(jié)果必為false。

6.4.2 類加載器種類

JVM提供如下三種類加載器:

  • 啟動類加載器

  • 負(fù)責(zé)加載Java_Home\lib中的class文件。

  • 擴(kuò)展類加載器

  • 負(fù)責(zé)加載Java_Home\lib\ext目錄下的class文件。

  • 應(yīng)用程序類加載器

  • 負(fù)責(zé)加載用戶classpath下的class文件。

6.4.3 雙親委派模型

  • 工作過程:如果一個類加載器收到了加載類的請求,它首先將請求交由父類加載器加載;若父類加載器加載失敗,當(dāng)前類加載器才會自己加載類。

  • 作用:像java.lang.Object這些存放在rt.jar中的類,無論使用哪個類加載器加載,最終都會委派給最頂端的啟動類加載器加載,從而使得不同加載器加載的Object類都是同一個。

  • 原理:雙親委派模型的代碼在java.lang.ClassLoader類中的loadClass函數(shù)中實(shí)現(xiàn),其邏輯如下:

  • 首先檢查類是否被加載;

  • 若未加載,則調(diào)用父類加載器的loadClass方法;

  • 若該方法拋出ClassNotFoundException異常,則表示父類加載器無法加載,則當(dāng)前類加載器調(diào)用findClass加載類;

  • 若父類加載器可以加載,則直接返回Class對象;


第七章 Java 虛擬機(jī)的鎖優(yōu)化策略

7.1 自旋鎖

  • 背景:互斥同步對性能最大的影響是阻塞,掛起和恢復(fù)線程都需要轉(zhuǎn)入內(nèi)核態(tài)中完成;并且通常情況下,共享數(shù)據(jù)的鎖定狀態(tài)只持續(xù)很短的一段時間,為了這很短的一段時間進(jìn)行上下文切換并不值得。

  • 原理:當(dāng)一條線程需要請求一把已經(jīng)被占用的鎖時,并不會進(jìn)入阻塞狀態(tài),而是繼續(xù)持有CPU執(zhí)行權(quán)等待一段時間,該過程稱為『自旋』。

  • 優(yōu)點(diǎn):由于自旋等待鎖的過程線程并不會引起上下文切換,因此比較高效;

  • 缺點(diǎn):自旋等待過程線程一直占用CPU執(zhí)行權(quán)但不處理任何任務(wù),因此若該過程過長,那就會造成CPU資源的浪費(fèi)。

  • 自適應(yīng)自旋:自適應(yīng)自旋可以根據(jù)以往自旋等待時間的經(jīng)驗(yàn),計(jì)算出一個較為合理的本次自旋等待時間。

7.2 鎖清除

編譯器會清除一些使用了同步,但同步塊中沒有涉及共享數(shù)據(jù)的鎖,從而減少多余的同步。

7.3 鎖粗化

若有一系列操作,反復(fù)地對同一把鎖進(jìn)行上鎖和解鎖操作,編譯器會擴(kuò)大這部分代碼的同步塊的邊界,從而只使用一次上鎖和解鎖操作。

7.4 輕量級鎖

  • 本質(zhì):使用CAS取代互斥同步。

  • 背景:『輕量級鎖』是相對于『重量級鎖』而言的,而重量級鎖就是傳統(tǒng)的鎖。

  • 輕量級鎖與重量級鎖的比較:

  • 重量級鎖是一種悲觀鎖,它認(rèn)為總是有多條線程要競爭鎖,所以它每次處理共享數(shù)據(jù)時,不管當(dāng)前系統(tǒng)中是否真的有線程在競爭鎖,它都會使用互斥同步來保證線程的安全;

  • 而輕量級鎖是一種樂觀鎖,它認(rèn)為鎖存在競爭的概率比較小,所以它不使用互斥同步,而是使用CAS操作來獲得鎖,這樣能減少互斥同步所使用的『互斥量』帶來的性能開銷。

  • 實(shí)現(xiàn)原理:

  • 對象頭稱為『Mark Word』,虛擬機(jī)為了節(jié)約對象的存儲空間,對象處于不同的狀態(tài)下,Mark Word中存儲的信息也所有不同。

  • Mark Word中有個標(biāo)志位用來表示當(dāng)前對象所處的狀態(tài)。

  • 當(dāng)線程請求鎖時,若該鎖對象的Mark Word中標(biāo)志位為01(未鎖定狀態(tài)),則在該線程的棧幀中創(chuàng)建一塊名為『鎖記錄』的空間,然后將鎖對象的Mark Word拷貝至該空間;最后通過CAS操作將鎖對象的Mark Word指向該鎖記錄;

  • 若CAS操作成功,則輕量級鎖的上鎖過程成功;

  • 若CAS操作失敗,再判斷當(dāng)前線程是否已經(jīng)持有了該輕量級鎖;若已經(jīng)持有,則直接進(jìn)入同步塊;若尚未持有,則表示該鎖已經(jīng)被其他線程占用,此時輕量級鎖就要膨脹成重量級鎖。

  • 前提:輕量級鎖比重量級鎖性能更高的前提是,在輕量級鎖被占用的整個同步周期內(nèi),不存在其他線程的競爭。若在該過程中一旦有其他線程競爭,那么就會膨脹成重量級鎖,從而除了使用互斥量以外,還額外發(fā)生了CAS操作,因此更慢!

7.5 偏向鎖

  • 作用:偏向鎖是為了消除無競爭情況下的同步原語,進(jìn)一步提升程序性能。

  • 與輕量級鎖的區(qū)別:輕量級鎖是在無競爭的情況下使用CAS操作來代替互斥量的使用,從而實(shí)現(xiàn)同步;而偏向鎖是在無競爭的情況下完全取消同步。

  • 與輕量級鎖的相同點(diǎn):它們都是樂觀鎖,都認(rèn)為同步期間不會有其他線程競爭鎖。

  • 原理:當(dāng)線程請求到鎖對象后,將鎖對象的狀態(tài)標(biāo)志位改為01,即偏向模式。然后使用CAS操作將線程的ID記錄在鎖對象的Mark Word中。以后該線程可以直接進(jìn)入同步塊,連CAS操作都不需要。但是,一旦有第二條線程需要競爭鎖,那么偏向模式立即結(jié)束,進(jìn)入輕量級鎖的狀態(tài)。

  • 優(yōu)點(diǎn):偏向鎖可以提高有同步但沒有競爭的程序性能。但是如果鎖對象時常被多條線程競爭,那偏向鎖就是多余的。

  • 偏向鎖可以通過虛擬機(jī)的參數(shù)來控制它是否開啟。


小編在學(xué)習(xí)過程中整理了一些學(xué)習(xí)資料,可以分享給做java的工程師朋友們,相互交流學(xué)習(xí),需要的可以加入我的學(xué)習(xí)交流群?778477315?即可免費(fèi)獲取Java架構(gòu)學(xué)習(xí)資料(里面有高可用、高并發(fā)、高性能及分布式、Jvm性能調(diào)優(yōu)、Spring源碼,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多個知識點(diǎn)的架構(gòu)資料)

其中覆蓋了互聯(lián)網(wǎng)的方方面面,期間碰到各種產(chǎn)品各種場景下的各種問題,很值得大家借鑒和學(xué)習(xí),擴(kuò)展自己的技術(shù)廣度和知識面。最后記得幫作者點(diǎn)個關(guān)注


向AI問一下細(xì)節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進(jìn)行舉報,并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。

AI