目錄
JVM 簡介
JVM 中的內存區域劃分
1. 堆(一個進程只有一份 ------ 線程共享)
2. 棧(一個進程可以有 N 份? ------ 線程私有)
Java 虛擬機棧:
本機方法棧:
3. 程序計數器(一個線程可以有 N 份? -------- 線程私有)
4. 元數據區(一個線程只有一份 -------- 線程共享)
一道經典的筆試題:
內存布局中的異常問題
Java 堆溢出
虛擬機棧和本地方法棧溢出
JVM 類加載
類加載的過程
1. 加載
2. 驗證
3. 準備
4. 解析
5. 初始化
雙親委派模型(加載環節)
雙親委派模型工作流程:
雙親委派模型的作用:
垃圾回收機制(GC)
引入
垃圾回收,是回收內存
垃圾回收的過程:
第一步:識別出垃圾
1. 引用計數算法
引用計數算法的描述
引用計數的缺點
2. 可達性分析
第二步:把標記為垃圾的對象的內存空間進行釋放
a. 標記 - 清除
b. 復制算法
c. 標記 - 整理
JVM 中使用的方案 --- 分代回收(依據不同種類的對象,采取不同的方案)
總結:一個對象的一生
JMM
1)主內存與工作內存
2)內存間交互操作
完!!!
JVM 簡介
JVM 是 Java Virtual Machine 的簡稱,意為 Java 虛擬機。
虛擬機是指通過軟件模擬的,具有完整硬件功能的,運行在一個完全隔離的環境中的完整計算機系統。
我們在學習 Java SE 的時候,簡單了解過 JVM,還有兩個相關的概念,jdk,Java 開發工具包,jre,Java 運行時環境,其中,jvm 虛擬機包括在 jre 其中,而 jre 又包含在 jdk 其中。所以我們在編寫 Java 代碼中,下載 jdk 即可在記事本中編寫代碼,然后通過命令行運行進程。
編譯語言,在以前大致可以分為兩種:
編譯型語言,在程序執行前,需要通過編譯器將源代碼一次性編譯成目標機器的機器碼,之后就可以直接運行生成的可執行文件。
解釋型語言,在程序運行時,由解釋器逐行讀取源代碼,并將其解釋成目標機器能夠理解的指令后立即執行。
上述的說法,如今其實已經不適用了,如果按照上述經典的劃分方法,Java 屬于是“半編譯,半解釋型”語言。
Java 這么做的最主要目的,是為了實現“跨平臺”!!!
C++ 這樣的語言,是直接編譯成了二進制的機器指令,但需要注意的是,不同的 CPU 中,支持的指令是不一樣的,而且,生成的可執行程序,在不同的系統上也由不同的格式。
Java 不想在不同的 CPU 中進行重新編譯,而是期望能夠直接執行~~
還記得我們最開始用記事本寫的 hello world 嗎???
創建一個記事本,寫出代碼
將記事本的后綴 .text 改為 .java?
然后在對應目錄下的命令行中先運行 javac,將 java 文件 ==》 .class 文件
然后再輸入 java 即可運行~~
上面的 .class 文件,是字節碼文件,包含的就是 Java 字節碼(是 Java 自己搞的一套“CPU 指令“),然后再某個具體的系統平臺上執行,此時再通過 jvm,把上述的字節碼轉換成對應的 CPU 能識別的機器指令。(在這個過程中,jvm 相當于一個”翻譯官“的角色)。
因此,我們編寫和發布一個 Java 程序,其實就只需要發布 .class 文件即可,jvm 拿到 .class 文件,就知道如何進行轉換了~~~
windows 上的 jvm 就可以把 .class 轉換成 windows 上支持的可執行指令了
linus 上的 jvm 就可以把 .class 轉換成 linux 上支持的可執行指令了
..................................
不同平臺的 jvm 是存在差異的,不是同一個~~~
補充:
jvm 也是由許多許多版本的,,目前 HotSpot VM 是占用絕的市場地位,稱霸武林~~~所以我們下面的內容中,默認都是使用 HotSpot 的~~
JVM 本身的一個非常復雜的東西,涉及到很多和底層密切相關的內容,我們這里主要關注三個話題:
1) JVM 中的內存區域劃分
2) JVM 中的類加載機制
3) JVM?中的垃圾回收機算法
JVM 中的內存區域劃分
JVM 其實也是一個進程,我們可以隨便運行一個之前的多線程代碼不結束,在任務管理器中,就可以看到 Java 進程。
進程運行的過程中,需要從操作系統中申請一些資源(內存就是其中的典型資源),這些內存空間,就支撐了后續 Java 程序的執行。比如,在 Java 中定義變量,就會申請內存,內存,其實是 jvm 從系統這邊申請到的內存~~
jvm 從系統中申請到了一大塊內存,這一大塊內存給 Java 的程序所使用,但也會根據實際的使用用途來分出不同的空間 ==》 也就是所謂的區域劃分~
就類似于我們的我們的學校,占有一大塊土地面積,要把整個空間分成不同的區域:
類似的,JVM 申請到的空間,也會劃分出幾個區域,每個區域都有不同的作用。
1. 堆(一個進程只有一份 ------ 線程共享)
我們代碼中 new 出來的對象,都是在堆里面的。對象中持有的非靜態成員變量,也是在堆里面的。
我們常見的 JVM 參數設置? -Xms10ms 最小啟動內存就是針對堆的,-Xmx10m 最大運行內容也是針對堆的(ms 是 memory start 的簡稱,mx 是 memory max 的簡稱)
2. 棧(一個進程可以有 N 份? ------ 線程私有)
棧分為 本地方法棧 和 Java 虛擬機棧
Java 虛擬機棧:
通過 C++ 實現的代碼,調用關系和局部變量。Java 虛擬機棧的生命周期和線程相同,Java 虛擬機棧描述的是 Java 方法指向的內存模型:每個方法在執行的同時都會創建一個棧幀(Stack Frame)用于存儲局部變量表,操作數棧,動態連接,方法出口等信息。我們經常所說的堆內存,棧內存,其中,棧內存指的是就算虛擬機棧。
1. 局部變量表,存放了編譯器可知的各種基本數據類型(8 大基本數據類型),對象引用。局部變量所需的內存空間在編譯間完成分配。當進入一個方法的時候,這個方法需要在幀中分配多大的局部變量空間是完全確定的,在執行期間不會改變局部變量表的大小。簡單來說,這里存放方法參數和局部變量。
2. 操作幀:每個方法會生成一個先出后進的操作幀。
3. 動態連接:指向運行時常量池的方法引用。
4. 方法返回地址:PC 寄存器的地址。
什么是線程私有???
由于 JVM 的多線程是通過線程輪流切換并分配處理器的執行時間來實現,因此在任何一個確定的時刻,一個處理器(多核處理器的話,則指的是其中的一個內核)都只會執行線程中的一條指令。因此為了切換縣城后能恢復到正確的位置,每個線程都需要獨立的程序計數器,各個線程之間的計數器互不影響,獨立存儲。我們就把類似這樣的區域,稱之為”線程私有“的內存~~
本機方法棧:
本地方法棧和虛擬機棧類似,只不過 Java 虛擬機棧是給 JVM 使用的,而本地方法棧,則是記錄了 Java 代碼的調用關系和 Java 代碼中的局部變量~~
我們一般不是很關注本地方法棧,一般談說到棧,默認指的是虛擬機棧。
3. 程序計數器(一個線程可以有 N 份? -------- 線程私有)
記錄當前線程所執行的字節碼指令的地址。在多線程環境中,每個線程都有獨立的程序計數器,用于確保線程切換之后能夠恢復到正確的執行位置。當線程執行到 Java 方法的時候,程序計數器記錄的是正在執行的虛擬機字節碼指令的地址。而當線程執行本地(Native)方法的時候,程序計數器的值為空(Undefined)。(本地方法指的是使用 Java 意外的語言編寫的方法)
4. 元數據區(一個線程只有一份 -------- 線程共享)
(以前的 Java 版本中,也叫做”方法區“,從 1.8 開始,改為了元數據區~~)
”元數據“是計算機中的一個常見術語(meta data),往往指的是一些輔助性質的,描述性質的屬性~~
比如我們的硬盤,硬盤中不僅僅要存儲文件的數據本體,還需要存儲一些輔助信息,比如:文件的大小,文件的位置,文件的擁有者,文件的修改時間,文件的權限信息等等.....這些輔助信息統稱為”輔助信息“。
JVM 中的元數據區:存儲被虛擬機加載的類信息,方法的信息,常量,靜態變量。即一個程序有那些類,每個類中有那些方法,每個方法的里面都要包含那些指令,都會記錄在元數據區中。
我們寫的 Java 代碼,if while for 等等各種邏輯運算,這些操作最終都會被轉換成 java 字節碼 ==》 javac 就會完成上述操作~~
此時,這些字節碼在程序運行的時候,就會被 JVM 加載到內存中,放到元數據(方法)區中。
此時,當前程序要如何執行,要做那些事情,就會按照上述元數據區里面記錄的字節碼依次執行了。
一道經典的筆試題:
有偽代碼如下:
class Test{private int n;private static int m;
}main() {Test t = new Test();
}
問:上述代碼中,t n m 各自都存儲在 JVM 內存中的那個區域???
n 是 Test 類中的非靜態成員變量,是處于 堆 上的。
t 是一個引用類型的局部變量,本身是在 棧 上的。
而 m 是帶有 static 修飾的變量,是在類對象中,也就是在 元數據區 中。
static 修飾的變量,稱為 類屬性。
static 修飾的方法,稱為 類方法。
非 static 的變量,稱為 實例屬性。
非 static 的方法,稱為實例方法。
類對象,我們前面提到過 ==》 類名.class。即例子中的 Test.class,JVM 把 .class 文件加載到內存之后,就會把這里的信息使用對象來表示,此時這樣的對象就是類對象。類對象里面包含了一些信息,包括但不限于:類的名稱,類繼承自那個類,實現了那些接口,都有那些屬性,都叫什么名字,都是什么類型,都是什么權限。都有那些方法,都叫什么名字,都需要什么參數,都是什么權限......? ? .java 文件中涉及到的信息,都會在 .class 中有所體現(注釋不會包含~~)
總結:
內存布局中的異常問題
Java 堆溢出
Java 堆用于存儲對象實例,只要不斷創建對象,并且保證 GC(垃圾回收) Roots 到 對象之間有可達路徑,來避免 GC 清除這些對象,那么在對象數量達到最大堆容量后就會產生內存溢出異常。
我們前面已經講到,可以設置 JVM 參數 -Xms 設置堆的最小值,-Xmx 設置堆的最大值。我們下面來看一個 Java 堆 OOM(OutOfMemoryError 的縮寫,表示內存溢出錯誤)的測試。測試一下代碼之前,我們可以先設置 idea 的啟動參數:
JVM 參數為?-Xmx20m-Xms20m-XX:+HeapDumpOnOutOfMemoryError
-Xmx20m :設定 JVM 堆內存的最大可用空間為 20 MB。當 Java 程序在運行過程中需要更多的堆內存,而當前使用的堆內存已經達到這個最大值時,無法進行有效的垃圾回收以釋放內存,就會拋出 OutOfMemoryError 異常
-Xms20m:設定 JVM 堆內存的初始大小為 20 MB,JVM 在啟動時會為堆內存分配這么大的空間。
-XX:+HeapDumpOnOutOfMemoryError:-XX 是 JVM 的高級參數前綴,用于設定一些特定的? JVM 行為。+ 表示棄用該參數對應的功能,這里的? HeapDumpOnOutOfMemoryError 表示當 JVM 拋出 OutOfMemoryError 異常的時候,自動生成堆轉儲文件(Heap Dump),堆轉儲文件包含了當時堆內存中所有對象的信息,是分析內存泄漏和性能問題的重要依據。
main 方法里面創建了一個 ArrayList 集合,接著通過一個無限循環不斷的往集合中添加 OOMObject 對象,由于這些對象都被集合引用著,垃圾回收器無法回收他們,隨著對象數量的持續增加,堆內存最終被耗盡,從而觸發 OutOfMemoryError 異常
Java 堆內存的 OOM 異常是實際應用中最常見的內存溢出情況。當出現 Java 堆內存溢出的時候,異常堆信息“java.lang.OutOfMemoryError”會進一步的提示“Java heap space”。當出現“Java heap space”的時候,就是很明確的告知我們,OOM 發生在堆上。
此時要對 Dump 出來的文件進行分析(會 Dump 出一個 hprof 文件,可以使用工具 VisualVM進行分析)。分析問題的產生到底是出現了內存泄漏(Memory Leak)還是內存溢出(Memory Overflow)。
內存泄漏:泄漏的對象無法被 GC
內存溢出:內存對象確實還應該存活。此時要根據 JVM 堆參數與物理內存相比較,檢查是否還應該將 JVM 的內存調大一些,或者檢查對象的生命周期是否過長。
虛擬機棧和本地方法棧溢出
HotSpot 虛擬機是將虛擬機棧與本地方法棧合二為一,因此對于 HotSpot 來說,棧容量只需要由? ?-Xss 參數設置。
關于虛擬機棧會產生的兩種異常:
如果線程請求的棧深度大于虛擬機所允許的最大深度,會拋出 StackOverFlow 異常
如果虛擬機在拓展棧無法申請到足夠的內存空間,則會拋出 OOM 異常。
補充:棧深度:
棧幀:JVM 中方法調用和執行的基本數據結構,每個方法調用都會創建一個對應的棧幀。當一個方法被調用時,JVM 會對該方法對應的棧幀壓入調用棧的棧頂,當方法執行完畢返回時,棧幀會從棧頂彈出。比如,方法 methodA 中調用了方法 methodB,此時 methodB 創建一個新的棧幀并壓入棧頂,位于 methodA 棧幀之上。
棧深度:就是當前線程的調用棧中的棧幀的數量。例如:主線程先調用 methodA,methodA 調用 methodB,methodB 中又調用 methodC,此時調用棧中就依次存在:主線程,methodA,methodB,methodC 的棧幀,棧深度為 4。
限制:JVM 對棧深度有一定的限制,一般可以通過 -Xss 參數來調整(-Xss1m 表示將每個線程的棧大小設置為 1MB,棧大小會間接的影響棧深度~~)
舉例:觀察 StackOverFlow 異常(單線程環境下)
上面的 Java 代碼通過一個遞歸方法不斷調用自身,每次調用時增加計數器 stackLength 的值,以此來模擬棧幀的不斷入棧過程,當站深度超過了 JVM 所允許的最大深度,會拋出 StackOverFlowError 異常,捕獲該異常后輸出當前的棧深度。
出現 StackOverflowError 異常的時候,有錯誤堆棧可以閱讀,比較好找到問題所在。如果使用虛擬機的默認參數,棧深度一般情況下可以到達 1000 - 2000,對于正常的方法調用(包括遞歸)。完全夠用了~~
如果是因為多線程導致的內存溢出問題,在不能減少線程數的情況下,只能減少最大堆和減少棧容量的方式來換取更多線程。
舉例:觀察多線程下的內存溢出異常
該 Java 代碼的核心目的是演示因創建大量線程而導致內存溢出的問題。當不斷創建新線程時,每個線程都會占用一定的棧內存。由于系統的內存資源是有限的,當創建的線程數量過多,使得所有線程的棧內存綜合超過了系統所能提供的內存時,就會拋出 OutOfMemoryError 異常,提示”unable to create native thread“,也就是無法創建新的本地線程。
注意:不要輕易嘗試上述代碼~~
JVM 類加載
類加載,指的是 Java 進程運行的時候,需要把 .class 文件從硬盤讀取到內存,并進行一系列校驗解析的過程。 .class 文件 ==》 類對象
類加載的過程
類加載的過程其實是 Java 官方文檔中給出的說明
跳轉網址如下:Java SE Specifications
紅色圈住的表示,該版本的 Java 語言規范(語法是什么樣的)
藍色圈主的表示,該版本的 Java 虛擬機規范(虛擬機是什么樣的)
其實正常來說,我們作為程序員來說,是不需要關注這些具體的加載過程的,需要了解的時候直接來翻一翻文檔即可,但是面試可能要考~~
?對于一個類來說,他的生命周期是這樣的:
其中前 5 步是固定的順序,同時也是累加載的過程,中間 3 步都屬于連接過程,所以對于類加載來說,總共分為如下的幾個步驟:
1. 加載 2. 連接(a. 驗證 b. 準備 c. 解析)3. 初始化
1. 加載
加載(Loading)階段是整個類加載(Class Loading)過程中的一個階段,和類加載是有所不同的,注意不要把二者混為一談~~? ? ? ?
把硬盤上的 .class 文件,找到,打開文件,讀取到文件內容(認為讀取到的是二進制的數據)
(找到硬盤上的 .class 文件這一步還有一些事項注意,我們后面介紹)
2. 驗證
驗證是連接階段的第一步,這一階段的目的是要確保,讀到的 .class 文件中的字節流中包含的信息,是符合《Java 虛擬機規范》的全部約束要求,保證這些信息被當作代碼運行后,不會危害虛擬機自身的安全~~
上述為 Java 類文件結構的描述,這里的描述方式,類似于 C 語言的結構體。
u4 表示 四個字節的無符號整數,u2 表示 兩個字節的無符號整數。Java 中,int 就是四個字節,short 就是兩個字節,但是 C++ 并不是,在 C++ 中程序員往往就會字節通過 typedef 定義出一些類型,往往就是 u2 u4 之類的~~
第一個 magic,也叫做 magic number -- 魔幻數字,廣泛應用于二進制文件格式中,用來表示當前二進制文件的格式是那種類型的。
2,3 用來表示版本號,我們平時說的 Java 8 Java 17 Java 23 什么的,是我們使用的版本,實際上 JVM 開發還有內部的版本。JVM 執行 .class 文件就會驗證版本是否符合要求,一般來說,高版本的 JVM 是可以運行低版本的 .class 的~~
剩下的就是一些類具體的信息啦~~
3. 準備
正式為類中定義的變量(即靜態變量,被 static 修飾的變量)分配內存并且設置類變量初始值的階段,但此時申請到的內存空間,里面的默認值都是全 0 的。
比如有這樣的代碼: public static int value = 123;
此時初始化 value 的 int 值為 0,而不是 123
4. 解析
Java 虛擬機將常量池內的符號引用替換為直接引用的過程,也就是初始化常量的過程。
上面這句話似乎非常拗口難懂,我們可以舉個栗子來解釋一下:
女神電腦壞了,給我們打電話讓我們上門服務,我靠,好機會呀。
1. 符號引用
我們拿著女神的地址:XX小區 X 號樓 X 單元 XXX 室就去了~~~
此時,女神給我們的地址,就是符號引用。
2. 解析過程
我們有了地址,但我們是第一次去女神家,打開了地圖 APP,輸入地址 XX小區 X 號樓 X 單元 XXX 室,地圖通過數據庫查詢,將地址轉換為了 經緯度坐標(北緯 XX.X°,東經 XX.X°),并規劃從當前位置到目的地址的路線。這就類似于虛擬機查找常量池,將符號引用綁定到實際內存地址。
3. 直接引用
地圖最終會給我們顯示具體的路線:沿人民路直行 500 米然后右轉,我們就可以直接按照直接路線找到女神家啦~~
那 Java 虛擬機中是怎么做的呢???
假如有如下代碼:
class Test {private String s = "hello";
}
我們上面的代碼中,是很明確的知道,s 變量里面相當于保存了 “hello”字符串常量的地址。
但是,在文件中,是不存在“地址”這樣的概念的,地址是內存的地址,我們是文件,是在硬盤中的,沒有地址這個概念~~
雖然沒有地址,我們可以存儲一個類似于地址“偏移量”這樣的概念,此時文件中和填充給 s 的“hello”的偏移量,就可以認為是“符號引用”。
接下來,把 .class 問價加載到內存中,就會先把 “hello” 這個字符串加載到內存中,加載到內存中后,“hello” 就有地址了,接下來,s 里面的值就可以替換成當前“hello”真實的地址了,也就是“直接引用”了。
5. 初始化
初始化階段,Java 虛擬機真正開始執行類中編寫的 Java 代碼。同時也針對類對象完成后續的初始化,還要執行靜態代碼塊的邏輯,也可能會觸發父類的初始化。
雙親委派模型(加載環節)
我們在加載的時候,說要在硬盤上找到 .class 文件打開,這個雙親委派模型就描述了如何查找 .class 文件的策略。
JVM 中進行類加載的操作,是有一個專門的模塊,稱為“類加載器”(ClassLoader)。
類加載器的作用:給他一個“全限定類名”(帶有報名的類名),例如 java.lang.String ==》 給定全限定類名之后,能找到對應的 .class 文件。
全限定類名(Fully Qualified Class Name)是指包括包名在內的類的完整名稱,用于在程序中唯一的標識一個類,可以避免類名沖突的問題。
JVM 中的類加載器默認是有 三個 的。(也可以進行自定義)
BootstrapClassLoader --------- 負責查找標準庫的目錄
ExtensionClassLoader --------- 負責查找擴展庫的目錄
(Java 語法規范里面描述了標準庫里面應該有那些功能,但是實現 JVM 的廠商,會在標準庫的基礎上再擴充一些額外的功能~~~不同的廠商擴展可能不太一樣,上古時期用處較大,現在極少用)
ApplicationClassLoader --------- 負責查找當前項目的代碼目錄以及第三方庫的目錄
上面的三個類加載器,存在“父子關系”(不是面向對象中的,父類 子類之間的繼承關系),是類似于“二叉樹”,有一個指針(引用)parent ,指向自己的“父”類加載器。
啟動類加載器:加載 JDK 中 lib 目錄中 Java 的核心類庫,即 JAVA_HOME/lib 目錄。
擴展類加載器:加載 lib/ext 目錄的類
應用戶程序類加載器:加載我們寫的應用程序
自定義類加載器:根據自己的需求定制類加載器
雙親委派模型,描述了上述類加載器之間是如何配合工作的。
雙親委派模型工作流程:
1. 從 ApplicationClassLoader 作為入口,先開始工作。
2. ApplicationClassLoader 不會立即搜索自己負責的目錄,會把搜索的任務交給自己的父親。
3. 代碼進入到 ExtensionClassLoader 范疇了,ExtensionClassLoader 也不會立即搜索自己負責的目錄,而是也把搜索的任務交給自己的父親。
4. 代碼就進入到了 BootstrapClassLoader 范疇了,BootstrapClassLoader 也不想立即搜索自己負責的目錄,也要把搜索的任務交給自己的父親。
5.?BootstrapClassLoader 發現自己沒有父親了,才會真正的搜索負責的目錄(標準庫目錄),通過全限定類名,嘗試在標準目錄中找到符合要求的 .class 文件。
即雙親委派模型,會先以 ApplicationClassLoader 為入口,一點點先向上找父親:
到最上面的 BootstrapClassLoader ,如果他找到了,接下來就直接進入到打開文件 / 讀文件等流程中。
如果沒找到,就會回到孩子這一輩的類加載器中,繼續嘗試加載
6. ExtensionClassLoader 收到父親交回給他的任務之后,就開始進行搜索自己負責的目錄(擴展庫的目錄)
如果找到了,就進入到后續的流程中。
如果沒找到,也是回到孩子這一輩的類加載器中繼續嘗試加載。
7. ApplicationClassLoader 收到父親交回給他的任務之后,就開始搜索自己負責的目錄(當前項目目錄 / 第三方庫目錄)
如果周到了,接下來進入后續流程。
如果沒找到,也是回到孩子這一輩的類加載器中繼續嘗試加載,由于默認情況下,ApplicationClassLoader 沒有孩子了。此時就說明類加載的過程失敗了!!!就會拋出 ClassNotFoundException 的異常了。
則整個工作流程圖為:
雙親委派模型的作用:
確保類的唯一性,避免重復加載:
當類加載器收到加載類的請求時,先將請求委派給父類加載器。若父類加載器已經加載過該類,就不會重復加載,直接返回已經加載的類對象。
例如:在一個大型 Java 項目中,多個模塊可能都依賴同一個類,通過雙親委派模型,這個類只會被加載一次,節省了內存資源。防止了同一個類被不同類加載器加載多次到 JVM 中。
保障核心類庫安全,防止核心類被篡改:
Java 核心類庫(如 java.lang 包下面的類)由啟動類加載器加載。即使有程序員編寫了與核心類庫同名的類,由于雙親委派機制,自定義類加載器在接到加載請求時候,會向上委派給父類加載器,最終由啟動類加載器優先加載核心類庫中的類,而不是加載程序員自定義的同名類,保證了 JVM 運行的安全性和一致性。
提供更好的模塊化支持,實現模塊隔離:
在 Java 應用程序中,不同模塊可能存在同名類。雙親委派模型使得不同模塊的同名類由不同的類加載器(類加載器是由層級關系的),這些類在內存中是相互隔離的。例如,在 Web 應用服務器中,不同 Web 應用的類加載通過雙親委派模型實現隔離,每個 Web 應用的類加載器加載到自己應用路徑下的類,同時共享服務器的公共庫。
上述這一系列規則,只是 JVM 自帶的類加載器默認遵守的規則。如果我們自己寫類加載器,也可以打破上述規則~~
垃圾回收機制(GC)
引入
我們在 C 語言中,學習過動態內存管理,malloc 函數申請內存,free 釋放內存。在 malloc 中,申請到的內存,生命周期是跟隨整個進程的。這一帶你對于 7?* 24 的服務器程序是非常不友好的。服務器每個請求都去 malloc 一塊內存,如果不 free 釋放,就會導致申請的內存越來越多,后續要向申請內存就沒得申請了 ==》 內存泄漏問題。
而我們在實際開發中,的確很容易出現一不小心就忘記調用 free 了,或者是因為一些情況,比如 if -> return 導致 free 沒有被執行到的情況~~
我們能否讓釋放內存的操作,讓程序自動負責完成,而不是依賴于程序員的手工釋放呢???
Java 就屬于早期支持 垃圾回收 的語言。
引入垃圾回收這樣的機制,就不需要手動來進行釋放了,程序會自動判定,某個內存是否會繼續使用,如果內存后續不使用了,就會自動釋放掉。
后世的各種編程語言,大部分都是帶有垃圾回收機制的~~~
垃圾回收機制中還有一個很重要的問題:STW(stop the world)問題。即觸發垃圾回收的時候,很可能會使當前程序的其他的業務邏輯被暫停。
但是隨著 Java 語言這么多年的發展,這么多大佬的不斷風險,GC 的技術積累也越來越強大,有辦法將 STW 的時間控制在 1ms 之內~~
垃圾回收,是回收內存
對于程序計數器,虛擬機棧,本地方法棧這三部分區域而言,其生命周期與相關線程有關,隨線程而生,隨線程而滅。并且這三個區域的內存分配與回收具有確定性。元數據區,一般都是涉及到“類加載”,很少涉及到“類卸載”。因此我們這里所講的內存分配和回收重點關注的是 Java 堆這個區域,這個區域也是 GC 的主要戰場。
這里的垃圾回收,說是回收內存,其實更準確的說是“回收對象”。每次垃圾回收的時候,釋放的若干個對象(實際的單位都是對象)。
垃圾回收,具體是怎樣進行展開的,大致分為兩步:
1)識別出垃圾,那些對象是垃圾(不再進行使用),那些對象不是垃圾
2)把標記為垃圾的對象的內存空間進行釋放。
垃圾回收的過程:
第一步:識別出垃圾
即判定這個對象后續是否還要繼續使用,在 Java 中,使用對象,就一定需要通過引用的方式來使用(當然,有一個例外是 匿名對象,即 new MyThread().start(); 但是,當這行代碼執行完畢之后,對應的 MyThread 對象就會被當作垃圾~~)
如果一個對象沒有任何引用指向他,我們就視為無法被代碼中使用了,就可以作為垃圾了~~
void fun() {Test t = new Test();t.testFun();
}
有上述代碼,Test t = new Test()。通過 new Test 就是在對上創建了對象。
與此同時,因為創建了類型為 Test 的局部變量 t,所以 t 會在棧上有空間,存儲 0x1002 這個地址
當代碼執行到 } 這個右花括弧的時候,此時局部變量 t 就直接被釋放了。此時再進一步,上述的 new Test() 對象,也就沒有引用再指向他了。此時,這個代碼就無法再訪問使用這個對象了,這個 對象就可以被認為是垃圾了~~
如果代碼更加復雜一些,這里的判定過程也就更加麻煩了~~
Test t1 = new Test();
Test t2 = t1;
Test t3 = t2;
Test t4 = t3;
此時就會有很多的引用指向 new Test() 同一個對象了,也就是此時有很多的引用,都保存了 Test 對象的地址。
此時通過任意的引用都能夠訪問 Test 對象,需要確保所有的指向都銷毀了,才能把 Test 對象視為垃圾。
如果代碼更加復雜,上述這些引用的生命周期各不相同,此時情況就不好辦了~~
1. 引用計數算法
引用計數算法的描述
給對象增加一個引用計數器,每當有一個地方引用它的時候,計數器就 +1,當引失效時,計數器就 -1,任何時刻計數器為 0 的對象就是不能再使用的,即對象已“死”。
代碼如下:
Test a = new Test();
當 new Test() 的時候,還是在堆上有 Test 對象的位置,此時還沒有引用變量指向這個 Test 對象,所以計數器的值為 0.
當創建一個 Test 類型的局部變量 a 的時候,Test 對象的前面的引用計數器就會變為 1
此時如果有 Test b = a;的代碼,棧和堆就會產生如下的變化:
當 有代碼 a = null;棧和堆就會產生如下的變化:
同樣的,再把 null 賦值給 b,就會有如下變化:
此時就就可把 Test 對象視為垃圾了~~
在垃圾回收機制,會有專門的掃描線程,去獲取到當前每個對象的引用計數的情況,發現對象的引用計數為 0,說明這個對象就可以釋放了。
引用計數的缺點
引用計數算法實現是非常簡單的,判定的效率也十分高,在大部分情況下,都是一個不錯的算法,比如 Python 語言就采用引用計數法來進行內存管理。但是,主流的 JVM 中并沒有選用引用計數法來管理內存。
問題一:消耗額外的內存空間
要給每個對象都安排一個計數器(如果計數器按照 2 個字節算),如果整個程序中的對象數目非常多,計數器總的消耗的空間也會非常多。尤其是如果每個對象體積比較小(假設每個對象 4 個字節),計數器消耗的空間,已經達到對象的空間的一般了~~
問題二: 引用計數器可能會產生“循環引用”的問題,此時,引用計數器就無法正確工作了。
例如有如下代碼:Test 類中有 Test 類型的成員變量 t。?
class Test {Test t;
}Test a = new Test();
Test b = new Test();a.t = b;
b.t = a;a = null;
b = null;
Test a = new Test(); 和 Test b = new Test();代碼執行完畢后,棧和堆上的狀態如下:
當執行 a.t = b;這行代碼后,棧和堆上的狀態如下:
同樣的,當實行 b.t = a;這行代碼后,棧和堆上的狀態如下:
但是再執行到 a = null;這行代碼,堆和棧上的狀態如下:
再執行 b = null;這行代碼,堆和棧上的狀態如下:
此時代碼就出現問題了,此時的兩個 Test 對象,無法被使用,沒有引用指向他們。但與此同時,他們的引用計數器卻都不是 0!!!
2. 可達性分析
JVM 中使用的就是可達性分析來識別出垃圾~~
這種算法,其實本質上是使用“時間”來換取“空間”的。相比于引用計數,可達性分析需要消耗更多的額外時間,但是總體來說,來是可控的~~· 不會產生類似于“循環引用”這樣的問題。
我們在寫代碼的過程中,會定義很多的變量。
比如,棧上的局部變量/方法區中的靜態類型的變量/常量池中引用的對象...
可以從這些變量為起點,嘗試去進行“遍歷”,所謂比的遍歷,就是沿著這些變量中持有的引用類型的成員,再進一步的往下進行訪問。
所有能被訪問到的對象,自然就不是垃圾,剩下的遍歷一圈也找不到的對象,自然就是垃圾~~
比如有如下代碼:
class Node {char val;Node left;Node right;
}Node buildTree() {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 = buildTree();
會創建出如下圖的二叉樹:
最后一行代碼 Node root = buildTree();雖然這個代碼中,只有一個 root 這樣的引用了,但是,實際上上述 7 個節點對象都是“可達的”。
JVM 中存在掃描線程,會不停的嘗試對代碼中已有的這些變量去進行這些遍歷,盡可能多的去訪問到對象。
如果代碼中出現: root.right = null;此時,c 就不可達了,由于 f 訪問必須要通過 c,c 不可達,就會造成 f 也不可達,此時就會認為 c 和 f 都是垃圾了~~
第二步:把標記為垃圾的對象的內存空間進行釋放
具體如何對標記為垃圾的對象進行釋放,還有一些說法~~
具體的釋放方式有三種
a. 標記 - 清除
把標記為垃圾的對象,直接釋放掉(最樸素的做法)
但一般不會使用這個方案,因為存在 內存碎片化問題,比較致命~~
如上圖,此時就是把標記為垃圾的對象對應的內存空間直接釋放掉 ==》 會產生很多 小的 并且是 離散的空閑內存空間 ==》 就會導致后續申請內存失敗!!!
比如:內存申請,都是一次申請一個連續的內存空間。申請 1M 的內存空間,此時,1M 字節,都是連續的。如果存在很多內存碎片,就可能導致,總的空閑空間,遠遠超過 1M,但是并不存在比 1M 大的連續的空間,此時,雖然有空閑空間,但是我們去申請空間就會失敗~~
注意:我們這里說的是,總的空閑空間比 1M 大,比如此時有 1000 個碎片,每個碎片的大小是 10K,此時總的空閑空間是 10M,但是由于每個碎片最大都是 10K,沒有超過 1M 的,所以我們申請 1M 連續的空閑空間會失敗~~
b. 復制算法
復制算法是為了解決標記 - 清理的效率問題。它會將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這塊內存需要進行垃圾回收的時候,會將區域中還存活著的對象復制到另一塊上面,然后再把已經使用過的內存區域一次清理掉。
這樣做的好處是:每次都是對整個半區進行內存回收,內存分配時也就不需要考慮到內存碎片等復雜情況,只需要移動堆頂指針,按照順序分配即可~~
但缺點也很明顯:1. 總的可用內存變少了(豆漿買兩碗,吃一碗倒一碗)~~~ 2. 如果每次要復制的對象比較多,此時復制的開銷也就很大了。需要是再當前這一輪 GC 的過程中,大部分對象都釋放,少數對象都存活的情況下,適合使用復制算法。
c. 標記 - 整理
這個算法的標記過程于 標記 - 清除 過程一致,但后續步驟不是直接對可回收對象進行清理,而是讓所有存活對象都向一端移動,然后直接清理掉邊界以外的內存即可。
舉個例子理解:想象有一排座位,座位上有的人在(對應存活對象),有的人離開(對應可回收對象)。現在要對作為進行整理,把還在座位上的人往一端集中。比如都往最左邊集中,這樣原本分散在各處的人就都挨在一起了。在這個過程中,每個人要清楚的知道自己的新的位置在哪里,并且其他人如果和自己有聯系(類似對象間的引用關系),也要知道自己的新位置。等所有人都集中到一端后,右邊空出來的作為(對應內存空間)就可以重新安排使用了~~
這個過程,也能有效的解決內存碎片的問題,并且,這個算法,也不會像復制算法一樣,需要浪費過多的內存空間。
但是!這里因為要進行移動對象,搬運內存的開銷也會很大。
因此,JVM 也沒有直接采用這種方案,而是結合上面的思想,搞出了一種“綜合性”的方案,取長補短~~~
JVM 中使用的方案 --- 分代回收(依據不同種類的對象,采取不同的方案)
在這種方案中,引入了一個概念 -- 對象的年齡
JVM 中有專門的線程負責周期性的掃描/釋放。
一個對象,如果被線程掃描到了一次,可達了(不是垃圾),年齡就 +1(初始年齡相當于是 0)
JVM 中就會根據對象年齡的差異,把整個堆內存分成兩個大的部分
==》
新生代(年齡小的對象) /? 老年代(年齡大的對象)
在新生代中,又分出三塊區域,一塊稱為 伊甸區,另外兩塊都稱為 生存區/幸存區(兩塊大小相等的空間)
1)當代碼中 new 出一個新的對象,這個對象就是被創建在伊甸區的。伊甸區中就會有很多的對象。
一個經驗規律:伊甸區中的對象,大部分是活不過第一輪 GC 的。這些對象都是“朝生夕死”的,生命周期非常短!!!
2)第一輪 GC 掃描完成之后,少數伊甸區中幸存的對象,就會通過復制算法,拷貝到幸存區。
后續 GC 的掃描線程還會繼續進行掃描,不僅要掃描伊甸區,也要掃描幸存區的對象。幸存區中的大部分對象也會在掃描中被標記為垃圾,少數存活的,就會再繼續使用復制算法,拷貝到另外一個幸存區中去。
只要這個對象能夠在幸村區中繼續存活,就會被復制算法繼續拷貝到另一半的幸存區中。
每次經歷一輪 GC 掃描,對象的年齡都會 +1
3)如果這個對象在幸存區中,經歷了若干輪 GC 仍然健在~~~
JVM 就會認為,這個對象的生命周期大概率很長,就會把這個對象從幸存區,拷貝到老年代~~~
4)老年代的對象,當然也要被 GC 掃描,但是,掃描的頻次就會大大降低了。
5)對象在老年代“壽終正寢”,此時 JVM 就會按照標記整理的方式,釋放內存~~
即,新生代中,每次垃圾回收都有大批對象死去,只有少量存活,因此我們采用復制算法;而老年代中對象存活率高,就采用“標記 - 清理”或者“標記 - 整理”算法。
上述的分代回收是 JVM 中 GC 的核心思想,但是 JVM 實際的垃圾回收的實現細節上,還會有一定的優化~~~
總結:一個對象的一生
我是一個普通的 Java 對象,出生在 Eden 區,在 Eden 區,我還看到了很多和我長的很像的小兄弟,我們在 Eden 區中玩了挺長時間的。有一天 Eden 區中的人實在是太多了,我就被迫區了 Survivoir 的 “From” 區(S0 區),自從去了 Survivor 區,我就開始飄飄然了,有時候在 Survivor 的“From” 區,有時候在 Survivor 的“To”區(S1 區),居無定所。知道我 18 歲那年,爸爸說我成年了,該到社會上闖蕩一下了。于是我就去了老年代那邊,老年代里面,人很多,并且年齡都挺大的,我也在這里認識了很多人。在老年代里面,我生活了很多年(每次 GC 加一歲),最終被回收了~~~
補充:
JMM
JVM 定義了一種 Java 內存模型(Java Memory Model ==》 JMM)來屏蔽掉各種硬件和操作系統的內存訪問差異,以實現讓 Java 程序在各種平臺下都能一次編譯到處訪問。
在此之前,C/C++ 是直接使用物理硬件和操作系統的內存模型,因此,由于不同平臺下的內存模型的差異,有可能導致程序在一套平臺上并發完全正常,卻在另一臺平臺上并發訪問經常出錯。
1)主內存與工作內存
Java 內存模型的主要目標是定義程序中各個變量的訪問規則,即在 JVM 中將變量存儲到內存和從內存中取出變量這樣的底層細節。此處的變量包括 實例字段,靜態字段和構成數組對象的元素,但不包括局部變量和方法參數,因為后兩者是線程私有的,不會被線程共享。
Java 內存模型規定了所有的變量都存儲在主內存中。每條線程還有自己的工作內存,線程的工作內存中保存了被該線程使用到的變量的主內存的副本拷貝,線程對變量的所有操作(讀取 賦值...),都必須在工作內存中進行,而不能直接讀取主內存中的變量。
不同線程之間也無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要通過主內存來完成。
其關系如下圖所示:
2)內存間交互操作
關于主內存與?作內存之間的具體交互協議,即?個變量如何從主內存中拷?到?作內存、如何從工作內存同步回主內存之類的實現細節,Java內存模型中定義了如下8種操作來完成。
JVM實現時必須保證下面提及的每?種操作的原?的、不可再分的。
- lock(鎖定):作?于主內存的變量,它把?個變量標識為?條線程獨占的狀態。
- unlock(解鎖):作?于主內存的變量,它把?個處于鎖定狀態的變量釋放出來,釋放后的變量才可以被其他線程鎖定。
- read(讀取):作?于主內存的變量,它把?個變量的值從主內存傳輸到線程的?作內存中,以便隨 后的load動作使?。
- load(載?):作?于?作內存的變量,它把read操作從主內存中得到的變量值放??作內存的變量 副本中。
- use(使?):作?于?作內存的變量,它把?作內存中?個變量的值傳遞給執?引擎。
- assign(賦值):作?于?作內存的變量,它把?個從執?引擎接收到的值賦給?作內存的變量。
- store(存儲):作?于?作內存的變量,它把?作內存中?個變量的值傳送到主內存中,以便后續的 write操作使?。
- write(寫?):作?于主內存的變量,它把store操作從?作內存中得到的變量的值放?主內存的變量 中。
Java 內存模型的三大特性:
- 原?性:由Java內存模型來直接保證的原?性變量操作包括read、load、assign、use、store和 read。?致可以認為,基本數據類型的訪問讀寫是具備原?性的。如若需要更?范圍的原?性,需 要synchronized關鍵字約束。(即?個操作或者多個操作要么全部執?并且執?的過程不會被任何 因素打斷,要么就都不執?。
- 可?性:可?性是指當?個線程修改了共享變量的值,其他線程能夠?即得知這個修改。volatile、 synchronized、final三個關鍵字可以實現可?性。
- 有序性:如果在本線程內觀察,所有的操作都是有序的;如果在線程中觀察另外?個線程,所有的 操作都是?序的。前半句是指"線程內表現為串?",后半句是指"指令重排序"和"?作內存與主內存同步延遲"現象。