前提
其實在前面寫過的《深入分析Java反射(一)-核心類庫和方法》已經介紹過通過類名或者java.lang.Class實例去實例化一個對象,在《淺析Java中的資源加載》中也比較詳細地介紹過類加載過程中的雙親委派模型,這篇文章主要是加深一些對類實例化和類加載的認識。
類實例化
在反射類庫中,用於實例化對象只有兩個方法:
T java.lang.Class#newInstance():這個方法只需要提供java.lang.Class<T>的實例就可以實例化對象,如果提供的是無限定類型Class<?>則得到的是Object類型的返回值,可以進行強轉。這個方法不支持任何入參,底層實際上也是依賴無參數的構造器Constructor進行實例化。T java.lang.reflect.Constructor#newInstance(Object ... initargs):這個方法需要提供java.lang.reflect.Constructor<T>實例和一個可變參數數組進行對象的實例化,上面提到的T java.lang.Class#newInstance()底層也是依賴此方法。這個方法除了可以傳入構造參數之外,還有一個好處就是可以通過``抑制修飾符訪問權限檢查,也就是私有的構造器也可以用於實例化對象。
在編寫反射類庫的時候,優先選擇T java.lang.reflect.Constructor#newInstance(Object ... initargs)進行對象實例化,目前參考很多優秀的框架(例如Spring)都是用這個方法進行對象實例化。
類加載
類加載實際上由類加載器(ClassLoader)完成,protected Class<?> java.lang.ClassLoader#loadClass(String name, boolean resolve)方法提現了類加載過程中遵循了雙親委派模型,實際上,我們可以覆寫此方法完全不遵循雙親委派模型,實現同一個類(這里指的是全類名完全相同)重新加載。JDK中提供類加載相關的特性有兩個方法:
protected Class<?> java.lang.ClassLoader#loadClass(String name, boolean resolve):通過類加載器實例去加載類,一般應用類路徑下的類是由jdk.internal.loader.ClassLoaders$AppClassLoader加載,也可以自行繼承java.lang.ClassLoader實現自己的類加載器。public static Class<?> forName(String name, boolean initialize, ClassLoader loader):通過全類名進行類加載,可以通過參數控制類初始化行為。
ClassLoader中的類加載
類加載過程其實是一個很復雜的過程,主要包括下面的步驟:
- 1、加載過程:使用(自定義)類加載器去獲取類文件字節碼字節類的過程,Class實例在這一步生成,作為方法區的各種數據類型的訪問入口。
- 2、驗證過程:JVM驗證字節碼的合法性。
- 3、准備過程:為類變量分配內存並且設置初始值。
- 4、解析過程:JVM把常量池中的符號替換為直接引用。
- 5、初始化過程:執行類構造器
<cinit>()方法,<cinit>()方法是編譯器自動收集所有類變量的賦值動作和靜態代碼塊中的語句合並生成,收集順序由語句在源文件中出現的順序決定,JVM保證在子類<cinit>()方法調用前父類的<cinit>()方法已經執行完畢。
ClassLoader#loadClass()方法就是用於控制類加載過程的第一步-加載過程,也就是控制字節碼字節數組和類名生成Class實例的過程。ClassLoader中還有一個protected final Class<?> defineClass(String name, byte[] b, int off, int len)方法用於指定全類名和字節碼字節數組去定義一個類,我們再次看下loadClass()的源碼:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException{
synchronized (getClassLoadingLock(name)) {
// 檢查類是否已經加載過,如果已經加載過,則直接返回
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
// 委派父類加載器去加載類
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
// 委派父類加載器如果加載失敗則調用findClass方法進行加載動作
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
PerfCounter.getParentDelegationTime().addTime(t1 - t0);
PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
// 擴展點-1
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
// 擴展點-2
protected final void resolveClass(Class<?> c) {
if (c == null) {
throw new NullPointerException();
}
}
實際上,loadClass()方法留下了兩個擴展點用於改變類加載的行為,而findClass()方法就是用於擴展父類加載器加載失敗的情況下,子類加載器的行為。當然,實際上Class<?> loadClass(String name, boolean resolve)方法是非final的方法,可以整個方法覆寫掉,這樣子就有辦法完全打破雙親委派機制。但是注意一點,即使打破雙親委派機制,子類加載器也不可能重新加載一些由Bootstrap類加載器加載的類庫如java.lang.String,這些是由JVM驗證和保證的。自定義類加載器的使用在下一節的"類重新加載"中詳細展開。
最后還有兩點十分重要:
- 1、對於任意一個類,都需要由加載它的類加載器和這個類本身一起確立其在Java虛擬機中的唯一性,也就是一個類在JVM中的簽名是加載它的類加載器和它本身,對於每一個類加載器,都擁有一個獨立的類命名空間。
- 2、比較兩個類是否"相等",只有這兩個類是由同一個類加載器加載的前提下才有意義。即使這兩個類的全類名一致、來源於同一個字節碼文件、被同一個Java虛擬機加載,但是加載它們的類加載器不同,那么它們必定不相等。這里相等的范疇包括:
Class對象的equals()方法、isAssignableForm()方法、isInstance()方法的返回結果以及使用instanceof關鍵字做對象所屬關系時候的判定等情況。
Class中的類加載
java.lang.Class中的類加載主要由public static Class<?> forName(String name, boolean initialize, ClassLoader loader)方法完成,該方法可以指定全類名、是否初始化和類加載器實例。源碼如下:
@CallerSensitive
public static Class<?> forName(String name, boolean initialize,
ClassLoader loader)
throws ClassNotFoundException
{
Class<?> caller = null;
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
// Reflective call to get caller class is only needed if a security manager
// is present. Avoid the overhead of making this call otherwise.
caller = Reflection.getCallerClass();
if (loader == null) {
ClassLoader ccl = ClassLoader.getClassLoader(caller);
if (ccl != null) {
sm.checkPermission(
SecurityConstants.GET_CLASSLOADER_PERMISSION);
}
}
}
return forName0(name, initialize, loader, caller);
}
private static native Class<?> forName0(String name, boolean initialize,
ClassLoader loader,
Class<?> caller) throws ClassNotFoundException;
它最終調用的是JVM的本地接口方法,由於暫時沒有能力分析JVM的源碼,只能通過forName方法的注釋理解方法的功能:
返回給定字符串全限定名稱、指定類加載器的類或者接口的Class實例,此方法會嘗試對類或者接口進行locate、load and link操作,如果loader參數為null,則使用bootstrap類加載器進行加載,如果initialize參數為true同時類或者接口在早期沒有被初始化,則會進行初始化操作。
也就是說initialize參數對於已經初始化過的類或者接口來說是沒有意義的。這個方法的特性還可以參考Java語言規范的12章中的內容,這里不做展開。
雖然暫時沒法分析JVM本地接口方法native Class<?> forName0()的功能,但是它依賴一個類加載器實例入參,可以大膽猜測它也是依賴於類加載器的loadClass()進行類加載的。
類重新加載
先提出一個實驗,如果定義一個類如下:
public class Sample {
public void say() {
System.out.println("Hello Doge!");
}
}
如果使用字節碼工具修改say()方法的內容為System.out.println("Hello Throwable!");,並且使用自定義的ClassLoader重新加載一個同類名的Sample類,那么通過new關鍵字實例化出來的Sample對象調用say()到底打印"Hello Doge!"還是"Hello Throwable!"?
先引入字節碼工具javassist用於修改類的字節碼:
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.24.0-GA</version>
</dependency>
下面是測試代碼:
// 例子
public class Demo {
public void say() {
System.out.println("Hello Doge!");
}
}
// 一次性使用的自定義類加載器
public class CustomClassLoader extends ClassLoader {
private final byte[] data;
public CustomClassLoader(byte[] data) {
this.data = data;
}
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
if (!Demo.class.getName().equals(name)) {
return super.loadClass(name);
}
return defineClass(name, data, 0, data.length);
}
}
public class Main {
public static void main(String[] args) throws Exception {
String name = Demo.class.getName();
CtClass ctClass = ClassPool.getDefault().getCtClass(name);
CtMethod method = ctClass.getMethod("say", "()V");
method.setBody("{System.out.println(\"Hello Throwable!\");}");
byte[] bytes = ctClass.toBytecode();
CustomClassLoader classLoader = new CustomClassLoader(bytes);
// 新的Demo類,只能反射調用,因為類路徑中的Demo類已經被應用類加載器加載
Class<?> newDemoClass = classLoader.loadClass(name);
// 類路徑中的Demo類
Demo demo = new Demo();
demo.say();
// 新的Demo類
newDemoClass.getDeclaredMethod("say").invoke(newDemoClass.newInstance());
// 比較
System.out.println(newDemoClass.equals(Demo.class));
}
}
執行后輸出:
Hello Doge!
Hello Throwable!
false
這里得出的結論是:
new關鍵字只能使用在當前類路徑下的類的實例化,而這些類都是由應用類加載器加載,如果上面的例子中newDemoClass.newInstance()強制轉換為Demo類型會報錯。- 通過自定義類加載器加載的和當前類路徑相同名全類名的類只能通過反射去使用,而且即使全類名相同,由於類加載器隔離,它們其實是不相同的類。
如何避免類重新加載導致內存溢出
實際上,JDK沒有提供方法去卸載一個已經加載的類,也就是類的生命周期是由JVM管理的,因此要解決類重新加載導致內存溢出的問題歸根結底就是解決重新加載的類被回收的問題。由於創建出來是的java.lang.Class對象,如果需要回收它,則要考慮下面幾點:
- 1、
java.lang.Class對象反射創建的實例需要被回收。 - 2、
java.lang.Class對象不能被任何地方強引用。 - 3、加載
java.lang.Class對象的ClassLoder已經被回收。
基於這幾點考慮可以做個試驗驗證一下:
public class Demo {
// 這里故意建立一個數組占用大量內存
private int[] array = new int[1000];
public void say() {
System.out.println("Hello Doge!");
}
}
public class Main {
private static final Map<ClassLoader, List<Class<?>>> CACHE = new HashMap<>();
public static void main(String[] args) throws Exception {
String name = Demo.class.getName();
CtClass ctClass = ClassPool.getDefault().getCtClass(name);
CtMethod method = ctClass.getMethod("say", "()V");
method.setBody("{System.out.println(\"Hello Throwable!\");}");
for (int i = 0; i < 100000; i++) {
byte[] bytes = ctClass.toBytecode();
CustomClassLoader classLoader = new CustomClassLoader(bytes);
// 新的Demo類,只能反射調用,因為類路徑中的Demo類已經被應用類加載器加載
Class<?> newDemoClass = classLoader.loadClass(name);
add(classLoader, newDemoClass);
}
// 清理類加載器和它加載過的類
clear();
System.gc();
Thread.sleep(Integer.MAX_VALUE);
}
private static void add(ClassLoader classLoader, Class<?> clazz) {
if (CACHE.containsKey(classLoader)) {
CACHE.get(classLoader).add(clazz);
} else {
List<Class<?>> classes = new ArrayList<>();
CACHE.put(classLoader, classes);
classes.add(clazz);
}
}
private static void clear() {
CACHE.clear();
}
}
使用VM參數-XX:+PrintGC -XX:+PrintGCDetails執行上面的方法,JDK11默認使用G1收集器,由於Z收集器還在實驗階段,不是很建議使用,執行main方法后輸出:
[11.374s][info ][gc,task ] GC(17) Using 8 workers of 8 for full compaction
[11.374s][info ][gc,start ] GC(17) Pause Full (System.gc())
[11.374s][info ][gc,phases,start] GC(17) Phase 1: Mark live objects
[11.429s][info ][gc,stringtable ] GC(17) Cleaned string and symbol table, strings: 5637 processed, 0 removed, symbols: 135915 processed, 0 removed
[11.429s][info ][gc,phases ] GC(17) Phase 1: Mark live objects 54.378ms
[11.429s][info ][gc,phases,start] GC(17) Phase 2: Prepare for compaction
[11.429s][info ][gc,phases ] GC(17) Phase 2: Prepare for compaction 0.422ms
[11.429s][info ][gc,phases,start] GC(17) Phase 3: Adjust pointers
[11.430s][info ][gc,phases ] GC(17) Phase 3: Adjust pointers 0.598ms
[11.430s][info ][gc,phases,start] GC(17) Phase 4: Compact heap
[11.430s][info ][gc,phases ] GC(17) Phase 4: Compact heap 0.362ms
[11.648s][info ][gc,heap ] GC(17) Eden regions: 44->0(9)
[11.648s][info ][gc,heap ] GC(17) Survivor regions: 12->0(12)
[11.648s][info ][gc,heap ] GC(17) Old regions: 146->7
[11.648s][info ][gc,heap ] GC(17) Humongous regions: 3->2
[11.648s][info ][gc,metaspace ] GC(17) Metaspace: 141897K->9084K(1062912K)
[11.648s][info ][gc ] GC(17) Pause Full (System.gc()) 205M->3M(30M) 273.440ms
[11.648s][info ][gc,cpu ] GC(17) User=0.31s Sys=0.08s Real=0.27s
可見FullGC之后,元空間(Metaspace)回收了(141897-9084)KB,一共回收了202M的內存空間,初步可以認為元空間的內存被回收了,接下來注釋掉main方法中調用的clear()方法,再調用一次main方法:
....
[4.083s][info ][gc,heap ] GC(17) Humongous regions: 3->2
[4.083s][info ][gc,metaspace ] GC(17) Metaspace: 141884K->141884K(1458176K)
[4.083s][info ][gc ] GC(17) Pause Full (System.gc()) 201M->166M(564M) 115.504ms
[4.083s][info ][gc,cpu ] GC(17) User=0.84s Sys=0.00s Real=0.12s
可見元空間在FullGC執行沒有進行回收,而堆內存的回收率也比較低,由此可以得出一個經驗性的結論:只需要通過ClassLoader對象做映射關系保存使用它加載出來的新的類,只需要確保這些類沒有沒強引用、類實例都已經銷毀,那么只需要移除ClassLoader對象的引用,那么在JVM進行GC的時候會把ClassLoader對象以及使用它加載的類回收,這樣做就可以避免元空間的內存泄漏。
小結
通過一些資料和實驗,深化了類加載過程的一些認識。
參考資料:
- 《深入理解Java虛擬機-第二版》
- JDK11部分源碼
個人博客
(本文完 e-2018129 c-2-d)
技術公眾號(《Throwable文摘》),不定期推送筆者原創技術文章(絕不抄襲或者轉載):

娛樂公眾號(《天天沙雕》),甄選奇趣沙雕圖文和視頻不定期推送,緩解生活工作壓力:

