有了java class文件之后,为了让class文件转换成为JVM可以真正运行的结构,需要经历加载,链接和初始化的过程。
加载过程
从上面的图中,我们可以看到JVM中有三大部分,分别是类加载系统,运行时数据区域和Execution Engine。加载就是根据特定名称查找类或者接口的二进制表示,并根据此二进制表示来创建类和接口的过程。
类的加载、连接和初始化
当Java程序中需要使用到某个类时,虚拟机会保证这个类已经被加载、连接和初始化。而连接又包含验证、准备和解析这三个子过程,这个过程必须严格的按照顺序执行。
类的加载
Class 只有在必须要使用的时候才会被装载,Java 虚拟机不会无条件地装载 Class 类型。Java 虚拟机规定,一个类或接口在初次使用前,必须要进行初始化。这里指的 “使用”, 是指主动使用,主动使用只有下列几种情况:
- 当创建一个类的实例时,比如使用 new 关键字,或者通过反射、克隆、反序列化。
- 当调用类的静态方法时,即当使用了字节码 invokestatic 指令。
- 当使用类或接口的静态字段时 (final 常量除外), 比如,使用 getstatic 或者 putstatic 指令。
- 当使用 java.lang.reflect 包中的方法反射类的方法时。
- 当初始化子类时,要求先初始化父类。
- 作为启动虚拟机,含有 main 方法的那个类。
加载类处于类装载的第一个阶段。在加载类时,Java 虚拟机必须完成以下工作:
- 通过类的全名,获取类的二进制数据流。
- 解析类的二进制数据流为方法区内的数据结构。
- 创建 java.lang.Class 类的实例,表示该类型。
启动类加载器负责加载最为基础、最为重要的类,比如存放在 JRE 的 lib目录下 jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)。除了启动类加载器之外,另外两个重要的类加载器是扩展类加载器(extension class loader)和应用类加载器(application class loader),均由 Java 核心类库提供。
扩展类加载器的父类加载器是启动类加载器。它负责加载相对次要、但又通用的类,比如存放在 JRE 的 lib/ext 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类)。
应用类加载器的父类加载器则是扩展类加载器。它负责加载应用程序路径下的类。(这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。
加载采用双亲委派模型,每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。
连接
链接指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。它可分为验证、准备以及解析三个阶段。
验证:确保被加载类能够满足 Java 虚拟机的约束条件。一般Java 编译器生成的类文件必然满足 Java 虚拟机的约束条件。字节码注入的例子之后会介绍。
准备:为类的静态变量分配内存,并将其初始化为默认值。此阶段仅仅只为静态类变量(即static修饰的字段变量)分配内存,并且设置该变量的初始值。(比如 static int num=5,这里只将num初始化为0,5的值将会在初始化时赋值)。对于final static修饰的变量,编译的时候就会分配了,也不会分配实例变量的内存。
解析:解析阶段的工作就是将类、接口、字段和方法的符号引用转为直接引用。符号引用就是一些字面量的引用,和虚拟机的内部数据结构和内存布局无关。在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java 编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。例如,对于一个方法调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接收参数类型以及返回值类型的符号引用,来指代所要调用的方法。解析阶段的目的,正是将这些符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化。
初始化
类加载最后阶段,若该类具有父类,则先对父类进行初始化,执行静态变量赋值和静态代码块代码,成员变量也将被初始化.要初始化一个静态字段,可以在声明时直接赋值,也可以在静态代码块中对其赋值。如果直接赋值的静态字段被 final 所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被 Java 编译器标记成常量值(ConstantValue),其初始化直接由 Java 虚拟机完成。除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为 < clinit >。
初始化,便是为标记为常量值的字段赋值,以及执行 < clinit > 方法的过程。Java 虚拟机会通过加锁来确保类的 < clinit > 方法仅被执行一次。初始化触发的条件有:
当虚拟机启动时,初始化用户指定的主类(main);
当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;
当遇到调用静态方法的指令时,初始化该静态方法所在的类;
当遇到访问静态字段的指令时,初始化该静态字段所在的类;
public class Singleton {
private Singleton() {}
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}
只有当调用Singleton.getInstance 时,程序才会访问 LazyHolder.INSTANCE,才会触发对LazyHolder 的初始化
子类的初始化会触发父类的初始化;
如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
使用反射 API 对某个类进行反射调用时,初始化这个类;
当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。
例子:
public class Singleton {
private Singleton() {}
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
static {
System.out.println("LazyHolder.");
}
}
public static Object getInstance(boolean flag) {
if (flag) return new LazyHolder[2];
return LazyHolder.INSTANCE;
}
public static void main(String[] args) {
getInstance(true);
System.out.println("----");
getInstance(false);
}
}
新建数组new LazyHolder[2]会加载元素类LazyHolder;不会初始化元素类。虚拟机必须知道(加载)有这个类,才能创建这个类的数组(容器),但是这个类并没有被使用到(没有达到初始化的条件),所以不会初始化,也不会链接元素类LazyHolder;
调用getInstance(false)的时候约等于告诉虚拟机要使用这个类了,你把这个类造好(链接),然后把static修饰的字符赋予变量(初始化)。
运行时常量池
每个class文件都包含一个表结构的常量池(constant_pool),该常量池的功能跟C/C++编译过程中用到的符号表是一样的,主要用于保存源代码文件中的各种字面常量(如字符串常量,字段名,方法名等)和符号引用(如对其他某个类的方法调用)。当类或者接口创建时,常量池表会被用来构造运行时常量池,如new对象时使用的类的符号引用来自于CONSTANT_Class_info结构,读取对象字段值时使用的字段的符号引用来自于CONSTANT_Fieldref_info,方法调用中使用的方法的符号引用来自于CONSTANT_Methordref_info结构。运行时常量池是二进制形式的常量池表在Hotspot中的对应C++实现,即oops模块下的ConstantPool类,定义在oops/constantPool.hpp中,运行时常量池所有引用最初都是符号引用,在链接阶段会将符号引用解析成对应的内存地址。
测试代码如下:
package jvmTest;
import java.lang.management.ManagementFactory;
import java.lang.management.RuntimeMXBean;
class D{
private int a=123;
public int b=456;
public D() {
}
public void show(){
System.out.println("test");
}
}
public class MainTest3 {
public static void main(String[] args) {
test();
while (true) {
try {
System.out.println(getProcessID());
Thread.sleep(600 * 1000);
} catch (Exception e) {
}
}
}
public static final int getProcessID() {
RuntimeMXBean runtimeMXBean = ManagementFactory.getRuntimeMXBean();
System.out.println(runtimeMXBean.getName());
return Integer.valueOf(runtimeMXBean.getName().split("@")[0])
.intValue();
}
public static void test(){
D d=new D();
d.show();
System.out.println(d.b);
}
}
使用HSDB查看运行时常量池的具体内容,在Class Browser中搜索jvmTest可找到示例中的两个类,找到后点击最下方的Constant Pool链接,如下图
先看类D的常量池,如下图:
再看MainTest3的test方法,如下图:
类名和方法名的符号引用可直接解析成对应的内存地址,因为这些是运行时内存地址不可变的;字段的符号引用无法解析成保存该字段值的内存地址,因为对应的内存地址是运行时随着对象实例的地址而不断改变的。
可通过javap命令查看最初的字符形式的符号引用,如下图:
加载
启动类
日常的Java工程开发中会引入很多第三方jar包,这些jar包中的类都会被加载么?类的加载是如何触发的?何时触发的?将上述示例中main方法中调用test()方法的代码注掉再执行,在Class Browser下搜索jvmTest,结果如下图:
类D搜不出来了,说明D未加载,再看MainTest3的常量池,如下图:
UnresolvedClass表明D还未符号解析,跟D一样,java/lang/Exception和java/lang/Object两个类也未符号解析。该示例说明只有在需要该类的时候如创建类实例,调用类方法,获取类的字段值等才会加载该类,这样做是为了减少无用的类的内存占用,注意JDK自带的标准类除外,标准类在JVM启动时全部加载到内存中。常量池的符号引用的解析可以预解析,也可以按需解析,JVM规范未做要求,Hotspot选择后者。
Java程序启动时必须指定一个启动类,在JVM初始化完成后会执行此启动类的main方法,此main方法跟C/C++中的main方法一样都是作为启动入口的。在JVM执行main方法前必须先加载该启动类,该启动类加载的过程中和main方法执行的过程中都会按需加载其他的类,如启动类使用了类A,类A使用了类B和类C,类B使用了类D,类C使用了类E,如此递归循环,最终启动类,A,B,C,D,E都会依次加载到JVM内存中。正是因为并不是在程序启动时一次性加载所有未来需要用到的类,所以在使用第三方jar包时,程序运行期间可能会抛出NoClassDefFoundError错误。
类加载器
类加载器主要负责加载类的,按照给定的全限定类名如java/lang/String,从文件或者网络中读取二进制形式的类,将其转化成JVM能够识别并直接使用的Klass模型。JDK提供了三种标准类加载器:
启动(Bootstrap)类加载器:是Java类加载层次中最顶层的JVM内置的类加载器,负责加载
/lib路径或者-Xbootclasspath参数指定的路径下的核心类库,如:rt.jar、resources.jar、charsets.jar等,由C++语言实现,在Java程序中无法直接访问,即定义在classfile/classLoader.hpp中的ClassLoader类,注意JVM会对加载的核心类库做强校验,避免非法篡改。 扩展(Extension)类加载器:即sun.misc.Launcher$ExtClassLoader类,由Java语言实现,是Launcher的静态内部类,负责加载
/lib/ext目录下或者由系统变量-Djava.ext.dir指定位路径中的类库。 应用(App)类加载器:即sun.misc.Launcher$AppClassLoader类,由Java语言实现,是Launcher的静态内部类,负责加载系统类路径java -classpath或-D java.class.path 指定路径下的类库,通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器,是应用程序的默认类加载器。
每个类的Class都包含有加载该Class的类加载器的引用,可调Class#getClassLoader()获取,JDK核心类库中的类除外,因为这部分类是由启动类加载器加载的。因为数组类的Class不是由类加载器生成的,而是JVM根据数组元素类型自动生成的,所以调用Class#getClassLoader()返回的是数组元素类的类加载器的引用。类加载器采用委托模型加载类或者其他资源,发出加载请求的类加载器和最终完成加载并定义类的类加载器不需要是同一个类加载器。每个类加载器实例都有一个关联的父类加载器,该父类加载器就是被委托对象,可调用ClassLoader#getParent()方法获取,注意启动类加载器没有父类加载器,而是作为其他类加载器的父类加载器存在,示例如下:
public static void main(String[] args) {
ClassLoader classLoader=MyTest.class.getClassLoader();
while (classLoader!=null) {
System.out.println(classLoader.getClass().getName());
classLoader=classLoader.getParent();
}
classLoader=String.class.getClassLoader();
System.out.println(classLoader==null);
ClassLoader classLoader2=int[].class.getClassLoader();
System.out.println(classLoader2==null);
}
执行结果如下:
即AppClassLoader的父类加载器是ExtClassLoader,ExtClassLoader的父类加载器为空,注意这里的为空仅是父类加载器引用为空,在逻辑上启动加载器是ExtClassLoader的父加载器,各类加载器的详细介绍参考《Hotspot 类加载器Java源码解析》。
类加载器与类
如果类加载器L直接创建了类C,就说L定义了C或者L是C的定义类加载器。如果L委托其他的类加载器完成加载并定义类C,则说L导致了C的加载或者L是C的初始加载器。在Java虚拟机运行时,类或者接口不仅仅是由他的名称来确定,而是由全限定名(二进制名称)和定义类加载器共同确定的,同理类或者接口所属的运行时包结构由所属的包名和定义类加载器决定。
一个功能良好的类加载器必须保证下面2个属性:
- 给定相同名称,类加载器总是应当返回相同的Class对象
- 如果类加载器L1将加载类C的请求委托给类加载器L2,则对于满足下列条件之一的任意类型T来说,L1和L2应该返回相同的Class对象:T是C的直接超类或者直接超接口,T是C中某个字段的类型,T是某个方法的构造参数或者返回值的类型,即对于类C中使用的其他类,L1和L2应该返回相同的Class对象
这两个属性其实是JVM底层的类加载机制保证的,上层的Java类加载器实现只要满足委托机制即可,因为Java类加载器中查找已经加载的类,将二进制流转换成Class对象以及解析验证Class的逻辑都是由native方法实现的,Java语言层面只是重写了如何根据类名找到并读取对应类文件的逻辑。
测试代码如下:
package jvmTest;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.lang.management.ManagementFactory;
import java.lang.management.RuntimeMXBean;
import java.lang.reflect.Constructor;
class TestA{
public TestA() {
}
public void say(){
System.out.println("TestA");
}
}
class MyClassLoader extends ClassLoader{
public MyClassLoader(ClassLoader parent) {
super(parent);
}
public MyClassLoader() {
}
@Override
public Class> findClass(String name) throws ClassNotFoundException {
String fileName="D:\\git\\"+name.replace(".", File.separator)+".class";
System.out.println(fileName);
try {
File file=new File(fileName);
if(file.exists()){
InputStream ins=new FileInputStream(file);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
int bytesNumRead = 0;
while ((bytesNumRead = ins.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}
byte[] classData=baos.toByteArray();
System.out.println("file size->"+classData.length);
return defineClass(name, classData, 0,classData.length);
}
} catch (Exception e) {
}
return null;
}
}
public class ClassLoaderTest {
public static void main(String[] args) throws Exception {
TestA a=new TestA();
a.say();
//MyClassLoader的默认父类加载器就是AppClassLoader
MyClassLoader classLoader=new MyClassLoader();
//调用loadClass方法会返回AppClassLoader已经加载过的Class
Class c=classLoader.loadClass("jvmTest.TestA");
System.out.println(c==a.getClass());
//调用findClass则绕开了委托机制,强制加载一个同名的已经存在的类
Class c2=classLoader.findClass("jvmTest.TestA");
System.out.println(c2==a.getClass());
// Constructor cons=c2.getConstructor(null);
// cons.setAccessible(true);
// TestA a2=(TestA) cons.newInstance();
// a2.say();
while (true){
try {
System.out.println(getProcessID());
Thread.sleep(600*1000);
} catch (Exception e) {
}
}
}
public static final int getProcessID() {
RuntimeMXBean runtimeMXBean = ManagementFactory.getRuntimeMXBean();
System.out.println(runtimeMXBean.getName());
return Integer.valueOf(runtimeMXBean.getName().split("@")[0])
.intValue();
}
}
上述文件编译后,将TestA.class然后运行,结果如下:
c和a.getClass()返回的是同一个Class对象,c2则是另一个Class对象,可用通过HSDB Class Browser工具进一步查看,如下图:
后面被注掉的几行代码是尝试用c2来创建一个新的TestA实例,结果报错:
这是因为代码层面无法区分不同ClassLoader加载的同名Class对象,所以编译时默认是只有一个TestA的Class对象,就存在上述的ClassCastException。
格式检查
JVM准备加载某个class文件,必须首先检查其是否符合class文件的基本格式,这个过程就称为格式检查,验证的事项如下:
- 前4个字节必须是正确的魔数
- 能够辨识出来的所有属性都必须具备合适的长度
- class文件内容的必选项不能缺失,尾部也不能有多余的字节
- 常量池必须符合class文件格式,如CONSTANT_class_info结构的name_index项必须是指向常量池中CONSTANT_Utf8_info结构的有效索引。
- 常量池中所有的字段和方法引用都必须具备有效的名称,类和描述符。
格式检查并不确保某字段或者方法真的在某个类中,也不保证某描述符会指向真实存在的类,只保证class文件中各项的格式是正确的,更为复杂详细的检查在验证字节码和解析class文件时执行。
从class文件得到类
使用类加载器L从class文件得到标记为N的非数组类或者接口C的Class对象,主要包含以下几个步骤:
- JVM检查L是否被记录成N所表示的类或者接口的初始类加载器,如果是则这次尝试创建是无效的,抛出LinkageError异常
- 如果不是则尝试解析class文件,这个过程会执行class文件格式检查,检查主副版本号是否是当前JVM支持的,检查该文件能否表示标记为N的类或者接口
- 如果C存在一个直接父类,则需要根据父类的符号引用解析该类,解析失败则抛出异常。如果C是一个接口,则必须以Object作为直接父类,且Object已经加载过了。
- 如果C存在一个直接父接口,则需要根据父接口的符号引用解析该父接口,解析失败则抛出异常
- JVM标记C的定义类加载器是L,L是C的初始类加载器。
链接
链接类或者接口包括验证和准备类或接口,它的直接父类,直接父接口,元素类型(如果是数组类型),而解析这个类的符号引用如对某个类的方法调用则是链接过程中可选的部分。Java虚拟机规范允许灵活的选择链接的时机,但必须保证以下几点成立:
- 在类或者接口被链接之前,它被成功的加载过
- 在类或者接口被初始化之前,它被成功的验证及准备过
- 若程序触发了一个需要直接或者间接链接某一个类或接口的动作,如使用了某个未解析的符号引用时,在链接过程中出现异常则必须触发链接动作的地方抛出异常,即必须指明触发链接异常的动作是啥
Java虚拟机实现可以选择在只有用到类或者接口的符号引用时才去逐一解析,称为延迟解析,也可以在验证类的时候就解析每个符号引用,称为预先解析,Hotspot为了节省内存占用选择延迟解析,也只有在解析需要某个类时才会加载对应的class文件。链接包含三个步骤,验证、准备和解析,下面逐一分析。
验证
验证用于保证类或者接口的二进制表示是否符合静态约束和结构化约束,验证过程会导致某些额外的类或者接口被加载进来,但是不一定导致他们也需要验证或者准备。
静态约束主要是指一系列用来定义文件是否良好编排的约束,加载过程中执行的格式检查就是静态约束的一部分,验证环节验证的静态约束主要包含对虚拟机指令的验证,包括虚拟机指令在Code数组中是否正确排列,部分特殊的指令是否带上了必要的操作数,如:
- code数组中不允许出现保留的或者规范中未定义的虚拟机指令
- code数组中第一条指令的操作码是从数组中索引为0处开始的
- 对于code数组中除最后一条指令外的其他指令,下一条指令的操作码的索引等于当前指令的操作码的索引加上当前指令的长度(包含指令带有的操作数),即虚拟机指令之间都是紧密排列的,不允许有多余的字节
- 所有跳转和分支指令必须的跳转目标必须是本方法内某个指令的操作码
- anewarray指令不能创建维度超过255维的数组
结构化约束主要是为了限定虚拟机指令之间的关系,如:
- 所有指令都只能在操作数栈和局部变量表中具备类型和数量合适的操作数时执行,但不用关心调用它的执行路径
- 如果某个指令可以通过不同的执行路径执行,则指令执行前,操作数栈必须具有相同的深度
- 在执行过程中不允许操作数栈增长到超过max_stack项的值的深度
- 所有方法调用的参数,其类型必须与方法描述符相兼容
- 所有返回指令必须与方法的返回类型相同
除检查虚拟机指令是否满足上述两种约束外,还需验证:
- final类没有子类
- final方法没有被其他方法覆写
- 除Object之外的其他类都有直接父类
链接期验证有助于增强解释器的运行期执行性能,因为解释器无需再对每条执行指令做检查。根据版本号的不同,JVM可以采用两种不同的策略来完成验证,版本号低于50.0即编译时JDK1.6之前的的class文件采用类型推导策略,版本号大于或者等于50.0的使用类型检查策略,这里的策略相当于Java规范给出的如何实现这些验证的官方指导,详情参考虚拟机规范。
准备
准备阶段的任务是创建类或者接口的静态字段,并用默认值初始化这些字段,这个阶段不会执行任何的虚拟机指令,在初始化阶段会有显示的初始化器来初始化这些字段,所以准备阶段不做初始化。除此之外,JVM会在准备阶段强制实施加载约束,具体的约束规则参考虚拟机规范。
执行完类验证后,任何时间都可以执行准备,但一定要保证在初始化阶段前完成。
解析
解析是根据运行时常量池里的符号引用来动态决定具体值的过程,如果某个符号引用解析过程出现异常,则应该在直接或者间接使用该符号引用的地方抛出异常。对类D引用的类或者接口C的符号引用解析时,大体解析流程如下:
- 解析C的类或者接口符号引用时,会使用D的类加载器来加载类C,并检查D对C的访问权限。
- 解析C的某个字段符号引用时,会先解析C的类符号引用,然后在C或者C的父类中查找目标字段是否存在,检查目标字段对D是否可见
- 解析C的普通方法的符号引用时,先解析C的类符号引用,检查C是否是接口,如果不是检查C和他的父类是否包含此方法
- 解析C的接口方法的符号引用时,先解析C的类符号引用,检查C是否是接口,如果是检查C和他的父接口是否包含此方法
- 方法类型和方法句柄的解析比较复杂,方法类型解析的结果是得到一个java.lang.invoke.MethodType的实例的引用,方法句柄解析的结果是得到一个指向java.lang.invoke.MethodHandler实例的引用,详情参考虚拟机规范
- 调用点限定符解析,需依次解析调用点中的方法句柄,方法类型的符号引用,再解析静态参数的符号引用,当通过反射调用Java方法时会出现此类解析。
初始化
初始化对类和接口来说就是执行它的初始化方法,只有在发生下列行为时,类或者接口才会被初始化:
- 执行new,getstatic,putstatic,invokestatic指令时
- 初次调用方法句柄java.lang.invoke.MethodHanlder实例,该实例的种类是REF_getstatic,REF_putstatic,REF_invokestatic
- 调用Class类或者反射类库中的某些反射方法
- 对类的某个子类进行初始化时
- 该类被选定为Java虚拟机启动时的初始类
因为JVM支持多线程,所以存在并发初始化某个类或者接口的问题,JVM实现需要处理好线程同步和递归初始化,通常采用与某个类或者接口唯一关联的全局初始化锁来控制,参考ClassLoader类中的getClassLoadingLock
的实现,如下:
protected Object getClassLoadingLock(String className) {
Object lock = this;
//如果当前ClassLoader是支持并发初始化则parallelLockMap在ClassLoader初始化时初始化
if (parallelLockMap != null) {
Object newLock = new Object();
//如果该类的锁已存在则返回该锁,否则会保存该锁
lock = parallelLockMap.putIfAbsent(className, newLock);
if (lock == null) {
lock = newLock;
}
}
return lock;
}