本篇博客給大家帶來的是JVM的知識點, 重點在類加載和垃圾回收機制上.
🐎文章專欄: JavaEE初階
🚀若有問題 評論區見
? 歡迎大家點贊 評論 收藏 分享
如果你不知道分享給誰,那就分享給薯條.
你們的支持是我不斷創作的動力 .
王子,公主請閱🚀
- 要開心
- 要快樂
- 順便進步
- 1. JVM簡介
- 1.1 HotSpot VM
- 1.2 Taobao JVM(國產研發)
- 1.3 JDK JRE JVM三者關系(經典面試題)
- 2. JVM 運行流程
- ★3. JVM運行時數據區(內存區域)
- 3.1 堆(線程共享)
- 3.2 Java虛擬機棧(線程私有)/本地方法棧
- 3.3 程序計數器(線程私有)
- 3.4 元數據區(方法區)
- ★4. JVM類加載
- 4.1 類加載過程
- 4.1.1 加載
- 4.1.2 驗證
- 4.1.3 準備
- 4.1.4 解析
- 4.1.5 初始化
- 4.2 雙親委派模型
- 4.2.1 什么是雙親委派模型?
- 4.2.2 雙親委派模型的工作過程.
- 4.3 雙親委派模型優點
- 4.4 破壞雙親委派模型
- 5. 垃圾回收機制
- 5.1 死亡對象判斷算法.
- 5.1.1 引用計數算法.
- 5.1.2 可達性分析算法
- 5.2 垃圾回收算法
- 5.2.1 標記-清除算法
- 5.2.2 復制算法
- 5.2.3 標記-整理算法
- 5.2.4 分代算法
要開心
要快樂
順便進步
1. JVM簡介
JVM 是 Java Virtual Machine 的簡稱,意為 Java虛擬機。
虛擬機是指通過軟件模擬的具有完整硬件功能的、運行在一個完全隔離的環境中的完整計算機系統。
常見的虛擬機:JVM、VMwave、Virtual Box.
JVM 和其他兩個虛擬機的區別:
① VMwave與VirtualBox是通過軟件模擬物理CPU的指令集,物理系統中會有很多的寄存器;
② JVM則是通過軟件模擬Java字節碼的指令集,JVM中只是主要保留了PC寄存器,其他的寄存器都進行了裁剪.
JVM 是一臺被定制過的現實當中不存在的計算機.
1.1 HotSpot VM
HotSpot最初由一家名為“Longview Technologies”的小公司設計;
1997年,此公司被Sun收購;2009年,Sun公司被甲骨文收購。
JDK1.3時,HotSpot VM成為默認虛擬機
目前 HotSpot 占用絕對的市場地位,稱霸武林。
不管是現在仍在廣泛使用JDK6,還是使用比較多的JDK8中,默認的虛擬機都是HotSpot;
Sun/Oracle JDK和OpenJDK的默認虛擬機。從服務器、桌面到移動端、嵌入式都有應用。
名稱中的HotSpot指的就是它的熱點代碼探測技術。它能通過計數器找到最具編譯價值的代碼,觸發即時編譯(JIT)或棧上替換;通過編譯器與解釋器協同工作,在最優化的程序響應時間與最佳執行性能中取得平衡。
1.2 Taobao JVM(國產研發)
阿里是國內使用Java最強大的公司,覆蓋云計算、金融、物流、電商等眾多領域,需要解決高并發、高可用、分布式的復合問題。有大量的開源產品。
基于OpenJDK 開發了自己的定制版本AlibabaJDK,簡稱AJDK。是整個阿里JAVA體系的基石;
基于OpenJDK HotSpot JVM發布的國內第一個優化、深度定制且開源的高性能服務器版Java虛擬機,
它具有以下特點(了解):
1. 創新的GCIH(GC invisible heap)技術實現了off-heap,即將生命周期較長的Java對象從heap中移到heap之外,并且GC不能管理GCIH內部的Java對象,以此達到降低GC的回收評率和提升GC的回收效率的目的;
2. GCIH中的對象還能夠在多個Java虛擬機進程中實現共享;
3. 使用crc32指令實現JVM intrinsic降低JNI的調用開銷;
4. PMU hardware的Java profiling tool和診斷協助功能;
5. 針對大數據場景的ZenGC。
taobaoJVM應用在阿里產品上性能高,硬件嚴重依賴intel的cpu,損失了兼容性,但提高了性能,目前已經在淘寶、天貓上線,把Oracle官方JVM版本全部替換了.
1.3 JDK JRE JVM三者關系(經典面試題)
JDK(Java Development Kit):Java開發工具包,提供給Java程序員使用,包含了JRE,同時還包含了編譯器 javac 與自帶的調試工具Jconsole、jstack等.
JRE(Java Runtime Environment):Java運行時環境,包含了JVM,Java基礎類庫。是使用Java語言編寫程序運行的所需環境.
JVM:Java虛擬機,運行Java代碼.
2. JVM 運行流程
JVM 是 Java 運行的基礎,也是實現一次編譯到處執行的關鍵,那么 JVM 是如何執行的呢?
Java程序在執行之前先要把 java 代碼轉換成字節碼(class文件),JVM 首先需要把字節碼通過一定的方式類加載器(ClassLoader) 把文件加載到內存中 運行時數據區(Runtime Data Area) ,而字節碼文件是 JVM 的一套指令集規范,并不能直接交給底層操作系統去執行,因此需要特定的命令解析器執行引擎(Execution Engine)將字節碼翻譯成底層系統指令再交由CPU去執行,而這個過程中需要調用其他語言的接口 本地庫接口(Native Interface) 來實現整個程序的功能,這就是這4個主要組成部分的職責與功能。
JVM 主要通過分為以下 4 個部分,來執行 Java 程序的,它們分別是:
1. 類加載器(ClassLoader)
2. 運行時數據區(Runtime Data Area)
3. 執行引擎(Execution Engine)
4. 本地庫接口(Native Interface)
★3. JVM運行時數據區(內存區域)
JVM 運行時數據區域也叫內存布局,但需要注意的是它和 Java 內存模型((Java Memory Model,簡
稱 JMM)完全不同,屬于完全不同的兩個概念,它由以下 五 大部分組成:
3.1 堆(線程共享)
代碼中 new 出來的對象,都是在堆里, 對象中持有的非靜態成員變量,也在堆里.
堆里面分為兩個區域:新生代和老生代,新生代放新建的對象,當經過一定 GC 次數之后還存活的對象會放入老生代。新生代還有 3 個區域:一個 Endn + 兩個 Survivor(S0/S1).
垃圾回收的時候會將 Endn 中存活的對象放到一個未使用的 Survivor 中,并把當前的 Endn 和正在使用的 Survivor 清除掉.
3.2 Java虛擬機棧(線程私有)/本地方法棧
Java 虛擬機棧的作用:Java 虛擬機棧的生命周期和線程相同,Java 虛擬機棧描述的是 Java 方法執行的內存模型: 每個方法在執行的同時都會創建一個棧幀(Stack Frame)用于存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。咱們常說的堆內存、棧內存中,棧內存指的就是虛擬機棧.
1. 局部變量表: 存放了編譯器可知的各種基本數據類型(8大基本數據類型)、對象引用。局部變量表所需的內存空間在編譯期間完成分配,當進入一個方法時,這個方法需要在幀中分配多大的空間是完全確定的,在執行期間不會改變局部變量表大小. 簡單來說就是存放方法參數和局部變量.
2. 操作棧:每個方法會生成一個先進后出的操作棧.
3. 動態鏈接:指向運行時常量池的方法引用.
4. 方法返回地址:PC 寄存器的地址.
什么是線程私有?
由于JVM的多線程是通過線程輪流切換并分配處理器執行時間的方式來實現,因此在任何一個確定的時刻,一個處理器(多核處理器則指的是一個內核)都只會執行一條線程中的指令。因此為了切換線程后能恢復到正確的執行位置,每條線程都需要獨立的程序計數器,各條線程之間計數器互不影響,獨立存儲。我們就把類似這類區域稱之為"線程私有"的內存。
本地方法棧和虛擬機棧類似,只不過 Java 虛擬機棧是給 JVM 使用的,而本地方法棧是給本地方法使用的。
此處談到的“堆” "棧"和數據結構中的"堆” "棧"是不同的! 面試中如果被問到堆和棧, 一定要反問面試官,搞清楚問的是哪個堆,哪個棧?
3.3 程序計數器(線程私有)
程序計數器的作用:用來記錄當前線程執行的行號的. 也是用來存儲下一條要執行的 java 指令的地址.
如果當前線程正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址;如果正在執行的是一個Native方法,這個計數器值為空.
3.4 元數據區(方法區)
方法區的作用:用來存儲被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯后的代碼等數據的.
運行時常量池是方法區的一部分,存放字面量與符號引用.
字面量 : 字符串(JDK 8 移動到堆中) 、final常量、基本數據類型的值.
符號引用 : 類和結構的完全限定名、字段的名稱和描述符、方法的名稱和描述符.
★4. JVM類加載
類加載指的是, java 進程運行的時候,需要把 .class 文件從硬盤讀取到內存,并進行一系列的校驗解析的過程.
4.1 類加載過程
整個 JVM 執行的流程中,和程序員關系最密切的就是類加載的過程了,所以接下來我們來看下類加載的執行流程。
對于一個類來說,它的生命周期是這樣的:
前 5 步是固定的順序并且也是類加載的過程,其中中間的 3 步我們都屬于連接,所以對于類加載來說總共分為以下幾個步驟:
1. 加載
2. 連接
a. 驗證
b. 準備
c. 解析
3. 初始化
4.1.1 加載
“加載”(Loading)階段是整個“類加載”(Class Loading)過程中的一個階段,它和類加載Class Loading 是不同的,一個是加載 Loading 另一個是類加載 Class Loading,不要把二者搞混了。
在加載 Loading 階段,Java虛擬機需要完成以下三件事情:
1)通過一個類的全限定名來獲取定義此類的二進制字節流.
2)將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構.
3)在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口.
4.1.2 驗證
驗證是連接階段的第一步,這一階段的目的是確保Class文件的字節流中包含的信息符合《Java虛擬機規范》的全部約束要求,保證這些信息被當作代碼運行后不會危害虛擬機自身的安全.
簡而言之,就是確保當前讀到的文件內容是合法的.
文件格式驗證
字節碼驗證
符號引用驗證…
4.1.3 準備
準備階段是正式為類中定義的變量(即靜態變量,被static修飾的變量)分配內存并設置類變量初始值的階段。
比如此時有這樣一行代碼:
public static int value = 888;
它是初始化 value 的 int 值為 0,而非 888.
4.1.4 解析
解析階段是 Java 虛擬機將常量池內的符號引用替換為直接引用的過程,也就是初始化常量的過程。
在硬盤或者文件中不存在地址這樣的概念, 雖然沒有地址但是可以引入一個偏移量這樣的概念來記錄字符串常量的位置. 此時的偏移量就可以認為是符號引用.
4.1.5 初始化
初始化階段,Java 虛擬機真正開始執行類中編寫的 Java 程序代碼,將主導權移交給應用程序。初始化階段就是執行類構造器方法的過程.
簡單總結:
加載: 找到 .class 文件,并且讀文件內容.
驗證: 校驗 .class 文件的格式是否符合 JVM 規范要求.
準備: 給類對象分配內存(此時內存空間是全0的 =>類的靜態成員也就是全 0的值,即 int 默認值為0).
解析: 針對類中的字符串常量進行處理.
初始化: 把類對象的各個部分的屬性進行賦值填充 =>觸發對父類的加載,初始化靜態成員,執行靜態代碼塊.
4.2 雙親委派模型
提到類加載機制,不得不提的一個概念就是“雙親委派模型”。
站在 Java 虛擬機的角度來看,只存在兩種不同的類加載器:一種是啟動類加載器(Bootstrap
ClassLoader),這個類加載器使用 C++ 語言實現,是虛擬機自身的一部分;另外一種就是其他所有的類加載器,這些類加載器都由Java語言實現,獨立存在于虛擬機外部,并且全都繼承自抽象類java.lang.ClassLoader.
站在 Java 開發人員的角度來看,類加載器就應當劃分得更細致一 些。自 JDK 1.2 以來,Java 一直保持著三層類加載器、雙親委派的類加載架構器。
4.2.1 什么是雙親委派模型?
如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到最頂層的啟動類加載器中,只有當父加載器反饋自己無法完成這個加載請求(它的搜索范圍中沒有找到所需的類)時,子加載器才會嘗試自己去完成加載.
BootstrapClassLoader
負責查找標準庫的目錄.
ExtensionClassLoader
負責查找擴展庫的目錄.
ApplicationClassLoader
負責查找當前項目的代碼目錄
以及第三方庫的目錄.
4.2.2 雙親委派模型的工作過程.
1.從 ApplicationClassLoader 作為入口,先開始工作.
2. ApplicationClassLoader 不會立即搜索自己負責的目錄,而會把搜索的任務交給自己的父親:
3.代碼就進入到 ExtensionClassLoader 范疇了ExtensionClassLoader 也不會立即搜索自己負責的目錄也要把搜索的任務交給自己的父親.
4.代碼就進入到 BootstrapClassLoader 范疇了BootstrapClassLoader 也不想立即搜索自己負責的目錄也要把搜索的任務交給自己的父親.
5.BootstrapClassLoader 發現自己沒有父親才會真正搜索負責的目錄 (標準庫目錄)通過全限定類名,嘗試在標準庫目錄中找到符合要求的 .class 文件. 如果找到了,接下來就直接進入到打開文件/讀文件等流程中如果沒找到,回到孩子這一輩的類加載器中,繼續嘗試加載.
6. ExtensionClassLoader 收到父親交回給他的任務之后,自己進行搜索負責目錄(擴展庫的目錄)…
7. ApplicationClassLoader 收到父親交回給他的任務之后自己進行搜索負責的目錄(當前項目目錄/第三方庫目錄)…
4.3 雙親委派模型優點
1. 避免重復加載類:比如 A 類和 B 類都有一個父類 C 類,那么當 A 啟動時就會將 C 類加載起來,那么在 B 類進行加載時就不需要在重復加載 C 類了.
2. 安全性:使用雙親委派模型也可以保證了 Java 的核心 API 不被篡改,如果沒有使用雙親委派模型,而是每個類加載器加載自己的話就會出現一些問題,比如我們編寫一個稱為 java.lang.Object
類的話,那么程序運行的時候,系統就會出現多個不同的 Object 類,而有些 Object 類又是用戶自己提供的因此安全性就不能得到保證了.
4.4 破壞雙親委派模型
上述這一系列規則,只是 JM 自帶的類加載器遵守的默認規則. 如果咱們自己寫類加載器,也可以打破上述規則比如自己寫類加載器, 指定這個加載器就在某個目錄中嘗試加載. 此時如果類加載器的 parent 不去和已有的這些類加載器連到一起, 此時就是獨立的,不涉及到雙親委派了.
5. 垃圾回收機制
堆是垃圾回收的主戰場.
Java堆中存放著幾乎所有的對象實例,垃圾回收器在對堆進行垃圾回收前, 首先要判斷這些對象哪些還存活,哪些已經"死去". 判斷對象是否已"死"有如下幾種算法.
在 Java 中,所有的對象都是要存在內存中的(也可以說內存中存儲的是一個個對象),因此我們將內存回收,也可以叫做死亡對象的回收。
5.1 死亡對象判斷算法.
在 Java 中,使用對象一定需要通過引用的方式來使用. (有個例外,匿名對象,但是它執行完之后就被回收了). 如果一個對象沒有任何引用指向它, 就視為是無法在代碼中使用, 就可以回收掉.
5.1.1 引用計數算法.
給對象增加一個引用計數器,每當有一個地方引用它時,計數器就+1;當引用失效時,計數器就-1;
任何時刻計數器為0的對象就是不能再被使用的,即對象已"死".
引用計數法實現簡單,判定效率也比較高,在大部分情況下都是一個不錯的算法。比如Python語言就采用引用計數法進行內存管理.
在主流的JVM中沒有選用引用計數法來管理內存,最主要的原因就是引用計數法無法解決對象的循環引用問題.
為了解釋循環引用問題, 寫出下列偽代碼:
class Test {Test t;
}
main() {Test a = new Test();Test b = new Test();a.t = b;b.t = a;
}
令 a = null, b = null;
此時雖然Test對象的引用計數為 1, 但確實無法使用, 也無法被回收.
5.1.2 可達性分析算法
在上面講了,Java并不采用引用計數法來判斷對象是否已"死",而采用可達性分析來判斷對象是否存活(同樣采用此法的還有C#、Lisp-最早的一門采用動態內存分配的語言).
此算法的核心思想為 : 通過一系列稱為"GC Roots"的對象作為起始點,從這些節點開始向下搜索,搜索走過的路徑稱之為"引用鏈",當一個對象到GC Roots沒有任何的引用鏈相連時(從GC Roots到這個對象不可達)時,證明此對象是不可用的。以下圖為例:
對象Object5-Object7之間雖然彼此還有關聯,但是它們到GC Roots是不可達的,因此他們會被判定為可回收對象。
JVM 自身知道一共有哪些對象, 通過可達性分析的遍歷, 把可達的對象都標記出來, 剩下的自然就是不可達的.
5.2 垃圾回收算法
將死亡對象標記出來了,標記出來之后我們就可以進行垃圾回收操作了.
5.2.1 標記-清除算法
標記-清除"算法是最基礎的收集算法。算法分為"標記"和"清除"兩個階段 : 首先標記出所有需要回收的對象,在標記完成后統一回收所有被標記的對象. 后續的收集算法都是基于這種思路并對其不足加以改進而已.
"標記-清除"算法的不足主要有兩個 :
1. 效率問題 : 標記和清除這兩個過程的效率都不高.
2. 空間問題 : 標記清除后會產生大量不連續的內存碎片,空間碎片太多可能會導致以后在程序運行中需要分配較大對象時,無法找到足夠連續內存而不得不提前觸發另一次垃圾收集。
5.2.2 復制算法
"復制"算法是為了解決"標記-清理"的效率問題。它將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這塊內存需要進行垃圾回收時,會將此區域還存活著的對象復制到另一塊上面,然后再把已經使用過的內存區域一次清理掉. 這樣做的好處是每次都是對整個半區進行內存回收,內存分配時也就不需要考慮內存碎片等復雜情況,只需要移動堆頂指針,按順序分配即可。此算法實現簡單,運行 , 高效。
5.2.3 標記-整理算法
引入概念: 對象的年齡.
JVM 中有專門的線程負責周期性掃描/釋放一個對象(初始年齡相當于是 0), 如果被線程掃描了一次,不是垃圾,年齡就+1.
JVM 中就會根據對象年齡的差異,把整個堆內存分成兩個大的新生代(年齡小的對象)和老年代(年齡大的對象).
復制收集算法在對象存活率較高時會進行比較多的復制操作,效率會變低。因此在老年代一般不能使用復制算法.
針對老年代的特點,提出了?種稱之為"標記-整理算法"。標記過程仍與"標記-清除"過程一致,但后續步驟不是直接對可回收對象進行清理,而是讓所有存活對象都向一端移動,然后直接清理掉端邊界以外的內存.
5.2.4 分代算法
分代算法和上面講的 3 種算法不同,分代算法是通過區域劃分,實現不同區域和不同的垃圾回收策略,從而實現更好的垃圾回收.
這個算法并沒有新思想,只是根據對象存活周期的不同將內存劃分為幾塊。一般是把Java堆分為新生代和老年代。在新生代中,每次垃圾回收都有大批對象死去,只有少量存活,因此我們采用復制算法;而老年代中對象存活率高、沒有額外空間對它進行分配擔保,就必須采用"標記-清理"算法。
老年代中有大量對象存活,是因為如果要死亡, 在新生代中早就死了, 能活到老年代的對象,說明其生命周期長.
總結來看:
新生代中, 只有少量存活,因此我們采用復制算法; 老年代中有大量存活, 采用"標記-清理"算法.
本篇博客到這里就結束啦, 感謝觀看 ???
🐎期待與你的下一次相遇😊😊😊