虚拟机类加载机制

731 阅读17分钟

虚拟机把字节码文件从磁盘加载进内存的这个过程,我们可以粗糙的称之为「类加载」,因为「类加载」不仅仅是读取一段字节码文件那么简单,虚拟机还要进行必要的「验证」、「初始化」等操作,下文将一一叙述。

类加载的基本流程

一个类从被加载进内存,到卸载出内存,完整的生命周期包括:加载,验证,准备,解析,初始化,使用,卸载。如图:

image

这七个阶段按序开始,但不意味着一个阶段结束另一个阶段才能开始。也就是说,不同的阶段往往是穿插着进行的,加载阶段中可能会激活验证的开始,而验证阶段又有可能激活准备阶段的赋值操作等,但整体的开始顺序是不会变的。

具体的内容,下文将详细描述,这里只需要建立一个宏观上的认识,了解整个类加载过程需要经过的几个阶段即可。

加载

「加载」和「类加载」是两个不同的概念,后者包含前者,即「加载」是「类加载」的一个阶段,而这个阶段需要完成以下三件事:

  • 通过一个类的全限定名获取对应于该类的二进制字节流
  • 将这个二进制字节流转储为方法区的运行时数据结构
  • 于内存中生成一个 java.lang.class 类型的对象,用于表示该类的类型信息。

首先,第一个过程,读取字节码文件进入内存。具体如何读取,虚拟机规范中并没有明确指明。也就是说,你可以从 ZIP 包中读取,也可以从网络中获取,还可以动态生成,或者从数据库中读取等,反正最终得到的结果一样:字节码文件的二进制流

第二步,将这个内存中的二进制流重新编码存储,依照方法区的存储结构进行存储,方便后续的验证和解析。方法区数据结构如下:

image

大体上的格式和我们虚拟机规范中的 Class 文件格式是差不多的,只是这里增加了一些项,重排了某些项的顺序。

第三步,生成一个 java.lang.class 类型的对象。这个类型的对象创建的具体细节,我们不得而知,但是这个对象存在于方法区之中的唯一目的就是,唯一表示了当前类的基本信息,外部所有该类的对象的创建都是要基于这个 class 对象的,因为它描述了当前类的所有信息。

可见,整个加载阶段,后两个步骤我们不可控,唯一可控的是第一步,加载字节码。具体如何加载,这部分内容,这里不打算详细说明,具体内容将于下文描述「类加载器」时进行说明。

验证

验证阶段的目的是为了确保加载的 Class 文件中的字节流是符合虚拟机运行要求的,不能威胁到虚拟机自身安全。

这个阶段「把控」的如何,将直接决定了我们虚拟机能否承受住恶意代码的攻击。整个验证又分为四个阶段:文件格式验证、元数据验证、字节码验证,符号引用验证

1、文件格式验证

这个阶段将于「加载」阶段的第一个子阶段结束后被激活,主要对已经进入内存的二进制流进行判断,是否满足虚拟机规范中要求的 Class 文件格式。例如:

  • 魔数的值是否为:0xCAFEBABE
  • 主次版本号是否在当前虚拟机处理范围之内
  • 检查常量池中的各项常量是否为常量池所支持的类型(tag 字段是否异常取值)
  • 常量项 CONSTATNT_Utf8_info 中存储的字面量值是否不符合 utf8 编码标准
  • 等等等等

当通过该阶段的验证后,字节码文件将顺利的存储为方法区数据结构,此后的任何操作都不在基于这个字节码文件了,都将直接操作存储在方法区中的类数据结构。

2、元数据验证

该阶段的验证主要针对字节码文件所描述的语义进行验证,验证它是否符合 Java 语言规范的要求。例如:

  • 这个类是否有父类,Object 类除外
  • 这个类是否继承了某个不允许被继承的类
  • 这个类中定义的方法,字段是否存在冲突
  • 等等等等

虽然某些校验在编译器中已经验证过了,这里却依然需要验证的原因是,并不是所有的 Class 文件都是由编译器产生的,也可以根据 Class 文件格式规范,直接编写二进制得到。虽然这种情况少之又少,但是不代表不存在,所以这一步的验证的存在是很有必要的。

3、字节码验证

经过「元数据验证」之后,整个字节码文件中定义的语义必然会符合 Java 语言规范。但是并不能保证方法内部的字节码指令能够很好的协作,比如出现:跳转指令跳转到方法体之外的字节码指令上,字节码指令取错操作数栈中的数据等问题

这部分的验证比较复杂,我查了很多资料,大部分都一带而过。总体上来说,这阶段的验证主要是对方法中的字节码指令在运行时可能出现的一部分问题进行一个校验。

4、符号引用验证

这个验证相对而言就比较简单了,它发生在「解析」阶段之中。当「解析」阶段开始完成一个符号引用类型的加载之后,符号引用验证将会被激活,针对常量池中的符号引用进行一些校验。比如:

  • CONSANT_Class_info 所对应的类是否已经被加载进内内存了
  • 类的相关字段,方法的符号引用是否能得到对应
  • 对类,方法,字段的访问性是否能得到满足
  • 等等等等

符号引用验证通过之后,解析阶段才能继续。

总结一下,验证阶段总共分为四个子阶段,任意一个阶段出现错误,都将抛出 java.lang.VerifyError 异常或其子类异常。当然,如果你觉得验证阶段会拖慢你的程序,jvm 提供:-Xverify:none 启动参数关闭验证阶段,缩短虚拟机类加载时间。

准备

准备阶段实际上是为类变量赋「系统初值」的过程,这里的「系统初值」并不是指通过赋值语句初始化变量的意思,基本数据类型的零值,如图:

image

例如:

public static int num = 999;

准备阶段之后,num 的值将会被赋值为 0。一句话概括,这个阶段就是为类变量赋默认值的一个过程。

但是有一个特例需要注意一下,对于常量类型变量而言,它们的字段属性表中有一项属性 ConstantValue 是有值的,所以这个阶段会将这个值初始化给变量。例如:

public static final int num = 999;

准备阶段之后,num 的值不是 0,而是 999。

解析

整个解析过程其实只干了一件事情,就是将==符号引用转换成直接引用==。原先,在我们 Class 文件中的常量池里面,存在两种类型的常量,直接字面量(直接引用)和符号引用。

直接引用指向的是具体的字面量,即数字或者字符串。而符号引用存储的是对直接引用的描述,并不是指向直接的字面量。例如我们的 CONSTANT_Class_info 中的 name_index 存储就是对常量池的一个偏量值,而不是直接存储的字符串的地址,也就是说,符号引用指向直接引用,而直接引用指向具体的字面量。

为什么要这样设计,其实就是为了共用常量项。 如果不是为了共享常量,我也可以定义 name_index 后连续两个字节用来表述类的全限定名的 utf8 编码,只不过一旦整个类中有多个重复的常量项的话,就显得浪费内存了。

当一个类被加载进方法区之后,该类的常量池中的所有常量将会入驻方法区的运行时常量池。这是一块对所有线程公开的内存区域,多个类之间如果有重复的常量将会被合并。直接引用会直接入驻常量池,而符号引用则需要通过解析阶段来实际指向运行时常量池中的直接引用的地址。

这就是解析阶段所要完成的事情,下面我们具体看看不同的符号引用是如何被翻译成直接引用的。

1、类或接口的解析

假设当前代码所处的类是 A,在 A 中遇到一个新类型 B,也可以理解为 A 中存在一个 B 类型的符号引用。那么对于 B 类型的解析过程如下:

  • 通过常量池找到 B 这个符号引用所对应的直接引用(类的全限定名的 utf8 编码)
  • 把这个全限定名称传递给虚拟机完成类加载(包括我们完整的七个步骤)
  • 替换 B 的符号引用的值为内存中刚加载的类或者接口的地址

当然,对于我们的数组类型是稍有不同的,因为数组类型在运行时由 jvm 动态创建,所以在解析阶段的第一步,jvm 需要额外去创建一个数组类型放在常量池中,其余步骤基本相同。

2、字段的解析

字段在常量池中由常量项 Fieldref 描述,解析开始时,首先会去解析它的 class_index 项,解析过程如上。如果顺利将会得到字段所属的类 A,接下来的解析过程如下:

  • 通过字段项 nameAndType 查找 A 中是否有匹配的项,如果有则直接返回该字段的引用。
  • 如果没有,递归向上搜索 A 实现的所有接口去匹配。
  • 如果还是未能成功,向上搜索 A 的父类
  • 若依然失败,抛出 java.lang.NoSuchFieldError 异常

这部分内容实在很抽象,很多资料都没有明确说明,字段的符号引用最后会指向哪里。我的理解是,常量池中的字段项会指向类文件字段表中某个字段的首地址(纯属个人理解)。

方法的符号解析的过程和字段解析过程是类似的,此处不再赘述。

初始化

初始化阶段是类加载的最后一步,在这个阶段,虚拟机会调用编译器为类生成的 「」 方法执行对类变量的初始化语句。

和准备阶段所做的事情截然不同,准备阶段只是为所有类变量赋系统初值,而初始化阶段才会执行我们的程序代码(仅限于类变量的赋值语句)。编译器会在编译的时候收集类中所有的静态语句块和静态赋值语句合并到一个方法中,然后我们的虚拟机在初始化阶段只要调用这个方法就可以完成对类的初始化了。

这个方法就是 「」。

例如,我们可以看一道经典的面试题:

class SingleTon {
    private static SingleTon singleTon = new SingleTon();
    public static int count1;
    public static int count2 = 0;
 
    private SingleTon() {
        count1++;
        count2++;
    }
 
    public static SingleTon getInstance() {
        return singleTon;
    }
}
 
public class Test {
    public static void main(String[] args) {
        SingleTon singleTon = SingleTon.getInstance();
        System.out.println("count1=" + singleTon.count1);
        System.out.println("count2=" + singleTon.count2);
    }
}

答案是:

count1=1

count2=0

首先,Test 类会被第一个加载,然后程序开始执行 main 方法的字节码。

遇到 SingleTon 这个类,检索了一下方法区,发现没有被加载,于是开始加载 SingleTon类:

第一步,将 SingleTon 这个类的字节码文件加载进方法区,经过文件格式验证,这个字节码文件顺利转储为方法区的数据结构

第二步,继续进行元数据验证,确保字节码文件中的语义合法,接着字节码验证,保证方法中的字节码指令之间不存在异常

第三步,准备阶段,开始为类变量赋系统初值,本例中 singleTon = null,count1 = 0,count2 = 0

第四步,将类常量池中的直接引用入驻方法区运行时常量池,接着解析符号引用到具体的直接引用

第五步,执行类变量的初始化语句。这里,类变量 singleTon 会被赋值为一个对象的引用,这个对象在创建的途中会为类变量 count1 和 count2 加一。

到此,类变量 singleton 初始化完成,count1 = 1,count2 = 1。此时继续初始化操作,将 count 2 = 0。

结果出来了。

最后,关于初始化还有一点需要注意一下,虚拟机保证当前类的 方法执行之前,其父类的该方法已经执行完毕,所以 Object 的 方法一定在所有类之前被执行。

类加载器

类加载的第一步就是将一个二进制字节码文件加载进方法区内存中,而这部分内容我们前文并没有详细说明,接下来我们就来看看如何将一个磁盘上的字节码文件加载进虚拟机内存中。

类加载器主要分为四个不同类别

  • Bootstrap 启动类加载器
  • Extention 扩展类加载器
  • Application 系统类加载器
  • 用户自定义类加载器

它们之间的调用关系如下:

image

这个调用关系,官方名称:双亲委派 。无论你使用哪个类加载器加载一个类,它必然会向上委托,在确认上级不能加载之后,自己才会尝试加载它。当然,没有上级的引导类加载器除外。

一般情况,我们很少自己写类加载器来加载一个类,就像我们程序中会经常使用到各种各样的类,但是用你关心它们的加载问题么?

这些类基本都是在主类加载的解析阶段被间接加载了,但是这样的前提是,程序中有这些类型的引用,也就是说,只有程序中需要使用的类才会被加载,你一个程序中没有出现的类,jvm 肯定不会去加载它。

如果想要自定义类加载器来加载我们的 Class 文件,那么至少需要继承类 ClassLoader 类,然后外部调用它的 loadClass 方法,就可以完成一个类型的加载了。

我们看看这个 loadClass 的实现:

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            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;
        }
    }

在之前的 jdk 版本中,我们通过继承 ClassLoader 并重写其 loadClass 即可完成自定义的类加载器的具体实现。但是现在 jdk 1.8 已经不再推荐这么做了,具体我们一点一点来看。

首先明确一点,loadClass 方法的这个参数 name 指的是待加载类的全限定名称,例如:java.lang.String 。

然后第一步,调用方法 findLoadedClass 判断这个类是否已经被当前的类加载器加载。如果已经被当前的类加载器加载了,那么直接返回方法区中的该类型的 class 对象即可,否则返回 null。

如果该类未被当前类加载器加载,那么将进入 if 的判断体中,这段代码即完成了「双亲委托」模型的实现。我们具体看一看:

先拿到当前类加载器的父加载器,如果不是 null,那么传递当前类给父加载器加载去,接着会递归进入 loadClass。如果父加载器为 null,那么就启动 Bootstrap 启动类加载器进行加载。

如果上级的类加载器在自己负责的「目录范围」里,找不到传递过来待加载的类,那么会抛出 ClassNotFoundException 异常,而捕获异常后什么也没做,即当前调用结束。也就是说,下级类加载器请求上级类加载器加载某个类,而如果上级加载器不能加载,会导致此次调用安全结束。那么此时的 c 必然为 null。

这样的话,当前类加载器就会调用 findClass 方法自己去加载该类,而这个 findClass 的实现为空,换句话说,jdk 希望我们通过实现这个方法来完成自定义的类型加载。

整体上来看这个 loadClass,你会发现它很巧妙的实现了「双亲委托」模型,而核心就是那段『捕获异常而什么都不做』的操作。

下面我们自定义一个类加载器并加载任意一个类:

public class MyClassLoader extends ClassLoader {
	
	@Override
	public Class<?> findClass(String name) {
		String fileName = "C:\\Users\\yanga\\Desktop\\" +
							name.substring(name.lastIndexOf(".") + 1) + ".class";
		InputStream in = null;
		ByteArrayOutputStream bu = null;
		try {
			in = new FileInputStream(new File(fileName));
			bu = new ByteArrayOutputStream();
			int len = 0;
			byte[] buffer = new byte[1024];
			while((len = in.read(buffer, 0, buffer.length)) > 0) {
				bu.write(buffer, 0, len);
			}
			byte[] result = bu.toByteArray();
			return defineClass(name,result,0,result.length);
		} catch (FileNotFoundException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		}finally {
			try {
				in.close();
				bu.close();
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
		return null;
    }
}
//主函数调用
public static void main(String[] args){
        ClassLoader loader = new MyClassLoader();
        try {
            Class<?> myClass = loader.loadClass("MyPackage.Out");
            System.out.println(myClass.getClassLoader());
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

输出结果:

image

整体上来说,我们的 MyClassLoader 其实只干了一件事情,就是将磁盘文件读取进内存,保存在 byte 数组中,然后调用 defineClass 方法进行后续的类加载过程,这是个本地方法,我们看不到它的实现。

换句话说,虽然 jdk 允许我们自定义类加载器加载字节码文件,但是我们能做的也只是读文件而已,底层的东西都被封装的好好的,后续等我们看 Hotspot 源码的时候再去剖析它的底层实现。

一种类加载器总是负责某个范围或者目录下的所有文件的加载,就像 bootstrap 加载器负责加载 <JAVA_HOME>\lib 这个目录中存放的所有字节码文件,extenttion 加载器负责 <JAVA_HOME>\lib\ext 目录下的所有字节码文件,而 application 类加载器则负责我们项目类路径下的字节码文件的加载。

至于自定义的类加载器而言,加载目录也随之自定义了,例如我们这里实现的类加载器则负责桌面目录下所有的 Class 文件的加载。

总结一下,有关虚拟机类加载机制的相关内容,网上的资料大多相同并且对于一些细节之处很粗糙的一带而过,我也是看了很多的资料,尽可能的描述这其中的细节。当然,很多地方也只是我个人理解,各位如有不同见解,欢迎交流~


文章中的所有代码、图片、文件都云存储在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

欢迎关注微信公众号:扑在代码上的高尔基,所有文章都将同步在公众号上。

image