當你開始想系統性的學習 Java 的時候,JVM 是不可能跳過的部分,哪個國家我沒去過對象不是 JVM 創建的呢?之前的學習中遇到過很多 JVM 相關的內容,也只是了解,沒有深入的學習。
當你開始想系統性的學習 Java 的時候,JVM 是不可能跳過的部分,哪個~國家我沒去過~對象不是 JVM 創建的呢?之前的學習中遇到過很多 JVM 相關的內容,也只是了解,沒有深入的學習。那這篇博客就記錄一下學習 JVM 的過程。小部分內容是從網上摘錄的。大部分都是查各種技術博客和《深入理解 java 虛擬機》然後經過我自己的理解敘述的。
Java 類的加載過程#
當程序運行的時候需要一個類的時候,如果在內存中沒有找到這個類,JVM 就會通過三個步驟來把類加載到內存中供程序的使用。分別是類的加載、連接、初始化。
加載#
首先是類的加載,虛擬機通過 ClassLoader 來把字節碼文件(.class)轉化成內存形式的 Class 對象。這裡的字節碼文件可以是本地的. class,也可以是 jar 包裡的文件,還可以是遠程服務器提供的字節流。本質上就是 byte []。常見的 ClassLoader 有三個分別是 BootStrapClassLoader、ExtensionClassLoader、以及 AppClassLoader。他們都各司其職加載不同地方的類。
BootStarpClassLoader 負責加載 JVM 的核心類,位於 JAVA_HOME/lib/rt.jar。通常 java.* 下的類就是他加載的。
ExtensionClassLoader 負責加載擴展類,位於 JAVA_HOME/lib/ext/*.jar 中
AppClassLoader 就是加載我們自己寫的類了,他會加載 ClassPath 中的 jar 包和我們自己寫完編譯的 class 文件。
自定義類加載器負責加載特定的類,比如通過網絡傳來的經過加密的字節碼文件。就需要配套一個自定義類加載器來解析加載。可以直接繼承 URLClassLoader 類
雙親委派機制#
先來看一段 Java 源碼中的 ClassLoader 類 loadclass (),配上我蹩腳的翻譯。
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // First, check if the class has already been loaded //首先,檢查一下這個類是否已經被加載 Class<?> c = findLoadedClass(name); //如果沒有被加載 if (c == null) { long t0 = System.nanoTime(); try { //如果父類加載器不為空 if (parent != null) { //由父類加載器加載, // 雙親委派機制的重點 c = parent.loadClass(name, false); } else { //父類加載器為空,就是到頭了。選擇BootStarpClassLoader加載 c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) { // If still not found, then invoke findClass in order // to find the class. long t1 = System.nanoTime(); //相當於自定義加載器,override的方法 c = findClass(name); // this is the defining class loader; record the stats //開始加載類 sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } }
這段代碼看懂就明白什麼是雙親委派機制了,當要加載一個類的時候先判斷是否已經加載了,防止重複加載,沒有加載的話就委派給父類加載器加載。這是個遞歸函數,調到父類加載器還會判斷是否已經加載。未加載繼續在向上委派。當委派到頭了沒有父類加載器了,就交給 BootStrapClass 處理。BootStarpClass 加載了就返回,如果還沒加載。就向下返回來給 “孩子” 加載,直至加載完成。如果到最後 AppClassLoader 或者自定義加載器還是沒有加載成功則會拋出 ClassNotFound 異常。注意關注兩點:其實每個加載器加載不到都會返回 ClassNotFound 但是被 catch 到了並沒有異常處理。第二點就是最後的自定義類加載器的 findClass 是沒有實現的,對於 ExtensionClassLoader 和 AppClassLoader 來說都會拋出 ClassNotFound。
連接#
第二步就是連接。連接分為三個步驟:驗證、準備、解析;
驗證#
驗證是連接的第一步,這一階段是確保了 class 文件的字節流中包含的信息符合當前虛擬就要求,不會危害虛擬機自身安全。特別是對於一些不是自己寫的類,從網絡傳過來這種,做一個校驗。
- 文件格式驗證
- 元數據驗證
- 字節碼驗證
- 符號引用驗證
準備#
當完成字節碼文件的校驗之後,JVM 便會開始為類變量分配內存並初始化。這裡需要注意兩個關鍵點:
內存分配的對象:Java 中的變量有「類變量」和「類成員變量」兩種類型,「類變量」指的是被 static 修飾的變量,而其他所有類型的變量都屬於「類成員變量」。在準備階段,JVM 只會為「類變量」分配內存,而不會為「類成員變量」分配內存。「類成員變量」的內存分配需要等到初始化階段才開始。
初始化類型:在準備階段,JVM 會為類變量分配內存,並為其初始化。但是這裡的初始化指的是為變量賦予 Java 語言中該數據類型的零值,而不是用戶代碼裡初始化的值
public static int x = 666; //此時只會把int賦值為0; 666要等到初始化才能賦值 public final static int y = 666; //如果是常量則在此處就賦值為666
解析#
解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程,解新動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符 7 類符號引用進行,分別對應於常量池的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_IntrfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info 和 CONSTANT_InvokeDynamic_info7 種常量類型,解析階段中所說的直接引用與符號引用關係如下:
符號引用 (Symlxiuc References): 符號引用以一組符號來描述所引用的日標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可,特號引用與配組機實現的內存 1 布。局 11i - 美,引用的日標並不一定已組加裁到內存中
直接引用 (Direct References): 直接引用可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是與虛擬機實現的內存佈局相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在內存中存在
初始化#
到了這步,我們寫的代碼才會真正的開始執行,JVM 會根據語句執行順序對類對象進行初始化。從代碼角度,初始化階段是執行類構造器 () 方法的過程。虛擬機規範觸發初始化有且只有五種情況:
- 遇到 new、getstatic、putstatic 或 invokestatic 這四條字節碼指令(注意,newarray 指令觸發的只是數組類型本身的初始化,而不會導致其相關類型的初始化,比如,new String [] 只會直接觸發 String [] 類的初始化,也就是觸發對類 [java.lang.String] 的初始化,而直接不會觸發 String 類的初始化)時,如果類沒有進行過初始化,則需要先對其進行初始化
常見的情景是 new 一個對象、讀取一個靜態對象的字段或者方法。- 使用 java.lang.reflect 包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
- 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
- 當虛擬機啟動時,用戶需要指定一個要執行的主類(包含 main () 方法的那個類),虛擬機會先初始化這個主類。
- 當使用 jdk1.7 動態語言支持時,如果一個 java.lang.invoke.MethodHandle 實例最後的解析結果 REF_getstatic,REF_putstatic,REF_invokeStatic 的方法句柄,並且這個方法句柄所對應的類沒有進行初始化,則需要先出觸發其初始化。
類初始化階段是類加載過程的最後一步。在前面的類加載過程中,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其餘動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的 java 程序代碼 (字節碼)。
在準備階段,變量已經賦過一次系統要求的初始值 (零值);而在初始化階段,則根據程序猿通過程序制定的主觀計劃去初始化類變量和其他資源,或者更直接地說:初始化階段是執行類構造器 < clinit>() 方法的過程。() 方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊 static {} 中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊只能訪問到定義在靜態語句塊之前的變量,定義在它之後的變量,在前面的靜態語句塊可以賦值,但是不能訪問。
public class Test{ static{ i=0; System.out.println(i);//Error:Cannot reference a field before it is defined(非法向前應用) } static int i=1; }
類構造器 () 與實例構造器 () 不同,它不需要程序員進行顯式調用,虛擬機會保證在子類類構造器 () 執行之前,父類的類構造 () 執行完畢。由於父類的構造器 () 先執行,也就意味著父類中定義的靜態語句塊 / 靜態變量的初始化要優先於子類的靜態語句塊 / 靜態變量的初始化執行。特別地,類構造器 () 對於類或者接口來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對類變量的賦值操作,那麼編譯器可以不為這個類生成類構造器 ()。
虛擬機會保證一個類的類構造器 () 在多線程環境中被正確的加鎖、同步,如果多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的類構造器 (),其他線程都需要阻塞等待,直到活動線程執行 () 方法完畢。特別需要注意的是,在這種情形下,其他線程雖然會被阻塞,但如果執行 () 方法的那條線程退出後,其他線程在喚醒之後不會再次進入 / 執行 () 方法,因為在同一個類加載器下,一個類型只會被初始化一次。
我們知道,在 Java 中, 創建一個對象常常需要經歷如下幾個過程:父類的類構造器 () -> 子類的類構造器 () -> 父類的成員變量和實例代碼塊 -> 父類的構造函數 -> 子類的成員變量和實例代碼塊 -> 子類的構造函數。
以上部分內容引自《深入理解 java 虛擬機》周志明著
JVM 運行時的數據區#
JDK8 之前的內存區域圖如下:
JDK8 之後的內存區域:
下面就根據上面的圖來分別解析一下各個數據區,以及為什麼會改變。
程序計數器:本身是一塊很小的區域,線程私有。可以理解在進行上下文切換的時候記錄了線程執行代碼的位置。如果有多個線程正在執行指令,那麼每個線程都會有一個程序計數器,它是線程私有的。在任意時刻,一個線程只允許執行一個方法的代碼。每當執行到一條 Java 方法的指令時,程序計數器保存當前執行字節碼的地址;若執行的為 native 方法,則 PC 的值為 undefined。類似於 CPU 中寄存器的程序計數器。
Java 虛擬機棧:同樣也是線程私有的。描述了 Java 方法執行的內存模型,每個方法在執行的時候會創建一個棧幀,用於存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。各個方法執行的過程也就是棧幀的入棧和出棧過程。虛擬機棧規定了兩種異常狀況:如果線程請求的棧深度大於虛擬機所允許的深度,將拋出 StackOverflowError 異常;如果虛擬機棧可以動態擴展(當前大部分的 Java 虛擬機都可動態擴展),如果擴展時無法申請到足夠的內存,就會拋出 OutOfMemoryError 異常。
本地方法棧:和虛擬機棧類似,為虛擬機使用到的 Native 方法服務;在 HotSpot JVM 中 Java 虛擬機棧和本地方法棧合二為一。
Java 堆:與棧不同,Java heap 是所有線程共享的區域,幾乎所有的對象實例都在這裡分配內存。所以對於大部分情況來說。堆也是最大的一塊內存區域。從內存回收的角度來看,由於現在收集器基本都採用分代收集算法,所以 Java 堆中還可以細分為:新生代和老年代;再細致一點的有 Eden 空間、From Survivor 空間、To Survivor 空間等。對於 GC 一會兒單獨說。
方法區(也稱永久區):所有線程共享,一種特殊的堆結構。但是在 JVM 規範中卻描述為(Non-Heap)它用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。可以理解為 Java 堆的邏輯部分。在 JDK1.7 及以前的 HotSpot JVM 中,方法區位於永久代(PermGen)中。由於永久代內可能會發生內存泄露或溢出等問題而導致的 java.lang.OutOfMemoryError: PermGen ,JEP 小組從 JDK1.7 開始就籌劃移除永久代,並且在 JDK 1.7 中把字符串常量,符號引用等移出了永久代。到了 Java 8,永久代被徹底地移出了 JVM,取而代之的是元空間(Metaspace)
運行時常量池:運行時常量池(Runtime Constant Pool)是方法區的一部分。Class 文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池(Constant Pool Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類加載後進入方法區的運行時常量池中存放。比如 String 類的 intern ()
https://www.cnblogs.com/czwbig/p/11127124.html
https://www.sczyh30.com/posts/Java/jvm-memory/GC#
Garbage Collection, 垃圾回收。Java 相較於 C++ 的一個主要的區別即使 Java 有垃圾回收機制。一般情況下不需要程序員手動釋放內存。
對於垃圾回收的思路主要有兩種:
引用計數法:他的思想是給對象一個引用計數器,每當被引用一次就 + 1. 不引用了就 - 1,當計數器為 0 的時候就可以判斷當前對象沒用,可以被 GC 了。但是這個思路有個嚴重的缺陷就是無法解決循環引用的問題,A 引用了 B,B 又引用了 A。當這兩個對象都沒用的時候,GC 是無法判斷的。從而造成了內存的浪費。
可達性分析:這個的思路是把一組對象作為根節點,從根節點開始遍歷。遍歷結束後如果有對象是不可達的就說明這個對象沒有被用到可以被 GC。JVM 就是的 GC 就是用了這個思路
那麼那些對象可以座位根節點(GC Rooot)呢?存活的線程
虛擬機棧中本地變量表中引用所引用的對象。
方法區中靜態屬性和常量引用的對象。
本地方法棧中 JNI 引用的變量
Java 中的四種引用 | 強弱軟虛#
StrongReference
StrongReference(強引用)是最普通的引用類型,只要強引用存在,GC 就不會進行垃圾回收。SoftReference
SoftReference(軟引用)用來描述一些有用但是非必需的對象。如果一個對象只具有軟引用,則內存空間足夠,垃圾回收器就不會回收它;如果內存空間不足了,就會回收這些對象的內存。只要垃圾回收器沒有回收它,該對象就可以被程序使用。軟引用可以和一個引用隊列(ReferenceQueue)聯合使用,如果軟引用所引用的對象被垃圾回收器回收,JVM 就會把這個軟引用加入到與之關聯的引用隊列中。軟引用可用來實現內存敏感的高速緩存。WeakReference
WeakReference(弱引用)是一種生命周期比軟引用更短的引用。當 GC 掃描啟動時,只要掃描到只具有弱引用的對象,無論內存是否夠用都會執行 GC,但由於 GC 線程優先級很低,因此並不一定能迅速發現這些弱引用對象。弱引用也可以和一個引用隊列聯合使用。
WeakReference 在 Android 中用的挺多。PhantomReference
PhantomReference(虛引用)不同於其餘三種引用,虛引用不會影響對象的生命周期,也無法通過虛引用獲得對象的一個實例;如果一個對象僅持有虛引用,那麼它就和沒有任何引用一樣,在任何時候都可能被垃圾回收器回收。虛引用主要用來跟蹤對象被垃圾回收器回收的活動,它必須和引用隊列聯合使用。
~ 說實話目前還不了解這麼設計的用處,也沒有用過~GC 算法#
GC 算法主要有三種,分別是 MS (標記清除法)、MC (標記整理法)、Copying (複製算法)
MS 算法:Marked-Sweep,標記清除算法。很簡單,可達性分析對不可達對象進行標記,把要清理然後清除就 OK 了。多用於老年代。這個算法的主要缺點有兩個。
- 標記和清理效率都不高
- 容易產生內存碎片,如果有大對象進來沒有足夠內存就會觸發 GC
MC 算法:Marked-Compact,標記壓縮算法,根據可達性分析對不可達對象進行標記後,把存活的對象移至一端 (reallocate),然後將剩餘的部分進行回收,這個過程要進行 remapping。多用於老年代。
Copying 複製算法:將內存劃分為兩塊,每次只用一塊。進行回收的時候把所有生存的對象放到另一塊。之後對之前的那塊內存整個清理即可。新生代用這種算法。
當前 JVM 的 GC 一般都是分代收集,幾種垃圾回收算法進行組合。
下面來分析一下 JVM 內存的分代模型:
由圖可以看出,JVM 將內存區域分為新生代,老年代和永久代(也就是方法區,JDK1.8 之後去除了)而新生代又分為一個 Eden 代 和兩個 survivor 代。當對象在新生代經歷了默認 15 次的 gc 後還存活就放到老年代。大對象也可以直接去老年代。這些都可以配置。那為什麼要一個 E 和兩個 S 呢,主要原因還是避免產生內存碎片。每次用一個 E 和一個 S。每次進行 GC 的時候把 E 和 S0 的存活對象都放到 S1 中。然後對 E 和 S0 進行清理。接下來 S1 和 S0 相當於邏輯上的互換。這樣就避免了內存碎片的產生。HotSpot JVM 的默認比例為 8:2。這樣默認空間利用率可達 90%。
PS:在發生 FULL GC 的時候,意味著 JVM 會安全的暫停所有正在執行的線程(Stop The World),來回收內存空間,在這個時間內,所有除了回收垃圾的線程外,其他有關 JAVA 的程序,代碼都會靜止,反映到系統上,就會出現系統響應大幅度變慢,卡機等狀態。《深入理解 Java 虛擬機》- 周志明
GC 的實現–垃圾回收器#
- 新生代收集器:Serial、ParNew、Parallel Scavenage
- 老年代收集器:Serial old、Parallel Old、CMS
- 整堆回收:G1
首先介紹 JVM 的模式有兩種 Server (重量)和 Client(輕量)Serial:單線程,簡單高效。Stop the World。使用 Client 模式的 JVM
ParNew:Serial 的多線程版本,使用 Server 模式。並行多線程
Parallel Scavenage:與吞吐量關係密切,故也稱為吞吐量優先收集器。該收集器的目標是達到一個可控制的吞吐量。並行多線程。
Serial Old :Serial 老年代版本,M-C 垃圾回收算法。
Parallel Old :Parallel Scavenage 老年代版本。多線程,採用 M-C 垃圾回收算法。注重高吞吐量以及 CPU 資源敏感的場合使用。
CMS 收集器:Concurrent-Marked-Sweep,一種以獲取最短回收停頓時間為目標的收集器。 CMS 收集器的內存回收過程是與用戶線程一起並發執行的。是 HotSpot 虛擬機第一款真正意義上的並發收集器,它第一次實現了讓垃圾收集線程與用戶線程(基本上)同時工作。
G1 收集器:Garbage-First 是一款面向服務端應用的垃圾收集器。詳情請參閱 Jvm 垃圾回收器(終結篇)