文章目錄
- 前言
- JVM概述
- JVM是什么?解決了什么問題?
- JVM運行流程
- JVM 與 JRE,JDK的關系
- JVM內存結構
- JVM區域劃分
- 程序計數器
- 棧
- 堆
- 方法區
- 類加載機制
- 五個階段
- 加載
- 驗證
- 準備
- 解析
- 初始化
- 總結
- 雙親委派模型
- 垃圾回收內存管理
- 什么是GC?如何判定誰是垃圾?
- 1.引用計數 判定
- 2.可達性分析
- 內存回收算法
- 1.標記-清除
- 2.復制算法
- 3.標記-整理
- JVM中的回收算法
- Minor GC 與 Full GC 的區別與觸發條件
- JVM線程與鎖機制
- 程序計數器與線程的關系
- 偏向鎖、輕量級鎖、重量級鎖的演化過程
- 例子:對象的創建全過程 從new到內存分配(類加載->內存分配)
- 參考文章
前言
本來要開始學習Spring的,但聽說最好學框架之前先把JVM,HTTP協議全都捋一遍,除了有助于Spring學習外,更重要的是這倆面試也經常會被問到,所以我必須本著“喜歡探索知識的個性”來學習了。
JVM概述
JVM是什么?解決了什么問題?
JVM(Java Virtual Machine,Java虛擬機)通俗一點說是一個假裝是計算機的程序,專門運行.java程序。擬人化說就是它像一個翻譯+保姆+管家的綜合體,它可以解決什么問題呢?
翻譯:把你寫的.java程序翻譯成別人能看懂的.class字節碼
保姆:管理內存,幫你清理垃圾(垃圾回收)
管家:跨平臺運行(可以在不同的OS上運行)
專業點來說,JVM 是運行 .class 字節碼文件的虛擬計算機,它屏蔽了底層操作系統和硬件的差異,實現“一次編寫,到處運行”。
有句話說得好,凡是都需要對比一下友商,讓我們看看C++的相關替代品是啥。
答:C++不需要這些,因為C++是編譯型語言,編譯之后變成了機器碼,直接可以運行在操作系統上。編譯型語言執行效率更高,時間消耗更少。 這么一看是不是感覺踢到鋼板了? 其實不然,友商雖然不需要這個就可以執行,這是它高性能的優點但同時也是一個很大的缺點。因為沒有管家幫它接觸各類操作系統,也沒有保姆幫他回收垃圾。這些都是C++的痛點。具體來說,對比表格如下
特性 | Java | C++ |
---|---|---|
編譯結果 | 字節碼(.class) | 機器碼(.exe / .out) |
是否依賴虛擬機 | 是,JVM 才能運行 | 否,編譯后直接運行 |
是否跨平臺 | 是(因為 JVM 屏蔽平臺差異) | 否(不同平臺要重新編譯) |
內存管理 | 自動垃圾回收(GC) | 手動管理(new/delete) |
類加載 | 運行時加載(類加載器) | 編譯時靜態鏈接,沒“加載器” |
JVM運行流程
- 編譯階段(helloworld.java -> helloworld.class) 開發時
- 類加載階段(helloworld.class 被類加載器加載進內存 JVM進行驗證->準備->解析->初始化) 看看你合法嗎,合法給你分配住的地方,分配完了之后準備給我干活。
- 執行階段(將字節碼翻譯為機器碼)穿上工裝開始干活
- 內存管理,干著干著發現你的空間怎么越戰越大,得給你釋放一下了(垃圾回收)
JVM 與 JRE,JDK的關系
這就像一家三口 JDK最大包含 JRE JRE包含JVM
名稱 | 作用 | 舉例說明 |
---|---|---|
JVM | Java虛擬機,運行字節碼 | 就是你寫的程序在哪執行 |
JRE | Java運行環境,包含 JVM + 核心類庫 | 就像一個 Java 程序能跑的最小環境 |
JDK | Java開發工具包,包含 JRE + 編譯器(javac)等工具 | 你寫程序、編譯、運行都靠它 |
JVM內存結構
JVM區域劃分
為什么要有區域劃分?答:不同區域的內存有著不同的功能便于高效管理。比如你買了房子,你得給安排洗手間,廚房,客廳,臥室一樣。不同的空間有不同的職責。
程序計數器
程序計數器的作用是保存指令地址的地方
什么是指令?指令就是字節碼,也就是我們的寫的代碼,代碼都是有作用的所以轉化為字節碼后也就是指令了,用來命令CPU下一步該干啥。什么是指令地址?就是存儲指令的地方,我們執行指令的時候需要先將指令一個個從內存中取出來。
前面的文章中說過,線程調度,主要也靠程序計數器恢復上下文。這里的程序計數器也是存儲當前線程執行的指令地址,那么JVM的程序計數器和線程的是一個嘛?顯然不是,可以理解JVM是總的程序計數器,是當前程序執行到哪了,而線程中存的則是當前線程執行到什么位置了。如果線程發生阻塞,在調度回來執行的時候方便恢復現場。
棧
棧是存儲局部變量和函數調用信息的地方。什么是局部變量,顧名思義是指只在一個范圍內生效的變量(比如一個函數內定義的變量,for循環內部定義的變量if語句內部定義的變量) 函數調用信息:傳入函數的實參,函數內部的局部變量,調用函數的位置等。棧的基本屬性是先進后出,比如在遞歸或函數內部調用函數中最內層調用函數最先出來,最外層調用函數最后出來。舉個例子,判斷樹高度的例子
假設樹長這樣
public int deep(TreeNode root,int x){if(root==null){return x;}int x1=deep(root.left,x+1);int x2=deep(root.right,x+1);return Math.max(x1,x2);}int h = deep(root,0)+1;
棧的最底部 會存儲實參(3,0),局部變量(x1,x2)這里說的是節點值,了解存儲的那些節點就行,實際上是存儲的一個個引用。依次類推往上分別存儲(9,1) ,但調用此節點時發現已經沒有孩子節點了 直接返回高度1,因此底部會變為(3,0),(1,x2) 。隨后右孩子入棧 分別往上存儲(20,1),(15,2),(7,2).最終逐個出棧(這里是(15,2)先出棧)。
同樣的有多個線程調用函數的話,每個線程內部也是都有棧的。
堆
堆是占用內存較大的數據結構了,其內部主要存儲 new出來的對象實例,以及對象內部的成員變量。注意堆在線程可就沒有了,因為線程太小了。
public void f(){
HashMap<Integer,Integer> hsm = new HashMap<>();
}
那這個例子中hsm 存儲在哪呢? hsm 是局部引用 所以會存儲在棧中,這里分清引用和對象實例,引用指的是對象在堆的存儲位置,對象實例則是對象本身。 這也順便解釋 對象傳參時引用調用為什么會改變原始對象本身,因為傳參時傳的是對象的地址的引用,而修改時會修改對象在堆中的本身,所以引用調用會改變其本身。而值傳遞,是將值傳參了,不會改變原有的值(我理解的,不知道對不對,但有一種引用情況除外,那就是String類型,傳參傳入String的引用時不會改變原始值,這是因為一旦操作String類型都會創建一個副本,所以不會改變原始值)。
理解不了 沒關系 這里就記住
堆存儲new的對象實例 和 內部的成員變量
局部變量或引用都存儲在棧中
方法區
方法區存儲的是類對象,但我喜歡叫類屬性。因為更好理解,類對象不是指前面的對象的實例。而是一個類中結構是啥樣的,比如里邊的變量的類型,方法的類型,有哪些方法,名字叫什么等。
類加載機制
類加載機制是指 JVM 在運行期間,將 .class 字節碼文件加載到內存中,并轉換成 Class 對象的整個過程。也就是說,當你在代碼里用 new User() 或 User.class 的時候,JVM 要確保這個類的字節碼已經在內存中了,這個“確保 + 加載 + 準備 + 連接 + 初始化”的過程,就是類加載機制。
五個階段
加載-驗證-準備-解析-初始化
加載
干了什么? 讀取字節碼內,創建一個class對象,存進方法區,此class對象是后面你反射的基礎
目的:將類的字節碼讀取到JVM內存
舉例子來說,你在運行Java對象前需要先把說明書(字節碼) 從硬盤搬進內存,才能參考它干活
驗證
干了什么?檢查.class文件是否符合規范,是否非法越界,引用了不存在的類。
目的:保證虛擬機安全的運行,防止惡意或錯誤字節碼破壞
就像你拿到了這個說明書,你得檢查是不是騙人的。
準備
給類的靜態變量分配內存,并初始化默認值。注意是初始化默認值,不是初始化。默認值指的是(0/null/false) 這種
比如 int x = 3 不會初始化為3,而是初始化為0.
就像你租一間房,房東先給你空房子和床,等你入住時再布置和擺東西(下一步才賦值)
解析
把常量池中的符號引用轉化直接引用
例如:User u = new User(); 在常量池里是個字符串 “User”,解析后才變成真正的方法地址、內存地址等。
提前把類中引用的類/字段/方法都找好,變成可執行的“具體地址”
你看到說明書說“點這里”(符號引用),你得先知道“這里”是哪里(真正的函數地址)
初始化
這是真正的初始化,給類中的靜態變量初始化提前設定的值
房間準備好了,現在你把桌子椅子搬進來、墻上掛個畫,開始“入住
總結
階段 | 干了什么 | 目的 | 是否常考 |
---|---|---|---|
加載 | 讀取 .class → 生成 Class 對象 | 把類搬進 JVM | ? |
驗證 | 校驗字節碼合法性 | 防止非法破壞 JVM | ? |
準備 | 給 static 字段分配內存 & 默認值 | 建立“類模板” | ? |
解析 | 符號引用 → 真實引用 | 做好準備執行 | ??(次要) |
初始化 | 執行 <clinit> 初始化代碼 | 正式讓類“準備就緒” | ? |
雙親委派模型
這個屬于加載階段的部分,為什么單獨要拿出來說。因為這部分理解對于你學習java有著重要意義,劣勢基礎,最終成為大牛。。。好了不說廢話,因為這部分面試喜歡問。它的目的是找.class文件。 因為.class的文件可能存放在多個地方。比如在JDK中,比如自定義的在項目目錄中。 類加載器有三個,每一個負責不同的區域。就像外賣小哥,每個區域可能會安排一個外賣小哥。
1、BootStrapClassLoader【模擬線路類加載器】 負責標準庫常用的類
2、ExtensionClassLoader【擴展類加載器】加載JDK擴展的類,很少用
3、ApplicationClassLoader【應用類加載器】負責我們在項目中自定義的類
工作流程:首先進入應用類加載器,此時他會檢查擴展類加載器是否加載過了。沒有則進入擴展類加載器,進入之后,也會判斷是模擬線路類加載器加載過了,沒有則進入模擬線路類加載器。 就是一句話,如果沒被加載過會先到自己的父類加載器去加載。
為什么這樣設計?因為這保證加載類時的一致性,不會出現自定義的類和標準庫的類重名了不知道加載哪個。這里會優先加載標準庫的類。
垃圾回收內存管理
垃圾回收這些一直是由JVM自動判定并回收,所以我們接觸的很少。但對比友商的程序員(C),這就是不得不接觸的了,因為C語言追求高性能,這種垃圾回收一類的東西它不care,所以只能辛苦程序員來完成了。看到這里是不是心里寬慰了一些
什么是GC?如何判定誰是垃圾?
什么是垃圾回收?我們寫程序new的對象 創建的變量這些只要不用了就是垃圾,但垃圾依然占用著內存,我們需要清理它并回收內存。但不能亂回收,比如一個對象明明你還有用你卻給它扔了這樣會有大問題。因此如何判定誰是垃圾成為了重中之重。
如何判定誰是垃圾?
在判定之前,我們要明確目標,內存區域中只有堆中占用空間最大且是最需要回收垃圾釋放內存的地方。所以我們默認垃圾回收針對的是堆中的數據。
如何判定?
1.引用計數 判定
此方法不是用在java中,了解如何運作的就可。引用計數對每個對象實例都會增加一份額外的空間用來計數,注意是對象實例,不是引用。例如:
Result re = new Result() \\這里re 是引用,Result是對象實例我們針對的是對象實例,那有人會問引用如何回收\\引用的生命周期更短,只要超過作用域或者被定為null就回收了。
Result re1 =re; \\Result()的計數是2.因為有兩個指向它
當引用計數變為0時,就回收這部分內存。也就是說取消一個引用時,其計數器減一。
這種方法存在什么問題呢? 具體看代碼注釋
public class RefCountDemo {public Object instance = null; // 引用另一個對象public static void main(String[] args) {//此時第一個RefCountDemo();計數器為1RefCountDemo objA = new RefCountDemo();此時第二個RefCountDemo();計數器為1RefCountDemo objB = new RefCountDemo();// A 引用了 B 此時第二個RefCountDemo();計數器為2objA.instance = objB;// B 又引用了 A 此時A的計數器為1 此時第1個RefCountDemo();計數器為2objB.instance = objA;// 斷開外部引用 objA = null;objB = null;// 此時 objA 和 objB 相互引用,引用計數 ≠ 0// 但它們已經無用了,GC 無法識別(引用計數法失效)}
}
此外 空間利用率低,因為每次new 一個對象你都要分配額外的空間去計數。
2.可達性分析
于是為了解決以上兩個問題,Java決定另辟蹊徑。
可達性分析(Reachability Analysis) 是一種判斷對象是否“存活”的算法:
從一組稱為 GC Roots 的根對象出發,沿著引用鏈向下搜索,能被訪問到的對象就是“可達的”,不可達的對象則認為“已經死亡”,可以被垃圾回收。
工作流程:
從 GC Roots 出發
沿著所有引用向下搜索(廣度或深度優先)
標記所有能訪問到的對象為“活著”
未被訪問到的對象則是“死對象”,可以被回收
GCRoots有那些來源?
GC Roots 來源 | 說明 |
---|---|
棧幀中的本地變量表 | 方法調用中的局部變量,如 new 出來的對象引用 |
方法區中靜態變量 | 如 static 字段的引用 |
方法區中常量引用 | final 常量等 |
JNI 引用(本地方法) | 通過 C/C++ 引用的 Java 對象 |
活躍線程對象 | 每個正在運行的線程本身 |
public class ReachabilityDemo {
public Object ref = null;
public static void main(String[] args) {ReachabilityDemo a = new ReachabilityDemo();ReachabilityDemo b = new ReachabilityDemo();a.ref = b;b.ref = a;// 外部斷開引用a = null;b = null;// 現在 JVM 會觸發 GC,能正確識別 a 和 b 都不可達(雖然互相引用)
}
}
以上例子中 首先從棧幀中的本地變量表 a,b開始掃描 ,發現 a 和b 都為null了,所以第一個和第二個ReachabilityDemo(); 都無法訪問到,于是回收這部分空間。同時將引用也回收
內存回收算法
找完垃圾后,就需要清理,如何清理呢?(釋放內存)
1.標記-清除
通過可達性分析找到要回收的對象后,我們直接對垃圾占用內存釋放。面試遇到的話,理解性記憶,標記了就清除嘛不就是,誰是垃圾就清楚誰其他的我不管,這就是標記清除。這種存在什么問題呢? 猛地一看貌似沒毛病啊不就是誰是垃圾,誰就要扔啊,難不成還不扔垃圾?道理是這樣地,但問題是扔了垃圾你不收拾一下房間嘛? 也就是說清理了垃圾,但會變成碎片化內存,就是隔一段有一小快是空閑,這些小塊加起來是很大地,但分配這樣大地空間 我們卻無法分配(因為空間分配必須是連續地)。所以清理垃圾必須也得收拾房間,要不然雜亂無章,每地方都有東西放,但卻又放不下大點地東西。
2.復制算法
此算法就是解決以上問題,整理內存。首先將內存一分為二,一半用,一半備用。當清理垃圾時,會先將不是垃圾地內存地值復制到另一把中去。然后在講這一半地所有空間全部清除,這樣就釋放了這一半地所有空間。 復制算法,面試遇到就要想到加了復制倆字,就說明他會整理內存,變聰明了。但此時又會有什么問題呢? 這我好像只能用一半空間啊,內存這么金貴好不容易申請到,卻只能用一半(空間利用率低)。此外,每次都要從這個房間把東西搬過去,萬一這個房間垃圾不多有用地很多,全搬過去好累地(復制開銷較大)。
3.標記-整理
對于復制出現地問題,標記整理解決了一部分(空間利用率低) 如何做呢?它將不是垃圾的內存的值覆蓋到前邊時垃圾的內存哪里,注意后者的內存一定滿足大于等于前者才可直接覆蓋。隨后對后面的元素直接釋放。此方法問題還是沒有解決需要復制的問題
JVM中的回收算法
上面每一種算法單拎出來發現都不夠完美,所以JVM采用了三者的結合分代回收! 顧名思義,將不同的對象根據存在時間的長短分為輩分大的和輩分小的。然后將堆內存一分為二, 其一存放輩分大的,其二存放輩分小的。 其中第二部分,又分為兩部分,一部分伊甸區,一部分時幸存區。話不多說直接偷張圖(這篇圖的作者講的特別好,文章最后我會表明引用他文章的)
1.剛new出來的對象直接放到伊甸區。
2.如果伊甸區對象熬過了可達性分析,則就放入幸村區。
3.幸存區繼續開熬,來回復制
4.經過多輪后,幸存區已經有資格放到老年區,此時放到老年區。
Minor GC 與 Full GC 的區別與觸發條件
Minor GC 針對年輕代判定并回收垃圾
Full GC 指 JVM 對整個堆空間進行回收
項目 | Minor GC | Full GC |
---|---|---|
作用范圍 | 僅年輕代(Eden + Survivor) | 整個堆(新生代 + 老年代 + 方法區) |
觸發條件 | 年輕 區滿了,需要分配新對象 | 老年代滿、System.gc()、元空間不足、CMS失敗等 |
耗時 | 較短(幾十 ms) | 較長(幾百 ms 或秒級) |
是否會 Stop-The-World | ? 是 | ? 是 |
是否影響用戶體驗 | 輕微 | 明顯卡頓,尤其是 Full GC 頻繁時 |
回收頻率 | 高頻 | 盡量少 |
JVM線程與鎖機制
程序計數器與線程的關系
-
程序計數器(Program Counter Register)
每條線程都有獨立的程序計數器,是線程私有的內存空間。
是 JVM 中唯一一個線程私有的內存區域。
用來記錄當前線程正在執行的字節碼指令地址。
如果當前正在執行的是 native 方法,則該計數器值為 undefined。 -
為什么線程私有?
Java 是多線程語言,而 JVM 采用線程隔離的方式來執行多線程代碼。每個線程需要知道自己執行到哪一行代碼了,所以每個線程必須有自己的 PC 寄存器,否則線程切換后無法恢復上下文。
偏向鎖、輕量級鎖、重量級鎖的演化過程
無鎖
↓(第一個線程獲取)
偏向鎖
↓(出現競爭)
輕量級鎖
↓(競爭激烈,阻塞)
重量級鎖
1 偏向鎖(Biased Lock)
特點:偏向于第一個獲取鎖的線程
無需 CAS(Compare-And-Swap)操作,無鎖競爭
將線程 ID 記錄在對象頭中
使用場景:大量線程反復進入同步塊但沒有競爭的情況
觸發升級:如果另一個線程試圖獲取這個鎖,則撤銷偏向 → 升級為輕量級鎖
2 輕量級鎖(Lightweight Lock)
特點:多線程交替進入同步塊時使用,使用 CAS 實現樂觀鎖
沒有線程阻塞,嘗試加鎖失敗的線程會 自旋 等待鎖釋放
使用場景:短時間同步,線程競爭不激烈(兩個線程交替進入)
觸發升級:如果 CAS 多次失敗,自旋多次未成功 → 升級為重量級鎖
3 重量級鎖(Heavyweight Lock)
特點:使用操作系統 Mutex 來實現(synchronized 的早期實現)會使線程阻塞掛起和喚醒
問題:線程掛起、恢復的上下文切換代價非常大
例子:對象的創建全過程 從new到內存分配(類加載->內存分配)
public class JVMExample {public static void main(String[] args) {System.out.println("程序啟動");User user = new User("Alice", 28);user.printInfo();user = null; // 使對象變為垃圾System.gc(); // 主動調用 GC(并不一定立即執行)System.out.println("程序結束");}
}class User {private String name;private int age;public User(String name, int age) {this.name = name; // 存在于堆中對象的屬性this.age = age;}public void printInfo() {String info = "用戶:" + name + ",年齡:" + age;System.out.println(info);}@Overrideprotected void finalize() throws Throwable {// finalize 可能被 GC 調用(不保證一定被調用)System.out.println("User 對象正在被 GC 回收!");}
}
1.類加載過程
階段 | 說明 |
---|---|
加載 | 從 .class 文件中加載字節碼進方法區(元空間) |
驗證 | 校驗字節碼正確性(如格式、指令合法) |
準備 | 分配靜態變量的內存,并賦默認值 |
解析 | 將常量池中符號引用替換為直接引用(如方法、字段地址) |
初始化 | 執行類的 <clinit> 靜態初始化塊或靜態變量賦值 |
2.內存分配
區域 | 內容及示例 |
---|---|
程序計數器 | 每個線程一個,當前執行指令地址。比如 System.out.println(...) 執行時,PC 保存當前 JVM 指令的地址。 |
虛擬機棧(Java棧) | 每個線程一個,保存方法調用棧幀。方法中的局部變量 user 就保存在此處。 |
堆(Heap) | 所有對象實例都在堆中分配,例如 new User(...) 創建的對象。 |
方法區(元空間) | 類的結構信息(方法表、常量池、字段表)被加載到這里,如 User.class |
本地方法棧 | 調用 Native 方法時使用,例:System.gc() 最終可能調用 native 函數觸發 GC |
3.垃圾回收
user = null;
System.gc();
上述代碼使得 user 不再引用堆中的對象,變成“垃圾對象”。
GC 機制會:
通過可達性分析(引用鏈)找出不可達對象
標記-清除 / 標記-整理 / 分代收集(Young、Old、Perm)等策略執行清理
如果 User 類中定義了 finalize() 方法,GC 會調用它(只調用一次,不保證執行)
參考文章
最后參考一下細節佬的文章
JVM - JavaEE初階最后一篇 - 細節狂魔