您的位置:首页 > 编程语言 > Java开发

Java ClassLoader 深入理解

2016-04-29 18:19 435 查看

ClassLoader问题剖析

做为一个java开发人员,我们都曾经受这些异常的折磨:ClassNotFoundException、NoClassDefFoundError、ClassCastException、ClassCircularityError,究其根源,我们不可避免地要面对java的大人物: ClassLoader !


ClassLoader基础

java程序不是本地的可执行程序,它的执行依赖jvm,jvm运行后将 class 文件加载到jvm,然后才能在jvm内部运行。负责加载这些class的组件就是ClassLoader。

JVM本身包含了一个ClassLoader称为BootstrapClassLoader,和JVM自身一样,BootstrapClassLoader是用本地代码(c/c++等)实现的,它负责加载核心Java class(如rt.jar里的class)。另外JVM还提供了两个ClassLoader,它们都是用Java语言编写的,由BootstrapClassLoader加载到jvm,它们是ExtClassLoaderAppClassLoader,其中ExtClassLoader负责加载Java扩展 class(如jre/lib/ext下的类),AppClassLoader负责加载应用程序自身的类(如-classpath下的class)。

当运行一个程序的时候,JVM启动,运行Bootstrap ClassLoader,该ClassLoader加载java核心API(ExtClassLoader和AppClassLoader也在此时被加载),然后调用ExtClassLoader加载扩展API,最后调用AppClassLoader加载应用CLASSPATH下定义的Class,这就是一个程序最基本的加载流程。

JVM自带的ClassLoader只是懂得从本地文件系统加载标准的java class文件,但是我们可能会有下面的应用场景:

1)在执行类加载之前,自动验证数字签名

2)动态地创建符合用户特定需要的定制化构建类

3)从特定的场所取得 class,例如数据库、网络等

4) 自定义加解密Class 等等

这时候我们就需要定制自己的ClassLoader,java提供了很方便的api供我们定制自己的ClassLoader。

事实上当使用Applet的时候,就用到了特定的ClassLoader,因为需要从网络上加载java class,并且要检查相关的安全信息。另外,应用服务器大都使用了自定义ClassLoader技术,了解类加载原理也有助于我们更好地开发自己的应用。


ClassLoader结构

java中内置了很多类加载器,本文只讨论几个核心类加载器:

ClassLoader:所有类加载器的基类,它是抽象的,定义了类加载最核心的操作。

SecureClassLoader:继承自ClassLoader,添加了关联类源码、关联系统policy权限等支持。

URLClassLoader:继承自SecureClassLoader,支持从jar文件和文件夹中获取class

ExtClassLoader:扩展类加载器,继承自URLClassLoader,负责加载java的扩展类(javax.*等),查看源码可知其查找范围为System.getProperty(“java.ext.dirs”),通常是jre/lib/ext

AppClassLoader:应用类加载器,继承自URLClassLoader,也叫系统类加载器(ClassLoader.getSystemClassLoader()可得到它),它负载加载应用的classpath下的类,查找范围System.getProperty(“java.class.path”),通过-cp或-classpath指定的类都会被其加载

java没有提供Launcher的源码,可参考openjdk的Launcher类源码。


上面介绍的是ClassLoader的静态类结构,在概念上,它们还有树形结构,java中的每个ClassLoader都有自己的父加载器(注意区分,不是类关系上的 super),可以通过ClassLoader.getParent() 获取到,当jvm启动完成后,默认情况下,他们的树形结构是这样的:

自定义的ClassLoader其父加载器为AppClassLoader,

AppClassLoader的父加载器为ExtClassLoader,

ExtClassLoader的父加载器为null,

null表示其父加载器为 BootstrapClassLoader(非java实现故显示为null)。

ClassLoader类加载模型

ClassLoader加载类用的是委托模型(也叫双亲委派模型)。即先让父加载器(注意不是super,不是继承关系的父,而是通过一个属性保存了一个父的引用)加载,父加载器加载不到才能轮到自己加载。

需要注意:父加载器加载到的类对子加载器可见,但子加载器加载到的类对父加载器是不可见的,这句话很好理解,但往往因为它产生的问题都不好分析。

需要注意:在jvm中,一个class实例的唯一标识是:类全名+ClassLoader,所以,经由不同ClassLoader加载到的相同类全名的class也是不相同的,它们的类型也是不兼容的,这也是某些类型转换异常的原因,这种异常通常也不好分析。

要想看到类装入详细信息,可以用 -verbose:class 选项启动。详细输出有助于解决类路径问题,例如没有打开 JAR 文件(因此不在类路径中)或从错误的位置装入了类等。


查看Launcher的构造函数,有如下:

// Create the extension class loader
ClassLoader extcl;
try {
extcl = ExtClassLoader.getExtClassLoader();
} catch (IOException e) {
throw new InternalError(
"Could not create extension class loader");
}

// Now create the class loader to use to launch the application
try {
loader = AppClassLoader.getAppClassLoader(extcl);
} catch (IOException e) {
throw new InternalError(
"Could not create application class loader");
}

// Also set the context class loader for the primordial thread.
Thread.currentThread().setContextClassLoader(loader);
....


从Launcher的构造方法中,可以看出Launcher的构造先创建了ExtClassLoader,后又创建了AppClassLoader,并将AppClassLoader的父ClassLoader设为ExtClassLoader,最后,取得当前线程,并将当前线程的ContextClassLoader设为AppClassLoader

再查看Thread的构造函数,会得知,Thread在构造时候会取得当前线程,并将当前线程的contextClassLoader赋给新创建的线程

我们在建立一个线程Thread的时候,可以为这个线程通过setContextClassLoader方法来指定一个合适的classloader作为这个线程的context classloader,当此线程运行的时候,我们可以通过getContextClassLoader方法来获得此context classloader,就可以用它来载入我们所需要的Class默认的就是system classloader(从上面源码中可得知)。利用这个特性,我们可以“打破”classloader委托机制了,父classloader可以获得当前线程的context classloader,而这个context classloader可以是它的子classloader或者其他的 classloader,那么父classloader就可以从其获得所需的 Class,这就打破了只能向父classloader请求的限制了。这个机制可以满足我们的classpath是在运行时才能确定的需求,可以通过context classloader获得定制的classloader并加载入特定的class,例如web应用中的servlet等技术就是用这种机制加载的。

Classloader虽然称为类加载器,但并不意味着只能用来加载Class,我们还可以利用它也获得图片,音频文件等资源的URL,当然,这些资源必须在CLASSPATH中的jar类库中或目录下。因此我们可以将图片等资源随同Class一同打包到jar类库中(当然,也可单独打包这些资源)并添加它们到class loader的搜索路径中,我们就无需关心这些资源的具体位置了,让class loader来帮我们寻找吧!


一些类加载问题示例

示例1

两个类,A、B, A依赖B,B不依赖A,将两者分别放于不同的目录加载,有以下现象:

1)B放于jre/lib/ext下,A放于classpath下,正常。

2)A放于jre/lib/ext下,B放于classpath下,报错:java.lang.NoClassDefFoundError:B

出错原因:A放于jre/lib/ext下,其ClassLoader为ExtClassLoader(可通过Class.getClassLoader()查看),B放于classpath下,其ClassLoader为AppClassLoader,ExtClassLoader是AppClassLoader的父加载器,故ExtClassLoader无法加载到B就会报错。这是个很基础的示例,同时也是很多类加载不到案例的问题模型。

有人会想,将两个类放一起就没有这个顾虑了,的确是,但事实上有时候它们无法放到一起,比如web开发中,javax中定义了很多接口(如jdbc规范)默认都被放在系统类路径下,而它们的实现(如mysql驱动)往往被放在web应用自定义的类路径下,他们的开发和部署都是分开的,一般不会放到一起,继续看下面的案例会更有体会。


案例2

在JAXP(XML处理相关) 中的, javax.xml.parsers.DocumentBuilderFactory类(位于rt.jar)的 newInstance()方法用来生成一个新的 DocumentBuilderFactory的实例,此实例必需是javax.xml.parsers.DocumentBuilderFactory的实现类,它是由JAXP的SPI具体实现所提供的,如 Apache Xerces 中的 org.apache.xerces.jaxp.DocumentBuilderFactoryImpl,

问题出现了,SPI 的接口是 Java 核心库的一部分,是由引导类加载器来加载的,SPI 的实现一般都是由系统类加载器来加载的,引导类加载器无法加载到SPI的实现类,那么SPI的接口如何使用SPI的实现呢?

答案就是:线程上下文类加载器

如果不做任何的设置,线程的上下文类加载器默认就是系统上下文类加载器(AppClassLoader)。在 SPI 接口的代码中使用线程上下文类加载器,就可以成功的加载到 SPI 实现的类。

线程上下文类加载器在很多 SPI 的实现机制中都会用到。


ClassLoader异常

ClassNotFoundException

ClassNotFoundException 是最常见的类装入异常。它发生在装入阶段。Java 规范对它的描述是这样的:

当应用程序试图通过类的字符串名称以下面三种方法装入类但却找不到指定名称时就会抛出该异常。

1)类 Class 中的 forName() 方法。

2)类 ClassLoader 中的 findSystemClass() 方法。

3)类 ClassLoader 中的 loadClass() 方法。

所以,显式装入类失败,就会抛出 ClassNotFoundException, -verbose 启动参数可打印出详细的类装入信息,

要修复这个问题,可以把类移动到类路径中指定的目录或JAR文件中,或者把类所在的位置添加到类路径中。

NoClassDefFoundError

JVM 规范对 NoClassDefFoundError 的定义如下:

ClassLoader试图装入类定义(类结构或方法等的一部分,可理解为类的依赖),但却没有找到类定义时抛出该异常。

通常此异常抛出在在这样的场景,我们试图装入一个类(显示装入),该类可以找到,然而装入过程中发现此类有依赖其它类,此时就去隐式转入其依赖类,如果没有找到其依赖类,则认为其依赖类没有定义,就会抛出此异常。

NoClassDefFoundError 的抛出,是不成功的隐式类装入的结果

示例代码:

public class NoClassDefFoundErrorTest {

public static void main(String[] args) {

A a = new A();

}

}

public class A extends B {

}

public class B {

}

这几个清单中的代码编译好之后,删除B的类文件。当代码执行时,就会出现以下错误:Exception in thread “main” java.lang.NoClassDefFoundError: B

如果显式地告诉类装入器装入类 B(例如通过 loadClass(“B”) 调用),那么就会抛出 ClassNotFoundException,注意这两个异常的区别。

在这个例子中,A 继承了 B,即使 A 用其他方式引用 B,也会出现同样的问题,例如,以方法参数引用或作为实例字段。如果两个类之间的关系是引用关系而不是继承关系,那么会在第一次使用到时抛出异常,而不是在装入 A 时抛出。

ClassCastException

JVM 规范定义:

当代码企图把对象的类型转换成一个子类,而该对象并不是这个子类的实例时就会抛出该异常。

一般来说,父类转换到子类出现不兼容的场景是很容易发现并修订的,如String可以转换为Object,而Object不能转换到String,但是有一类问题却不容易解决,比如我们定义一个接口MyInterface,和其实现类MyImpl,并一起打包进loader.jar,我们会有类似如下的代码:

URL url = new URL(“file:/home/wangpl/Desktop/loader.jar”);

URLClassLoader loader = new URLClassLoader(new URL[]{url}, null);//设置父加载器为null

Class clazz = loader.loadClass(“test.loader.MyImpl”);

Object obj = clazz.newInstance();

MyInterface myInterface = (MyInterface) obj;

这个看似很正常的代码却会出现 转换异常,原因在于我们使用了自己的URLClassLoader,并且其父加载器为null,它在加载MyImpl时,发现它实现了MyInterface接口,于是就去找MyInterface的定义,因为父为null,只能自己从loader.jar中加载,此时,MyImpl实现的接口就是URLClassLoader加载的MyInterface,然而在代码中,我们使用的MyInterface是由系统类加载器(AppClassLoader)加载的,所以会发生类型不兼容的现象,这也再次印证了:jvm里Class实例的唯一标识是类 全名+ClassLoader。

上述问题的解决方案:去掉父加载器为null的代码,这样我们URLClassLoader在寻找MyInterface定义的时候就会优先从父加载器中寻找,这样就会忽略掉自己的MyInterface,如此,MyImpl实现的是系统加载器的MyInterface,转换便没有异常。

UnsatisfiedLinkError

在JNI开发中,如果程序试图装入一个不存在或者位置放错的本机库时,在链接阶段的解析过程会发生 UnsatisfiedLinkError。

JVM 规范指定 UnsatisfiedLinkError 是:

对于声明为 native 的方法,如果jvm找不到和它对应的本机语言定义,就会抛出该异常。

示例代码:

public class UnsatisfiedLinkErrorTest {

public native void call_A_Native_Method();

static {

System.loadLibrary(“myNativeLibrary”);

}

public static void main(String[] args) {

new UnsatisfiedLinkErrorTest().call_A_Native_Method();

}

}

error:The java class could not be loaded. java.lang.UnsatisfiedLinkError…

要装入所引用的本机库,这个类装入器先查找 sun.boot.library.path,然后查找 java.library.path。因为在两个位置中都没有需要的库,所以类装入器抛出 UnsatisfiedLinkageError。

一旦理解了库装入过程所涉及的类装入器,就可以通过把库放在合适位置来解决这类问题。

ClassCircularityError

下面的代码展示了类或接口由于是自己的超类或超接口而不能被装入。

public class A extends B {

}

public class B {

}

public class A {

}

public class B extends A {

}

这个错误是在链接阶段的解析过程中抛出的。这个错误有点奇怪,因为 Java 编译器不允许发生这种循环情况。但是,如果独立地编译类,然后再把它们放在一起,就可能发生这个错误,比如单独编译上面两个或下面两个都不会出错,这样就可以形成了循环依赖的情景,这种类结构在resolve过程中就会抛出异常。

ClassFormatError

这个异常是在类装入的链接阶段的校验过程中抛出。如果字节码发生了更改,例如主版本号或次版本号发生了更改,那么二进制数据的形式就会有误。例如,如果对字节码故意做了更改,或者在通过网络传送类文件时现出了错误,那么就可能发生这个异常。

修复这个问题的惟一方法就是获得字节码的正确副本,可能需要重新进行编译。

tongweb遇到的一个jdk1.8不兼容的问题就报出了这个异常,原因是因为jdk1.8中的HashMap中的内部类Entry去掉了,即class版本改变。


ExceptionInInitializerError

如果在初始化过程中突然结束,抛出一些异常 E,而且 E 的类不是 Error 或者它的某个子类,那么就会创建 ExceptionInInitializerError 类的一个新实例(Error和Exception平级,无继承关系,它们都直接继承自Throwable),并用 E 作为参数,用这个实例代替 E。

如果jvm在试图创建类 ExceptionInInitializerError 的实例时,因为出现 Out-Of-Memory-Error 而无法创建新实例,那么就抛出 OutOfMemoryError 对象作为代替。

下面代码会出现这种异常:

public class ExceptionInInitializerErrorTest {

public static void main(String[] args) {

A a = new A();

}

}

class A {

static {

throw new SecurityException();//Exception的子类,不是Error的子类

}

}

示例中的静态代码块中发生异常时,会被自动捕捉并用 ExceptionInInitializerError 包装该异常。在输出中可以看到:

Exception in thread “main” java.lang.ExceptionInInitializerError

at ExceptionInInitializerErrorTest.main(ExceptionInInitializerErrorTest.java:3)

Caused by: java.lang.SecurityException

at A.<clinit>(ExceptionInInitializerErrorTest.java:12)

… 1 more

修复这个错误的方法是检查造成 ExceptionInInitializerError 的异常(在堆栈跟踪的 Caused by … 下显示)并寻找阻止抛出这个异常的方式。

简谈Class热替换

java本身不支持热替换,即当class文件已经被加载到内存,这时再使用新编译的class文件替换不会有任何变化,这很好理解,因为java本身不会检测class文件的变动,但是,即便是我们手动调用ClassLoader再次加载此类,依旧不会有任何变化,原因是,在同一个ClassLoader内是以包名+类名做唯一标识,如果新编译的class文件包名+类名没有变化(热替换肯定不会变化),就不会再次解析该class文件而是直接返回之前解析到的Class(每个被加载的Class都会在ClassLoader内部缓存)。

如何热替换?

既然在同一个ClassLoader内部无法进行二次加载同一个类,那么要想加载并解析到新的类文件就必需使用一个未曾加载过此类的ClassLoader,即使用一个新的ClassLoader加载新的类文件,这也是热替换方案的必经之路。

一般做法:

1.为需要热替换的Class创建一个接口,目的是引用Class的实例并在其更新后转而引用其新的实例。

2.开一个定时线程,周期性检测Class文件是否有改动(可检测文件的最后修改时间是有变化),如果有改动则创建一个新的ClassLoader加载此Class文件,得到一个新的Class,新的Class通过使用反射技术得到一个实例,再将此实例强转为之前定义的接口,之后再对此接口引用,就可看到更新后的变化。

这种替换方案也只能对后来引用的上下文产生影响,更新前的实例依旧不会也不可能被替换掉,所以如果要制定热替换方案,则需要多加考虑接口的使用方式与实例的生命周期等因素,以确保不要总是守着旧的实例不放而使用不到新的实例,可以考虑使用统一的实例接口引用方法来进行控制。另外也要记得及时切断对旧实例的引用并通知GC进行回收。


结束

要更深入地了解ClassLoader,搜索:OSGI
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  java classloader