虚拟机类加载机制
一.引言
虚拟机把描述类的class文件加载到内存,并对数据进行校验、转换解析和初始化,最终生成可以被虚拟机直接使用的java类型,这个过程就是类加载机制。
简言之就是讲class文件转换为虚拟机直接使用的java类型。
注意这里所说的class文件是一串代表类或接口的二进制流,来源可以是本地文件或者网络。
1.1 类加载机制概述
类加载机制流程通常指的类生命周期的前五个阶段,即·加载loading-> 链接linking(验证verification->准备preparation->解析resolution)->初始化initialization-->使用using-->卸载uploading
。
其中加载、验证、准备、初始化和卸载这五个阶段的开始顺序是一定的。而为了支持java语言的运行时绑定,解析阶段可能在初始化阶段之后在开始。
1.2 触发类初始化的五种情况
有且只有五种情况应该立即对类进行初始化:
- 遇到new、getstatic、putstatic和invokestatic这四条指令时,需要先初始化类。则四条指令对应的场景是:创建类对象、读取或设置类的静态字段(除了final修饰的已经在编译阶段放进常量池的字段),调用类的静态方法;
- 使用
java.lang.reflect
包的方法对类进行反射调用时; - 初始化一个类的时候必须先初始化其父类。但是初始化一个接口时,并不要求对其父接口进行初始化
interface inte1 extends inte2
。初始化类也并不要求必须初始化其实现的接口; - 虚拟机启动时,指定的main方法所在类会被初始化——仅仅指定main方法所在的类,所有类都可以有main方法;
- “当使用jdk1.7动态语言支持时,如果一个
java.lang.invoke.MethodHandle
实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,则这个方法对应的类应该初始化,若还没有进行初始化。
以上五种情况成为对一个类的主动引用,除此之外所有的引用类的方式都不会触发初始化,成为被动引用:
1.通过子类调用父类的静态字段,不会导致子类初始化;
2.通过数组定义来引用类,不会触发此类的初始化:
DemoClass []clzArr=new DemoClass[10];
类不会被初始化。但是会出发一个名为[Lpackage.name.DemoClass
的类的初始化,这个类是Object的子类,创建动作由字节码指令newarray触发,这个类代表了一个元素类型为package.name.DemoClass
的一维数组。
3.final修饰的常量在编译(.java->.class)阶段会存入调用类的常量池,并没有直接引用到定义常量的类,因此不会触发初始化;
1.3 初始化
初始化就是通过程序制定的计划,去初始化类变量和其他资源(静态代码块)的过程,是除了加载阶段外的又一个程序可以参与的阶段——加载阶段用户应用程序可以自定义的类加载器。
也可以说初始化时执行类构造器
1.由来:
class Demo{
static{
i=0;
System.out.println(i);//报错:非法向前引用
//1.静态代码块不能访问非静态常量;
//2.参考一下不好的访问方式,当必须访问时:
Demo t=new Demo();
System.out.println(t.i);
}
stataic int i=1;
}
- 对一个类初始化的过程就是执行其
的过程,前边降到一个类初始化时其父类必须已经初始化。但是这里并没有显式的调用父类 方法 保证父类在子类之前初始化,JVM会保证这种顺序——由此可以推断Object是第一个执行()方法的类; - 由1/2可知父类静态变量和静态代码块先于子类执行;
- 如果一个类没有静态语句块也没有对变量的赋值操作,编译器就不会为这个类生成
()方法; - interface也涉及变量初始化,因此也存在
()方法。但是接口初始化的时候其父类不一定初始化,类初始化时其实现的接口不一定需要初始化,方法执行也如此; - 类的
()通过加锁保证线程安全,前边降到 ()是初始化类变量和静态代码块,如果有一个线程执行时间过长——即对应类变量和静态代码执行时间过程,其他线程会阻塞 。
二.类加载过程
类加载过程有5个步骤:加载、验证、准备、解析和初始化。
2.1 加载
加载阶段完成三件事儿:
- 通过一个类的全限定名获取定义此类的二进制流(class);
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
- 在内存中生成一个代表此类的java.lang.Class对象,存放在方法区而非java堆中,作为方法区这个类的各种数据结构的访问入口。
相比其他阶段,类加载节点的可控性是最强的:非数组类加载阶段即可以通过系统提供的引导类加载器完成,也可以自定义类加载器控制字节流的获取方式(第一件事),即重写类加载器的loadClass()
方法。
数组类有jvm直接创建,其创建过程遵循以下三个规则:
- 如果数组的组件类型(component type,数组去掉一个维度后的类型)是引用类型,递归采用以上定义的过程加载这个组件;
- 如果数组的组件类型不是引用类型,比如
int []
基本类型,jvm将数组与引导类加载器关联; - 数组的可见性与其组件类型可见性一直,如果数组组件类型不是引用类型,则数组类默认的可见性为public。
加载阶段尚未完成是连接阶段的验证可能已经开始。
2.2 连接第一阶段:验证
验证阶段是为了确保class文件中的字节流包含的信息符合当前jvm(程序运行平台上)的要求,并且不会危害jvm自身的安全。
虽然java代码相对安全,比如访问数组边界之外时会编译不通过,但是class文件不一定是通过java语言编译的(scal),编译后的结果很可能是语义不正确的,进而危害系统,因此需要验证,查看其是否符合jvm运行要求,防止有害操作。
验证阶段如果检查到class二进制流不符合class文件格式的约束,jvm就会抛出java.lang.VerifyError
异常或子类异常。
验证阶段一共分为四个步骤:文件格式验证、元数据验证、字节码验证和符号引用验证。
2.2.1 文件格式验证
字节流是否符合class文件格式规范(参看另一片博文):
- 字节流是否以魔数0xCAFEBABE开头;
- 主持版本号是否在当前虚拟机处理范围内:向下兼容;
- 常量池常量是否有不被支持常量类型:查看常量tag标志(14种)
- CONSTANT_Utf8_info型的常量是否有不符合UTF8编码的数据等等...
加载阶段二进制流并不会在内存中的方法去进行存储,通过验证阶段后字节流才会存储于内存的方法区。而后边的三个验证阶段元数据验证、字节码验证和符号引用验证都是基于方法区的存储结构的。
2.2.2 元数据验证
“java语言有很多规范,比如final类不能被继承,非抽象类必须实现父类和接口的抽象方法等等,元数据验证阶段就是验证字节流表达的语义是否符合这些规范”。
对字节码描述的信息进行语义分析,查看其是否符合java语言规范的要求,验证点如下:
- 这个类是否有父类——除了
Object
外,任何类都应该有父类; - 这个类是否继承了不被允许继承的类,final类;
- 如果这个不是抽象的,是否实现了父类或接口中的抽象方法——接口方法默认为抽象方法,没有方法体;
- 类中的字段、方法是否与父类冲突:覆盖父类final字段、出现不合规则的重载——方法名、参数相同,返回值不同。
关键词:语义校验、java语言规范。
2.2.3 字节码验证
元数据验证是验证数据类型符合java语言规范,字节码验证则是通过数据流和控制流验证程序是合法符合逻辑的,不会做出危害虚拟机的事情,比如:
- 在操作栈中放入了一个int类型,却按照long类型加载本地变量表;
- 跳转指令不会跳转到方法体以外的字节码指令上;
- 方法体中类型转换是安全的:可以子类对象赋值给父类引用,但是赋值给毫无关联的对象这是危险的。
//todo stackMapTable属性
2.2.4 符号引用验证
符号引用发生在连接的第三个阶段解析。是校验常量池中的各种符号引用,对类以外的各种信息进行验证。一般内容如下:
- 符号引用中是通过字符串的描述能否找到对应的类;
- 符号引用中的类、字段、方法是否能够被当前类访问(private default protected public)。
符号引用验证发生在解析之前,也是为了确保解析能够正常进行。验证不通过会抛出异常:`java.lang.IncompatibleClassChangeError、IllegalAccessError、NoSucnFieldError、NoSuchMethodError等。
可以使用-Xverify:none
关闭大部分验证。
2.3 准备
准备阶段是为类变量在方法区分配内存,并且设置类变量初始值的阶段。比如
static int i=1;
在准备阶段赋值为0。
将i
赋值为1的putstatic
指令存放于类构造器
如果类变量使用final修饰,则在编译时会为变量生成ConstantValue属性,在准备阶段虚拟机就会根据变量ConstantValue属性为其赋值。
2.4 解析
解析是将虚拟机常量池内符号引用转换为直接引用的过程。在class文件中以CONSTANT_Class_info、XX_Fieldref_info等形式存在,示例如下:
- 如图,符号引用使用一组符号来描述引用的目标,符号引用与内存布局无关。典型格式如下:
#19 = Class #120 // java/lang/StringBuilder
#120 = Utf8 java/lang/StringBuilder
直接引用是直接指向目标的指针、相对偏移量或者间接定位到目标的句柄。直接引用的目标一定存在于内存中,直接引用和jvm实现的内存布局相关。
在执行以下16个用于操作符号引用的字节码指令之前,首先要对他们引用的符号进行解析:
- anewarray、checkcast、
- getfield、putfield、getstatic、pubstatic、invokestatic、instanceof、invokeinterface、new;
- invokedynamic、invokeespecial、invokevirtual;
- ldc、ldc_w、multianewarray。
**除了invokedynamic(动态语言)外,jvm经常会对同一个符号进行多次解析,第一次以后其他解析都是使用的第一次解析结果的缓存。
2.4.1 类和接口CONSTANT_Class_info
解析动作主要针对类/接口、字段、类方法、接口方法、方法类型、方法句柄、和调用电限定符七种。分别对应
CONSTANT_Class_info;类或接口
Fieldref;字段
Methodref;类方法
InterfaceMethodref;接口方法
class类型的符号引用解析为直接引用分为三个步骤(假设代码所在类为D、将符号引用N解析为对类或接口C的直接引用):
#1 = Class #2 //N
- 如果C不是数组类型,则jvm会把代表N的全限定名传递给D的类加载器去加载类C。加载过程中会触发元数据验证、字节码验证(数据定义和语义是否符合规范);
- 如果C是数组且元素类型是对象,则首先会按照第一点加载元素类型,然后又虚拟机生成一个代表此数组维度和元素的数组对象;
- 上述对数组类型或者非数组对象解析完毕之后,会进行符号引用验证,即确认代码所在类D是否具备对C的访问权限,不具备则抛出
IllegalAccessError
;
2.4.2 字段解析:CONSTANT_Fieldref_info
- 首先会对字段所在的类或接口C进行解析;
- 如果类C包含了简单名称和描述符与目标相符的字段则返回这个字段的直接引用(即引用的是自己所在类的字段);
- 否则,递归从小网上递归搜索其实现的接口和接口的父接口,包含简单名称和描述符与目标相符的字段则返回这个字段的直接引用;
- 递归向上查找其父类及父类实现的接口;
- 如果在类中、实现的接口及父接口和递归父类及递归父类实现的接口中都找不到,查找失败,抛
NoSucnFieldError
异常。 - 如果查找成功,则对这个字段进行权限验证,不具备访问权限则抛
IllegalAccessError
异常。
public class Test{
interface Interface0{
int A=0;//默认public static final;公共静态常量
}
interface Interface1 extends Interface0{
int A=1;
}
static class Parent implements Interface1{
public static int A=3;
}
interface Interface2{
int A=2;
}
static class Sub extends Parent implements Interface2{
public static int A=4;//fixme 如果注释掉了这一行,main中使用Sub对A的访问将会报错“A引用是含义不明确的父类和接口都有“。
}
public static void main(String[] args) {
System.out.println(Sub.A);
}
}
2.4.3 类方法解析
类方法解析同样首先会解析类方法表中class_index索引的方法所述的类或者接口,用C表示。
- 但是如果发现C是接口,则抛异常
IncompatibleClassChangeError
(接口方法必须在接口中、类方法必须在类中; - 在类C中查找是否有简单名称和描述符符合目标的方法,有则返回(所在类查找);
- 递归查找其父类,有则返回;
- 在C实现的接口及其父接口中查找,有则说明C是一个抽象类(引用类抽象类的抽象方法),抛出
AbstractMethodError
异常; - 如果查找成功且不抛异常,则会进行权限验证,或抛出
IllegaoAccessError
错误。
2.4.4 接口方法解析
首先解析出接口所在类或接口C(类加载器、权限验证)。
- 如果C是类则抛出IncompatibleClassChangeError;
- 在C中查找、C的递归父接口中进行查找,有则返回直接引用,无则抛异常NoSuchMethodError;
- 接口中所有方法、字段都是public的,所以不用进行方法验证,不会抛出IllegalAccessError。
5. 某个类的初始化
见开始:初始化静态变量和其他资源(静态代码块),执行类构造器
与编程有关规则见上文相关小节。
三.类加载器
在加载阶段,实现“通过类的全限定名获取描述此类的二进制流”的代码块就叫做类加载器。
类的在jvm中的唯一性:加载器+类本身
任何一个类,都需要有加载他的类加载器和这个类本身确立其在jvm中的唯一性,其一不同则类就不想等,相等概念是指类对象的equals()方法、instanceof关键字对对象从属关系判定为TRUE。示例如下:
public class Test{
public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
//自定义类加载器
ClassLoader myLoader=new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// return super.loadClass(name);fixme 这一句的话则为返回为TRUE
try{
String fileName=name.substring(name.lastIndexOf(".")+1)+".class";
InputStream is=getClass().getResourceAsStream(fileName);
if(is==null){//资源对应的输入流为空,启动类加载器
return super.loadClass(name);
}
byte []b=new byte[is.available()];
is.read(b);
return defineClass(name,b,0,b.length);
} catch (IOException e) {
e.printStackTrace();
}
throw new ClassNotFoundException(name);
}
};
Object obj=myLoader.loadClass("test.Test").newInstance();
Object obj2=new Test();
System.out.println(obj.getClass()+"\n"+obj2.getClass());
System.out.println(obj instanceof Test);
System.out.println(obj2 instanceof Test);
}
}
5.2 双亲委派模型
从jvm的角度讲,类加载器有两种:C++实现的启动类加载器bootstrap classloader 和java语言实现的其他类加载器。
如上节所示,java实现的类加载器都需要继承抽象类java.lang.ClassLoader
.
从开发角度看可以分为三类:
- 启动类加载器(bootstrap classloader):C++实现,加载<JAVA_HOME>\lib目录或者被-Xbootclasspath参数指定路径中的类,并且必须能够被虚拟机按照文件名(rt.jar)识别。程序不能直接使用,??或者在编写自定义类加载器时getClassLoader返回null??;
- 扩展类加载器(extension ClassLoader):由sum.mic.lancher$ExtClassLoader实现,负责加载<JAVA_HOME>\lib\ext目录中和java.ext.dirs系统变量指定路径的类库,可以直接使用;
- 应用类加载器,又称系统类加载器:由sum.mic.Launcher$AppClassLoader实现,是
ClassLoader.getSystemClassLoader()
的返回值,负责用户类路径ClassPath上的指定的类库,开发者可以直接使用,一般情况下就是程序默认的类加载器。
应用程序也可以自定义类加载器,他们关系如下:
-如上图所展示的层次关系就叫做类加载器的双亲委派模型parents delegation model。除了顶层的启动类加载器外,其他类加载器都有自己的父类加载器——这种父子关系一般不是以基层的关系实现,而是使用组合的关系复用父类加载器的代码——类加载器就是通过类的全限定名获取描述此类的二进制流的代码块。
双亲委派模型工作过程是:
当一个类加载器收到类加载请求时,自己不会立即去加载类,而是递归向上请求父类加载——找到最顶层,如果父类无法完成类加载(比如方式目录和文件名称不符合此加载器识别规范),则传递给子类进行加载。
双亲委派模型的优点是:
java类随着他的类加载器具备了一种带优先级的层次关系。比如Object类,不论哪一个类要加载这个类,都会被启动类加载器加载,因此Object类在各种环境下都是同一个类。
双亲委派模型通过ClassLoader的loadClass()
方法实现:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先检查类是否已经被加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {//如果没有被加载过
//递归使用父类加载器加载
c = parent.loadClass(name, false);
} else {//如果父亲加载器不存在的话,用启动类加载器——直接找到最顶层
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
//异常表示父类无法加载
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// 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;
}
}
- 首先检查类是否已经被加载过;
- 如果没有被加载过,递归使用父类加载器加载;
- 如果父亲加载器不存在的话,用启动类加载器——直接找到最顶层;
- 异常表示父类无法加载,调用自己类加载器进行加载。
在基础类调用用户代码时会破坏双亲摸排模型,比如JNDI服务:java命名和目录接口。