深入淺出ClassLoader(譯)
該文章來自阿里巴巴技術協會( ATA )精選集
你真的了解ClassLoader嗎?
這篇文章翻譯自zeroturnaround.com的 Do You Really Get Classloaders? ,融入和補充了筆者的一些實踐、經驗和樣例。本文的例子比原文更加具有實際意義,文字內容也更充沛一些,非常感謝作者 Jevgeni Kabanov 能夠共享如此優秀的文檔。
為什么你需要了解和懼怕ClassLoader
ClassLoader在Java語言中占據了核心地位,Java應用服務器,OSGi,以及大量的網絡框架,它們大多數都用到了ClassLoader。如果在使用過程中出現了類加載錯誤,你能解決它嗎?
我們將從JVM和開發者兩個角度講述ClassLoader,將會選擇一些典型的案例,然后演示如何解決它們。NoClassDefFoundError,LinkageError等很多錯誤都會有特定的表征,我們分析每個例子,然后進行解決。
進入ClassLoader
每個ClassLoader對象都是一個java.lang.ClassLoader的實例。每個Class對象都被這些ClassLoader對象所加載,通過繼承java.lang.ClassLoader可以擴展出自定義ClassLoader,并使用這些自定義的ClassLoader對類進行加載。
先大體了解一下ClassLoader的API:
package java.lang;
public abstract class ClassLoader {
public Class loadClass(String name);
protected Class defineClass(byte[] b);
public URL getResource(String name);
public Enumeration getResources(String name);
public ClassLoader getParent();
}</span></code></pre>
最重要的是ClassLoader的 loadClass 方法,它接受一個全類名,然后返回一個Class類型的實例。
defineClass 方法接受一組字節,然后將其具體化為一個Class類型實例,它一般從磁盤上加載一個文件,然后將文件的字節傳遞給JVM,通過JVM(native 方法)對于Class的定義,將其具體化,實例化為一個Class類型實例。
getParent 方法返回其parent ClassLoader。
getResource 和 getResources 方法,從給定的repository中查找URLs,同時它們也具備類似 loadClass 一樣的代理機制,我們可以將 loadClass 視為: defineClass(getResource(name).getBytes()) 。
Java由于其晚綁定和“解釋型”的特性,類型的加載是到最晚才進行,一個類型直到被調用構造函數、靜態方法或者在字段上使用時才會被加載。
考慮如下代碼:
public class A {
public void doSomething() {
B b = new B();
b.doSomethingElse();
}
}
代碼: B b = new B(); 等同于 B b = Class.forName(“B”, false, A.class.getClassLoader()).newInstance() ;
這代表著,在類型A中使用到的類型,將由加載了類型A的類加載器來進行加載。
ClassLoader繼承體系
當啟動一個JVM時,bootstrap 類加載器就會加載java的核心類,例如:rt.jar中的類。bootstrap 類加載器是其他類加載器的parent,它使唯一一個沒有parent的類加載器。
接下來是extension 類加載器,它以bootstrap 類加載器作為parent,它用來從Java系統變量 java.ext.dir 中的jar包中加載類的。
第三個,也是最重要的一個就是開發者使用的system classpath 類加載器 。它是extension 類加載器 的child,它用來從Java系統變量 java.class.path 下面加載類,可以通過 -classpath 來指定這個位置。
注意類加載器的體系并不是“繼承”體系,而是一個“委派”體系。大多數類加載器首先會到自己的parent中查找類或者資源,如果找不到,才會在自己的本地進行查找。事實上,類加載器被定義加載哪些在parent中無法加載到的類,這樣在較高層級的類加載器上的類型能夠被“賦值”為較低類加載器加載的類型。
類加載器的委托行為動機是為了避免相同的類被加載多次。回到1995年,Java的主要方向被放在Applet上,那時候網絡帶寬優先,所以程序中的類直到用時才會被加載。但是事實上,Java在服務器端展示了強勁的能力,但是服務器端要求類加載器能夠反轉委派原則,也就是先加載本地的類,如果加載不到,再到parent中加載。
JavaEE的 委派模型
每個方塊都是一個類加載器,JavaEE規范推薦每個模塊的類加載器先加載本類加載的內容,如果加載不到才回到parent類加載器中嘗試加載。
反轉委派原則的原因是應用服務器中所攜帶的類庫并不是應用所期待的,也許不適合應用開發者,一個常見的例子就是log4j的依賴在容器和不同的應用中都存在,但是它們的版本大都不同。
Tomcat的 類加載順序
在Tomcat中,默認的行為是先嘗試在Bootstrap和Extension中進行類型加載,如果加載不到則在WebappClassLoader中進行加載,如果還是找不到則在Common中進行查找。
NoClassDefFoundError
NoClassDefFoundError是在開發JavaEE程序中常見的一種問題。該問題會隨著你所使用的JavaEE中間件環境的復雜度以及應用本身的體量變得更加復雜,尤其是現在的JavaEE服務器具有大量的類加載器。
在JavaDoc中對NoClassDefFoundError的產生是由于JVM或者類加載器實例嘗試加載類型的定義,但是該定義卻沒有找到,影響了執行路徑。換句話說,在編譯時這個類是能夠被找到的,但是在執行時卻沒有找到。
這一刻IDE是沒有出錯提醒的,但是在運行時卻出現了錯誤。
看看如下示例:
/* @author weipeng2k 2015年3月27日 下午5:15:15 */
@WebServlet(name = "NoClassDefFoundErrorServlet", urlPatterns = "/noClassDefFoundError.do")
public class NoClassDefFoundErrorServlet extends HttpServlet {
private static final long serialVersionUID = 61585757018374721L;
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.getWriter().println(TestCase.class.toString());
}
}</span></code></pre>
在看pom.xml中對于依賴的定義:
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>3.8.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>servlet-api</artifactId>
<version>3.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring</artifactId>
<version>2.5.6</version>
</dependency>
</dependencies>
其中對于junit的依賴是provided級別的,這里是為了能簡化錯誤出現的條件。可以看到,在NoClassDefFoundErrorServlet中,使用了junit.jar中的TestCase,但是 junit.jar 在WEB-INF/lib中卻沒有,從而導致WebappClassLoader在進行加載TestCase時無法找到,從而拋出NoClassDefFoundError。我們需要從最終的war包中確定是否存在這個類,而不是在IDE中進行搜索。
NoSuchMethodError
在另一個場景中,我們可能遇到了另一個錯誤,也就是NoSuchMethodError。
NoSuchMethodError代表這個類型確實存在,但是一個不正確的版本被加載了。為了解決這個問題我們可以使用 ‘-verbose:class’ 來判斷該JVM加載的到底是哪個版本。
看如下示例:
import org.springframework.beans.factory.BeanFactoryUtils;
/* @author weipeng2k 2015年3月31日 上午9:09:58 */
@WebServlet(name = "NoSuchMethodErrorServlet", urlPatterns = { "/noSuchMethodError.do" })
public class NoSuchMethodErrorServlet extends HttpServlet {
private static final long serialVersionUID = 1699609060417354821L;
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
BeanFactoryUtils.isGeneratedBeanName("xxx");
resp.getWriter().println("done.");
}
}</span></code></pre>
在doGet方法中調用了 BeanFactoryUtils.isGeneratedBeanName(”xxx“); ,看一下項目的pom依賴。
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.11</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>servlet-api</artifactId>
<version>3.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>org.springframework.context</artifactId>
<version>3.0.5.RELEASE</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.mina</groupId>
<artifactId>mina-core</artifactId>
<version>2.0.7</version>
</dependency>
<dependency>
<groupId>com.alibaba.external</groupId>
<artifactId>sourceforge.spring</artifactId>
<version>2.0.7</version>
</dependency>
</dependencies>
這里為了方便觀察到結果,將 org.springframework.context 的 scope 改為了 provided ,目的是不將其打包入war包,而只是使用了 sourceforge.spring 中定義的 2.0.7 版本,這個版本肯定沒有 isGeneratedBeanName(String name) 方法,但是在IDE中,由于應用依賴到了高版本的spring從而能夠編譯通過,但是在運行時卻沒有那么好運了。這種錯誤,常見于 Maven坐標 的變動,使得應用依賴了多個 相同內容,不同版本 的jar包,以致在運行時選擇了非期望的版本。
ClassCastException
NoClassDefFoundError和NoSuchMethodError是兩個在 JavaEE 環境中經常出現的問題,這些問題需要 開發人員 了解問題的本質,才能夠被 從容 的處理。
下面我們看一下ClassCastException,在一個類加載器的情況下,一般出現這種錯誤都會是在轉型操作時,比如: A a = (A) method(); ,很容易判斷出來 method() 方法返回的類型不是類型A,但是在 JavaEE 多個類加載器的環境下就會出現一些難以定位的情況。
看如下示例:
package com.murdock.classloader.servlet;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.mina.proxy.utils.MD4;
import com.murdock.classloader.CachedClassLoader;
/* @author weipeng2k 2015年4月4日 下午6:00:54 */
@WebServlet(name = "ClassCastExceptionServlet", urlPatterns = "/classCastException.do")
public class ClassCastExceptionServlet extends HttpServlet {
private static final long serialVersionUID = -8959000121057369987L;
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String localFirst = req.getParameter("localFirst");
CachedClassLoader cl = null;
cl = new CachedClassLoader(
new URL[] { new File(
"/Users/weipeng2k/.m2/repository/org/apache/mina/mina-core/2.0.7/mina-core-2.0.7.jar").toURI()
.toURL() }, this.getClass().getClassLoader());
if ("false".equals(localFirst)) {
cl.setLocalFirst(false);
}
try {
Class<?> klass = cl.loadClass("org.apache.mina.proxy.utils.MD4");
MD4 md4 = (MD4) klass.newInstance();
resp.getWriter().println(md4);
} catch (Exception ex) {
throw new RuntimeException(ex);
} finally {
cl.close();
}
}
}</span></code></pre>
在ClassCastExceptionServlet中,構建了一個CachedClassLoader,利用這個ClassLoader加載 org.apache.mina.proxy.utils.MD4 ,然后反射調用構造該類的實例,將其賦給 MD4 ,最后將其打印到瀏覽器。
請求URL : http://localhost:8080/classCastException.do
響應頁面,出現錯誤:
java.lang.RuntimeException: java.lang.ClassCastException: org.apache.mina.proxy.utils.MD4 cannot be cast to org.apache.mina.proxy.utils.MD4
com.murdock.classloader.servlet.ClassCastExceptionServlet.doGet(ClassCastExceptionServlet.java:42)
javax.servlet.http.HttpServlet.service(HttpServlet.java:622)
javax.servlet.http.HttpServlet.service(HttpServlet.java:729)
org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:52)
請求URL : http://localhost:8080/classCastException.do?localFirst=false
響應頁面,輸出正常:
org.apache.mina.proxy.utils.MD4@401c8af5
請求的URL加上了 localFirst=false 就可以正常的輸出,而它也就是在CachedClassLoder上設置了一下,為什么有這么大的差別。 org.apache.mina.proxy.utils.MD4 全類名一致,為什么會出現ClassCastException呢?
在JVM中,如何確定一個類型實例?答:全類名嗎?不是,是類加載器加上全類名。在JVM中,類型被定義在一個叫 SystemDictionary 的數據結構中,該數據結構接受類加載器和全類名作為參數,返回類型實例。
SystemDictionary 如圖所示:
更多內容可以參考 Pandora基礎知識
類型加載時,需要傳入類加載器和需要加載的全類名,如果在 SystemDictionary 中能夠命中一條記錄,則返回 class 列上對應的類型實例引用,如果無法命中記錄,則會調用 loader.loadClass(name); 進行類型加載。
這里不會更加深入的介紹 SystemDictionary 如何進行類型加載的過程,而是需要指出 JVM中確定一個類型的坐標是通過類加載器和全類名做到的 。回想一下 MD4 md4 = (MD4) klass.newInstance(); ,是不是代表著等式兩邊的MD4是不同的類加載器加載的呢?那問題一定出在 CachedClassLoader 上。這里貼一下 loadClass(String name) 方法的部分邏輯。
CachedClassLoader 的loadClass邏輯:
if (localFirst) {
try {
clazz = findClass(name);
if (clazz != null) {
return clazz;
}
} catch (ClassNotFoundException ex) {
}
return super.loadClass(name);
} else {
return super.loadClass(name);
}</code></pre>
可以看到在 localFirst 為 true 時,該類加載器會首先加載自身 repository 中的類型,如果加載不到,則會嘗試默認的加載機制進行加載,也就是parent優先加載。這樣就可以解釋 MD4 md4 = (MD4) klass.newInstance(); ,等式左邊 MD4 md4 ,這個類型是 WebappClassLoader.org.apache.mina.proxy.utils.MD4 ,等式右邊 klass.newInstance() 返回的類型是 CachedClassLoader.org.apache.mina.proxy.utils.MD4 ,二者并不是同一個類型,所以無法完成類型轉換,最終拋出 ClassCastException 。而當 localFirst 為 false 時,該類加載器遵循parent優先,從而會先委派給WebappClassLoader進行加載,當然轉型也就不會有問題了。
在傳統的雙親委派模型下,這種 ClassCastException 是不會發生的,因為它的加載順序杜絕了出現這種問題的可能,而在 JavaEE 環境下,每個資源模塊(比如一個war包)都優先使用自身的資源,正因為突破了雙親委派模型, 奇怪的問題 就發生了。
LinkageError
有時候事情會變得更糟,和 ClassCastException 本質一樣,加載自不同位置的*相同類*在同一段邏輯(比如:方法)中交互時,會出現 LinkageError 。
我們先看一下出錯的異常信息,然后分析一下它產生的條件和原因:
java.lang.LinkageError: loader constraint violation: when resolving overridden method "com.murdock.classloader.linkageerror.Param2.generate()Lcom/murdock/classloader/linkageerror/Param2;" the class loader (instance of com/murdock/classloader/linkageerror/LinkageErrorTest$1) of the current class, com/murdock/classloader/linkageerror/Param2, and its superclass loader (instance of sun/misc/Launcher$AppClassLoader), have different Class objects for the type com/murdock/classloader/linkageerror/Param2 used in the signature
at java.lang.Class.getDeclaredConstructors0(Native Method)
at java.lang.Class.privateGetDeclaredConstructors(Class.java:2671)
at java.lang.Class.getConstructor0(Class.java:3075)
at java.lang.Class.newInstance(Class.java:412)
at com.murdock.classloader.linkageerror.LinkageErrorTest.test(LinkageErrorTest.java:34)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:497)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:47)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:44)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
看到一堆出錯信息,但是不要緊張,慢慢的讀一下出錯信息,這種錯誤一般會讓你直覺感覺不會出現。 loader constraint violation 表示類加載器沖突了,這句話暗示: 相同的類,由不同的ClassLoader加載,但是在這里遇到了 。 when resolving overridden method "com.murdock.classloader.linkageerror.Param2.generate()Lcom/murdock/classloader/linkageerror/Param2;" 表示在解析那條語句出現了問題,這里表示在 Param2.generate() 方法的解析過程中出現了問題。 the class loader (instance of com/murdock/classloader/linkageerror/LinkageErrorTest$1) of the current class, com/murdock/classloader/linkageerror/Param2, 表示解析的語句所在的類型 Param2 是 LinkageErrorTest$1 類加載器加載的。 and its superclass loader (instance of sun/misc/Launcher$AppClassLoader), have different Class objects for the type com/murdock/classloader/linkageerror/Param2 used in the signature 表示 Param2 的超類 Param 中被覆蓋的方法返回的類型 Param2 為 Launcher$AppClassLoader 加載。
Linkage在常規情況下非常難以制造,只有在多個類加載器交互時才有可能出現,下面看一下問題代碼。出現問題的類和參數:
package com.murdock.classloader.linkageerror;
/* @author weipeng2k 2015年4月28日 上午10:04:26 */
public class HandleUtils {
public void m(Param param) {
param.generate();
}
}
package com.murdock.classloader.linkageerror;
public class Param {
public Param2 generate() {
return new Param2();
}
}
package com.murdock.classloader.linkageerror;
public class Param2 extends Param {
public Param2 generate() {
return new Param2();
}
}</span></span></code></pre>
測試用例如下:
@Test
public void test() throws Exception {
// cl1在加載HandleUtils和Param時將會使用AppClassLoader
URLClassLoader cl1 = new URLClassLoader(new URL[] {new File("target/test-classes").toURI().toURL()}, null) {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
if ("com.murdock.classloader.linkageerror.HandleUtils".equals(name)) {
return ClassLoader.getSystemClassLoader().loadClass(name);
}
if ("com.murdock.classloader.linkageerror.Param".equals(name)) {
return ClassLoader.getSystemClassLoader().loadClass(name);
}
return super.loadClass(name);
}
};
ClassLoader.getSystemClassLoader().loadClass("com.murdock.classloader.linkageerror.Param2");
HandleUtils hu = (HandleUtils) cl1.loadClass("com.murdock.classloader.linkageerror.HandleUtils").newInstance();
hu.m((Param) cl1.loadClass("com.murdock.classloader.linkageerror.Param2").newInstance());
}</code></pre><code data-language="java" style="background:#23241f;font-size:inherit;white-space:pre-wrap;display:block;font-family:Menlo, Monaco, Consolas, 'Courier New', monospace;padding:.5em;">
LinkageError 需要觀察哪個類被不同的類加載器加載了,在哪個方法或者調用處發生(交匯)的,然后才能想解決方法,解決方法無外乎兩種。第一,還是不同的類加載器加載,但是相互不再交匯影響,這里需要針對發生問題的地方做一些改動,比如更換實現方式,避免出現上述問題;第二,沖突的類需要由一個Parent類加載器進行加載。**LinkageError** 和**ClassCastException** 本質是一樣的,加載自不同類加載器的類型,在同一個類的方法或者調用中出現,如果有轉型操作那么就會拋 ClassCastException ,如果是直接的方法調用處的參數或者返回值解析,那么就會產生 LinkageError 。
類加載器問題對照表
遇到類加載器問題時,可以嘗試使用下面的表格進行問題排查。
類找不到
加載了不正確的類
多于一個類被加載
ClassNotFoundException NoClassDefFoundError
IncompatibleClassChangeError NoSuchMethodError NoSuchFieldError IllegalAccessError
ClassCastException LinkageError
IDE class lookup (Ctrl+Shift+T in Eclipse) 或者 find . -name "*.jar" -exec jar -tf {} \; \
grep DateUtils 使用middelware-detector
通過在啟動參數中加 -verbose:class ,觀察加載的類來自哪個jar包 使用middelware-detector
使用Middleware-Detector進行類查找
出現了 ClassNotFoundException 或者 NoClassDefFoundError ,需要檢查一下程序的classpath下面是否存在你所預想的類。這時可以使用Middleware-Detector工具進行類查找,該工具是 中間件團隊 開發的一款中間件問題診斷工具,當然也包括了許多支持性質的工具。
下面我們使用Middleware-Detector進行類查找,比如我們要查找apache的Utils,我們懷疑這個類在classpath下找不到。
啟動middleware-detector,查看 Pandora 提供的自定義檢查器,目前編號為1的Pandora自定義檢查器就是進行classpath下的指定類或者接口的查找工作。
配置classpath目錄以及需要查找的類名,這里類名支持 * 號進行模糊匹配。可以看到設定當前的classpath目錄到了 WEB-INF/lib 下面,然后找尋 *apache*comm*A*Utils 是否存在,如果能夠找到則會輸出到終端,這里就找到了ArchiveUtils和ArrayUtils兩個符合要求的類。如果無法找到,那么就可能是 pom.xml 的依賴配置不正確了,需要檢查一下。
使用Middleware-Detector進行檢查類沖突
出現了 NoSuchMethodError 或者 NoSuchFieldError ,這時一般是應用的classpath下包含了多個包含了想同類的jar包,而很不幸的加載到了 不正確 的jar包。
我們可以通過使用Middleware-Detector的類查找進行定位,但是不能發現一個修復一個,這里Middleware-Detector提供了一個檢查classpath下有沖突jar包的功能。只需要設置classpath的目錄,然后運行 cc --check tomcat#1 即可。有沖突的jar就需要自己在 pom.xml 里面進行仲裁或者排除了。