java 类文件加载 博客分类: jvm classloaderjvm热部署
一、类加载器基本概念
类加载器(class loader)用来加载 Java 类到 Java 虚拟机中。一般来说,Java 虚拟机使用 Java 类的方式如下:Java 源程序(.java 文件)在经过 Java 编译器编译之后就被转换成 Java 字节代码(.class 文件)。类加载器负责读取 Java 字节代码,然后重新解析成JVM统一要求的格式,最终转换成java.lang.Class
类的一个实例(会加载到perm区也就是方法区),每个这样的实例用来表示一个 Java 类。通过此实例的 newInstance()
方法就可以创建出该类的一个对象。实际的情况可能更加复杂,比如 Java 字节代码可能是通过工具动态生成的,也可能是通过网络下载的。类加载器除了可以读取字节码文件,还可以加载文件、图片等资源。下面主要介绍类加载器的类别、运行机制等。
二、ClassLoader分类及层次结构
jvm在运行时会用到三个类加载器:Bootstrap ClassLoader、Extension ClassLoader和system class loader.Bootstrap是用C++编写的,我们在Java中看不到它,是null,是JVM自带的类装载器,用来装载核心类库,如java.lang.*等。Extension 是用来加载ext扩展目录下的类库,而System是用来加载classpath所指定的类库。System Class Loader是一个特殊的用户自定义类装载器,由JVM的实现者提供,在编程者不特别指定装载器的情况下默认装载用户类。系统类装载器可以通过ClassLoader.getSystemClassLoader() 方法得到。
Java提供了抽象类ClassLoader,所有用户自定义类装载器都实例化自ClassLoader的子类。 这个抽象类里主要有四个方法:
1、defineClass(byte[],int,int) 这个方法是用来将字节流解析成JVM能够识别的Class对象,而字节流的获取可以有多种方式:文件、网络等。
2、findClass(String) 通常通过覆盖这个方法,在方法内获取到字节流,然后调用defineClass根据字节流生成Class对象。
3、resolveClass(Class<?>) 在调用完defineClass之后,还可以调用resolveClass方法来link,也可以交给JVM在对象真正实例化时去link。
4、loadClass(String) 是系统自己实现的逐层往上调用父类的loadClass方法去加载class,一般自己实现的类加载器不建议覆盖此方法,而是覆盖findClass方法。
一般我们可以继承URLClassLoader这个子类来实现自定义类加载,因为它已经帮我们在ClassLoader上封装了一层,做了大部分工作。通常通过指定一个URL数组(指定查找的来源)来构造一个URLClassLoader对象,然后调用findClass来将找到的字节码加载到内存,然后调用defineClass、resolveClass等方法。
ClassLoader加载一个class文件到JVM时需要经过三个阶段:
1> 找到.class文件并把这个文件包含的字节码加载到内存中。
2> 对字节码进行验证、Class类数据结构分析、相应内存分配和最后的符号表的链接。
3> 类中静态属性和初始化赋值,以及静态块的执行等。
三、classloader机制——双亲委托加载模型
类加载器在尝试自己去查找某个类的字节代码并定义它时,会先代理给其父类加载器,由父类加载器先去尝试加载这个类,依次类推。在介绍代理模式背后的动机之前,首先需要说明一下 Java 虚拟机是如何判定两个 Java 类是相同的。Java 虚拟机不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两者都相同的情况,才认为两个类是相同的。即便是同样的字节代码,被不同的类加载器加载之后所得到的类,也是不同的。比如一个 Java 类 com.example.Sample
,编译之后生成了字节代码文件 Sample.class
。两个不同的类加载器ClassLoaderA
和 ClassLoaderB
分别读取了这个 Sample.class
文件,并定义出两个java.lang.Class
类的实例来表示这个类。这两个实例是不相同的。基于这个,可以检查已经加载的class文件是否被修改,如果修改了,可以重新加载这个类,从而实现类的热部署。
若有一个类加载器能成功装载,实际装载的类装载器被称为定义类装载器,所有能成功返回Class对象的装载器(包括定义类装载器)被称为初始类装载器。假设loader2的parent是loader1,loader1实际装载了MyClass,则loader1为MyClass的定义类装载器,loader2和loader1为MyClass的初始类装载器。
当虚拟机来加载某个类时,先通过loadClass来启动类加载的过程(初始类装载器),然后通过defineClass来真正的完成类加载过程(定义类装载器)。
java.lang.ClassNotFoundException: 是指通过ClassLoader显式加载类时找不到类文件。
java.lang.NoClassDefFoundError:这个异常出现的根本原因是JVM隐式加载这些类时发现这些类不存在的异常。隐式加载一般包括属性引用某个类、继承了某个接口或类,以及方法的某个参数中引用了某个类等,从而要间接加载某个类的时候。
例如:某个依赖类升级后不兼容,原来公有的方法变为私有方法,导致调用出错
四、自定义classloader来加载指定的类
自定义的classLoader的步骤:
1、继承ClassLoader类,并覆盖findClass(String name)方法
2、定义loadClassData方法,将目标资源转换成字节码数组 byte[] b返回;
3、调用defineClass方法根据字节数组生成class实例装载到内存。
代码如下:
public Class findClass(String name)
{
byte [] data = loadClassData(name);
return defineClass(name, data, 0 , data.length);
}
public byte [] loadClassData(String name)
{
FileInputStream fis = null ;
byte [] data = null ;
try
{
fis = new FileInputStream( new File (name ));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bt = 0 ;
while ((bt = fis.read()) != - 1 )
{
baos.write(bt );
}
data = baos.toByteArray();
} catch (IOException e)
{
e.printStackTrace();
}
return data;
}
五、其它类加载器
1、线程上下文加载器
Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的 SPI 有 JDBC、JCE、JNDI、JAXP 和 JBI 等。这些 SPI 的接口由 Java 核心库来提供,如 JAXP 的 SPI 接口定义包含在 javax.xml.parsers
包中。这些 SPI 的实现代码很可能是作为 Java 应用所依赖的 jar 包被包含进来,可以通过类路径(CLASSPATH)来找到,如实现了 JAXP SPI 的 Apache Xerces所包含的 jar 包。SPI 接口中的代码经常需要加载具体的实现类。如 JAXP 中的 javax.xml.parsers.DocumentBuilderFactory
类中的 newInstance()
方法用来生成一个新的 DocumentBuilderFactory
的实例。这里的实例的真正的类是继承自 javax.xml.parsers.DocumentBuilderFactory
,由 SPI 的实现所提供的。如在 Apache Xerces 中,实现的类是 org.apache.xerces.jaxp.DocumentBuilderFactoryImpl
。而问题在于,SPI 的接口是 Java 核心库的一部分,是由引导类加载器来加载的;SPI 实现的 Java 类一般是由系统类加载器来加载的。引导类加载器是无法找到 SPI 的实现类的,因为它只加载 Java 的核心库。它也不能代理给系统类加载器,因为它是系统类加载器的祖先类加载器。也就是说,类加载器的代理模式无法解决这个问题。
线程上下文类加载器正好解决了这个问题。如果不做任何的设置,Java 应用的线程的上下文类加载器默认就是系统上下文类加载器。在 SPI 接口的代码中使用线程上下文类加载器,就可以成功的加载到 SPI 实现的类。线程上下文类加载器在很多 SPI 的实现中都会用到。
2、class.forName加载类
Class.forName
是一个静态方法,同样可以用来加载类。该方法有两种形式:Class.forName(String name, boolean initialize, ClassLoader loader)
和 Class.forName(String className)
。第一种形式的参数 name
表示的是类的全名;initialize
表示是否初始化类;loader
表示加载时使用的类加载器。第二种形式则相当于设置了参数 initialize
的值为 true
,loader
的值为当前类的类加载器。Class.forName
的一个很常见的用法是在加载数据库驱动的时候。如Class.forName("org.apache.derby.jdbc.EmbeddedDriver").newInstance()
用来加载 Apache Derby 数据库的驱动。
3、网络类加载器
类 NetworkClassLoader
负责通过网络下载 Java 类字节代码并定义出 Java 类。它的实现与 FileSystemClassLoader
类似。在通过NetworkClassLoader
加载了某个版本的类之后,一般有两种做法来使用它。第一种做法是使用 Java 反射 API。另外一种做法是使用接口。需要注意的是,并不能直接在客户端代码中引用从服务器上下载的类,因为客户端代码的类加载器找不到这些类。使用 Java 反射 API 可以直接调用 Java 类的方法。而使用接口的做法则是把接口的类放在客户端中,从服务器上加载实现此接口的不同版本的类。在客户端通过相同的接口来使用这些实现类。
4、web容器的类加载器
对于运行在 Java EE™容器中的 Web 应用来说,类加载器的实现方式与一般的 Java 应用有所不同。不同的 Web 容器的实现方式也会有所不同。以 Apache Tomcat 来说,每个 Web 应用都有一个对应的类加载器实例。该类加载器也使用代理模式,所不同的是它是首先尝试去加载某个类,如果找不到再代理给父类加载器。这与一般类加载器的顺序是相反的。这是 Java Servlet 规范中的推荐做法,其目的是使得 Web 应用自己的类的优先级高于 Web 容器提供的类。这种代理模式的一个例外是:Java 核心库的类是不在查找范围之内的。这也是为了保证 Java 核心库的类型安全。
绝大多数情况下,Web 应用的开发人员不需要考虑与类加载器相关的细节。下面给出几条简单的原则:每个 Web 应用自己的 Java 类文件和使用的库的 jar 包,分别放在 WEB-INF/classes
和 WEB-INF/lib
目录下面。多个应用共享的 Java 类文件和 jar 包,分别放在 Web 容器指定的由所有 Web 应用共享的目录下面当出现找不到类的错误时,检查当前类的类加载器和当前线程的上下文类加载器是否正确。
5、OSGI类加载器
OSGi™是 Java 上的动态模块系统。它为开发人员提供了面向服务和基于组件的运行环境,并提供标准的方式用来管理软件的生命周期。OSGi 已经被实现和部署在很多产品上,在开源社区也得到了广泛的支持。Eclipse 就是基于 OSGi 技术来构建的。
OSGi 中的每个模块(bundle)都包含 Java 包和类。模块可以声明它所依赖的需要导入(import)的其它模块的 Java 包和类(通过 Import-Package
),也可以声明导出(export)自己的包和类,供其它模块使用(通过 Export-Package
)。也就是说需要能够隐藏和共享一个模块中的某些 Java 包和类。这是通过 OSGi 特有的类加载器机制来实现的。OSGi 中的每个模块都有对应的一个类加载器。它负责加载模块自己包含的 Java 包和类。当它需要加载 Java 核心库的类时(以 java
开头的包和类),它会代理给父类加载器(通常是启动类加载器)来完成。当它需要加载所导入的 Java 类时,它会代理给导出此 Java 类的模块来完成加载。模块也可以显式的声明某些 Java 包和类,必须由父类加载器来加载。只需要设置系统属性 org.osgi.framework.bootdelegation
的值即可。
假设有两个模块 bundleA 和 bundleB,它们都有自己对应的类加载器 classLoaderA 和 classLoaderB。在 bundleA 中包含类com.bundleA.Sample
,并且该类被声明为导出的,也就是说可以被其它模块所使用的。bundleB 声明了导入 bundleA 提供的类com.bundleA.Sample
,并包含一个类 com.bundleB.NewSample
继承自 com.bundleA.Sample
。在 bundleB 启动的时候,其类加载器 classLoaderB 需要加载类 com.bundleB.NewSample
,进而需要加载类 com.bundleA.Sample
。由于 bundleB 声明了类 com.bundleA.Sample
是导入的,classLoaderB 把加载类 com.bundleA.Sample
的工作代理给导出该类的 bundleA 的类加载器 classLoaderA。classLoaderA 在其模块内部查找类 com.bundleA.Sample
并定义它,所得到的类 com.bundleA.Sample
实例就可以被所有声明导入了此类的模块使用。对于以 java
开头的类,都是由父类加载器来加载的。如果声明了系统属性 org.osgi.framework.bootdelegation=com.example.core.*
,那么对于包com.example.core
中的类,都是由父类加载器来完成的。
OSGi 模块的这种类加载器结构,使得一个类的不同版本可以共存在 Java 虚拟机中,带来了很大的灵活性。不过它的这种不同,也会给开发人员带来一些麻烦,尤其当模块需要使用第三方提供的库的时候。下面提供几条比较好的建议:
如果一个类库只有一个模块使用,把该类库的 jar 包放在模块中,在 Bundle-ClassPath
中指明即可。如果一个类库被多个模块共用,可以为这个类库单独的创建一个模块,把其它模块需要用到的 Java 包声明为导出的。其它模块声明导入这些类。
如果类库提供了 SPI 接口,并且利用线程上下文类加载器来加载 SPI 实现的 Java 类,有可能会找不到 Java 类。如果出现了NoClassDefFoundError
异常,首先检查当前线程的上下文类加载器是否正确。通过 Thread.currentThread().getContextClassLoader()
就可以得到该类加载器。该类加载器应该是该模块对应的类加载器。如果不是的话,可以首先通过 class.getClassLoader()
来得到模块对应的类加载器,再通过 Thread.currentThread().setContextClassLoader()
来设置当前线程的上下文类加载器。
六、类的热部署
我们知道在修改一个java文件后必须要重启服务器才能生效,这个非常费时,那么能否修改后马上生效,实现热部署呢,答案是肯定的,但是其中确有一些问题需要解决好。
JVM在加载类之前会先去调用findLoaderClass()方法查看是否能够返回类实例。如果类已经加载过滤,就不会再去加载。但是JVM表示一个类是否为同一个类有2个条件:1、看类的全名是否一样,2、看加载此类的类加载器是否是同一个实例。所以要实现热部署就得使用不同的类加载器实例来加载同一个类。所以我们只需要在类文件被修改后,启动新的类加载实例来加载这个类就可以实现类的热部署。
但是这个过程中加载的字节码都会保存在PermGen区,而这个区域只有full gc时才会回收,所以必须关注PermGen区域的大小,防止内存溢出。还有一个问题,类实例对象在JVM中都是共享的,JVM通过保存对象状态而省去类信息的重复创建和回收,而对象一旦被创建,肯定会被持有和利用。通过重新生成classloader实例来加载类,然后替换原有的对象,并更新java栈中对原对象的引用,这样做看起来合理,但实际不可行。如果一个对象的属性结构被修改,但是运行时其它对象还在使用修改前的属性,这样便会出现错误。因为它违反了JVM的设计原则,JVM不能干预对象的引用关系,对象的引用关系只有对象的创建者来持有和使用。JVM只知道对象的编译类型,而不知道对象的运行时类型。
上述问题的关键是对象的状态被保存了,并且被其它对象引用了,一个简单的办法就是不保存对象的状态,对象被创建使用后就被释放掉,下次修改后,对象是新创建的。这种方式就可以动态加载类了,而这正是JSP的实现方式,很多解释性语言也是这样。
参考文章:https://www.ibm.com/developerworks/cn/java/j-lo-classloader/
上一篇: Java编程实现排他锁代码详解