虚拟机类加载机制

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

类加载的基本流程

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

image

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

具体的内容,下文将详细描述,这里只须要创建一个宏观上的认识,了解整个类加载过程须要通过的几个阶段便可。面试

加载

「加载」和「类加载」是两个不一样的概念,后者包含前者,即「加载」是「类加载」的一个阶段,而这个阶段须要完成如下三件事:数据库

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

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

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

image

大致上的格式和咱们虚拟机规范中的 Class 文件格式是差很少的,只是这里增长了一些项,重排了某些项的顺序。安全

第三步,生成一个 java.lang.class 类型的对象。这个类型的对象建立的具体细节,咱们不得而知,可是这个对象存在于方法区之中的惟一目的就是,惟一表示了当前类的基本信息,外部全部该类的对象的建立都是要基于这个 class 对象的,由于它描述了当前类的全部信息。bash

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

验证

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

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

一、文件格式验证

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

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

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

二、元数据验证

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

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

虽然某些校验在编译器中已经验证过了,这里却依然须要验证的缘由是,并非全部的 Class 文件都是由编译器产生的,也能够根据 Class 文件格式规范,直接编写二进制获得。虽然这种状况少之又少,可是不表明不存在,因此这一步的验证的存在是颇有必要的。

三、字节码验证

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

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

四、符号引用验证

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

  • 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 编码,只不过一旦整个类中有多个重复的常量项的话,就显得浪费内存了。

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

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

一、类或接口的解析

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

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

固然,对于咱们的数组类型是稍有不一样的,由于数组类型在运行时由 jvm 动态建立,因此在解析阶段的第一步,jvm 须要额外去建立一个数组类型放在常量池中,其他步骤基本相同。

二、字段的解析

字段在常量池中由常量项 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
相关文章
相关标签/搜索