清楚了 JVM 内存结构、GC、类结构,上篇文章我们知道了字节码是 JVM 实现无关性的重要设计,这篇文章我们来看一下这些字节码文件是如何被虚拟机加载和执行的。

虚拟机类加载机制

Java类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using) 和 卸载(Unloading)七个阶段。其中准备、验证、解析3个部分统称为连接(Linking),如图所示:

下面我们来一步一步的看类是如何被加载的。

类加载的时机

虚拟机规范则是严格规定了有且只有 5 种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

- 遇到 new、 getstatic、 putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。

- 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。

- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

- 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。

- 当使用 JDK1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果REF_getStatic、 REF_putStatic、 REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
  • 通过子类引用父类的静态字段,不会导致子类初始化。是否要触发子类的加载和验证,在虚拟机规范中并未明确规定,这点取决于虚拟机的具体实现。
  • 通过数组定义来引用类,不会触发此类的初始化。比如SupperClass[] sca = new SupperClass[10];这里不会初始化SupperClass,但是触发了另外一个名为“com.gavin.SuperClass”的类的初始化阶段,它是一个由虚拟机自动生成的,直接继承于Object的子类,创建动作由字节码指令newarray触发。
  • 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量类的初始化。比如“private static final CONST=”123””,不会引发此类的初始化。
  • 当一个类在初始化的时候,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父类接口全部完成初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

类加载的过程

  • 加载
    把存储类的实体从各类介质(文件/网络/数据库/内存中实时生成等)加载到 JVM 内存的方法区中,生成对应对象java.lang.Class。数组类不通过类的加载器创建,而是由虚拟机直接创建,但数组类的元素类型(Element Type)需要类加载器创建。

  • 验证
    为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,,并且不会危害虚拟机的安全,如果验证失败,会抛出 java.lang.VerifyError 异常。验证过程较为复杂,JVM主要验证了class文件格式/java语义限制/java程序逻辑正确性和安全性,其主要技术为静态的字节码分析,所以不能保证100%的可靠。

  • 准备
    为类的静态变量分配内存并初始化为零值,final 则赋真实值,这些变量所使用的内存都将在方法区中进行分配。

  • 解析
    符号引用(如方法名/变量名/类等等的符号)转化为直接引用(直接指向一块内存区域)的过程,所有符号引用都必须转化为直接引用。

  • 初始化
    在初始化之前,程序已经可以在内存中访问类和它的变量了,这个时候 JVM 会调用类构造器<clinit>()在类被主动引用前,按代码赋初值的计划去给这些变量赋予一个值,它与实例加载时调用的构造器<init>()不同,不需要显示调用父类构造器,而是由虚拟机保证父类<clinit>()已经执行,这也就意味着父类中定义的静态语句优先级要高于子类变量的赋值操作。
    如果一个类没有静态语句块,也没有对变量的复制操作,编译器可以部位这个类生成<clinit>()方法。
    接口的<clinit>()是单独执行的,不会受继承和类实现的影响。
    虚拟机会保证一个类的<clinit>()方法在多线程环境中被加锁、同步,因此如果在一个类的<clinit>()方法中有耗时操作,就会造成多个进程阻塞。

  • 使用
    当初始化完成之后,java 虚拟机就可以根据new或反射的调用执行Class的业务逻辑指令,通过堆中java.lang.Class对象的入口地址,调用方法区的方法逻辑,最后将方法的运算结果通过方法返回地址存放到方法区或堆中。

  • 卸载
    参见Java系列(二)——JVM内存回收什么时候回收-方法区里的类部分。

虚拟机字节码执行引擎

每当启动一个线程时,JVM 就为它分配一个 Java ,栈是以栈帧为单位保存当前线程的运行状态的。栈帧是虚拟机栈的栈元素,每当在调用一个方法时,才为当前方法分配一个帧,然后将该帧压入栈顶,这个帧就成了当前帧,当执行这个方法时,它使用这个帧来存储参数、局部变量,中间计算结果等。线程的切换对应着栈的出栈入栈,线程中的不同方法依次运行对应着帧的出栈和入栈。栈帧的概念结构如图所示:

图片来源:https://my.oschina.net/jiangmitiao/blog/483824

栈帧

栈帧是虚拟机栈的栈元素,栈帧存储了局部变量表,操作数栈,动态连接,返回地址等信息。

局部变量表

局部变量表存储了方法参数以及方法内定义的局部变量。在 Java 程序被编译成 Class 文件时,就在方法的 Code 属性的max_locals数据项中确定了该方法所需分配的最大局部变量表的最大容器。

虚拟机通过索引定位的方式使用局部变量表。在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非 static 方法),则局部变量表的第一个索引为该对象的引用,用this可以取到。为了节省栈空间,局部变量表中的变量槽(Variable Slot)是可以重用的。

操作数栈

Java 虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的栈就是操作数栈。操作数栈(Operand Stack),也称操作栈,是一个后入先出(LIFO)栈。同局部变量表一样,操作栈的最大深度也在编译期间写入到 Code 属性的max_stacks中。操作栈最开始为空,由字节码指令往栈中存数据和取数据,也就是出栈入栈动作,方法的返回值也会存到上一个方法的操作数栈中。

动态连接

动态连接含有一个指向常量池中该栈帧所属方法的引用,持有该引用是为了进行动态分派。

在Class文件中存在着大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分在类加载阶段第一次使用阶段的时候转换为直接引用,这种转换称为静态解析。另外一部分将在每次的运行期间转化为直接引用,这部分称为动态转换。

方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法,分别是正常返回和异常返回,不管是何种方式退出,退出后都需要返回到方法被调用的位置,程序才能正常执行。

  • 正常完成出口(Normal Method Invocation Completion):当执行引擎遇到任意一个方法返回的字节码指令,是否有返回值和返回值类型依据遇到何种方法返回指令而定。栈帧中会保留调用者的 PC 计数器的值作为返回地址。
  • 异常完成出口(Abrupt Method Invocation Completion):在执行过程中遇到了异常且没有在方法体内处理,不产生返回值,返回地址由异常处理表来确定,栈帧中一般不会有这部分信息

方法退出时可能执行的操作有:回复上层方法的局部变量表和操作数栈,吧返回值(如果有的话)压如调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后的一条指令。

关于方法重载

《深入理解 JVM 虚拟机》这本书中有提到一个关于重载的面试题(题目见此处)来解释”确定静态分派的动作实际上不是由虚拟机来执行的“。

对于代码Human man = new man()而言,Human叫作静态类型(外观类型),Man为实际类型,静态类型编译期可知,而实际类型却只有在运行时才能知道。使用哪个重载版本完全取决于传入参数的数量数据类型。实例代码中刻意的定义了两个静态类型相同但是实际类型不同的变量,但是JVM在重载时是通过参数的静态类型(Static Type)而不是实际类型(Actual Type)作为判定依据的。同时静态类型是编译期可知的,因此在编译阶段,Javac 编译器会根据参数的类型来决定选择哪个重载版本,所以在通常情况下重载方法的选择是在编译器由静态分派来决定的。

小结

类的加载和执行构成了 JVM 运行程序的核心,掌握理论知识的同时还需要联系实际看看我们书写的程序是如何在被编译解释和运行的。