【Java面試八股文】JVM


參考資料:

面經:

image-20210419231650177

image-20210419231732358

image-20210419231758168

image-20210419231932307

1. 講一下JVM內存模型(運行時數據區)

JVM內存模型分為兩部分:線程共享和線程私有

image-20210424225209906

JDK1.8之后方法區被元空間Metaspace替代。

  • 程序計數器PC:代碼流程的控制和多線程上下文切換恢復現場

  • 虛擬機棧:也就是我們常說的棧內存。Java中線程執行代碼其實都是在執行一個個方法,每執行一個方法,該線程的虛擬 棧空間就會被壓入一個棧幀,因此虛擬機棧是由一個個棧幀組成的。每個棧幀中都擁有該方法執行過程中產生的局部變量表、操作數棧、動態鏈接以及方法出口信息。每一個方法被調用直至執行完畢的過程,就對應着一個棧幀在虛擬機棧中從入棧到出棧的過程。

    棧內存隨線程的創建而創建,死亡而死亡

  • 本地方法棧:虛擬機棧提供Java方法服務,本地方法提供Native方法服務,也是由一個個棧幀組成。

  • 堆:虛擬機所管理的內存中最大的一塊,所有線程共享,存放幾乎所有的對象實例(new出來的東西都放在,但是引用是局部變量,放在棧內存)和數組

    Java世界中“幾乎”所有的對象都在堆中分配,但是,隨着JIT編譯期的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化,所有的對象都分配到堆上也漸漸變得不那么“絕對”了。從jdk 1.7開始已經默認開啟逃逸分析,如果某些方法中的對象引用沒有被返回或者未被外面使用(也就是未逃逸出去),那么對象可以直接在棧上分配內存

    堆也叫做GC堆(Garbage Collected Heap)

  • 方法區:又叫非堆,用於存儲類信息,常量、靜態變量以及即時編譯器編譯后的代碼緩存等數據。運行時常量池是方法區的一部分,保存的信息有類的版本、字段、方法、接口等描述信息以及常量池表(Final,String等)。

2. 對象創建的過程

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

  • 為新生對象分配內存

    對象所需要的內存大小在類加載完成后便可確定,分配內存即把堆中的一塊等同大小的內存划分出來給對象。但是因為Java堆中空閑內存和已被分配的內存有兩種不同的情況:

    • 規整:已分配的內存在一邊,空閑的內存在一邊,中間放着一個指針作為指示器。分配內存的時候僅僅需要指針向空閑方向移動對象大小相同的距離。這種分配方式叫做“指針碰撞”
    • 不規整:已分配的內存和空閑的內存相互交錯在一起。這種情況下虛擬機必須維護一個列表來記錄哪些內存是可用的,分配的時候從列表中找出一塊足夠大的內存分配給對象。這種分配方式叫做“空閑列表”。

    堆是否規整取決於垃圾回收器是否帶有空間壓縮整理的能力。當使用Serial、ParNew等帶壓縮整理過程的收集器時,系統采用的分配算法是指針碰撞,既簡單又高效;而當使用CMS這種基於清除算法的收集器時,理論上就只能采用較為復雜的空閑列表來分配內存。

    分配內存如何解決線程安全的問題?

    對象創建在虛擬機中是非常頻繁的行為,即使僅僅修改一個指針所指向的位置,在並發情況下也並不是線程安全的,可能出現正在給對象A分配內存,指針還沒來得及修改,對象B又同時使用了原來的指針來分配內存的情況。

    • CAS樂觀鎖+失敗重試,保證更新操作的原子性
    • TLAB(本地線程分配緩沖):為每一個線程預先在 Eden 區分配一塊兒內存,JVM 在給線程中的對象分配內存時,首先在 TLAB 分配,當對象大於 TLAB 中的剩余內存或 TLAB 的內存已用盡時,再采用上述的 CAS 進行內存分配。虛擬機是否使用TLAB,可以通過-XX:+/-UseTLAB參數來設定。
  • 初始化零值

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

  • 設置對象頭

    虛擬機要對對象進行必要的設置,例如這個對象是哪個類的實例、如何才能找到類的元數據信息、對象的哈希碼、對象的 GC 分代年齡等信息。這些信息存放在對象頭中。另外,根據虛擬機當前運行狀態的不同,如是否啟用偏向鎖等,對象頭會有不同的設置方式。

  • 執行init方法(有點類似於依賴注入,由程序員控制注入什么)

    在上面工作都完成之后,從虛擬機的視⻆來看,一個新的對象已經產生了,但從 Java 程序的視⻆來看,對象創建才剛開始, 方法還沒有執行,所有的字段都還為零。所以一般來說,執行 new 指令之后會接着執行 方法,把對象按照程序員的意願進行初始化,這樣一個真正可用的對象才算完全產生出來。

3. 對象的內存布局

  • 對象頭

    • 運行時數據:hashcode、GC分代年齡、鎖狀態、持有的鎖
    • 類型指針:即對象指向它的類型元數據的指針,Java虛擬機通過這個指針來確定該對象是哪個類的實例
  • 實例數據

    字段內存、繼承下來的字段內存

  • 對齊填充

4. 棧內存中的reference如何訪問堆內存中的變量

  • 句柄訪問
  • 直接指針

垃圾回收算法

5. JVM垃圾回收概述

哪些內存要進行垃圾回收

線程私有的內存空間是不需要進行垃圾回收的,因為當方法結束或者線程終止,內存自然會跟隨着回收。垃圾回收的主戰場是堆,主目標就是堆中分配的對象。這部分的內存分配和回收是動態的。

什么對象需要被回收?

死亡的對象需要被回收,判定對象是否存活都和“引用”離不開關系。也就是說,沒有被引用,沒有指針指向的對象將被回收。

Java中的引用詳解

以前我對引用的認識是:如果reference類型的數據中存儲的數值代表的是另外一塊內存的起始地址,就稱該reference數據是代表某塊內存、某個對象的引用。這種定義沒有錯,但是不足以應付我們復雜的業務邏輯。比如我們希望能描述一類對象:當內存空間還足夠時,能保留在內存之中,如果內存空間在進行垃圾收集后仍然非常緊張,那就可以拋棄這些對象。這個時候就引入擴充引用這個概念了。

JDK1.2之后,Java對引用進行了擴充,將引用分為:(強度注解降低)

  • 強引用
  • 軟引用
  • 弱引用
  • 虛引用

強引用:即類似“Objectobj=newObject()”這種引用關系。無論任何情況下,只要強引用關系還存在,垃圾收集器就永遠不會回收掉被引用的對象。(肯定不回收)

軟引用:用來描述一些還有用,但非必須的對象。只被軟引用關聯着的對象,在系統將要發生內存溢出異常前,會把這些對象列進回收范圍之中進行第二次回收。(滿了就先回收你)

弱引用:也是描述一些還有用,但非必須的對象,但強度更低,只能生存到下一次垃圾收集為止。(不用等滿,下一次就回收你)

虛引用:相當於沒有引用,完全不會對其生存時間構成影響。唯一目的只是為了能在這個對象被收集器回收時收到一個系統通知。

JVM如何判斷對象已死(沒有引用)

  • 引用計數法

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

    缺點:無法解決相互循環引用的問題

  • 可達性分析算法

    通過一系列稱為“GCRoots”的根對象作為起始節點集,從這些節點開始,根據引用關系向下搜索,搜索過程所走過的路徑稱為“引用鏈”(ReferenceChain),如果某個對象到GCRoots間沒有任何引用鏈相連則證明此對象是不可能再被使用的。簡單來說,GC Root 就是經過精心挑選的一組活躍引用,這些引用是肯定存活的。那么通過這些引用延伸到的對象,自然也是存活的。

    引用,GC Root是引用的集合。這個引用集合由以下組成:

    • 當前所有正在被調用的方法的引用類型的參數/局部變量/臨時值
    • JVM的一些靜態數據結構里指向GC堆里的對象的引用,例如說HotSpot VM里的Universe里有很多這樣的引用。

6. 方法區的垃圾回收

方法區的垃圾收集主要回收兩部分內容:廢棄的常量和不再使用的類信息。

如何判斷一個常量為廢棄常量

假如在常量池中存在字符串 "abc",如果當前沒有任何String對象引用該字符串常量的話,就說明常量 "abc" 就是廢棄常量,如果這時發生內存回收的話而且有必要的話,"abc" 就會被系統清理出常量池。

如何判斷一個類為無用的類

  • 該類所有的實例都已經被回收,也就是 Java 堆中不存在該類的任何實例。
  • 加載該類的ClassLoader已經被回收。
  • 該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

虛擬機可以對滿足上述 3 個條件的無用類進行回收,這里說的僅僅是“可以”,而並不是和對象一樣不使用了就會必然被回收。

7. 垃圾回收算法

三個假說以及堆為什么要分代

根據大多數程序運行實際情況的經驗准則,我們發現堆中的對象有以下特點:

  • 絕大多數對象撐不過第一輪垃圾回收
  • 越是熬過多次垃圾回收過程的對象越是難以消亡
  • 跨代引用相對於同代引用來說僅占極少數,也就是說同一個方法中引用的對象一般都是同代的

根據這三條假說,收集器應該將Java堆划分出不同的區域,然后將回收對象依據其年齡(年齡即對象熬過垃圾收集過程的次數)分配到不同的區域之中存儲。不同代的堆采用不同的回收算法獲得最大效率,以此來提高垃圾回收的效率。

在新生代中,每次回收時只關注如何保留少量存活而不是去標記那些大量將要被回收的對象,就能以較低代價回收到大量的空間。簡單來說就是新生代都死得很快,我們只需要關注那些沒死的。

在老生代中,虛擬機便可以使用較低的頻率來回收這個區域,這就同時兼顧了垃圾收集的時間開銷和內存的空間有效利用。簡單來說就是老生代就死不了,因此很久才回收一次。

這就是堆為什么要分代的原因:選擇最合適的GC算法。

標記-清除算法

算法分為“標記”和“清除”兩個階段:首先標記出所有不需要回收的對象,在標記完成后,統一回收掉未被標記的對象。也可以反過來。標記過程就是對象是否屬於垃圾的判定過程。

最基礎的收集算法,后續收集算法都是基於其改進的。

缺點:

  • 執行效率不穩定;
  • 內存空間的碎片化問題,標記、清除之后會產生大量不連續的內存碎片,空間碎片太多可能會導致當以后在程序運行過程中需要分配較大對象時無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作

標記-復制算法

將可用內存按容量划分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活着的對象復制到另外一塊上面,然后再把所有已使用過的內存空間一次清理掉。

這個算法基於假說1:絕大部分的對象都撐不過第一輪垃圾收集。因此復制只是少量操作,回收就完事。

image-20210425164828591

缺點:

  • 對象存活率高時效率較低
  • 將可用內存縮小為了原來的一半,空間浪費未免太多了一點。

標記-整理算法

標記-整理算法就是一種“移動式”的標記-清除算法,先把存活的對象移動到內存的一側,再清空端邊界以外的內存。

image-20210425165541585

其實這個算法也有缺點,對於老生代區域來說,對象存活率較高,因此移動的代碼也很高。但是不移動就會造成內存碎片的問題。不難兩全其美。

還有一種“和稀泥”的方式,先用標記-清除算法回收垃圾,等內存碎片真的很多的時候再使用標記-整理算法處理內存碎片的問題。

分代收集算法

當前虛擬機的垃圾收集都采用分代收集算法,這種算法沒有什么新的思想,只是根據對象存活周期的不同將內存分為幾塊。一般將 Java 堆分為新生代和老年代,這樣我們就可以根據各個年代的特點選擇合適的垃圾收集算法。這些我們上面都說過了。

比如在新生代中,每次收集都會有大量對象死去,所以可以選擇復制算法,只需要付出少量對象的復制成本就可以完成每次垃圾收集。而老年代的對象存活幾率是比較高的,而且沒有額外的空間對它進行分配擔保,所以我們必須選擇“標記-清除”或“標記-整理”算法進行垃圾收集。

8. 常見的垃圾回收器

垃圾回收器一般分為四大類:

  • 串行-Serial-單個垃圾回收線程

  • 並行-Parallel-多個垃圾回收線程

  • 並發-CMS

  • G1(JDK8后)

  • ZGC(JDK11后才有)

JVM中並行和並發的概念

  • 並行:並行描述的是多條垃圾收集器線程之間的關系,說明同一時間有多條這樣的線程在協同工作,通常默認此時用戶線程是處於等待狀態。
  • 並發:並發描述的是垃圾收集器線程與用戶線程之間的關系,說明同一時間垃圾收集器線程與用戶線程都在運行

image-20210425225833700

HotSpot實現了很多垃圾回收器,可以自行搭配使用,一般為新生代選一個回收器,老生代選一個回收器。連線表示這兩個回收器可以適配。

如何查看默認垃圾回收器

java -XX:+PrintCommandLineFlags -version

image-20210425231112945

默認使用Parallel Scavnge + Parallel Old

Minor GC、Major CG和Full GC

  • Minor GC:針對整個新生代
  • Major GC:針對整個老年代
  • Full GC:針對堆

Serial收集器

  • 單線程串行
  • 新生代-標記復制算法

Serial Old

  • 單線程串行
  • 老年代-標記整理算法

ParNew(Parallel New)

  • 多線程並行
  • 新生代-標記復制算法

Parallel Scavenge

  • 多線程並行

  • 相比ParNew提供了參數設置和自適應調節策略以提高吞吐量

  • 新生代-標記復制算法

Parallel Old

  • 多線程並行
  • 老年代-標記整理
  • 同樣提供了參數設置和自適應調節策略

CMS

  • 多線程並發(第一款並發老年代收集器)

  • 老年代-改進的標記-清除算法

    CMS的垃圾回收算法(基於標記-清除,標記可達的對象,清除所有不可達的對象):

    • 初始標記(Stop the world)

      🍕標記一下GC Roots能直接關聯到的對象,雖然需要Stop the world,但是速度很快

    • 並發標記

      🍕從GCRoots的直接關聯對象開始遍歷整個對象圖的過程,這個過程耗時較長但是不需要停頓用戶線程,可以與垃圾收集線程一起並發運行

    • 重新標記(Stop the world)

      🍕修正並發標記期間,因用戶程序繼續運作而導致標記產生變動的那一部分對象的標記記錄,不會標記新產生的對象,只會改變此前的標記狀態。時間較短

    • 並發清除

      🍕清理刪除掉標記階段判斷的已經死亡的對象,由於不需要移動存活對象,所以這個階段也是可以與用戶線程同時並發的

    可以看到回收算法總共被分為四個階段,其中1,3這兩個時間段的需要停止用戶線程,2,4這兩個時間長的不需要停止用戶線程。總體看起來就是並發的。

    因此CMS也被叫做“並發低停頓收集器”

  • 如果CMS回收失敗,虛擬機會使用Serial Old垃圾收集器對老年代進行回收(Full gc),此時所有的工作進程都要停止,會產生一段長時間的停止。那么問題來了,什么時候CMS回收會失敗呢?

    在CMS的並發標記和並發清理階段,用戶線程是還在繼續運行的,程序在運行自然就還會伴隨有新的垃圾對象不斷產生,但這一部分垃圾對象是出現在標記過程結束以后,CMS無法在當次收集中處理掉它們,只好留待下一次垃圾收集時再清理掉。這一部分垃圾就稱為“浮動垃圾”。同樣也是由於在垃圾收集階段用戶線程還需要持續運行,那就還需要預留足夠內存空間提供給用戶線程使用,因此CMS收集器不能像其他收集器那樣等待到老年代幾乎完全被填滿了再進行收集,必須預留一部分空間供並發收集時的程序運作使用。在JDK5的默認設置下,CMS收集器當老年代使用了68%的空間后就會被激活,這是一個偏保守的設置,如果在實際應用中老年代增長並不是太快,可以適當調高參數-XX:CMSInitiatingOccu-pancyFraction的值來提高CMS的觸發百分比,降低內存回收頻率,獲取更好的性能。到了JDK6時,CMS收集器的啟動閾值就已經默認提升至92%。但這又會更容易面臨另一種風險:要是CMS運行期間預留的內存無法滿足程序分配新對象的需要,就會出現一次“並發失敗”(ConcurrentModeFailure),這時候虛擬機將不得不啟動后備預案:凍結用戶線程的執行,臨時啟用SerialOld收集器來重新進行老年代的垃圾收集,但這樣停頓時間就很長了。所以參數-XX:CMSInitiatingOccupancyFraction設置得太高將會很容易導致大量的並發失敗產生,性能反而降低,用戶應在生產環境中根據實際應用情況來權衡設置。

    我的總結:並發標記和並發清除過程中用戶線程還在持續運行,這個過程中產生的對象此次CMS垃圾回收是無法清除的,因此必須預留出一部分空間。但是如何預留的空間不夠用,在CMS垃圾回收過程中內存爆了,此次CMS回收並發失敗,虛擬機將使用Full GC對整個堆進行垃圾回收。

  • 缺點:

    • 對處理器資源敏感
    • 無法處理“浮動垃圾”
    • 標記清除算法的通病--內存碎片

G1

微觀上還是分代思想,內存宏觀上不再是分區了,化整為零

在G1收集器出現之前的所有其他收集器,垃圾收集的目標范圍要么是整個新生代(Minor GC),要么就是整個老 年代(Major GC),再要么就是整個Java堆(Full GC)。而G1跳出了這個樊籠,G1不再堅持固定大小以及固定數量的分代區域划分,而是把連續的Java堆划分為多個大小相等的獨立區域(Region),每一個Region都可以根據需要,扮演新生代的Eden空間、Survivor空間,或者老年代空間。回收衡量標准不再是它屬於哪個分代,而是哪塊內存中存放的垃圾數量最多,回收收益最大。JVM會在在后台維護一 個優先級列表,每次根據用戶設定允許的收集停頓時間(使用參數-XX:MaxGCPauseMillis指定,默認值是200毫秒),優先處理回收價值收益最大的那些Region,這也就是“Garbage First”名字的由來。

我的總結:其實G1收集器本質上還是分代回收算法,但是回收的單位不是整個新生代或者老年代了,而是一個個Region。具體先回收哪個Region由該Region的權重決定,優先回收所獲得的空間大以及回收所需時間少的Region。這種使用Region划分內存空間,以及具有優先級的區域回收方式,保證了G1收集器在有限的時間內獲取盡可能高的收集效率。

image-20210430225816061

  • 回收是以Region為單位的,一個Region可以存儲多個對象,但是大對象是不存放在Region中的,使用Humongous專門用來存儲大對象。G1認為只要大小超過了一個Region容量一半的對象即可判定為大對象。每個Region的大小可以通過參數-XX:G1HeapRegionSize設定,取值范圍為1MB~32MB,且應為2的N次冪。而對於那些超過了整個Region容量的超級大對象,將會被存放在N個連續的HumongousRegion之中,G1的大多數行為都把HumongousRegion作為老年代的一部分來進行看待,也就是說大對象會直接進入老年區

  • 一個對象和它內部所引用的對象可能不在同一個 Region 中,那么當垃圾回收時,是否需要掃描整個堆內存才能完整地進行一次可達性分析?

    並不!每個 Region 都有一個 Remembered Set,用於記錄本區域中所有對象引用的對象所在的區域,進行可達性分析時,只要在 GC Roots 中再加上 Remembered Set 即可防止對整個堆內存進行遍歷。

9. JVM的內存分配策略

  • 對象優先在新生代的Eden區分配。

    • 當 Eden 區沒有足夠空間進行分配時,虛擬機將發起一次 Minor GC。
    • 當新生代沒有多余的空間時,對象會通過分配擔保機制將新生代內存轉移到老年代
  • 大對象直接進入老年代

    • 因為新生代使用標記-復制算法,如果大對象沒死的話,就要在 Eden區及兩個Survivor區之間來回復制,產生大量的內存復制操作。

      為什么JVM要有兩個Survicor(from to)?

      假設只有一個survicor區:

      首次分配對象到Eden區,垃圾回收時把存活的對象復制到survivor區,清空eden。第二次垃圾回收的時候,eden區和survivor區都有死亡的對象,那怎么辦?總不能把survivor區死了的對象移動到eden區吧。所以這時候再需要一個survivor區了,eden區和from區存活的對象復制到to區,清空eden區和from區,交換from和to的指針。

      這樣看起來就很完美了,對象都是從eden,from -> to區,to區里面永遠是死不了的對象。

      eden區和survivor區的內存空間默認是八二開,為了提交內存利用率。

    • 容易出發垃圾回收機制,盡管新生代還有不少的空間

    • HotSpot虛擬機提供了-XX:PretenureSizeThreshold 參數,指定大於該設置值的對象直接在老年代分配

  • 長期存活的對象將進入老年代

    • 對象通常在Eden區里誕生,如果經過第一次 Minor GC后仍然存活,並且能被Survivor容納的話,該對象會被移動到Survivor空間中,並且將其對象年齡設為1歲。對象在Survivor區中每熬過一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(默認為15),就會被晉升到老年代中。
    • 對象晉升老年代的年齡閾值,可以通過參數-XX: MaxTenuringThreshold設置。
  • 動態年齡判定

    為了能更好地適應不同程序的內存狀況,HotSpot虛擬機並不是永遠要求對象的年齡必須達到XX:MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有對象大小的總和大於 Survivor空間的一半,年齡大於或等於該年齡的對象就可以直接進入老年代,無須等到-XX: MaxTenuringThreshold中要求的年齡

10. 空間分配擔保

發生Minor GC之前,虛擬機首先會檢查“老年代中最大連續區域的大小 是否大於 新生代中所有對象的總大小”,

也就是說老年代目前能否一定能將新生代中的所有對象全部裝下?

  • 若能裝下,此時進行Minor GC沒有任何風險,然后進行Minor GC;
  • 若不一定能裝下,此時進行Minor GC是有風險的,檢查HandlePromotionFailure設置值是否允許擔保失敗,
    • 若允許,那么檢查老年代最大可用的連續空間 是否大於 歷次晉升到老年代對象的平均大小;
      • 若大於,嘗試進行Minor GC,盡管這次Minor GC是有風險的;
      • 若小於,改為進行一次Full GC。
    • 不允許,改為進行一次Full GC,清除老年代的廢棄數據來擴大老年代的空閑空間,以便給新生代作分配擔保。

“風險”是指什么?

  • 新生代使用“復制”算法,需要分配擔保;而老年代使用的是“標記-整理”算法,不需要。
  • 在Minor GC后,當Survivor內存不夠時,老年代要進行擔保,必須要有足夠空間存放存活對象;
  • 但是有多少對象活下來在回收之前時不知道的,所以取 “歷次晉升到老年代對象的平均大小”作為經驗值,決定是否用Full GC來讓老年代騰出更多空間;
  • 若最終還是擔保失敗了,那么失敗之后重新發起一次Full GC。為了防止Full GC過於頻繁,HandlePromotionFailure一般設置為打開。

11. 類加載機制

魔數

每個class文件的頭四個字節被稱為魔數,它的作用是用來確定這個文件是否為一個能被虛擬機接受的class文件,值為0xCAFEBABE(咖啡寶貝)

Class文件的結構

  • 魔數
  • 版本號: 45-57
  • 常量池:字面量、符號引用
  • 訪問標志:識別一些類或者接口層次的訪問信息,包括:這個Class是類還是接口;是否定義為public類型;是否定義為abstract類型;如果是類的話,是否被聲明為final等等
  • 類索引、父類索引(確定父類索引的全限定類名)和接口索引
  • 字段表(變量名、變量值以及類型等)
  • 方法表
  • 屬性表

類加載過程詳解

一般來說,我們把Java的類加載過程分為三個主要步驟:加載、鏈接、初始化

image-20210818210933664

  • 加載:是Java將字節碼數據從不同的數據源讀取到JVM中,並映射為JVM認可的數據結構(Class對象)這里的數據源可能是各種各樣的形態,如jar文件、class文件,甚至是網絡數據源等;如果輸入數據不是ClassFile的結構,則會拋出ClassFormatError。

  • 連接:是把原始的類定義信息平滑地轉化入JVM運行的過程中。這里可進一步細分為三個步驟

    • 驗證(Verifcation):這是虛擬機安全的重要保障,JVM需要核驗字節信息是符合Java虛擬機規范的,否則就被認為是VerifyError,這樣就防止了惡意信息或者不合規的信息危害JVM的運行,驗證階段有可能觸發更多class的加載

      驗證點有以下幾個:

      1. 文件格式驗證:魔數開頭,版本號,常量類型等
      2. 元數據驗證:是否有父類,是否繼承了不能被繼承的final修飾的類,是否實現了父類或接口的全部方法
      3. 字節碼驗證:判斷程序語義是否合法
      4. 符號引用驗證:確保解析性能能正常執行
    • 准備(Preparation):創建類或接口中的靜態變量,並初始化靜態變量的初始值。但這里的“初始化”和下面的顯式初始化階段是有區別的,側重點在於分配所需要的內存空間, 分配初始值(int:0,boolean:false等),而不會去執行更進一步的JVM指令,比如賦值。

    • 解析(Resolution):在這一步會將常量池中的符號引用(symbolic reference)替換為直接引用。在Java虛擬機規范中,詳細介紹了類、接口、方法和字段等各個方面的解析。

  • 初始化:這一步真正去執行Java程序代碼,包括靜態字段賦值的動作,以及執行類定義中的靜態初始化塊內的邏輯,編譯器在編譯階段就會把這部分邏輯整理好,父類型的初始化邏輯優先於當前類型的邏輯。JVM規定有且只有六種情況必須立即對類進行初始化(此時加載和連接操作需要提前准備好)

    1. 使用new關鍵字實例化對象的時候 | 讀取或設置一個類型的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候 | 調用一個類型的靜態方法的時候
    2. 反射調用時
    3. 當初始化類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化
    4. 當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類
    5. 使用JDK7新加入的動態語言支持時
    6. 當一個接口中定義了JDK8新加入的默認方法(被default關鍵字修飾的接口方法)時

    類初始化的關鍵是執行類的clinit方法,clinit是編譯器生成的,編譯器會自動收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合並產生clinit方法,編譯器收集的順序是由語句在源文件中出現的順序決定的。執行子類的clinit方法會優先調用父類的clinit方法。如果有多個線程同時初始化一個類的話,只有一個線程能進入初始化,其他線程都會被阻塞等待,初始化完成喚醒這些線程后不會再進入clinit方法,因為一個類只會被初始化一次

12. 類加載器

第10點我們講了類的加載過程:加載-連接-初始化,初始化完成表示類信息已被成功加載入JVM,使用即使用new創建類對象,銷毀即清除對象內存。類加載器的作用就是完成類的加載過程。

從JVM的角度看,我們有三種類加載器,類加載器之間是雙親委派架構。

image-20210820223207418
  • 啟動類加載器:這個類加載器使用C/C++語言實現的,嵌套在JVM內部,java程序無法直接操作這個類。它用來加載Java核心類庫,如:JAVA_HOME/jre/lib/rt.jar``resources.jarsun.boot.class.path路徑下的包,用於提供jvm運行所需的包
  • 擴展類加載器:Java語言編寫,由sun.misc.Launcher$ExtClassLoader實現,我們可以用Java程序操作這個加載器。派生繼承自java.lang.ClassLoader,父類加載器為啟動類加載器從系統屬性:java.ext.dirs目錄中加載類庫,或者從JDK安裝目錄:jre/lib/ext目錄下加載類庫。我們就可以將我們自己的包放在以上目錄下,就會自動加載進來了
  • 應用程序類加載器:Java語言編寫,由sun.misc.Launcher$AppClassLoader實現。派生繼承自java.lang.ClassLoader,父類加載器為啟動類加載器。它負責加載環境變量classpath或者系統屬性java.class.path指定路徑下的類庫。它是程序中默認的類加載器,我們Java程序中的類,都是由它加載完成的。我們可以通過ClassLoader#getSystemClassLoader()獲取並操作這個加載器

13. 雙親委派機制

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

這樣做的好處有:

  1. Java中的類隨着它的類加載器一起具備了一種帶有優先級的層次關系,同一個類只會被加載一次,JVM中也只有一個類信息
  2. 避免重復加載Java類型

那如果打破雙親委派模式呢?方法有二

  1. 自定義ClassLoader類加載器,重寫loadClass方法,因為虛擬機在進行類加載時會調用類加載器的loadClass方法,這個方法的邏輯就是雙親委派模式的實現
  2. 父類加載器請求子類加載器完成類加載動作,具體實現是線程上下文類加載器Thread Context ClassLoader

14. 常用JVM調優參數匯總

https://cloud.tencent.com/developer/article/1198524?utm_source=ld246.com

15. JVM啟動流程

image-20210908101152927

image-20210908101232093

總結:Java 命名啟動JVM,根據路徑找到jvm.cfg配置JVM實例,根據配置找到jvm.dll,初始化JVM並找到class文件,找到main方法運行

16. GC優化流程

為什么要要進行GC優化,因為JVM的GC會對系統性能產生影響,所以我們要優化GC,提高性能。GC優化一般有三步:

  • 確定目標:這個最重要,明確應用程序的系統需求是性能優化的基礎,確定系統是要高可用、低延遲或是高吞吐
  • 優化參數:通過收集GC信息,結合系統需求,確定優化方案,例如選用合適的GC回收器、重新設置內存比例、調整JVM參數等。進行調整后,將不同的優化方案分別應用到多台機器上,然后比較這些機器上GC的性能差異,有針對性的做出選擇,再通過不斷的試驗和觀察,找到最合適的參數。
  • 驗收優化結果:將修改應用到所有服務器,判斷優化結果是否符合預期,總結相關經驗。

案例一:Minor GC和Major GC頻繁

  • Minor GC頻繁可能的原因是新生代空間較小,Eden區很快被填滿,就會導致頻繁Minor GC,因此可以通過增大新生代空間來降低Minor GC的頻率。例如在相同的內存分配率的前提下,新生代中的Eden區增加一倍,Minor GC的次數就會減少一半。
  • 通過查看GC日志發現晉升年齡為2,太小了,象僅經歷2次Minor GC后就晉升到老年代,這樣老年代會迅速被填滿,直接導致了頻繁的Major GC,可以通過調高晉升年齡,只有生命周期長的對象才進入老年代。這樣老年代增速變慢,Major GC頻率自然也會降低。

案例二:使用CMS回收器時請求高峰期發生GC,導致服務可用性下降

  • GC日志顯示,高峰期CMS在重標記(Remark)階段耗時1.39s。Remark階段是Stop-The-World(以下簡稱為STW)的,即在執行垃圾回收時,Java應用程序中除了垃圾回收器線程之外其他所有線程都被掛起,意味着在此期間,用戶正常工作的線程全部被暫停下來,這是低延時服務不能接受的。本次優化目標是降低Remark時間。

【目前就復習就這里,后續更新~】


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM