1 类的加载简介
一个类型从被加载到内存中到卸载出内存为止,整个生命周期将经历:加载、验证、准备、解析、初始化、使用、卸载七个阶段,其中验证、准备、解析三个部分统称为连接
2 类的加载时机
Java虚拟机规范规定,一个类或接口只有在初始使用时,才会进行初始化操作,而加载、验证、准备需要在初始化之前开始,也就是此时开始类的第一个加载阶段。这里的初始使用指的是主动使用,主动使用有六种情况:
- 使用new创建对象,调用静态方法,设置静态字段,获取静态字段
- 使用java.lang.reflect包的方法对类型进行反射调用
- 初始化类时,如果父类还未进行初始化,则会触发父类的初始化操作
- 虚拟机启动时,用户执行的主类(main方法所在的类),会先被初始化
- jdk7中java.lang.invoke.MethodHandle实例解析最终结果为REF_getStatic,REF_putStatic,REF_invokeStatic,REF_newInvokeSpecial四种类型的句柄,当所在的类未初始化时,将触发初始化操作
- jdk8中default接口方法实现类发生初始化,那么将触发接口的初始化
3 类的加载过程
3.1 加载
加载类是类整个加载过程的第一个阶段,虚拟机需要完成以下工作:
- 通过类的全限定名获取类的二进制字节流
- 将类的二进行字节流转化成方法区的数据结构
- 在内存中生成这个类代表的java.lang.Class对象,作为方法区这个类的各种数据访问入口
加载阶段与验证阶段是交叉进行的(如验证阶段的字节码格式验证),但是开始时间仍保持着先后顺序
3.2 验证
验证是连接阶段的第一部,目的是确保Class文件字节流中的信息符合Java虚拟机规范,以下是验证阶段所要进行的大致检查:
- 格式验证:判断是否以魔数开头,版本是否正确,常量池中是否有不被支持的常量,数据项的长度是否都正确
- 语义检查:是否所有的类都有父类(除Object),是否继承了final类或重载了final方法,在非抽象类中抽象方法和接口是否都被实现
- 字节码验证:在语义检查完毕后,这个阶段主要对类的方法体(Code属性)进行校验,保证方法在运行期是否安全,比如,字节码执行过程中不会跳转到一个不存在的指令,变量不会赋给一个不正确的数据类型
- 符号引用验证:该阶段会检查常量池中记录的类或方法的字符串记录是否能找到对应的类或方法,并且有权限访问
验证阶段在连接阶段中是非必需执行的,因为如果程序代码已经经过反复验证,则可以通过-Xverify:none参数来关闭大部分验证,来缩短虚拟机类加载的时间
3.3 准备
准备阶段为类定义的类变量(即static修饰的变量)分配内存并设置类变量的初始值,初始值通常情况下是零值,对应关系如下:
数据类型 | 零值 |
---|---|
int | 0 |
long | 0L |
short | (short)0 |
char | '\u0000' |
byte | (byte)0 |
boolean | false |
float | 0.0f |
double | 0.0d |
reference | null |
初始值在特殊的情况下,如果类变量被定义成常量,则会在准备阶段直接赋值,例如下面例子中,将在准备阶段直接将value赋值成1
public static final int value = 1;
注:
1)类变量的存储空间通常是方法区,该区是一个逻辑上的概念,在jdk7之前为永久代,在jdk8以后则是堆中
2)实例变量将在对象实例化时,随着对象一起分配在堆中
3)类变量的真正赋值操作将放在类构造器 <clinit> 方法中,将在类的初始化阶段执行
3.4 解析
解析阶段是将常量池中的类、接口、字段、方法的符号引用转换成直接引用
- 符号引用:以一组符号来描述所引用的目标,与虚拟机的内存布局无关,引用的目标不一定已经加载到内存,各虚拟机能接受的符号引用是一致的
- 直接引用:是一个能直接定位到目标的句柄,与虚拟机的内存布局直接相关,引用的目标必须已经加载到内存,各虚拟机能接受的符号引用一般不同
例如:System.out.println()的常量池结构如下
常量池中的均为符号引用,解析操作将符号引用转化成直接引用,最终将得到类、字段、方法在内存中的指针或偏移量
3.5 初始化
初始化阶段是类加载的最后一个阶段,此时才会开始执行Java字节码。初始化阶段的重要工作是执行类的 <clinit>初始化方法,该方法是编译器自动产生的,它由类的静态成员赋值语句及static语句块共同产生的。
在加载一个类时,虚拟机总会先加载该类的父类,所以父类的 <clinit> 方法总会优先于子类的 <clinit>方法之前被调用。
4 类的加载器
类的加载器是Java的核心组件,所有的Class文件都是通过ClassLoader来进行加载的,然后交给Java虚拟机进行连接(验证、准备、解析)、初始化等操作,因此ClassLoader只影响类的加载,不影响类的连接和初始化行为。
4.1 双亲委派模型
站在Java虚拟机的角度来看,只存在两种类型加载器,一种是启动类加载器(由C++语言实现),一种是其它所有类加载器(由Java语言实现),其它类加载器独立存在于虚拟机外部,全部继承抽象类java.lang.ClassLoader.
站在Java开发人员角度,类加载器分为三类
- Bootstrap Class Loader(启动类加载器):加载JAVA_HOME\lib目录下符合名字规范的类库(rt.jar、tools.jar)
- Extension Class Loader(扩展类加载器):加载JAVA_HOME\lib\ext目录下的类库
- Application Class Loader(应用类加载器):加载用户类路径下(ClassPath)下的所有类库,如果应用程序没有自定义自己的类加载器,一般为这个就是默认的类加载器。
图中展示的类加载器的层次结构称为“双亲委派模型”,除了顶层启动类加载器外,其余均应有自己的父类加载器,这种父子关系不是以继承的方式,而是以组合的方式来复用代码。
双亲委派模式工作模式是:当一个类加载器收到类的加载请求时,首先会把请求委派给父类加载器去完成,最终都会传达到顶层的启动类中,如果父类无法完成时,子加载器才会尝试自己去加载
4.2 双亲委派模型的优势
1)避免类的重复加载:如果父类加载器已经加载过某一类时,子加载器就不会再重复执行加载。
2)确保类加载的安全:Bootstrap Class Loader只会加载JAVA_HOME目录下的类库,保证核心类库不会被替换,例如用户自定义一个java.lang.Object类,虽然可以编译通过,但永远不会被加载运行,即使自定义类加载器,强行用defineClass去加载一个java.lang开头的类也不会成功,因为Java虚拟机会报SecurityException异常
4.3 自定义类加载器
ClassLoader代码简介如下:
package java.lang;
/**
* 类加载器
*/
public class ClassLoader {
/**
* 通过双亲委派模型来加载类
* @param name 类全限定名
* @param resolve 是否执行连接操作
* @return Class对象
*/
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 检查Class是否已经被加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
// 如果父加载器不为空,则通过父加载器来加载此Class
c = parent.loadClass(name, false);
} else {
// 如果父加载器为空,则直接使用Bootstrap ClassLoader来加载此Class
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {}
if (c == null) {
// 如果通过双亲委派后,均未找到类加载器,则使用当前类加载器加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
/**
* 通过当前类加载器来加载类
* @param name 类的全限定名
* @return Class对象
*/
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
/**
* 将字节数组转换成Class对象
* @param name 类全限定名
* @param b 字节数组
* @param off 类字节的起始位置
* @param len 类字节的终止位置
* @return Class对象
*/
protected final Class<?> defineClass(String name, byte[] b, int off, int len) throws ClassFormatError {
return defineClass(name, b, off, len, null);
}
}
自定义一个ClassLoader需要经过以下步骤
- 继承ClassLoader
- 重写findClass方法
- 调用defineClass方法
package net.kolbe.java;
import java.io.*;
public class MyClassLoader extends ClassLoader {
public static void main(String[] args) throws Exception {
MyClassLoader myClassLoader = new MyClassLoader();
Class<?> clazz = myClassLoader.loadClass("net.kolbe.java.User");
System.out.println(clazz.getClassLoader());
System.out.println(clazz.newInstance());
}
/**
* 根据名称加载字节码
*/
public Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] classByteArray = getClassByteArray();
// 将字节码转化为Class对象
return defineClass(name, classByteArray, 0, classByteArray.length);
} catch (Exception e) {
return null;
}
}
/**
* 获取字节码数组
*/
private byte[] getClassByteArray() throws Exception {
InputStream in = null;
ByteArrayOutputStream out = null;
try {
in = new FileInputStream("F:/Code/User.class");
out = new ByteArrayOutputStream();
byte[] buffer = new byte[2048];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
return out.toByteArray();
} catch (Exception e) {
e.printStackTrace();
} finally {
in.close();
out.close();
}
return null;
}
}
User类如下:
package net.kolbe.java;
public class User {
@Override
public String toString() {
return "Kolbe";
}
}
需要将User类编译成User.class后,放到 F:/Code目录下
运行MyClassLoader代码结得到如下结果:
net.kolbe.java.MyClassLoader@74a14482
Kolbe
4.4 破坏双亲委派模型
双亲委派模型并不是一个强制约束的模型,以下是几种双亲委派被破坏的例子
1)由于双亲委派模型在JDK1.2后才被引入,在此之前已经存在用户自定义的类加载器,为了兼容只能在JDK1.2后引用新的findClass()引导用户编译类加载器时通过findClass去实现,而不是直接重写loadClass
2)在双亲委派的模型下,上层的ClassLoader无法访问下层的ClassLoader所加载的类,为了解决这个问题,Java中引入线程上下文加载器(Thread Context ClassLoader),该类加载器中会从父线程继承一个类加载器,如果没有则使用应用程序类加载器
3)为了让代码实现动态生效,无需重启服务,就需要通过热替换的方式实现。这就违背了当一个类已经加载到系统中,通过修改类文件是无法重新加载和定义这个类。
4)Tomcat等Web容器中,因为一个容器需要布署多套程序,不同的应用程序可能依赖同一个第三方类库的不同版本,这就需要破坏双亲委派模型,为每个Web容器单独提供一个WebAppClassLoader,从而实现加载多个相同的类不同的版本。