虚拟机对象实例化过程

306 阅读14分钟

对象实例化生命周期

一个对象从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载验证准备解析初始化使用卸载七个阶段,其中验证准备解析三个部分统称为链接。这七个阶段的发生顺序如下图所示。

image.png

加载验证准备初始化卸载这五个阶段的顺序是确定的,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 语言的运行时绑定特性(也称为动态绑定晚期绑定)。

初始化的时机

《JVM 规范》并没有强制约束在什么情况下开始加载阶段。但严格规定了有且只有六种情况必须立即对类进行初始化(而加载验证准备需要在此之前开始)

  1. 遇到newgetstaticputstaticinvokestatic这四条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型 Java 代码场景有:
    • 使用 new 关键字实例化对象的时候。
    • 读取或设置一个类型的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外) 的时候。
    • 调用一个类的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化
  3. 初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用 JDK 7 新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStaticREF_putStaticREF_invokeStaticREF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化
  6. 当一个接口中定义了JDK 8新加入的默认方法时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化

接口的加载过程与类加载过程稍有不同,针对接口需要做一些特殊说明:接口也有初始化过程,这点与类是一致的,接口中不能使用static{}语句块,但编译器仍然会为接口生成<clinit>()类构造器,用于初始化接口中所定义的成员变量。接口与类真正有所区别的是:一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候才会初始化

加载

加载阶段,JVM 需要完成以下三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流
  2. 将这个字节流所表示的ClassFIle 结构转化为方法区运行时数据结构
  3. Java 堆中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

《JVM 规范》对这三点要求其实并不是特别具体,留给虚拟机实现与 Java 应用的灵活度都是相当大的。例如第一条规则,它并没有指明二进制字节流必须得从某个字节码文件中获取。因此 JVM 的使用者们就可以在加载阶段搭构建出一个相当开放广阔的舞台,Java 发展历程中,许多举足轻重的 Java 技术都建立在这一基础之上,例如:

  • 从 ZIP 压缩包中读取,这很常见,最终成为日后JAR、EAR、WAR 格式的基础。
  • 从网络中获取,这种场景最典型的应用就是 Web Applet。
  • 运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了 ProxyGenerator.generateProxyClass()来为特定接口生成形式为*$Proxy的代理类的二进制字节流
  • 由其他文件生成,典型场景是 JSP 应用,由 JSP 文件生成对应的字节码文件
  • 从数据库中读取,这种场景相对少见些,例如有些中间件服务器(如 SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。
  • 可以从加密文件中获取,这是典型的防字节码文件被反编译的保护措施,通过加载时解密字节码文件来保障程序运行逻辑不被窥探。
  • ...

数组类加载 阶段,不通过类加载器创建,它是由 JVM 直接在内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型最终还是要靠类加载器来完成加载,一个数组类创建过程遵循以下规则:

  • 如果数组的组件类型引用类型,那就递归采用定义的加载过程去加载这个组件类型
  • 如果数组的组件类型原始类型,JVM 将会把数组 C 标记为与引导类加载器关联。
  • 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型原始类型,它的数组类的可访问性将默认为 public,可被所有的类和接口访问到。

类加载器(Class Loader)

加载阶段中实现“通过一个类的全限定名来获取定义此类的二进制字节流”的动作的代码被称为类加载器

自 JDK 1.2 以来,Java 一直保持着三层类加载器双亲委派的类加载架构,尽管这套架构在 Java 模块化系统出现后有了一些调整变动,但依然未改变其主体结构。

三层类加载器

三层类加载器包括:

  • 启动类加载器(Bootstrap Class Loader)负责加载存放在 <JAVA_HOME>\lib 目录或被-Xbootclasspath参数所指定的路径中,JVM 能够识别的类库(按照文件名识别,如rt.jartools.jar,名字不符合的类库即使放在 lib 目录中也不会被加载加载到虚拟机的内存中启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时, 如果需要把加载请求委派给启动类加载器去处理,那直接使用 null 代替即可。
  • 扩展类加载器(Extension Class Loader):这个类加载器sun.misc.Launcher$ExtClassLoader来实现。负责加载 <JAVA_HOME>\lib\ext 目录中或被java.ext.dirs系统变量所指定的路径中所有的类库
  • 应用程序类加载器(Application Class Loader):这个类加载器sun.misc.Launcher$AppClassLoader来实现。由于应用程序类加载器是 ClassLoader 类中的 getSystemClassLoader() 方法的返回值,所以有些场合中也称它为“系统类加载器”。它负责加载用户类路径 (ClassPath) 上所有的类库,开发者同样可以直接在代码中使用这个类加载器如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器

image.png

JDK 9 之前的 Java 应用都是由这三种类加载器互相配合来完成加载的,如果用户认为有必要,还可以加入自定义的类加载器来进行拓展,典型的如增加除了磁盘位置之外的 Class 文件来源,或者通过类加载器实现类的隔离、重载等功能。

模块化下的类加载器

JDK 9 为了模块化系统的顺利施行,模块化下的类加载器发生了一些变动,主要包括以下几个方面:

  1. 扩展类加载器(Extension Class Loader)平台类加载器(Platform Class Loader)取代。既然整个 JDK 都基于模块化进行构建(原来的rt.jartools.jar被拆分成数十个JMOD 文件),其中的 Java 类库就已天然地满足了可扩展的需求,那自然无须再保留 <JAVA_HOME>\lib\ext 目录,此前使用这个目录或java.ext.dirs系统变量来扩展 JDK 功能的机制已经没有继续存在的价值了,用来加载这部分类库的扩展类加载器也完成了它的历史使命。
  2. 平台类加载器应用程序类加载器都不再派生自java.net.URLClassLoader,如果有程序直接依赖了这种继承关系,或者依赖了URLClassLoader类的特定方法,那代码很可能会在 JDK 9 及更高版本的 JDK 中崩溃。现在启动类加载器平台类加载器应用程序类加载器全都继承于jdk.internal.loader.BuiltinClassLoaderBuiltinClassLoader中实现了模块化架构下类如何从模块中加载的逻辑,以及模块中资源可访问性的处理。
  3. JDK 9 中虽然仍然维持着三层类加载器双亲委派的架构,但类加载的委派关系也发生了变动。平台类加载器应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载。如下图:

image.png

在 Java 模块化系统明确规定了三个类加载器负责各自加载的模块(即归属关系),如下所示。

  • 启动类加载器负责加载的模块:
java.base java.datatransfer java.instrument java.logging
java.management.rmi java.naming java.rmi java.security.sasl
jdk.httpserver jdk.internal.vm.ci jdk.management.agent jdk.naming.rmi
jdk.sctp jdk.unsupported
  • 平台类加载器负责加载的模块:
java.activation* java.compiler* java.scripting java.se
java.security.jgss java.smartcardio java.sql.rowset java.transaction*
java.xml.crypto java.xml.ws* jdk.accessibility jdk.charsets
jdk.crypto.ec jdk.dynalink jdk.internal.vm.compiler* jdk.jsobject
jdk.naming.dns jdk.scripting.nashorn jdk.security.jgss jdk.xml.dom
  • 应用程序类加载器负责加载的模块:
jdk.aot jdk.attach jdk.editpad jdk.hotspot.agent
jdk.internal.jvmstat jdk.internal.le jdk.jartool jdk.javadoc
jdk.jconsole jdk.jdeps jdk.jdwp.agent jdk.jlink
jdk.jstatd jdk.pack jdk.rmic jdk.scripting.nashorn.shell
jdk.xml.ws*

链接

加载阶段与链接阶段的部分动作(如文件格式验证动作)是交叉进行的,加载阶段尚未完成,链接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于链接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序。

验证

验证链接阶段的第一步,这一阶段的目的是确保字节码文件的字节流中包含的信息符合《JVM 规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。JVM 如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有错误或有恶意企图的字节码流而导致整个系统受攻击甚至崩溃,所以验证字节码是 JVM 保护自身的一项必要措施。

验证阶段大致上会完成下面四个阶段的检验动作:

  1. 文件格式验证
    • 是否以魔数0xCAFEBABE开头。
    • 主、次版本号是否在当前 JVM 接受范围之内。
    • 常量池的常量中是否有不被支持的常量类型(检查常量 tag 标志)。
    • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
    • CONSTANT_Utf8_info型的常量中是否有不符合 UTF-8 编码的数据。
    • 字节码文件中各个部分及文件本身是否有被删除的或附加的其他信息。
    • ...

验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入 JVM 内存的方法区中进行存储。后面的三个验证阶段全部是基于方法区的存储结构上进行的。

  1. 元数据验证 元数据验证是对字节码描述的信息进行语义分析,以保证其描述的信息符合《JVM 规范》的要求,这个阶段可能包括的验证点如下:
  • 这个类是否有父类。
  • 这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)。
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
  • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现不符合规则的 方法重载 )。
  • ...
  1. 字节码验证

字节码验证是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。在元数据验证元数据信息中的数据类型校验完毕以后,这阶段就要对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为,例如:

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个 int 类型的数据,使用时却按 long 类型来加载入局部变量表中”这样的情况。
  • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
  • 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。
  • ...

如果一个类中有方法体的字节码没有通过字节码验证,那它肯定是有问题的;但如果一个方法体通过了字节码验证,也仍然不能保证它一定就是安全的。即使字节码验证阶段中进行了再大量、再严密的检查,也依然不能保证这一点。

由于数据流分析和控制流分析的高度复杂性,JVM 的设计团队为了避免过多的执行时间消耗在字节码验证阶段中,在 JDK 6 之后的 Javac 编译器和 JVM 里进行了一项联合优化,把尽可能多的校验辅助措施挪到 Javac 编译器里进行。具体做法是给方法表Code 属性中新增加了一项名为StackMapTable的新属性,这项属性描述了方法体所有的基本块(Basic Block,指按照控制流拆分的代码块)开始时局部变量表操作栈应有的状态,在字节码验证期间,JVM 不需要根据程序推导这些状态的合法性,只需要检查StackMapTable 属性中的记录是否合法即可。这样就将字节码验证类型推导转变为类型检查,从而节省了大量校验时间。理论上StackMapTable 属性也存在错误或被篡改的可能,所以是否有可能在恶意篡改了Code 属性的同时,也生成相应的StackMapTable 属性来骗过虚拟机的类型校验,则是虚拟机设计者们需要仔细思考的问题。

  1. 符号引用验证

符号引用验证校验行为发生在虚拟机将符号引用转化为直接引用的时候。符号引用验证可以看作是对类自身以外的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。本阶段通常需要校验下列内容:

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
  • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
  • 符号引用中的类、字段、方法的可访问性(private、protected、public、package)是否可被当前类访问。
  • ...

符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,JVM 将会抛出一个java.lang.IncompatibleClassChangeError的子类异常。如:java.lang.IllegalAccessErrorjava.lang.NoSuchFieldErrorjava.lang.NoSuchMethodError等。

验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段,因为验证阶段只有通过或者不通过的差别,只要通过了验证,其后就对程序运行期没有任何影响了。如果程序运行的全部代码都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify: none参数来关闭大部分的验证措施。

准备

准备阶段是正式为类变量分配内存并设置类变量初始值(“通常情况“是零值)的阶段。在 JDK 7 及之前,HotSpot VM 使用永久代来实现方法区时,这些变量所使用的内存都应当在方法区中进行分配;而在 JDK 8 及之后,类变量则会随着Class 对象一起存放在Java 堆中。

下表列出了 Java 中所有基本数据类型的零值:

数据类型零值
int0
long0
short0
char'\u0000'
byte(byte) 0
booleanfalse
float0.0f
double0.0d
referencenull

当类字段的字段表中的属性表存在ConstantValue 属性,那在准备阶段类变量值就会被初始化为ConstantValue属性所指定的初始值。

解析

解析阶段是 JVM 将常量池内的符号引用替换为直接引用的过程,在字节码文件中它以CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_info等常量类型出现。

《JVM 规范》并未规定解析阶段发生的具体时间,只要求在执行anewarraycheckcastgetfieldgetstaticinstanceofinvokedynamicinvokeinterfaceinvokespecialinvokestaticinvokevirtualldcldc_wldc2_wmultianewarraynewputfieldputstatic这 17 个用于操作符号引用字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机实现可以根据需要来自行判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。

在对方法或者字段的访问,也会在解析阶段中对它们的可访问性 (public、protected、 private、package)进行检查。

对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存,譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。无论是否真正执行了多次解析动作,JVM 都需要保证的是在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直能够成功;同样地,如果第一次解析失败了,其他指令对这个符号的解析请求也应该收到相同的异常,哪怕这个请求的符号在后来已成功加载进 JVM 之中。