文章大纲
引言
众所周知Java 源文件最终会被编译成为字节码文件(class),而运行前需要先通过类加载器(ClassLoader)把class文件加载,因为在字节码文件中存储的各种关于类的信息,最终都是需要加载到虚拟机(JVM)之后才会运行和使用的。
一、类加载机制概述
JVM把描述类的信息从class文件加载到内存,并对数据完整性进行校验、转换解析和初始化,最终形成可以在JVM中直接使用的Java类型。Java 采用的是动态方案,类型的加载、链接和初始化都是在程序运行期间完成的,虽然在类加载时会增加一些额外的开销,但是正是因为运行期动态加载和动态链接使得Java语言具有优秀的扩展性,执行加载class文件工作时采用的是双亲委派机制。对于同一个类(package路径及类名都是一样的)在同一个ClassLoader中只会被加载一次
二、类加载的生命周期
JVM对于类的生命周期定义了四大阶段,七个步骤
如上图所示,加载、验证、准备、使用、卸载这几个步骤的相对顺序是固定的,类加载都必须按照这种固定顺序进行,为了支持动态绑定,会先进行初始化然后才进行解析。
加载阶段和链接阶段的部分工作是交叉进行的,比如一部分的字节码文件验证动作,加载阶段尚未完成,链接阶段可能给已经开始。
1、加载
加载是类加载全流程中的起点阶段,在加载阶段时,JVM一般需要完成以下3件事:
1.1、通过类的全限定名来获取对应的二进制字节流(字节码文件)
因为.class文件是以“全类名+ClassLoader名称”作为唯一标识,加载于方法区内部
Java虚拟机规范并没有严格限定字节流的来源,所以可以从:
- 从压缩包Zip包中读取,JAR、EAR、WAR本质上就是此类。
- 从网络中获取,Applet 就是典型的应用
- 运行时计算生成,动态代理就是典型应用,在java.lang.reflect.Proxy中的ProxyGenerator.generateProxyClass 来作为特定的接口生成代理类(名称形如$Proxy)的二进制字节流。
- 由其他文件生成,比如由JSP文件生成对应的Class类。
- 从数据库中去读取,应用在中间件服务器上。
总之,在加载时获取类的字节流时,是扩展性最强的,可以使用系统提供的引导类加载器来完成,也可以由用户自定义类加载器(继承ClassLoader并重写loadClass方法,再调用父类的loadClass方法进行加载)去完成。但对于数组类的加载情况比较特殊,虽然数组类本身是由JVM直接创建的,但是其元素最终还是需要依靠类加载器去加载的。
1.2、将这个字节流所代表的静态存储结构化为方法区的运行时数据结构
1.3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区此类的各种数据访问入口
2、验证
验证是链接阶段的第一个步骤,主要是为了确保class文件中的字节流包含的信息符合规范。因为class文件可以使用任何途径生成,甚至是直接用十六进制的编辑器直接编写创建Class文件都可以,若class文件不规范则会抛出一个java.lang.VerfifyErro的异常。验证阶段大致完成以下四方面的检验工作:
2.1、文件格式验证
验证字节流书否符合class文件格式的规范,主要有以下验证点(部分):
- 是否以魔数0xCAFEBABE开头
- 主、次版本后是否能被当前JVM处理。
- 常量池的常量中是否有不被支持常量类型
- 指向常量的各种索引值指向是否正确
- CONSTANT_Utf8_info 类的常量是否都符合UTF-8编码
- Class文件中各部分及文件本身是否有被删除或附加的信息
经过文件格式验证之后,才会把字节流保存到内存中的方法区(相当于是把字节流缓存为对应的存储结构,为后续的操作提供数据源)
2.2、元数据验证
对方法区中对应的存储结构所描述的信息进行语义解析,主要是一些语法规范验证(部分):
- 此类是否有父类(除了java.lang.Object之外,任何类都有父类)
- 此类是否继承了被final修饰的类
- 若这个类不是抽象类,是否实现了其父类或接口中需要实现的所有方法
- 类中的字段、方法是否与父类产生矛盾(比如不恰当地覆盖、不合规则的重载和方法签名)
2.3、字节码验证
字节码验证主要是通过数据流和控制流分析,确保程序语义是合法且符合逻辑的,主要是对方法体进行检验解析,确保方法运行时不会危害到JVM。
2.4、符号引用验证
在JVM将符号引用转化为直接引用时(这个转化在解析阶段时触发),可以看成是对类自身以外(常量池的各种符号引用)的信息进行匹配性校验(部分):
- 符号引用中通过字符串描述的全限定名是否能找到对应的类对象
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段
- 符号引用中的类、字段、方法的访问性是否可以被当前类访问
虽然验证阶段是一个非常重要的阶段,但并非是必要的,若能确保所运行的代码不已被反复使用和验证过,则在实施时就可以给JVM配置**- Xverify:none**参数来关闭大部分验证措施,以缩短JVM的加载时间。
3、准备
准备阶段时在方法区正式为类的静态变量分配内存并设置初始值,注意是默认的初始值,而非定义时的赋值且仅针对静态变量,因为实例变量是在对象实例化时随着对象一起分配到Java堆中的。
public static int v=666;//准备阶段后v的值依然为默认值0,而非666,因为此时尚未执行任何Java方法,而把v赋值为666的指令putstatic 是在编译后,存放在类构造器< clinit >方法中的,因此在初始化之后才会执行。
4、解析
在解析阶段,JVM将常量池的符号引用(Symbolic References)替换为直接引用(Direct References)。
-
符号引用——符号引用以一组符号来描述所引用的目标,可以是任何形式的字面量,只要使用时可以无歧义地精确定位到目标即可,在class文件中以CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。
-
直接引用——直接引用是直接指向目标的指针、相对偏移量或一个能间接定位到目标的句柄。
总之解析就是转为直接引用,比如对类或接口、字段、方法(包含类的方法和接口方法)的解析,由于篇幅问题不再详述。
5、初始化
初始化是类加载的最后一步(后面就是使用和卸载了),前面的类加载过程,除了在加载阶段用户可以通过自定义的类加载器参与之外,其余的动作都是完全由JVM主导和控制的(这也很好理解嘛,如果你的系统随意开放安全性相关的步骤是及其危险的),即到了初始化阶段时,才真正开始执行类中定义的Java程序代码(字节码)。
5.1、类初始化的时机
Java虚拟机规范中并没有强制约束何时开始进行类加载,具体细节交由JVM自己把握,但是对于类的初始化时机严格进行约束,以下五种情况JVM必须进行初始化(当然加载、验证、准备自然是先于此开始):
- 遇到new、getstatic、putstatic或invokestatic 字节码指令时,若类还未进行初始化,则必须先进行初始化,分别对应着Java代码:通过new实例化对象时、读取或设置某个类的静态字段(final修饰的除外,因为final修饰的在编译时进行优化处理,把结果存入到了常量池)以及调用类的静态方法。
- 使用java.lang.reflect包下的方法对类进行反射调用且类还未初始化时。
- 当初始化类时,若其父类还未初始化,则先对其父类进行初始化。
- 当JVM启动时,需要先初始化执行主类(包含main方法的那个入口类)。
- 当使用JDK 1.7 的动态语言支持时,若一个java.lang.invoke.MethodHandle实例最后的解析得到REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄且句柄对应的类没有初始化时,需要先进行初始化。
Java虚拟机规范对以上五种场景的行为称为对类的主动引用,有且仅有以上五种情况才会触发初始化。其他方式的引用称为被动引用,常见的有以下情况:
- 通过子类引用父类的静态字段,不会导致子类进行初始化。
- 通过数组定义来引用类不会导致此类的初始化。
- 引用静态常量字段时不会触发此类的初始化。
5.2、< clinit >与< init >
从另一个角度说初始化就是执行类构造器方法 < clinit > 方法的过程。
5.2.1、< clinit >类构造器方法
编译器根据语句在源文件中出现的顺序开始收集,当类中出现了静态代码块或者对静态变量的赋值操作时,编译器会自动为这个类生成< clinit >方法且执行< clinit >方法,执行时会先执行父类的< clinit >方法;不过,接口中虽然不能使用静态代码块,但编译器始终都会为接口生成 < clinit > 方法,但是接口中执行< clinit >方法时不需要先执行父接口的 < clinit >方法且接口的实现类在初始化时不一定会先执行接口的< clinit>方法,只有在父接口定了变量使用时,父接口才会被初始化。< clinit >类构造器方法主要是将静态代码块和静态变量的初始化,按照固定的顺序存放到< clinit > 方法下:
- 父类静态变量初始化
- 父类静态语句块
- 子类静态变量初始化
- 子类静态语句块
5.2.2、< init >实例构造器方法
< clinit >实例构造器方法是Java源文件在编译之后会在字节码文件中自动生成的,用于对象的实例化,实例构造器会将语句块,变量初始化,调用父类的实例构造器等操作,按照固定的顺序存放到< init > 方法下:
- 父类变量初始化
- 父类语句块
- 父类构造函数
- 子类变量初始化
- 子类语句块
- 子类构造函数
总之,< clinit >方法是在类初始化中执行的,而< init >则是在对象实例化执行的,所以< clinit >一定比< init >先执行。
三、类加载器
1、类加载器和类
类加载器虽然只用于实现类的加载,但在java程序中起到的作用远远不止于此,在同一个JVM中有且只有被同一个类加载器加载的类才“相等”,否则即使来源于同一个class文件,被同一个JVM中的不同加载器加载也必定不相等。
相等:指的是对应的Class对象的equals、isAssignableFrom、isInstance、instanceof方法返回的结果相等,也可以认为是同一对象。
2、类加载器
前文中描述,在类的加载流程中的类加载阶段,JVM规范中并没有限定字节码的来源,而是允许把这个动作扩展开放到允许应用程序自己决定如何去获取所需的字节码,而实现这个功能的代码模块统称为类加载器,通俗来说,类加载器通过全限定类名把对应的class文件加载到JVM内存并转为对应的Class对象。而从JVM的角度上来看,只存在两种不同的类加载器:引导类加载器Bootstrap ClassLoader和所有其他的类加载器
在Hotpot中 引导类加载器由C++实现,作为HotSpot的一部分,而其他的类加载器则由Java 语言实现且均继承自java.lang.ClassLoader,独立于HotSpot之外。
而从开发者角度上来看,可以分为四种类加载器:
- 引导类加载器(Bootstrap ClassLoader)——又称为根加载器,主要负责将存放在< JAVA_HOME>/lib<目录或被参数-Xbootclasspath 指定的路径下能被JVM 正确识别的类库(类库的名称具有一定的规范,若不符合规范也无法被加载)加载到JVM内存中,引导类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,若需要把加载请求委派给引导类加载器,直接返回null即可,简而言之,用于加载一些Java自带的核心类(因为这些核心类被签名不能被替换掉)
public ClassLoader getClassLoader(){
ClassLoader loader==getClassLoader();
if(loader==null){
return null;
}
SecurityManager manager=System.getSecurityManager();
if(manager!=null){
ClassLoader loader2=ClassLoader.getCallerLoader();
if(loader2!=null && loader2!=loader && !loader.isAncestor(loader2)){
manager.checkPermission(SecurityConstants.GET_CLASSLOADER_PERMISSION);
}
}
return loader;
}
- 扩展类加载器(Extension ClassLoader)——负责加载< JAVA_HOME>\lib\ext目录或者被java.ext.dirs系统变量对应的路径下的所有类库jar包,开发者也可以自己将jar包放到这个目录下直接使用这种扩展类加载器。
- 应用程序类加载器(Application ClassLoader)——负责加载用户classpath上所指定的类库,若没有自定义类加载器,则默认使用该类加载器(可以通过getSystemClassLoader方法返回)。简而言之,是加载的是classpath下的类。
扩展类加载器和引用程序类加载器都是由SUN 实现,分别对应着sun.mic.Launcher AppClassLoader。
- 用户自定义的ClassLoader——如果我们想要将自己写一段代码动态加载到JVM进程中,就可以通过自定义的ClassLoader。
3、双亲委派(Parents Delegation Model)
应用程序就是通过上面的三种类加载器(或许还有自定义的类加载器)互相配合完成加载工作的。(所谓双亲委派指的是每一次的应用程序类加载器加载时,都至少需要依次访问他上面两层的扩展类加载器和引导类加载器)因为Java中的类加载机制是层级委托的关系,除了顶层的根加载器之外,其余的类加载器都应有接收自己委托请求的加载器(即所谓的“父”类加载器,两者并不存在直接的继承关系,而是组合关系),本质上来说双亲委派关系就是层级优先关系。
3.1、工作流程
如上图所示,默认情况下当一个类加载器收到加载请求时,直接委托给他的“父”类加载器,“父”类加载器再委托给其“父”类,如此循环,因此所有请求都传递到引导类加载器中,有且仅当“父”类加载器反馈自己无法完成时,“子”类加载器才会尝试自己去加载。简而言之,就是每次都先委托给“父”类加载,当“父”类无法加载时,自己再去加载。
3.2、双亲委派的实现
在源码中的注释中,有一段说明,在自定义类加载器时,已经不建议去重写loadClass方法,而是应该把自己的类加载逻辑写到findClass方法中,因为在loadClass方法里,“父”类加载器无法加载时就会调用自己的findClass方法,当然这样是为了确保不破坏双亲委派模型,ClassLoader的默认实现就是双亲委派模型。
3.3、双亲委派模型的优点
- 避免了重复加载,因为每一个类都只会被加载一次。
- 每一个类都会被尽可能的加载,因为自根类加载器而下,每个类加载器都可能会根据优先级尝试加载它。
- 有效避免了某些恶意类的加载和攻击,比如自定义了Java.lang.Object类,一般而言在双亲委派模型下会加载系统的Object类而不是自定义的Object类
四、自定义类加载器
1、类加载器ClassLoader的核心方法
- loadClass方法——根据指定的二进制名称name加载对应的类。
protected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException{
synchronized (getClassLoadingLock(name)) {
// TODO 第1步,检测name对应的类是否已经加载过了
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();//
try {
if (parent != null) {
//TODO 第2,如果“父”ClassLoader不为空则委托给“父”ClassLoader {@link ClassLoader parent 是ClassLoader的成员}
c = parent.loadClass(name, false);
} else {
//TODO 第2,“父”ClassLoader为空则直接返回由根加载器加载的类对象,为null则说明没有找到
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 抛出异常,则说明“父”无法完成加载,再走到第3步
}
if (c == null) {
long t1 = System.nanoTime();
//TODO 第3、 若依然还没有找到,也说明“父”类不为null但也无法加载,则调用类加载器自身的findClass方法(自身指的是“子”类加载器)
c = findClass(name);
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
//通过jni 链接加载的类c
resolveClass(c);
}
return c;
}
}
- findClass方法——查找指定二进制名称的类。
ClassLoader下的findClass方法是直接抛出异常的,因此调用的是“子”类加载器中的findClass方法。
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
- defineClass方法——将一个字节数组(通常是)转为Class对象
字节数组一般是class文件读取后的最终的字节数组,若是经过加密的,则需要解密后再传入
protected final Class<?> defineClass(String name, byte[] b, int off, int len,ProtectionDomain protectionDomain)
throws ClassFormatError{
protectionDomain = preDefineClass(name, protectionDomain);
String source = defineClassSourceLocation(protectionDomain);
//通过JNI 调用
Class<?> c = defineClass1(name, b, off, len, protectionDomain, source);
postDefineClass(c, protectionDomain);
return c;
}
2、定义类类加载器
package com.example.david.gifdavid;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* @author : Crazy.Mo
*/
public class MyLoader extends ClassLoader {
private static final char CHAR_SEPARATOR = '/';
private String rootDir;
public MyLoader(String rootDir) {
this.rootDir = rootDir;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Class<?> c = findLoadedClass(name); //查找已经被加载的类,返回对应name的Class类的实例
//不为空则返回已经加载过的类
if (null != c) {
return c;
} else {
ClassLoader parent = this.getParent();
try {
c = parent.loadClass(name); //委派父类加载
} catch (Exception e) {
e.printStackTrace();
}
if (c != null) {
return c;
} else{
//如果还没获取,则读取d:/myjava/cn/sxt/in/User.class下的文件,转换成字节数组
byte[] classData = loadBytes(name);
if (classData == null) {
throw new ClassNotFoundException(); //如果没加载到,手动抛出异常
} else {
c = defineClass(name, classData, 0, classData.length);
}
}
}
return c;
}
/**
*
* @param classname class文件的全类名,不包含.class
* @return
*/
private byte[] loadBytes(String classname) {
String path = rootDir + File.separator + classname.replace('.', CHAR_SEPARATOR) + ".class";
ByteArrayOutputStream bytesOut = null;
InputStream input = null;
try {
input = new FileInputStream(path);
bytesOut = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len = -1;
while ((len = input.read(buffer)) != -1) {
bytesOut.write(buffer, 0, len);
}
bytesOut.flush();
return bytesOut.toByteArray();
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (null != input) {
input.close();
}
} catch (IOException e) {
e.printStackTrace();
}
try {
if (null != bytesOut) {
bytesOut.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
return null;
}
}
文章绝大部分内容整理摘自周志明《深入理解Java虚拟机 JVM高级特性与最佳实战》。