寫在最前面
這個項目是從20年末就立好的 flag,經過幾年的學習,回過頭再去看很多知識點又有新的理解。所以趁着找實習的准備,結合以前的學習儲備,創建一個主要針對應屆生和初學者的 Java 開源知識項目,專注 Java 后端面試題 + 解析 + 重點知識詳解 + 精選文章的開源項目,希望它能伴隨你我一直進步!
說明:此項目內容參考了諸多博主(已注明出處),資料,N本書籍,以及結合自己理解,重新繪圖,重新組織語言等等所制。個人之力綿薄,或有不足之處,在所難免,但更新/完善會一直進行。大家的每一個 Star 都是對我的鼓勵 !希望大家能喜歡。
注:所有涉及圖片未使用網絡圖床,文章等均開源提供給大家。
項目名: Java-Ideal-Interview
Github 地址: Java-Ideal-Interview - Github
Gitee 地址:Java-Ideal-Interview - Gitee(碼雲)
持續更新中,在線閱讀將會在后期提供,若認為 Gitee 或 Github 閱讀不便,可克隆到本地配合 Typora 等編輯器舒適閱讀
若 Github 克隆速度過慢,可選擇使用國內 Gitee 倉庫
一 JVM 知識問答總結
1. JVM 基礎
1.1 請你談談你對 JVM 的認識和理解
注:此部分在 /docs/java/javase-basis/001-Java基礎知識.md 已經提到過。
JVM 又被稱作 Java 虛擬機,用來運行 Java 字節碼文件(.class
),因為 JVM 對於特定系統(Windows,Linux,macOS)有不同的具體實現,即它屏蔽了具體的操作系統和平台等信息,因此同一字節碼文件可以在各種平台中任意運行,且得到同樣的結果。
1.1.1 什么是字節碼?
擴展名為 .class
的文件叫做字節碼,是程序的一種低級表示,它不面向任何特定的處理器,只面向虛擬機(JVM),在經過虛擬機的處理后,可以使得程序能在多個平台上運行。
1.1.2 采用字節碼的好處是什么?
Java 語言通過字節碼的方式,在一定程度上解決了傳統解釋型語言執行效率低的問題,同時又保留了解釋型語言可移植的特點。所以 Java 程序運行時比較高效,而且,由於字節碼並不專對一種特定的機器,因此,Java程序無須重新編譯便可在多種不同的計算機上運行。
為什么一定程度上解決了傳統解釋型語言執行效率低的問題(參考自思否-scherman ,僅供參考)
首先知道兩點,① 因為 Java 字節碼是偽機器碼,所以會比解析型語言效率高 ② JVM不是解析型語言,是半編譯半解析型語言
解析型語言沒有編譯過程,是直接解析源代碼文本的,相當於在執行時進行了一次編譯,而 Java 的字節碼雖然無法和本地機器碼完全一一對應,但可以簡單映射到本地機器碼,不需要做復雜的語法分析之類的編譯處理,當然比純解析語言快。
1.1.3 你能談一談 Java 程序從代碼到運行的一個過程嗎?
過程:編寫 -> 編譯 -> 解釋(這也是 Java編譯與解釋共存的原因)
首先通過IDE/編輯器編寫源代碼然后經過 JDK 中的編譯器(javac)編譯成 Java 字節碼文件(.class文件),字節碼通過虛擬機執行,虛擬機將每一條要執行的字節碼送給解釋器,解釋器會將其翻譯成特定機器上的機器碼(及其可執行的二進制機器碼)。
1.2 你對類加載器有了解嗎?
定義:類加載器會根據指定class文件的全限定名稱,將其加載到JVM內存,轉為Class對象。
1.2.1 類加載器的執行流程
1.2.1.1 加載
- 通過一個類的全限定名來獲取定義此類的二進制字節流。
- 將這個二進制字節流所代表的靜態存儲結構導入為方法區的運行時數據結構。
- 在java堆中生成一個java.lang.Class對象,來代表的這個類,作為方法區這些數據的入口。
1.2.1.2 鏈接
- 驗證:保證二進制的字節流所包含的信息符號虛擬機的要求,並且不會危害到虛擬機自身的安全。
- 准備:為 static 靜態變量(類變量)分配內存,並為其設置初始值。
- 注:這些內存都將在方法區內分配內存,實例變量在堆內存中,而且實例變量是在對象初始化時才賦值
- 解析:解析階段就是虛擬機將常量池中的符號引用轉化為直接引用的過程。
- 例如 import xxx.xxx.xxx 屬於符號引用,而通過指針或者對象地址引用就是直接引用
1.2.1.3 初始化
- 初始化會對變量進行賦值,即對最初的零值,進行顯式初始化,例如
static int num = 0
變成了static int num = 3
,這些工作都會在類構造器<clinit>()
方法中執行。而且虛擬機保證了會先去執行父類<clinit>()
方法 。- 如果在靜態代碼塊中修改了靜態變量的值,會對前面的顯示初始化的值進行覆蓋
1.2.1.4 卸載
GC 垃圾回收內存中的無用對象
1.2.2 類加載器有哪幾種,加載順序是什么樣的?
JVM 中本身提供的類加載器(ClassLoader)主要有三種 ,除了 BootstrapClassLoader 是 C++ 實現以外,其他的類加載器均為 Java實現,而且都繼承了 java.lang.ClassLoader
- BootStrapClassLoader(啟動類加載器):C++ 實現,JDK目錄/lib 下面的 jar 和類,以及被
-Xbootclasspath
參數指定的路徑中的所有類,都歸其負責加載。 - ExtensionClassLoader: 加載擴展的jar包:負責加載 JRE目錄/lib 下面的 jar 和類,以及被
java.ext.dirs
系統變量所指定的路徑下的 jar 包。 - AppClassLoader:負責加載用戶當前應用下 classpath 下面的 jar 包和類
注:順序為最底層向上
1.2.3 雙親委派機制有了解嗎?
1.2.3.1 概念
雙親委派模型會要求除了頂層的啟動類加載器外,其余的類加載器都應有自己的父類加載器,不過這里的父子關系一般不是通過繼承來實現的,通常是使用組合關系來復用父加載器的代碼
雙親委派模型的工作過程是:如果一個類加載器收到了類加載的請求,他首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載都是如此,因此所有的加載請求都最終應該傳送到最頂層的啟動類加載器中,只有當父加載器反饋自己無法完成這個加載請求(也就是它的范圍搜索中,也沒有找到所需要的類),子加載器才會嘗試自己去完成加載。
1.2.3.2 優點
-
加載位於rt.jar包中的類(例如 java.lang.Object)時不管是哪個加載器加載,最終都會委托最頂端的啟動類加載器 BootStrapClassLoader 進行加載,這樣保證它在各個類加載器環境下都是同一個結果。
-
避免了自定義代碼影響 JDK 的代碼,如果我們自己也創建了一個 java.lang.Object 然后放在程序的 classpath 中,就會導致系統中出現不同的 Object 類,Java 類型體系中最基礎的行為也就無法保證。
public class Object(){
public static void main(){
......
}
}
1.2.3.3 如果不想使用雙親委派模型怎么辦
自定義類加載器,然后重寫 loadClass() 方法
1.3 講一講 Java 內存區域(運行時數據區)
1.3.1 總體概述
Java 程序在被虛擬機執行的時候,內存區域被划分為多個區域,而且尤其在 JDK 1.6 和 JDK 1.8 的版本下,有一些明顯的變化,不過主題結構還是差不多的。
整體主要分為兩個部分:
- 線程共享部分:
- 程序計數器
- 虛擬機棧
- 本地方法棧
- 線程私有部分
- 堆
- 方法區(JDK 1.8 變為了元空間,元空間是位於直接內存中的)
注:我們配圖以 JDK 1.6 為例,至於發生的變化我們在下面有說明
1.3.2 程序計數器
概念:程序計數器是一塊較小的內存空間,可以看作是當前線程所執行的字節碼的行號指示器。
-
作用 1 (流程控制):字節碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等功能都需要依賴這個計數器來完成。
-
作用 2(線程恢復):為了線程切換后能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各線程之間計數器互不影響,獨立存儲,我們稱這類內存區域為“線程私有”的內存。
-
線程切換的原因是:Java 虛擬機的多線程是通過線程輪流切換,分配處理器時間片實現的,所以在任意時刻,一個處理器都(多核處理器來說是一個內核)只能執行一條指令。
1.3.2.1 為什么程序計數器是線程私有的?
答:主要為了線程切換恢復后,能回到自己原先的位置。
1.3.3 Java 虛擬機棧
Java 虛擬機棧描述的是 Java 方法執行的內存模型,每次方法調用時,都會創建一個棧幀,每個棧幀中都擁有:局部變量表、操作數棧、動態鏈接、方法出口信息。
大部分情況下,很多人會將 Java 內存籠統的划分為堆和棧(雖然這樣划分有些粗糙,但是這也能說明這兩者是程序員們最關注的位置),這個棧,其實就是 Java 虛擬機棧,或者說是其中的局部變量表部分。
- 局部變量表主要存放了編譯期可知的各種數據類型(boolean、byte、char、short、int、float、long、double)、對象引用(reference 類型,它不同於對象本身,可能是一個指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄或其他與此對象相關的位置)和 returnAddress 類型(指向一條字節碼指令的地址)
1.3.3.1 Java 虛擬機棧會出現哪兩種錯誤?
-
StackOverFlowError
: 如果 Java 虛擬機棧容量不能動態擴展,而此時線程請求棧的深度超過當前 Java 虛擬機棧的最大深度的時候,就拋出 StackOverFlowError 錯誤。 -
OutOfMemoryError
: 如果 Java 虛擬機棧容量可以動態擴展,當棧擴展的時候,無法申請到足夠的內存(Java 虛擬機堆中沒有空閑內存,垃圾回收器也沒辦法提供更多內存)
1.3.4 本地方法棧
和虛擬機棧所發揮的作用非常相似,其區別是: 虛擬機棧為虛擬機執行 Java 方法 (也就是字節碼)服務,而本地方法棧則為虛擬機使用到的 Native 方法服務。
- 因為本地方法棧中的方法,使用方式,數據結構,Java虛擬機規范,未做強制要求,具體的虛擬機可以自由的自己實現,例如:HotSpot 虛擬機中和 Java 虛擬機棧合二為一。
與虛擬機棧相同,在棧深度溢出,以及棧擴展失敗的時候,也會出現 StackOverFlowError
和 OutOfMemoryError
兩種錯誤。
1.3.4.1 虛擬機棧和本地方法棧為什么是私有的?
答:主要為了保證線程中的局部變量不被別的線程訪問到
1.3.5 堆
Java 虛擬機所管理的內存中最大的一塊,Java 堆是所有線程共享的一塊內存區域,在虛擬機啟動時創建。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例以及數組都在這里分配內存。
- 但是,隨着即時編譯技術的進步,尤其是逃逸分析技術日漸強大,棧上分配、標量替換優化技術將導致了一些微妙的變化,所以,所有的對象都在堆上分配也漸漸變得不那么“絕對”了。
- JDK 1.7 已經默認開啟逃逸分析,如果某些方法中的對象引用沒有被返回或者未被外面使用(即未逃逸出去),那么對象可以直接在棧上分配內存。
補充:Java 堆是垃圾收集器管理的主要區域,因此也被稱作 GC 堆(Garbage Collected Heap)
1.3.6 方法區
方法區與 Java 堆一樣,是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯后的代碼等數據。雖然 Java 虛擬機規范把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做 Non-Heap(非堆),目的應該是與 Java 堆區分開來。
注意:JDK1.7 開始,到 JDK 8 版本之后方法區(HotSpot 的永久代)被徹底移除了,變成了元空間,元空間使用的是直接內存。
1.3.6.1 永久代是什么
在JD K1.8之前,許多Java程序員都習慣在 hotspot 虛擬機上開發,部署程序,很多人更願意把方法去稱呼為永久代,或者將兩者混為一談,本質上這兩者不是等價的,因為僅僅是當時 hotspot 虛擬機設計團隊選擇把收集器的分代設計擴展至方法區,或者說使用永久代來實現方法區而已,這樣使得 hotspot 的垃圾收集器能夠像管理 Java 堆一樣管理這部分內存,省去專門為方法去編寫內存管理代碼的工作,但是對於其他虛擬機實現是不存在永久代的概念的。
1.3.6.2 永久代為什么被替換成了元空間?
- 永久代大小上限為固定的,無法調整修改。而元空間使用直接內存,與本機的可用內存有關,大大減少了溢出的幾率
- JRockit 想要移植到 HotSpot 虛擬機的時候,因為兩者對方法區的實現存在差異面臨很多困難,所以 JDK 1.6 的時候 HotSpot 開發團隊就有了放棄永久代,逐漸改變為本地內存的計划,到 JDK 1.7已經把原本放在永久代的字符串常量池,靜態變量等移出,而到了JDK 1.8 完全放棄了永久代。
1.3.7 運行時常量池
運行時常量池是方法區的一部分。Class 文件中除了有類的版本、字段、方法、接口等描述信息外,還有常量池表(用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類加載后存放到方法區的運行時常量池中)
1.3.7.1 方法區的運行時常量池在 JDK 1.6 到 JDK 1.8 的版本中有什么變化?
- 根據上面的 Java 內存區域圖可知,JDK 1.6 方法區(HotSpot 的永久代)中的運行時常量池中包括了字符串常量池,
- JDK 1.7 版本下,字符串常量池從方法區中被移到了堆中(注:只有這一個移動了)
- JDK 1.8 版本下,HotSpot 的永久代變為了元空間,字符串常量池還在堆中,運行時常量也還在方法區中,只不過方法區變成了元空間
1.3.7 直接內存
直接內存並不是虛擬機運行時數據區的一部分,也不是虛擬機規范中定義的內存區域,但是這部分內存也被頻繁地使用。而且也可能導致 OutOfMemoryError 錯誤出現。
1.4 Java 對象創建訪問到死亡
1.4.1 Java 對象的創建(JVM方向)
1.4.1.1 類加載檢查
- 概念:JVM(此處指 HotSpot)遇到 new 指令時,先檢查指令參數是否能在常量池中定位到一個類的符號引用。
- A:如果能定位到,就檢查這個符號引用代表的類是否已被加載、解析和初始化過。
- B:如果不能定位到,或沒有檢查到,就先執行相應的類加載過程。
1.4.1.2 為對象分配內存
概念:加載檢查和加載后,就是分配內存,對象所需內存的大小在類加載完成后便完全確定(對象的大小 JVM 可以通過Java對象的類元數據獲取)為對象分配內存相當於把一塊確定大小的內存從Java堆里划分出來。
- ① 分配方式
- A: 指針碰撞:中間有一個區分邊界的指針,兩邊分別是用過的內存區域,和沒用過的內存區域,分配內存的時候,就向着空閑的那邊移動指針。
- 適用於:Java 堆是規整的情況下。
- 應用:Serial 收集器、ParNew 收集器
- B: 空閑列表:維護一個列表,其中記錄哪些內存可用,分配時會找到一塊足夠大的內存來划分給對象實例,然后更新列表。
- 適用於:堆內存不是很規整的情況下。
- 應用:CMS 收集器
- A: 指針碰撞:中間有一個區分邊界的指針,兩邊分別是用過的內存區域,和沒用過的內存區域,分配內存的時候,就向着空閑的那邊移動指針。
注:Java 堆是否規整,取決於 GC 收集器的算法是什么,如 “標記-清除” 就是不規整的,“標記-整理(壓縮)” 、 “復制算法” 是規整的。這幾種算法我們后面都會分別講解。
-
② 線程安全問題
-
並發情況下,上述兩種分配方式都不是線程安全的,JVM 虛擬機提供了兩種解決方案
-
A:同步處理:CAS + 失敗重試
-
CAS的全稱是 Compare-and-Swap,也就是比較並交換。它包含了三個參數:V:內存值 、A:當前值(舊值)、B:要修改成的新值
CAS 在執行時,只有 V 和 A 的值相等的情況下,才會將 V 的值設置為 B,如果 V 和 A 不同,這說明可能其他線程已經做了更新操作,那么當前線程值就什么也不做,最后 CAS 返回的是 V 的值。
在多線程的的情況下,多個線程使用 CAS 操作同一個變量的時候,只有一個會成功,其他失敗的線程,就會繼續重試。
正是這種機制,使得 CAS 在沒有鎖的情況下,也能實現安全,同時這種機制在很多情況下,也會顯得比較高效。
-
-
B:本地線程分配緩沖區:TLAB
- 為每一個線程在 Java 堆的 Eden 區分配一小塊內存,哪個線程需要分配內存,就從哪個線程的 TLAB 上分配 ,只有 TLAB 的內存不夠用,或者用完的情況下,再采用 CAS 機制
-
1.4.1.3 對象初始化零值
內存分配結束后,執行初始化零值操作,即保證對象不顯式初始化零值的情況下,程序也能訪問到零值
1.4.1.4 設置對象頭
初始化零值后,顯式賦值前,需要先對對象頭進行一些必要的設置,即設置對象頭信息,類元數據的引用,對象的哈希碼,對象的 GC 分代年齡等。
1.4.1.5 執行對象 init 方法
此處用來對對象進行顯式初始化,即根據程序者的意願進行初始化,會覆蓋掉前面的零值
1.4.2 對象的訪問定位方式哪兩種方式?
首先舉個例子: Student student = new Student();
假設我們創建了這樣一個學生類,Student student 就代表作為一個本地引用,被存儲在了 JVM 虛擬機棧的局部變量表中,此處代表一個 reference 類型的數據,而 new Student 作為實例數據存儲在了堆中。還保存了對象類型數據(類信息,常量,靜態變量)
而我們在使用對象的時候,就是通過棧上的這個 reference 類型的數據來操作對象,它有兩種方式訪問這個具體對象
- 句柄:在堆中划分出一塊內存作為句柄池,reference 中存儲的就是對象的句柄地址,而句柄中存儲着對象實例數據和數據類型的地址。這種方式比較穩定,因為對象移動的時候,只改變句柄中的實例數據的指針,reference 是不需要修改的。
- 直接指針:即 reference 中存儲的就是對象的地址。這種方式的優勢就是快速,因為少了一次指針定位的開銷
句柄方式配圖:
直接指針方式配圖:
1.4.3 如何判斷對象死亡
堆中幾乎放着所有的對象實例,對堆垃圾回收前的第一步就是要判斷哪些對象已經死亡(即不能再被任何途徑使用的對象)。
- 引用計數法:給對象中添加一個引用計數器,每當有一個地方引用它,計數器就加1;當引用失效,計數器就減1;任何時候計數器為0的對象就是不可能再被使用的。
- 引用計數法原理簡單,判定效率也很高,在很多場景下是一個不錯的算法,但是在主流的 Java 領域中,因為其需要配合大量額外處理才能保證正確地工作。
- 例如它很難解決兩個對象之間循環引用的問題:對象 objA 和 objB 均含有 instance 字段,賦值令 objA.instance = objB, objB.instance = objA,除此之外,這兩個對象已經再無引用,實際上這兩個已經不可能被再訪問了,因為雙方互相因喲紅着對方,它們的引用計數不為零,引用技術算法也就無法回收它們。
- 引用計數法原理簡單,判定效率也很高,在很多場景下是一個不錯的算法,但是在主流的 Java 領域中,因為其需要配合大量額外處理才能保證正確地工作。
- 可達性分析算法:這個算法的基本思想就是通過一系列的稱為 “GC Roots” 的對象作為起點,從這些節點開始向下搜索,節點所走過的路徑稱為引用鏈,當一個對象到 GC Roots 沒有任何引用鏈相連的話,則證明此對象是不可能再被使用的
1.4.3.1 四種引用類型的程度
無論是引用計數算法,還是可達性分析算法,判定對象的存活都與引用有關,但是 JDK 1.2 之間的版本,將對象的引用狀態分為 “被引用” 和 “未被引用” 實際上有些狹隘,描述一些食之無味,棄之可惜的對象就有一些無能為力,所以1.2 之后邊進行了更細致的划分。
JDK1.2之前,引用的概念就是,引用類型存儲的是一塊內存的起始地址,代表這是這塊內存的一個引用。
JDK1.2以后,細分為強引用、軟引用、弱引用、虛引用四種(逐漸變弱)
- 強引用:垃圾回收器不會回收它,當內存不足的時候,JVM 寧願拋出 OutOfMemoryError 錯誤,也不願意回收它。
- 軟引用:只有在內存空間不足的情況下,才會考慮回收軟引用。
- 弱引用:弱引用比軟引用聲明周期更短,在垃圾回收器線程掃描它管轄的內存區域的過程中,只要發現了弱引用對象,就會回收它,但是因為垃圾回收器線程的優先級很低,所以,一般也不會很快發現並回收。
- 虛引用:級別最低的引用類型,它任何時候都可能被垃圾回收器回收
1.5 講講幾種垃圾收集算法
1.5.1 標記清除算法
標記清除算法首先標記出所有不需要回收的對象,在標記完成后統一回收掉所有沒有被標記的對象,也可以反過來。
它的主要缺點有兩個:
- 第1個是執行效率不穩定,如果Java最終含有大量對象,而且其中大部分都是需要回收的,這是需要進行大量標記和清除動作,導致標記和清除兩個過程的執行效率隨着對象數量增長而降低。
- 第2個是內存空間的碎片化問題,標記清除后會產生大量不存連續的內存碎片空間,碎片太多會導致以后程序運行時需要分配較大對象時無法找到足夠的連續內存,而不得不提前觸發一次垃圾收集工作。
它屬於基礎算法,后續的大部分算法,都是在其基礎上改進的。
1.5.2 標記復制算法
標記復制算法將可用內存按容量划分為大小相等的兩塊,每次只使用其中的一塊,當這一塊的內存用完了就將還存活着的對象復制到另一塊上面,然后再把已經使用過的內存空間再次清理掉。
缺點:如果內存中多數對象都是存活的,這種算法將會產生大量的內存間復制的開銷。****
優點:
- 但是對於多數對象都是可回收的情況,算法需要復制的就是占有少數的存活對象
- 每次都是針對整個半區進行內存回收,分配內存時,也不用考慮有空間碎片的復雜情況,只要移動堆頂指針按順序分配即可
1.5.3 標記整理算法(標記壓縮算法)
標記復制算法在對象存活率較高的時候就要進行較多的復制,操作效率將會降低,更關鍵的是如果不想浪費50%的空間,就需要有額外的空間進行分配擔保以應對唄,使用內存所有對象都百分百存活的極端情況,所以在老年代一般是不采用這種算法的。
標記整理算法與標記清除算法一致,但后續步驟不是直接對可回收對象進行清理,而是讓所有存貨的對象都向內存空間一端移動,然后直接清理掉邊界以外的內存
但移動存活對象也是有缺點的:尤其是在老年代這種每次回收都有大量對象存活的區域,移動存活對象並更新所有引用這些對象的地方,將會是一種極為負重的操作,而且這種對象移動操作必須全程暫停用戶應用進程才能進行,這種停頓被稱為 stop the world。
1.6 什么是分代收集算法
分代收集理論,首先它建立在兩個假說之上:
- 弱分代假說:絕大多數對象都是朝生夕滅的
- 強分帶假說:熬過越多次垃圾收集過程的對象就越難以消亡
所以多款常用垃圾收集器的一致設計原則即為:收集器應該將 Java 堆划分出不同的區域,然后將回收對象依據其年齡(即熬過垃圾收集過程次數)分配到不同的區域之中存儲。
很明顯的,如果一個區域中大部分的對象都是朝生夕滅,難以熬過垃圾收集過程,那么把它們集中放在一起,每次回收就只需要考慮如何保留少量存活的對象,而不是去標記那些大量要被回收的對象,這樣就能以一種比較低的代價回收大量空間,如果剩下的都是難以消亡的對象,就把它們集中到一塊,虛擬機便可以使用較低的頻率來回收這個區域。
所以,分代收集算法的思想就是根據對象存活周期的不同,將內存分為幾塊,例如分為新生代(Eden 空間、From Survivor 0、To Survivor 1 )和老年代,然后再各個年代選擇合適的垃圾收集算法
- 新生代 ( Young ) 與老年代 ( Old ) 的比例的值為 1:2
- Edem : From Survivor 0 : To Survivor 1 = 8 : 1 : 1
新生代中每次都會有大量對象死去,所以選擇清除復制算法,要比標記清除更高效,只需要復制移動少量存活下來的對象即可。
老年代中對象存活的幾率比較高,所以要選擇標記清除或者標記整理算法。
1.6.1 為什么新生代要分為Eden區和Survivor區?
注:此處參考引用博文:為什么新生代內存需要有兩個Survivor區 注明出處,請尊重原創
補充:
- Minor GC / Young GC :新生代收集
- Major GC / Old GC :老年代收集
- Full GC 整堆收集
如果沒有Survivor,Eden區每進行一次Minor GC,存活的對象就會被送到老年代。老年代很快被填滿,觸發Major GC(因為Major GC一般伴隨着Minor GC,也可以看做觸發了Full GC)。老年代的內存空間遠大於新生代,進行一次Full GC消耗的時間比Minor GC長得多。你也許會問,執行時間長有什么壞處?頻發的Full GC消耗的時間是非常可觀的,這一點會影響大型程序的執行和響應速度,更不要說某些連接會因為超時發生連接錯誤了。
- Survivor的存在意義,就是減少被送到老年代的對象,進而減少Full GC的發生,Survivor的預篩選保證,只有經歷16次Minor GC還能在新生代中存活的對象,才會被送到老年代。
1.6.2 為什么要設置兩個Survivor區?(有爭議,待修改)
引用博文的作者觀點:設置兩個Survivor區最大的好處就是解決了碎片化,剛剛新建的對象在Eden中,經歷一次Minor GC,Eden中的存活對象就會被移動到第一塊survivor space S0,Eden被清空;等Eden區再滿了,就再觸發一次Minor GC,Eden和S0中的存活對象又會被復制送入第二塊survivor space S1(這個過程非常重要,因為這種復制算法保證了S1中來自S0和Eden兩部分的存活對象占用連續的內存空間,避免了碎片化的發生)。S0和Eden被清空,然后下一輪S0與S1交換角色,如此循環往復。如果對象的復制次數達到16次,該對象就會被送到老年代中。
個人觀點,更本質是考慮了效率問題,如果是因為產生了碎片的問題,我完全可以使用標記整理方法解決,我更傾向於理解為整理空間帶來的性能消耗是遠大於使用兩塊 survivor 區進行復制移動的消耗的。
注:如果這一塊不清楚,可以參考一下引用文章的圖片。
1.6.3 哪些對象會直接進入老年代
-
大對象直接進入老年代
- 在分配空間時它容易導致內存,明明還有不少空間時就提前觸發垃圾收集,以獲取足夠的連續空間才能好安置他們,而當復制對象時大對象就意味着高額的內存復制開銷,這樣做的目的就是避免在 Eden區 以及兩個 survivor 區之間來回復制產生大量的內存復制操作
-
長期存活的對象進入老年代
-
HotSpot 虛擬機采用了分代收集的思想來管理內存,那么內存回收時就必須能識別哪些對象應放在新生代,哪些對象應放在老年代中。為了做到這一點,虛擬機給每個對象一個對象年齡(Age)計數器,存儲在對象頭中。
如果對象在 Eden 出生並經過第一次 Minor GC 后仍然能夠存活,並且能被 Survivor 容納的話,將被移動到 Survivor 空間中,並將對象年齡設為 1.對象在 Survivor 中每熬過一次 MinorGC,年齡就增加 1 歲,當它的年齡增加到一定程度(默認為 15 歲),就會被晉升到老年代中。對象晉升到老年代的年齡閾值,可以通過參數
-XX:MaxTenuringThreshold
來設置。
-
1.6.3 動態對象年齡判定
為了能更好的適應不同程序的內存狀況,HotSpot 虛擬機並不是永遠要求對象年齡必須達到 -XX:MaxTenuringThreshold,才能晉升老年代,如果在 Survivor 空間中相同年齡所有對象大小的總和大於 Survivor 空間的一半,年齡大於或等於該年齡的對象就可以直接進入老年代。
1.7 介紹一下常見的垃圾回收器
1.7.1 Serial 收集器
Serial 收集器是最基本、歷史最悠久的垃圾收集器了。在 JDK 1.3.1 之前是 HotSpot 虛擬機新生代收集器的唯一選擇,大家看名字就知道這個收集器是一個單線程收集器了。它的 “單線程” 的意義不僅僅意味着它只會使用一條垃圾收集線程去完成垃圾收集工作,更重要的是它在進行垃圾收集工作的時候必須暫停其他所有的工作線程( "Stop The World" ),直到它收集結束。
- 新生代采用復制算法,老年代采用標記-整理算法。
對於 "Stop The World" 帶給用戶的惡劣體驗早期 HotSpot 虛擬機的設計者們表示完全理解,但也表示委屈:你媽媽在給你打掃房間的時候,肯定會讓你老老實實的在椅子上或者房間外等待,如果她一邊打掃你一邊亂扔紙屑,這房間還能打掃完嗎?這其實是一個合情合理的矛盾,雖然垃圾收集這項工作聽起來和打掃房間屬於一個工種,但實際上肯定要比打掃房間復雜很多。
雖然從現在看來,這個收集器已經老而無用,棄之可惜,但是它仍然是 HotSpot 虛擬機在客戶端模式下默認的新生代收集器,因為其有着優秀的地方,就是簡單而又高效,內存消耗也是最小的。
1.7.2 ParNew 收集器
ParNew 收集器其實就是 Serial 收集器的多線程版本,除了使用多線程進行垃圾收集外,其余行為(控制參數、收集算法、Stop The World、對象分配規則、回收策略等)和 Serial 收集器完全一樣。
它除了支持多線程並行收集之外,與 Serial 收集器相比沒有太多的創新之處,但卻是不少運行在Server 服務端模式下的 HotSpot 虛擬機的選擇。
- 新生代采用復制算法,老年代采用標記-整理算法。
1.7.3 Parallel Scavenge 收集器
Parallel Scavenge 收集器也是基於標記-復制算法的多線程收集器,看起來和 ParNew 收集器很相似。
Parallel Scavenge 的目標是達到一個可控制的吞吐量(處理器用於運行這個程序的時間和處理器總消耗的時間之比),即高效利用 CPU,同時它也提供了很多參數供用戶找到最合適的停頓時間或最大吞吐量。
1.7.4 Serial Old 收集器
Serial 收集器的老年代版本,它同樣是一個單線程收集器。其主要意義還是提供客戶端模式下的 HotSpot 虛擬機使用。
- 如果實在服務端的模式下,也可能有兩種用途:
- 一種用途是在 JDK1.5 以及以前的版本中與 Parallel Scavenge 收集器搭配使用,
- 一種用途是作為 CMS 收集器的后備方案。
1.7.5 Parallel Old 收集器
Parallel Scavenge 收集器的老年代版本。也是一個基於 “標記-整理”算法的多線程收集器。在注重吞吐量以及 CPU 資源的場合,都可以優先考慮 Parallel Scavenge 收集器和 Parallel Old 收集器。
1.7.6 CMS 收集器
CMS(Concurrent Mark Sweep) 收集器是一種以獲得最短回收停頓時間為目標的收集器,能給用戶帶來比較好的交互體驗。基於標記清除算法。
- 初始標記: 初始標記僅僅是標記一下 GC Roots 能 直接關聯到的對象,速度很快
- 並發標記:並發標記就是從 GC Roots 的直接關聯對象,開始遍歷整個對象圖的過程,這個過程耗時較長,但不需要停頓,用戶線程可以與垃圾收集線程一起並發執行
- 重新標記: 重新標記階段就是為了修正並發標記期間,因為用戶程序繼續運行而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記階段的時間稍長,遠遠比並發標記階段時間短。
- 並發清除: 最后是並發清除階段,清理刪除掉標記階段判斷的已經死亡的對象,由於不需要移動存活對象,所以這個階段也是可以與用戶線程並發的。
1.7.7 G1 收集器
G1 (Garbage-First) 是一款面向服務器的垃圾收集器,主要針對配備多顆處理器及大容量內存的機器. 以極高概率滿足 GC 停頓時間要求的同時,還具備高吞吐量性能特征。同時不會產生碎片。
-
初始標記:僅僅是標記一下 GC Roots 能直接關聯到的對象,並且修改 TAMS 指針的值,讓下一階段用戶線程並發運行時能正確的在可用的 Region中分配新對象,這個階段需要停頓線程,但耗時很短,而且是借用進行Minor GC 的時候同步完成的,所以 G1 收集器在這個階段其實沒有額外的停頓。
-
並發標記:從GC Root 開始,對堆中對象進行可達性分析,遞歸掃描整個堆里的對象圖找出要回收對象,這階段耗時較長,但可以與用戶程序並發執行,當對象掃描完成后,還要重新處理 SATB 記錄下的,在並發時有引用變動的對象。
-
最終標記:對用戶現場做另一個短暫的暫停,用於處理並發階段結束后仍遺留下來最后那少量的 SATB 記錄。
-
篩選回收:負責更新 Region 的統計數據,對各個 Region 的回收價值和成本進行排序,根據用戶所期望的停頓時間來制定回收計划,可以自由選擇任意多個 Region 構成回收集,然后決定回收的那一部分 Region 的存貨對象復制到空的 Region 中,在清理到整個就 Region 的全部空間,這里面涉及操作存活對象的移動是必須暫停用戶線程,由多條收集線程並行完成的
優點和特點:
- G1 能在充分利用 CPU 的情況下,縮短 Stop-The-World 的時間,GC 時為並發狀態,不會暫停 Java 程序運行。
- 保留了分代概念,但是它其實可以獨立管理整個 GC 堆。
- G1 從整理上看是基於標記整理算法實現的,從局部上看是基於標記復制算法的。
- G1 除了追求停頓以外,還建立了可以預測的停頓時間模型,能讓使用者明確指定在一個長度為 M 毫秒的時間片段內。