类加载器深入理解

时间:2022-12-29 09:28:31

虚拟机设计团队把类加载阶段中“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的模块称为“类加载器”。

类加载器在类层次划分、OSGI、热部署、代码加密等领域大放异彩,成为了Java技术体系中一块重要的基石。

类与类加载器

类的唯一性

对于任意一个类,都需要由加载它的类加载器类的全限定名一同确定其在Java虚拟机中的唯一性

换句话说就是,比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等。

这里所指的“相等”,包括Class对象的equals()方法,isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。

示例代码

public class JavaBean {}//我们要加载的JavaBean类
//该加载器可以加载与自己在同一路径下的Class文件public class MyClassLoader extends ClassLoader{@Overridepublic Class<?> loadClass(String name) throws ClassNotFoundException{try {  String fileName=name.substring(name.lastIndexOf(".")+1)+".class";InputStream is=getClass().getResourceAsStream(fileName);  if(is==null){//不在当前路径下的类,例如Object类(JavaBean的父类),采用委派模型加载return super.loadClass(name); }else{//在当前路径下的类,例如JavaBean类,直接通过自己加载其Class文件byte[] b=new byte[is.available()];is.read(b);return defineClass(name,b,0,b.length);   }} catch (IOException e) { throw new ClassNotFoundException();}}}

public class ClassLoaderTest {//测试类
public static void main(String[] args) throws Exception{
ClassLoader myLoaderA=new MyClassLoader();
Object objA=myLoaderA.loadClass("test.JavaBean").newInstance();
Object objB=new JavaBean();
System.out.println(objA.getClass());
System.out.println(objB.getClass());
System.out.println(objA.getClass().getClassLoader());
System.out.println(objB.getClass().getClassLoader());
System.out.println(objA.getClass().equals(objB.getClass()));
}
}

输出:

class test.JavaBean
class test.JavaBean
test.MyClassLoader@5d9084
sun.misc.Launcher$AppClassLoader@1a7508a
false
前两行输出可以看出,objA的所属类和objB的所属类都是test.JavaBean类。

第三行和第四行输出可以看出,objA的所属类的类加载器是我们自定义的类加载器MyClassLoader,objB的所属类的类加载器是AppClassLoader。

第五行输出可以看出,即使objA的所属类和objB的所属类一样并且来自同一个Class文件,只要加载他们的类加载器不同,它们就是两个独立的类,结果自然为false。

初始类装载器/定义类装载器

public class JavaBean {}//我们要加载的JavaBean
//该加载器什么都不做,只是通过委派模型加载类public class MyClassLoader extends ClassLoader{@Overridepublic Class<?> findClass(String name) throws ClassNotFoundException{return super.loadClass(name);  }}
public class ClassLoaderTest {//测试类public static void main(String[] args) throws Exception{  ClassLoader myLoaderA=new MyClassLoader(); Object objA=myLoaderA.loadClass("test.JavaBean").newInstance();   System.out.println(objA.getClass()); System.out.println(objA.getClass().getClassLoader()); }}
输出:

class test.JavaBean
sun.misc.Launcher$AppClassLoader@1a7508a

此处,显然我们在Java代码中是通过自定义的MyClassLoader类加载器的loadClass()方法来加载类,称其为初始类加载器。但实际加载类的加载器却是AppClassLoader,因此AppClassLoader被称为定义类加载器

之前我们说过:“对于任意一个类,都需要由加载它的类加载器类的全限定名一同确定其在Java虚拟机中的唯一性

那么,这里说的加载它的类加载器到底是指初始类加载器还是定义类加载器呢?我们通过实例来进行验证

public class JavaBean {}//我们要加载的JavaBean

//该加载器什么都不做,只是通过委派模型加载类public class MyClassLoader extends ClassLoader{@Overridepublic Class<?> findClass(String name) throws ClassNotFoundException{return super.loadClass(name);  }}
public class ClassLoaderTest {//测试类public static void main(String[] args) throws Exception{  ClassLoader myLoaderA=new MyClassLoader(); ClassLoader myLoaderB=new MyClassLoader(); Object objA=myLoaderA.loadClass("test.JavaBean").newInstance();  Object objB=myLoaderB.loadClass("test.JavaBean").newInstance();  System.out.println(objA.getClass().getClassLoader()==objB.getClass().getClassLoader());System.out.println(objA.getClass()==objB.getClass());}}

输出:

true
true

objA和objB的初始类加载器是两个不同的实例,但是定义类加载器却是同一个实例,最终objA的所属类与objB的所属类相等。

因此,得出结论:定义类加载器和类本身一同确定其在虚拟机中的唯一性。

类加载器的命名空间

每个类加载器有自己的命名空间,命名空间由所有以此加载器为初始加装载器的类组成。

不同类加载器的命名空间关系:

1、同一个命名空间内的类是相互可见的,即可以互相访问。

2、父加载器的命名空间对子加载器可见。

3、子加载器的命名空间对父加载器不可见。

4、如果两个加载器之间没有直接或间接的父子关系,那么它们各自加载的类相互不可见。

双亲委派模型

从Java虚拟机的角度讲,只存在两种不同的类加载器:

1、启动类加载器(Bootstrap ClassLoader):这个类加载器使用C++语言实现,是虚拟机自身的一部分。

3、其他的类加载器:这些类加载器由Java语言实现,独立于虚拟机,并且全部都继承自抽象类java.lang.ClassLoader。


从Java开发人员的角度讲,类加载器还可以划分得更加细致一些,绝大多数Java程序都会使用到以下三种系统提供的类加载器

启动类加载器(Bootstrap ClassLoader:这个类加载器负责将存放在<JAVA_HOME>\lib目录中,或者被-Xbootclasspath虚拟机参数指定的路径中,并且是虚拟机识别的类库加载到虚拟机内存中。(仅按照文件名识别,如rt.jar,名称不符合的类库即使放在lib目录中也不会被加载。)

扩展类加载器(Extension ClassLoader):这个类加载器由sun.misc.Launcher&ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。

应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher$ApplicationClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

我们的应用程序都是由这三种类加载器相互配合进行加载的,如果有必要,还可以加入自己定义的类加载器。

类加载器深入理解

类加载器之间的层次关系,称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器外,其余类加载器都应该有自己的父类加载器。注意,这里类加载器之间的父子关系一般不会以继承的关系实现,而是使用组合关系来复用父加载器的代码。

双亲委派模型的工作过程

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该首先传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。

双亲委派模型的实现

类加载器均是继承自java.lang.ClassLoader抽象类。首先,我们看一看java.lang.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 {
//父加载器为null,说明this为扩展类加载器的实例,父加载器为启动类加载器
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父加载器抛出ClassNotFoundException
// 说明父加载器无法完成加载请求
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}

if (c == null) {
// 如果父加载器无法加载
// 调用本身的findClass方法来进行类加载
// 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
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

通过进一步分析标准扩展类加载器(sun.misc.Launcher$ExtClassLoader)和系统类加载器(sun.misc.Launcher$AppClassLoader)的代码以及其公共父类(java.net.URLClassLoader和java.security.SecureClassLoader)的代码可以看出,都没有重写java.lang.ClassLoader中默认的委派加载规则——loadClass(…)方法

双亲委派模型的验证

public static void main(String[] args) throws Exception{ 
//java.lang.ClassLoader.getSystemClassLoader()可以直接获取到系统类加载器。
System.out.println(ClassLoader.getSystemClassLoader());
System.out.println(ClassLoader.getSystemClassLoader().getParent());
System.out.println(ClassLoader.getSystemClassLoader().getParent().getParent());
}
输出

sun.misc.Launcher$AppClassLoader@1a7508a
sun.misc.Launcher$ExtClassLoader@198cb3d
null
通过输出,我们可以判定系统类加载器的父加载器是标准扩展类加载器,但是我们试图获取标准扩展类加载器的父加载器时,却得到了null标准扩展类加载器的父加载器不是启动类加载器吗?
首先,我们看一看ClassLoader类中parent的定义:

    private final ClassLoader parent;//父加载器为ClassLoader类型
我们知道,启动类加载器使用C++实现,因此启动类加载器无法被Java程序直接引用,显然其类型也不是ClassLoader。因此,启动类加载器使用null进行代替。

现在我们可能会有这样的疑问:既然扩展类加载器的父加载器被强制设置为null了,那么扩展类加载器为什么还能将加载任务委派给启动类加载器呢?

我们还是回到java.lang.ClassLoader类的loadClass()方法和findBootstrapClassOrNull()方法

    protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
......
if (parent != null) {//父加载器不为null,即父加载器为ClassLoader类型
c = parent.loadClass(name, false);//委派请求给父加载器
} else {//父加载器为null,说明this为扩展类加载器的实例
c = findBootstrapClassOrNull(name);//通过启动类加载器加载类
}
......
}

    /**通过启动类加载器加载类
* Returns a class loaded by the bootstrap class loader;
* or return null if not found.
*/
private Class findBootstrapClassOrNull(String name)
{
if (!checkName(name)) return null;

return findBootstrapClass(name);
}
    // return null if not found 启动类加载器通过本地方法加载类    private native Class findBootstrapClass(String name);
不难发现,所谓的启动类加载器并非是一个具体的类,而是一个抽象的逻辑概念。

双亲委派模型的实例

public class ClassLoaderTest {//测试类
public static void main(String[] args) throws Exception{
try {
//查看当前系统类路径中包含的类路径条目
System.out.println(System.getProperty("java.class.path"));
//通过"加载当前类的类加载器"(这里即为系统类加载器)加载ClassLoaderTest类
Class typeLoaded = Class.forName("test.ClassLoaderTest");
//查看被加载的ClassLoaderTest类型是被那个类加载器加载的
System.out.println(typeLoaded.getClassLoader());
} catch (Exception e) {
e.printStackTrace();
}
}
}

输出:

C:\Documents and Settings\Administrator\workspace\test\bin
sun.misc.Launcher$AppClassLoader@3411a
默认情况下,应用程序中的类由应用程序类加载器AppClassLoader)加载。该加载器加载系统类路径下的类,因此一般也称为系统类加载器

现在,我们将当前工程打包成ClassLoaderTest.jar,然后剪贴到<Java_Runtime_Home>/lib/ext目录下(现在工程的输出目录下和JRE扩展目录下都有待加载类型的class文件)。再运行测试代码,输出如下:

C:\Documents and Settings\Administrator\workspace\test\bin
sun.misc.Launcher$ExtClassLoader@198cb3d

我们明显可以验证前面说的双亲委派机制,系统类加载器在收到加载test.ClassLoaderTest类的请求时,首先将请求委派给父加载器(标准扩展类加载器),标准扩展类加载器抢先完成了加载请求。

如果我们将ClassLoaderTest.jar拷贝到<Java_Runtime_Home>/lib目录下,会是怎样的情况呢?

输出:

C:\Documents and Settings\Administrator\workspace\test\bin
sun.misc.Launcher$ExtClassLoader@198cb3d

从输出可以看出,放置到<Java_Runtime_Home>/lib目录下的ClassLoaderTest类并没有被启动类加载器加载。而是由扩展类加载器加载了<Java_Runtime_Home>/lib/ext目录下ClassLoaderTest。这似乎与前面讲的双亲委派机制矛盾?虚拟机出于安全等因素考虑,不会加载<Java_Runtime_Home>/lib中的陌生类,开发者把非JDK自身的类放置到此目录下期待启动类加载器加载是不可能的。

    private Class findBootstrapClassOrNull(String name)
{
if (!checkName(name)) return null;//通过启动类加载器加载类是,需要先进行验证

return findBootstrapClass(name);
}

双亲委派模型的优点

安全性

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如,类java.lang.Object,它存放在rt.jar中,无论哪一个类加载器要加载这个了类,最终都是委派给模型最顶端的启动类加载器进行加载,因此Object类在程序的各个类加载器环境中都是同一个类。

相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基本的行为也就无法保证,应用程序也会变得一片混乱。

统一性

因为Java类随着它的类加载器一起具备了一种带有优先级的层次关系。双亲委派模型很好的解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载)。

双亲委派模型架构实例(Tomcat)

主流的Java Web服务器,如Tomcat、Jetty等,都实现了自定义的类加载器(一般都不止一个)。一个功能健全的Web服务器,要解决如下几个问题:

1、部署在同一个服务器上的两个Web应用程序所使用的Java类库可以实现相互隔离。这是最基本的需求,两个不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求一个类库在一个服务器中只有一份,服务器应当保证两个应用程序的类库可以相互独立使用。

2、部署在同一个服务器上的两个Web应用程序所使用的Java类库可以实现相互共享。这个需求也很常见,例如,用户可能有10个使用Spring的应用程序部署在同一台服务器上,如果把10份Spring分别存放在各个应用程序的隔离目录中,将会是很大的资源浪费——这主要倒不是浪费磁盘空间的问题,而是指类库在使用时都要被加载到服务器内存,如果类库不能共享,虚拟机的方法区就会很容易出现过渡膨胀的风险。

3、服务器需要尽可能的保证自身的安全不受部署的Web应用程序影响。目前,许多主流的的Java Web服务器自身也是使用Java语言实现的。因此,服务器本身也有类库依赖问题,一般来说,基于安全考虑,服务器所使用的类库应该与应用程序的类库相互隔离。

4、支持JSP应用的Web服务器,大多数都需要支持热替换功能(HotSwap)。我们知道JSP文件最终要编译成Java Class才能由虚拟机执行,但JSP文件由于其纯文本存储的特性,运行时修改的概率远远大于第三方库或程序自身的Class文件。因此,主流的Web服务器都会支持JSP生成类的热替换,而无需重启服务器。


由于上述问题的存在,在部署Web应用时,单独的一个ClassPath就无法满足需求了,所以各种Web服务器都“不约而同”地提供了好几个ClassPath路径供用户存放第三方类库,这些路径一般以“lib”或“classes”命名。被放置在不同路径中的类库,具备不同的访问范围和服务对象,通常,每一个目录都会有一个相应的自定义类加载器去加载放置在里面的Java类库。

接下来,以Tomcat服务器为例,看一看Tomcat具体是如何规划用户类库结构和类加载器的。

在Tomcat的目录结构中,有3组目录(“/common/*”,“/server/*”,“/shared/*”)可以存放Java类库,另外还可以加上Web应用程序自身的目录“/WEB-INF/*”,一共4组,把Java类库放置在这些目录中的含义分别如下:

common:类库可以被Tomcat和所有的Web应用程序共同使用。

server:类库可以被Tomcat使用,对所有的Web应用程序都不可见。

shared:类库可以被所有的Web应用程序共同使用,但对Tomcat不可见。

WEB-INF:类库仅仅可以被当前Web应用程序使用,对Tomcat和其他Web应用程序都不可见。

为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat自定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现,其关系如图所示:

类加载器深入理解

如何实现热替换?

hot swap即热替换的意思,这里表示一个类已经被一个加载器加载了以后,在不卸载它的情况下重新再加载它一次。我们知道Java缺省的加载器对相同全限定名的类只会加载一次,以后直接从缓存中取这个Class object。因此要实现hot swap,必须在加载的那一刻进行拦截,先判断是否已经加载,若是则重新加载一次,否则直接首次加载它。

就拿JSP类加载器来说,JSP类加载器的加载范围仅仅是这个JSP文件所编译出来的一个class,它出现的目的就是为了被丢弃,当服务器检测到JSP文件被修改时,会替换掉目前的JSP类加载器实例,并通过再建立一个新的JSP类加载器来实现JSP文件的Hot Swap。


思考:如果有10个Web应用程序都是用Spring来进行组织和管理的,可以把Spring放到Common或Shared目录下让这些程序共享。Spring要对用户程序的类进行管理,自然要能访问到用户程序的类,而用户程序显然是放置在/WebApp/WEB-INF/目录中的,那么被CommonClassLoader或SharedClassLoader加载的Spring如何访问并不在其加载范围内的用户程序呢?

解答:该场景就是基础类需要回调用户的代码。我们知道,父加载器的类对子加载器是可见的,但是子加载器的类对父加载器默认是不可见的。那么我们如何实现父加载器中的类访问子加载器中的类呢?

答案是用线程上下文类加载器,通过线程上下文类加载器可以实现父加载器对子加载器的逆向访问。

线程上下文加载器

public class Thread implements Runnable {
......
/* The context ClassLoader for this thread */
private ClassLoader contextClassLoader;
public void setContextClassLoader(ClassLoader cl) {
......
contextClassLoader = cl;
}
public ClassLoader getContextClassLoader() {
......
return contextClassLoader;
}
......
}

所谓线程上下文类加载器,就是Thread类的一个实例变量。 线程上下文类加载器,可以实现父加载器对子加载器的 逆向访问

在默认情况下,一个线程上下文类加载器设置为其父线程的上下文类加载器。初始线程的类加载器被设置为加载应用程序的类加载器。所以,除非显式的变更线程上下文类加载器,线程的上下文类加载器应该是应用程序的类加载器。也就是说,通过这个上下文类加载器能加载应用程序能加载的类。

使用线程上下文类加载器,可以在执行线程中抛弃双亲委派加载链模式,使用线程上下文里的类加载器加载类。线程上下文从根本解决了一般应用不能违背双亲委派模式的问题。使java类加载体系显得更灵活。

一般来说,上下文类加载器要比当前类加载器更适合于框架编程,而当前类加载器则更适合于业务逻辑编程。

OSGI:灵活的类加载结构

OSGI中的每个模块(称为Bundle)与普通的Java类库的区别并不太大,两者一般都以JAR格式进行封装,并且内部存储的都是Java Package和Class。但是一个Bundle可以声明它所依赖的Java Package(通过Import-Package描述),也可以声明它允许导出发布的Java Package(通过Export-Package描述)。

在OSGI里面,Bundle之间的依赖关系从传统的上层模块依赖底层模块转变成为平级模块之间的依赖(至少外观上如此),而且类库的可见性得到了非常精确的控制,一个模块只有被Export过的Package才可能由外界访问,其他的Package和Class将会隐藏起来。除了更加精确地模块划分和可见性控制外,引入OSGI的另外一个重要理由是,基于OSGI的程序很可能可以实现模块级别的热插拔功能,当程序升级更新或调试排错时,可以只停用、重新安装、然后启用程序的其中一部分,这对企业级程序开发来说是一个非常有诱惑力的特性。

OSGI之所以有上述“诱人”的特点,要归功于它灵活的类加载器架构。OSGI的Bundle类加载器之间只有规则,没有固定的委派关系。例如,某个Bundle声明了一个它依赖的Package,如果有其他Bundle声明发布了这个Package,那么所有对这个Package的类加载动作都委派给发布它的Bundle类加载器去完成。不涉及某个具体的Package时,各个Bundle加载器都是平级关系,只有具体使用某个Package和Class的时候,才会根据Package导入导出定义来构造Bundle间的委派依赖。

我们可以举一个更具体一点的简单例子,加入存在BundleA,BundleB和BundleC三个模块,并且这三个Bundle定义的依赖关系为:
Bundle A:声明发布了PackageA,依赖了java.*包;
Bundel B:声明依赖了PackageA和PackageC,同时也依赖了java.*包;
Bundle C:声明发布了packageC,依赖了PackageA.
那么,这三个Bundle之间的类加载器及父类 加载器之间的关系如图

类加载器深入理解

可以看出,在OSGi里面,加载器之间的关系不再是双亲委派模型的树形结构,而是已经进一步发展成了一种运行时才能确定的网状结构。这个网状结构的类加载器架构在带来更优秀的灵活性的同时,也可能会产生许多新的隐患。

例如死锁,如果出现了Bundle A依赖Bundle B的Package B,而Bundle B又依赖了Bundel A的Package A,这两个Bundle进行类加载时就很容易发生死锁。具体情况是当Bundel A 加载Package B时,首先需要锁定当前加载器的实例(ClassLoader.loadClass()  是一个synchronized方法),然后把请求委派给Bundle B的加载器处理,但如果这时候Bundle B的也正好想加载Package A的类,它也先锁定自己的加载器再把请求委派给Bundle A的加载器处理,这样两个加载器都在等待对方处理自己的请求,而对方处理完之前自己又一直处于同步锁定的状态,因此他们就互相死锁,永远无法完成加载请求了。

总体来说,OSGi描绘了一个很美好的模块化开发的目标,而且定义了实现这个目标所需要的各种服务,同时也有成熟框架对其提供实现支持。对于单个虚拟机下的应用,从开发初期就建立在OSGi上是一个很不错的选择,这样便于约束依赖。但并非所有的应用都适合采用OSGi作为架构基础,OSGi在提供强大功能的同时也引入的额外的复杂度,带来了线程死锁和内存泄漏的风险。


参考

http://blog.csdn.net/zhoudaxia/article/details/35824249

http://blog.csdn.net/zhoudaxia/article/details/35897057

《深入理解Java虚拟机》