一、雙親委派機制(類加載機制中,最經常考到的問題)
類加載的第一個環節中,根據類的全限定類名(包名+類名)找到對應的.class文件的過程。
JVM中進行類加載的操作,需要以來內部的模塊“類加載器”(class loader)
JVM自帶了三種類加載器
Bootstrap ClassLoader 負責在Java的標準庫中進行查找
ExtensionClassLoader? 負責在Java的擴展庫中進行查找
ApplicationClassLoader 負責在Java的第三方庫/當前項目中進行查找
其中,Java的擴展庫是JDK自帶的,但是不是標準約定的庫,是JDK的廠商自行擴展的功能。現在很少涉及到,一般都是使用第三方庫。
Java的官方(Oracle)推出Java的標準文檔,其他的廠商就會依據官方的文檔,開發對應的JDK(官方確實也開發了JDK,還有一些第三方的,比如知名的OpenJDK,比如知名大廠也會有自己版本的JDK)
不同廠商,都能保證,標準約定的功能都是包含的,并且表現一致。但是這些廠商也會根據需要,擴展出一些功能出來。
這三類加載器之間,存在“父子關系”(不是父類子類,繼承關系),每個類加載器中有一個parent這樣的屬性,保存了自己的父親是誰。這是在JVM的源碼中已經寫死的。
雙親委派模型的目的,是為了確保三個類加載的優先級:標準庫優先加載,第三方庫/當前項目類最后加載。比如自己寫一個類,和標準庫恰好重復了。java.lang.String。此時JVM保證加載的仍然是標準庫的String,而不是你自己寫的。
雙親委派模型也是可以打破的。程序員在特定場景下,也可以實現自己的類加載器(實現庫/框架 可能涉及到),自己實現的類加載器可以讓他遵守,也可以不遵守。
二、JVM的垃圾回收機制 GC
C/C++ 這樣的編程語言中,申請內存的時候,是需要用完了手動進行釋放的
C 申請內存
1) 局部變量
2) 全局變量 不需要手動釋放
3) 動態申請 malloc 通過 free 進行釋放的
C++ 申請內存
1) 局部變量
2) 全局變量 / 靜態變量
3) 動態申請 new 通過 delete 進行釋放
這樣的釋放操作,容易遺忘(執行不到)就會導致 “內存泄露”
malloc
free
邏輯代碼
1) 條件判定 觸發 return
2) 拋出異常
很多編程語言,引入了 垃圾回收 機制
不需要程序員寫代碼手動釋放內存,會有專門的邏輯,幫助自動進行釋放
垃圾回收,大大的解放了程序員,提高了開發效率
Java, Python, Go, PHP, JS.... 大多數主流語言都包含 GC 功能
為啥 C/C++ 沒有引入 GC 呢?
C++ 的設計的核心理念,有兩個(C++ 的紅線)
1) 和 C 兼容 (C 語言寫的代碼,用 C++ 編譯器可以正常編譯運行的)
2) 把性能發揮到極致
隔壁會有很多的技巧?提高 "性能"
++i 代替 i++
通過返回 右值引用 代替返回值對象
通過引用傳參代替值傳參
通過 constexpr 增加編譯期做的工作,減少運行時開銷
...................
引入 GC 會影響性能,?引入了額外的運行時開銷。
很早之前,C++ 的標準委員會討論這個事情。
C++ 引入了 "智能指針",可以一定程度的解決內存泄露的問題。(雖然可用性,遠不如 GC,總比 C 語言啥都不做,直接擺爛強
在對性能有要求的開發場景中 C++ 是無可替代的
AI
游戲引擎
搜索引擎 (現在 java 性能也趕上來不少,也有 java 實現的版本了...)
交易系統 (股票,基金,外匯,期貨...)
操作系統級的開發
...................
挑戰者,Rust,嘗試挑戰 C++ 的生態位
走高性能的路線
主打優勢,能夠很好的應對內存錯誤問題
(內存泄露,內存訪問越界...)
Rust 通過特殊的語法,在編譯期做檢查的。
假設代碼寫出內存泄露,編譯通過不了
目前,Rust 發展下來,也變的語法非常復雜了
為什么GC 會影響執行效率?因為觸發 GC 的時候,可能會涉及到 STW 問題
stop the world 世界都停止
1. GC 回收的內存區域是哪個部分呢?
IVM
程序計數器
元數據區
棧
堆 => GC 主要回收這個區域
2. GC 的目的是為了釋放內存,是以字節為單位“釋放”嘛?
不是的,而是以“對象為單位”
正在使用的內存 不回收
不再使用(尚未回收) 不回收
沒有使用的區域 回收
按照對象為維度進行回收,更簡單方便。
如果是按照“字節維度”,就可能針對每個對象都得描述出哪部分需要回收,哪部分不需要。比較麻煩了。
堆上的內存 => new 的對象
3. 如何回收?
1) 找出垃圾,區分出哪些對象是垃圾(后續代碼不再使用)
2) 釋放這些垃圾對象的內存
如何“找出垃圾” ?
由于在 Java 中使用對象,都是通過 “引用” 來進行的,使用對象,無非是使用對象的屬性/方法,都要通過對象的引用進行。.前面的部分就是指向對象的引用。
如果一個對象已經沒有任何引用指向它了,此時這個對象就注定無法被使用了。
判斷一個對象是否是垃圾這個問題比較抽象,因此我們將其轉換成判斷是否有引用指向這個對象,這樣子問題就比較具體了。
JVM 內部是有一些辦法可以做到的以上這種解決方案的,周大佬 《深入理解 Java 虛擬機》 這本書介紹了兩種方案:
*面試的時候,區分好,看面試官是咋問的:
1) 讓你介紹下 垃圾回收 中如何判定對象是垃圾的 兩個方案都可以介紹
2) 讓你介紹 JVM 中如何判定對象是垃圾的 別說引用計數
1) 引用計數(Java 沒有使用,Python,PHP... 采用的方案)
簡單粗暴的方案。
給每個對象都分配了一個 “計數器”
1. 引用計數 [Java 沒有使用,Python,PHP... 采用的方案]
簡單粗暴的方案。
給每個對象都分配了一個 “計數器”
Test a = new Test();
Test b = a;
a = null;
b = null;
當引用計數為 0,此時對象就沒有任何引用指向了。
對象就是 垃圾了
Python / PHP 等語言
會搭配其他垃圾回收機制,識別當前的引用是否構成循環引用
兩個弊端
1) 消耗額外的內存空間較大。
如果對象本身很小(就 4 個字節)
計數器占了倆字節,相當于額外的內存空間多了 50%
2) 循環引用問題 (類似于死鎖)
class Test {
? ? Test t;
}
Test a = new Test();
Test b = new Test();
a.t = b
b.t = a
此時,這倆對象的引用計數是 1,不能釋放。
但是,這倆對象卻無法通過任何引用來訪問到。
AB 相互證明對方不是垃圾
實際上 AB 都是垃圾
以下是圖中的文字內容:
```
2. 可達性分析 [Java 使用的方案]
在 Java 代碼中,每個 “可訪問的對象” 一定是可以通過一系列的引用操作,訪問到的。
Node build() {
? ? Node a = new Node();
? ? Node b = new Node();
? ? Node c = new Node();
? ? Node d = new Node();
? ? Node e = new Node();
? ? Node f = new Node();
? ? Node g = new Node();
? ? a.left = b;
? ? a.right = c;
? ? b.left = d;
? ? b.right = e;
? ? e.left = g;
? ? c.right = f;
? ? return a;
}
Node root = build(); ?構建二叉樹
此時通過 root 這個引用,是可以訪問到這個樹上的任何一個對象的
此時通過 root 這個引用,是可以訪問到這個樹上的任何一個對象的
root => a
root.left => b
root.left.left => d
root.left.right.left => g
...........
假設,寫 root.right.right = null
這樣的代碼會使 f 無法被訪問到(f 已經沒有引用指向了)
假設,寫 root.right = null
這樣的代碼使 c 不可達。由于 f 必須依賴 c,f 也一起不可達
JVM 安排專門的線程,負責上述的 “掃描” 的過程
會從一些特殊的引用開始掃描 (GC roots)
1. 棧上的局部變量 (引用類型)
2. 常量池里指向的對象 (final 修飾的,引用類型)
3. 元數據區 (靜態成員,引用類型)
這三組里可能有很多變量
以這些變量為起點,盡可能的往里訪問所有可能被訪問到的對象
但凡被訪問到的對象,都 “標記為可達”
JVM 又能夠知道所有的對象列表,去掉 “標記為可達的”,剩下的就是垃圾了
不引入額外的內存空間
但是需要消耗較多的時間,進行上述掃描過程,這些過程中也是容易觸發STW的
(時間換了空間)
另外這里也不會涉及到 “循環引用”
如何釋放垃圾 (回收內存)
關于內存回收,涉及到幾種算法.
1. 標記 - 清除
標記,就是可達性分析,找到垃圾的過程.
清除,直接釋放這部分的內存(相當于直接調用 free / delete 釋放這個內存給操作系統)
存在內存碎片問題
總的空閑內存空間,是比較多的 (一共 4MB)
但是這些空閑空間,不連續.
在申請內存的時候,都是在申請連續的內存空間
當嘗試申請 2MB 的內存時候,就會申請失敗
2. 復制算法解 決內存碎片
把不是垃圾的對象,復制到另外一側
把整個空間都釋放掉
很好的解決了內存碎片問題。
弊端:
1. 內存浪費比較多
2. 如果存活的對象比較多/比較大,復制開銷非常明顯的
3. 標記 - 整理 類似于順序表刪除元素 - 搬運元素
4. 分代回收 (綜合方案), 把上述幾個方案,結合起來,揚長避短
整個堆空間,分成 "新生代" "老年代"
年輕對象 年老對象
年齡:一個對象經過垃圾回收掃描線程的輪次
對于年輕對象來說,是容易成為垃圾的。
年老對象,則不容易成為垃圾
可達性分析中,JVM 會不停使用線程掃描這些對象是否是垃圾。每隔一定時間,掃描一次。如果一個對象掃描一次,不是垃圾,年齡就 + 1。一般來說年齡超過 15 (可以配) 的就進入老年代。
"要死早死了"
比如 C 語言,已經存在了 50 年了,可以遇見到這個 C 語言還有很大的希望再活 50 年
> 和 C 語言同時期的 C++ 語言,都死的差不多...
剛創建的新鮮對象放到伊甸區。如果對象活過一輪 GC,進入幸存區。
新對象,大多數是生命周期非常短的 “朝生夕死”,經驗規律。這倆幸存區,同一時刻使用一個(相當于復制算法,分出兩個部分)。每次經過一輪 GC,都會淘汰掉幸存區中的一大部分對象,把存活的對象和伊甸區中新存活下來的對象,復制算法拷貝到另一個幸存區。新生代非常適合復制算法的。
如果這個對象在新生代中存活多輪之后,就會進入老年代。老年代的對象由于生命周期大概率很長,沒有必要頻繁掃描。如果這個對象非常大,不適合使用復制算法了。直接進入老年代
老年代回收內存采取的是 標記-清除 / 標記-整理(取決于垃圾回收器的具體實現了)
主流垃圾收集器詳解
1.G1垃圾收集器(GarbageFirst)
定位:自Java11起成為默認收集器,是目前最主流的垃圾收集器。
核心特點:
采用分區堆(Region)設計,將堆內存劃分為多個大小相等的塊(通常為1MB~32MB)。
通過優先回收垃圾最多的Region("GarbageFirst"策略)實現高效回收。
支持大內存(幾十GB級別),同時保持可控的STW(StopTheWorld)停頓時間。
2.ZGC垃圾收集器(ZGarbageCollector)
定位:新一代低延遲收集器,未來可能逐步取代G1。
核心特點:
突破性低延遲,STW時間可控制在1ms以內。
同樣采用分區堆設計,但通過染色指針(ColoredPointers)和讀屏障(LoadBarriers)技術實現并發標記與整理。
支持超大堆內存(TB級別),適合現代高性能應用。