一、類加載器
ClassLoader 能根據需要將 class 文件加載到 JVM 中,它使用雙親委托模型,在加載類的時候會判斷如果類未被自己加載過,就優先讓父加載器加載。另外在使用 instanceof 關鍵字、equals()方法、isAssignableFrom()方法、isInstance()方法時,就要判斷是不是由同一個類加載器加載。
1 類加載器的種類
1.1 啟動類加載器(Bootstrap ClassLoader)
負責加載JDK中的核心類庫,即 %JRE_HOME%/lib 目錄下,這個類完全由 JVM 自己控制,外界無法訪問這個類。不過在啟動 JVM 時可以通過參數 -Xbootclasspath 來改變加載目錄,有以下三種使用方式
- -Xbootclasspath 完全取代系統默認目錄;
- -Xbootclasspath/a 在系統加載默認目錄后,加載此目錄;
- -Xbootclasspath/p 在系統加載默認目錄前,加載此目錄。
1.2 擴展類加載器(ExtClassLoader)

繼承自 URLClassLoader 類,默認加載 %JRE_HOME%/lib/ext 目錄下的 jar 包。可以用-D java.ext.dirs 來指定加載位置。-D 是設置系統屬性,即System.getProperty()的屬性。
1.3 應用類加載器(AppClassLoader)

繼承自 URLClassLoader 類,加載當前項目 bin 目錄下的所有類。可以通過 System.getProperty("java.class.path") 獲取到目錄地址。
1.4 自定義類加載器
如果我們自己實現類加載器,一般都會繼承 URLClassLoader 這個子類,因為這個類已經實現了大部分工作,只需要在適當的地方做些修改就好,就像我們要實現 Servlet 時通常會直接繼承 HttpServlet。
不管是直接實現抽象類 ClassLoader,還是繼承 URLClassLoader 類,或其它子類,它的父類加載器都是 AppClassLoader,因為不管調用那個父類構造器,創建對象都必須最終調用 getSystemClassLoader() 作為父類加載器,然后獲取到 AppClassLoader。
2 類加載器的加載順序
在 JVM 啟動時,首先“啟動類加載器”會去加載核心類,然后再由“擴展類加載器”去加載,最后讓“應用類加載器”加載項目下的類。
另外我們知道,類加載器使用雙親委托模型,可以保證類只會被加載一次(當父類加載了該類的時候,子類就不必再加載),避免重復加載。在加載類的時候會判斷如果類未被自己加載過,就讓父加載器進行加載。這個父加載器並不是父類加載器,而是在構造方法中傳入(如果不在構造方法中傳入,默認的父加載器是加載這個類的的加載器),並且委派加載流程是在 loadClass 方法中實現的。當我們自定義類加載器的時候一般不推薦覆蓋 loadClass 方法,ClassLoader 抽象類中的 loadClass 方法如下

從圖中可以看到在 loadClass 方法中,當該類沒有被自己加載過時,就調用父加載器的 loadClass 方法(沒有父加載器則使用“啟動類加載器”)。如果父類加載器沒有加載到該類,就使用自己的 findClass 方法查找該類進行加載。如果沒有找到這個類則會拋出 ClassNotFoundException 異常。得到這個類的 Class 對象后,調用 resolveClass 方法來鏈接這個類,最后返回這個類的 Class 對象。
loadClass 中使用的幾個方法如下:
-
findClass 通常是和 defineClass 方法一起使用。首先要去查找所加載的類字節碼文件(不同的類加載器可以通過重寫這個方法來實現不同的加載規則,如ExtClassLoader 和 AppClassLoader 加載不同的類),然后調用 defineClass 方法生成類的 Class 對象並返回。
-
defineClass 方法將 byte 字節流解析成 JVM 能識別的 Class 對象,有了這個方法使我們不僅可以通過 class 文件實例化對象,還可以通過其它方式實例化對象,比如我們通過網絡接收到一個類的字節碼,可以利用這個字節碼流直接創建類的 Class 對象形式實例化對象。
- resolveClass 是對這個類進行連接。如果想在類被加載到 JVM 中時就被連接,那么可以調用 resolveClass 方法,也可以選擇讓 JVM 在什么時候才連接這個類。
3 自定義類加載器的作用
上面提到過自定義類加載器,那么自定義類加載器有什么作用呢?
在 Tomcat 也自己實現類自定義類加載器,因為要解決如下功能:
- 隔離兩個 Web 應用程序所使用的類庫,因為兩個應用程序可能會用到同一個類的不同版本;
- 共享兩個 Web 應用程序所使用的類庫,如果兩個應用程序使用的類完全相同;
- 支持熱替換。
所以 Tomcat 服務器就自己實現了類加載器,如下

前面提到過 loadClass 方法,雙親委派模型就是在其中實現的。所以如果不想打破雙親委派模型,那么只需要重寫 findClass 方法;如果想打破雙親委派模型,那可以重寫 loadClass 方法。
4 類加載器的使用
使用當前類的類加載器
MyTest.class.getClassLoader().loadClass("");
ClassLoader.getSystemClassLoader().loadClass("");
二、類加載的步驟
類從被加載到虛擬機內存中開始,到卸載出內存為止,它的生命周期包括七個階段:加載(Loading)、驗證(Verification)、准備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸載(Unloading),其中驗證、准備、解析三個階段統稱為連接。

注:加載、驗證、准備、初始化、卸載這五個階段順序是一定的,而解析階段在某些情況下可以在初始化之后再開始。
1、加載階段:
首先獲取這個類的二進制字節流,將這個字節流中的數據存儲到方法區中,然后生成一個代表該類的 java.lang.Class 對象(HotSpot 是把 Class 對象放在方法區中),用來訪問方法區這些數據。
關於這個類的二進制字節流,我們可以利用自定義類加載器從以下渠道獲取:
- 從壓縮包中讀取:如 JAR、WAR 格式;
- 從網絡中獲取:如果 Applet 的應用;
- 從數據庫中讀取:如有些中間件服務器;
- 運行時生成:如在 java.lang.reflect.Proxy 中為特定接口生成代理類;
- 從其他文件中生成:如 JSP 生成對應的 Class 類;
- ......
對於數組而言,加載情況有所不同,數組類本身不通過類加載器創建,是由 JVM 直接創建的。但是數組中的元素還是要靠類加載器去創建,如果數組去掉一個維度后是引用類型,就采用類加載器去加載,否則就交給啟動類加載器去加載。
另外加載階段與連接階段的部分內容(如一部分字節碼文件格式驗證動作)是交叉進行的,加載階段尚未完成,連接階段可能已經開始。
2、連接階段:
第一步,驗證:是為了確保類中字節碼的信息符合 JVM 的要求,並且不會危害虛擬機自身的安全,有文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證。只有通過了文件格式驗證,字節流中的數據才會被儲存到方法區中,而后面的三種驗證則是在方法區中進行的。符號引用驗證發生在符號引用轉化為直接引用的時候。
第二步,准備:是為類的靜態變量(常量除外)分配內存並設為默認值(如static int a=123 此時a值為0,在初始化階段才會變成123),這些內存都將在方法區中進行分配。這一階段不分配類中的實例變量的內存,實例變量將會在對象實例化時隨着對象一起分配在 Java 堆中。
第三步,解析:將class 常量池 內的 符號引用,加載到 運行時常量池 內成為 直接引用 的過程。符號引用是以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可,但引用目標並不一定已經加載到內存中;直接引用在不同的虛擬機中有不同的實現方式,它可以是 直接指向目標的指針、相對偏移量 或是 一個能間接定位到目標的句柄,引用的目標必定已經在內存中(類變量、類方法的直接引用可能是直接指針或句柄,實例變量、實例方法的直接引用都是偏移量。實例變量的直接引用可能是從對象的映像開始算起到這個實例變量位置的偏移量,實例方法的直接引用可能是方法表的偏移量)。
3、初始化階段:
首先什么情況下類會初始化?什么情況下類不會初始化?
類的“主動引用”(一定發生初始化)
- 創建類的實例(如通過new、反射、克隆、反序列化)
- 訪問類的靜態變量(除了常量)和靜態方法
- 利用反射調用方法時
- 初始化類時發現其父類未初始化,則初始化其父類
- 虛擬機啟動時,包含main()方法的類
類的“被動引用”(一定不發生初始化)
- 訪問一個靜態變量,但這個變量屬於其父類,只會初始化其父類。
- 創建類的數組不會發生初始化 ( A[] a = new A[10] )。
- 引用常量不會發生初始化(常量在編譯階段就存入所屬類的常量池中了)。
接口的加載過程與類的加載過程稍有不同,接口中不能使用static{}快。當一個接口在初始化時,並不要求其父接口全部都完成初始化,只有在真正用到父接口時(如引用接口中定義的變量)才會初始化。
三、類的對象
1 對象的創建
當 JVM 遇到 new 指令時,首先去檢查這個指令的參數能否在常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已經被加載過,如果沒有就先執行類加載。如果類已經被加載過,則會為新生對象分配內存(所需內存大小在類加載后就可以確定),分配對象內存采取的方式是“指針碰撞”或“空閑列表”,前者是在內存比較規整的情況下,后者是在空閑內存和已使用內存相互交錯的情況下,而內存是否規整這又取決於垃圾回收器。
對象的創建是很頻繁的,即使是簡單的指針位置的修改,在並發情況下可能會出現線程安全問題。解決這個問題的方式有兩種,一種是進行同步處理——JVM 采用了 CAS 方式失敗重試來保證的原子性操作;另一種是把內存分配划分在不同空間中——即每個線程預先分配一小塊內存,稱為本地線程分配緩沖(TLAB),可以通過 -XX:+/-UseTLAB 參數來設定是否使用。
內存分配完成后,設置對象的對象頭中的信息,如這個對象是哪個類的實例,如何找到類的元數據信息、對象的哈希碼、對象的GC分代年齡等信息,此時對象已經產生,但是還沒有初始化,所有字段都為零。
2 對象的內存布局
對象在內存中存儲的布局可以分為 3 塊區域:對象頭、實例數據和對齊填充。
- 對象頭:包括兩部分信息,第一部分儲存對象自身運行時的數據,如哈希碼、GC分代年齡、鎖狀態標志、線程持有的鎖、偏向線程ID、偏向時間戳。另一部分是類型指針,指向它的類元數據的指針(不是所有的虛擬機都有)。如果是數組,那在對象頭中還必須有一塊記錄數組長度的數據。
- 實例數據:這部分是對象真正存儲的有效信息,即在對象中定義的各種字段內容(無論是從父類中繼承下來的,還是本身所定義的)。存儲順序受虛擬機的分配策略和定義順序的影響,HotSpot 默認的分配策略為 longs/doubles、ints、shorts/chars、bytes/booleans、oops。
- 對齊填充:不是必然存在的,也沒有特別含義,僅僅起着占位符作用。因為 HotSpot 要求對象起始地址必須是 8 字節的整數倍。
3 對象的訪問定位
我們通過 Java 棧中對象的引用去訪問這個對象,訪問對象的主流方式有 2 種:使用句柄和直接指針。
- 使用句柄訪問:在 Java 堆中會划分出一塊內存作為句柄池,引用中儲存的內容就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自的具體地址信息。
- 直接指針訪問:在對象的內存布局中就要放置訪問類型數據的指針。
這兩種方式各有優勢,使用句柄的好處是引用中存儲的是穩定的句柄,對象被移動時(垃圾回收時對象被移動)只需改變句柄中的實例數據的指針,不需要改動引用本身。而使用直接指針的好處是速度更快,它節省了一次指針定位的開銷。HotSpot 使用的是第二種方式進行對象的訪問。
四、內存溢出
除了程序計數器外,JVM 中其他幾個內存區域都有可能發生 OutOfMemoryError 異常。
1 Java堆溢出
如果不斷創建對象,並且對象始終被強引用,則垃圾回收器無法回收這些對象,最終會生產內存溢出。通過 -XX:+HeapDumpOnOutOfMemoryError 可以讓虛擬機出現內存溢出時 Dump 出當前堆儲存為快照,以后事后分析。
解決堆溢出,一般先通過內存映像分析工具對這個快照進行分析,弄清楚出現了內存泄漏還是內存溢出。如果是內存泄漏,可以通過工具查看泄漏對象到 GC Root 的引用鏈,以此來判斷泄漏原因;如果不存在泄漏,即內存中的對象確是都必須活着,可以調整堆的大小參數和對代碼進行優化。
2 Java棧溢出和本地方法棧溢出
在 HotSpot 中不區分 Java 棧和本地方法棧,雖然可以通過 -Xoss 參數設置本地方法棧大小,但是並沒有效果,棧容量只有由 -Xss 參數設定。棧中發生的異常有兩種:
- 如果需要的深度超過最大深度時拋出 StackOverflowError 異常;
- 如果棧無法申請到足夠內存時拋出 OutOfMemoryError 異常。
3 方法區和運行時常量池溢出
String 字符串的 intern() 方法作用是,如果字符串常量池存在這個字符串則返回其對象的引用,否則將字符串拷貝到方法區中的字符串常量池。在 Java7 之后方法區被移入堆中,intern() 方法也有所變化,不會將首次遇到的字符串對象本身放入常量池,只會在常量池中記錄這個字符串對象的引用。
在使用 GCLib 動態的將類加載進內存時,很容易造成溢出。
4 本機內存溢出
NIO
