类加载及字节码执行

438 阅读20分钟

java程序依托于java虚拟机,而java虚拟机只认字节码(Class文件)

类加载

1.装载(loading)

查找和导入class文件 (借助类加载器完成字节流的查找),java中的所有类,都需要由类加载器装载到JVM中才能运行

  1. 通过一个类的全限定名来获取二进制字节流(java、war、网络、&Proxy、JSP)
  2. 将字节流代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口

Class Loader 类加载器

对于任意一个类,都必须由加载它的类加载器和这个类一起共同确立其唯一性,即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。在大型应用中,我们可借助这一特性,来运行同一个类的不同版本。

启动类加载器

启动类加载器是由 C++ 实现的(HotSpot),没有对应的 Java 对象,因此在 Java 中只能用 null 来指代。

除了启动类加载器之外,其他的类加载器都是 java.lang.ClassLoader 的子类,因此有对应的 Java 对象。这些类加载器需要先由另一个类加载器,比如说启动类加载器,加载至 Java 虚拟机中,方能执行类加载。

在 Java 9 之前,启动类加载器负责加载最为基础、最为重要的类,比如存放在 JRE 的 lib 目录下 jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)

扩展类加载器(extension class loader)

扩展类加载器的父类加载器是启动类加载器。它负责加载相对次要、但又通用的类,比如存放在 JRE 的 lib/ext 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类)。

Java 9 引入了模块系统,并且略微更改了上述的类加载器。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。

应用类加载器(application class loader)

应用类加载器/系统类加载器 的父类加载器则是扩展类加载器。它负责加载应用程序路径下的类。(这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。

类加载机制

全盘负责:

所谓全盘负责,就是当一个类加载器负责加载某个Class时,该Class所依赖和引用其他Class也将由该类加载器负责载入,除显式使用另外一个类加载器来载入

双亲委派

类加载器需要加载类的时候,先请示其Parent(即上一层加载器)在其搜索路径载入,如果找不到,才在自己的搜索路径搜索该类。双亲委派确立了优先级的层次关系,惹人一个类加载器要加载java.lang.Object最终都会委派给模型最顶端的启动类加载器进行加载,从而保证Object类在程序的各个环境中都是一个类

双亲委派代码实现逻辑

java.lang.ClassLoader#loadClass(java.lang.String, boolean)

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 {
                //如果父加载器不为空 就调用父加载器加载  ,为空就JNDI调用启动类加载器加载
                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();
                //如果还为空 就调用自记得findClass方法尝试加载
                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;
    }
}
线程上下文类加载器

双亲委派解决了各个类加载器协作时基础类型用的一致性问题,但同时又无法解决另一个问题:上层类加载器无法加载应用层代码。在诸如JDBC等SPI(service provider interface,是JDK内置的一种服务提供发现机制。SPI是一种动态替换发现的机制, 比如有个接口,想运行时动态的给它添加实现,你只需要添加一个实现。我们经常遇到的就是java.sql.Driver接口,其他不同厂商可以针对同一接口做出不同的实现,mysql和postgresql都有不同的实现提供给用户,而Java的SPI机制可以为某个接口寻找服务实现。)调用时,相关的接口已经被启动类加载器加载了,而其需要调用应用程序的ClassPath下的服务实现代码,启动类加载器是绝不可能认识、加载这些代码的,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。

线程上下文类加载器加载器可以通过java.lang.Thread类的setContextClassLoaser()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。父ClassLoader 可以使用当前线程 Thread.currentThread().getContextClassLoader() 所指定的 classloader 加载的类。 这就改变了 父ClassLoader 不能使用 子ClassLoader 或是其他没有直接父子关系的 ClassLoader 加载的类的情况,即,改变了双亲委派模型

缓存机制

保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区中搜寻该Class,只有当缓存区中不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓冲区中。这就是为什么修改了Class后,必须重新启动JVM,程序所做的修改才会生效的原因

2.连接(linking)

2.1 验证verification

检查载入的class文件数据的正确性,是否符合虚拟机的要求;

2.2 准备preparation

  1. 为类的静态变量分配内存并设置初始值(所有与Class相关的信息都应存放在方法区中)
public static int a = 1;  //准备阶段过后其初始值为0而不是1,赋值为1是在类的初始化阶段调用类构造器<clinit>()方法中执行;
如果被修饰为final 则在准备阶段就被赋值为1
  1. 构建方法表

2.3 解析resolution

将符号引用转换成直接引用(这一步是可选的 类/接口解析、字段解析、方法解析、接口方法解析)

在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java 编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。

举例来说,对于一个方法调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接收参数类型以及返回值类型的符号引用,来指代所要调用的方法。

解析阶段的目的,正是将这些符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化。)(对于可以静态绑定的方法调用而言,实际引用是一个指向方法的指针。对于需要动态绑定的方法调用而言,实际引用则是一个方法表的索引)

Java 虚拟机规范并没有要求在链接过程中完成解析。它仅规定了:如果某些字节码使用了符号引用,那么在执行这些字节码之前,需要完成对这些符号引用的解析。

3.初始化

初始化静态变量,静态代码块。初始化阶段就是执行类构造器clinit()方法的过程,Java 虚拟机会通过加锁来确保类的clinit()方法仅被执行一次。

clinit()方法由编译器自动收集类中的所有类变量的赋值动作和静态语句块合并产生,顺序由语句在源文件中出现的顺序决定。java虚拟机会保证在子类的clinit()方法执行前,父类的clinit()已经执行完毕,所以第一个被执行的clinit()类型时java.lang.Object

在 Java 代码中,如果要初始化一个静态字段,我们可以在声明时直接赋值,也可以在静态代码块中对其赋值。 如果直接赋值的静态字段被 final 所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被 Java 编译器标记成常量值(ConstantValue),其初始化直接由 Java 虚拟机完成。

只有当初始化完成之后,类才正式成为可执行的状态。

类何时初始化

  1. 当虚拟机启动时,初始化用户指定的主类
  2. new 访问静态字段(被final修饰已在编译期将结果放入常量池的不算) 调用静态方法
  3. 子类的初始化会触发父类的初始化;
  4. 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
  5. 使用反射 API 对某个类进行反射调用时,初始化这个类;
  6. 当初次调用 MethodHandle (jdk7)实例时,初始化该 MethodHandle 指向的方法所在的类

对象创建过程

  1. 检查相关类型是否已经加载并初始化,如果没有,则 JVM立即加载
  2. 基于new指令分配存储空间
  3. 内存空间初始化,成员变量设置为默认值
  4. 基于invokespecial指令 调用构造方法,完成类的完整初始化
  5. 连接引用和实例

4. 使用using

5. 卸载unloading

字节码执行

image.png

栈帧

java虚拟机以方法作为最基本的执行单元,栈帧则是用于支持虚拟机进行方法调用、执行的数据结构,每一个方法的调用至结束,都对应着一个栈帧在虚拟机栈从入栈到出栈的过程。在编译java程序源码时,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被计算出来(栈帧需要分配多少内存在编译期就已确定)

对于执行引擎而言,活动线程中只有位于栈顶的方法才是在运行的,位于栈顶的栈帧被称为当前栈帧,其所关联的方法被称为当前方法

局部变量表

局部变量表(Local Variable Table) 是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。

局部变量表的容量以变量槽(variabkes Slot)为最小单位,一个变量槽可以存放一个32位以内的数据类型。可以将局部变量表理解为一个数组,用正整数来索引。除了 long、double 值需要用两个数组单元来存储之外,其他基本类型以及引用类型的值均占用一个数组单元(也就是说,boolean、byte、char、short 这四种类型,在栈上占用的空间和 int 是一样的,和引用类型也是一样的。因此,在 32 位的 HotSpot 中,这些类型在栈上将占用 4 个字节;而在 64 位的 HotSpot 中,他们将占 8 个字节。)。

在实例方法中,局部变量表第0位索引的变量槽默认是用于传递方法所属对象实例的引用,即this

在一些源码阅读的过程中可以看到 xxx = null的操作,其目的就是在当前方法未被即时编译优化时,xxx变量可能占用大量内存,且后续代码有一些耗时较长的操作,置为null可将局部变量表中变量槽清空,解除对象的引用啊,此时该对象就不会被gc roots关联,从而可以被作为垃圾回收掉。

操作数栈

操作数栈/操作栈(Operand Stack),32位数据类型所占的栈容量为1,64位为2。可通过操作数栈来进行算数运算或调用其他方法进行参数传递

public int sub(int, int);
    descriptor: (II)I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=3
         0: iload_1
         1: iload_2
         2: isub
         3: getstatic     #4                  // Field NUM1:I
         6: isub
         7: ireturn
      LineNumberTable:
        line 18: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       8     0  this   Lcom/wuba/asmdemo/Test;
            0       8     1     a   I
            0       8     2     b   I
 
load 命令:用于将局部变量表的指定位置的相应类型变量加载到操作数栈顶;
store命令:用于将操作数栈顶的相应类型数据存入局部变量表的指定位置;            

动态连接

每个栈帧都包含一个指向运行时常量池该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。 (方法调用的符号引用一部分在在类加载阶段或第一次使用时就被转化为直接引用,这种转化被称为静态解析。另一部分在每次运行时都转化为直接引用,这部分称为动态连接)

方法返回地址

无论是正常调用完成还是异常调用完成,方法退出后都需要返回最初方法被调用时的位置,程序才能正常进行。

方法退出的过程实际上就是将当前栈帧出栈,恢复上层方法的局部变量表和操作数栈,返回值压入调用者栈帧的操作数栈中,调整程序计数器的值指向方法调用指令后面的一条指令。

方法调用

方法:类名+ 方法名+ 方法描述符 (参数类型 及 返回值类型)

一个类中如果存在 方法名相同 和 方法描述符相同的方法,在类的验证期间会报错

1.解析

前文有提到在类加载的解析阶段,会将一部分的符号引用转化为直接引用,对于方法而言,其前提是方法在运行前就有一个确定的调用版本,且运行期间不会发生改变(invokestatic、invokespecial指令调用的方法,final修饰的方法(属于invokevirtual)),这些方法被称为非虚方法,其他方法被称为虚方法

方法调用字节码指令

  1. invokestatic : 调用静态方法
  2. invokespecial:调用私有实例方法、构造器,super关键字调用父类的实例方法或构造器,和所实现接口的默认方法
  3. invokevirtual:调用非私有实例方法(虚方法)
  4. invokeinterface:调用接口方法 (运行时确定一个实现该接口的对象)
  5. invokedynamic:调用动态方法

2.分派 Dispatch

Java中,对函数/方法而言,dispatch(分派)== binding(绑定)。 严格地说,绑定(binding)是编译器对源代码的变量、函数等名字同引用关联起来,编译器能够搞定的都是提前/静态绑定。dispatch(分派)指运行时选择执行的代码。

理论上来说重载方法会被静态分派/绑定,因为在编译期间已经能够确定。重写方法会使用动态绑定。但是父类重载的方法又可能被子类重写,所以这种父类的重载方法就没办法使用静态绑定,基于这种情况,编译器在处理非静态非私有非final方法时,都是直接使用动态绑定的(final方法因为不会被继承,所以使用静态绑定)

总结就是,不可被子类继承的方法(静态方法,私有方法,final方法)都会被编译成静态绑定。 有可能被子类继承重写造成需要运行时判断对象实例类型后才能决定调用哪个方法的,都会被编译成动态绑定

静态分派/静态绑定

Parent a = new Sub();

上述代码中Parent称为变量的静态类型/外观类型,Sub称为变量的实际类型/运行时类型。虚拟机在重载方法时选择的是参数的静态类型作为判断依据,而静态类型在编译期可知,所以方法的重载在编译期即可确定,属于静态分派/静态绑定

重载 重载在编译期即可完成识别(编译器不看返回值(因为调用方法只关心方法是否处理,可以不接收返回值),同名方法必须参数类型不一致)

选取重载方法过程分三个阶段

  1. 不考虑自动拆装箱,不考虑可变参数 (优先选取子类,如null 优先匹配String 而不是 Object参数类型)
  2. 考虑自动拆装箱,不考虑可变参数
  3. 考虑自动拆装箱,考虑可变参数

动态分派/动态绑定

invokevirtual指令运行解析过程

  1. 找到操作数栈顶的第一个元素指向对象的实际类型,记作C
  2. 如在类型C中找到与常量描述符 和 名称都相同的方法,则进行访问权限校验,校验通过则返回该方法的直接引用,失败返回IllegalAccessError异常
  3. 否则按继承关系向上查找
  4. 最终找不到则抛出AbstractMethodError异常

重写 子类定义了父类中非私有 非静态方法同名的方法,且方法描述符一致,java虚拟机才判定为重写(编译器会强制要求同名方法返回值必须一致或为其子类)。。 对于 Java 语言中重写而 Java 虚拟机中非重写的情况,编译器会通过生成桥接方法来实现 Java 中的重写语义。(桥接方法场景:1.重写时返回子类 2.泛型擦除)

实现

在方法区中建立一个虚方法表(virtual method table/vtable),(invokeinterface也会用到接口方法表 interface method table/vtable)来提高检索元数据性能。除此之外,虚拟机还会使用类型继承关系分析(Class Hierarchy Analysis,CHA)、守护内联(Guarded Inlining)、内联缓存(Inline Cache)等多种非稳定的激进优化来争取更大的性能空间。

方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。 方法表满足两个特质:

  1. 子类方法表中包含父类方法表中的所有方法;
  2. 子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同。

使用了方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用操作:访问栈上的调用者,获取其动态类型,读取该类型的方法表,读取某个索引对应的方法

3.动态类型

java是静态类型语言,其在编译期就确定的变量类型,可另编译器提供全面严谨的类型检查,这样与数据类型相关的潜在问题就能在编码时被及时发现。而动态类型语言在运行时才确定类型可为开发人员提供极大的灵活性,某些需要在静态类型语言花大量臃肿代码来实现的功能,由动态类型语言来实现可能会更清晰简洁。

MethodHandle 和 invokedynamic 目的都是为了解决 invoke* 指令方法固化在虚拟机之中的问题,让如何查找目标方法的决定权转嫁到用户代码中,让用户代码具备更高的自由度。前者用上层代码和API来实现,后者用字节码和Class中其他属性、常量来完成

MethodHandle 方法句柄

反射Reflection同方法句柄 MethodHandle的区别:

  1. 两者都是在模拟方法调用,Reflection是在模拟java代码层次的方法调用,MethodHandle则是在模拟字节码层次的方法调用
  2. Reflection中的 java.lang.reflect.Method对象远比MethodHandle机制的 java.lang.invoke.MethodHandle对象所包含的信息更多,前者包含了方法在java端的全面映射,而后者仅包含该方法的相关信息。前者比后者更"重"。
  3. MethodHandle设计为 可服务于所有java虚拟机之上的语言,Reflection设计只为了java。

invokeynamic

该指令的调用机制抽象出调用点这一个概念,并允许应用程序将调用点链接至任意符合条件的方法上。(指鹿为马) 它将调用点(CallSite)抽象成一个 Java 类,并且将原本由 Java 虚拟机控制的方法调用以及方法链接暴露给了应用程序。在运行过程中,每一条 invokedynamic 指令将捆绑一个调用点,并且会调用该调用点所链接的方法句柄。

反射

java.lang.reflect.Method#invoke

public Object invoke(Object obj, Object... args) throws ... { .
     .. // 权限检查 
       MethodAccessor ma = methodAccessor;
       if (ma == null) { 
            ma = acquireMethodAccessor(); 
       } 
       return ma.invoke(obj, args); 
}

MethodAccessor 是一个接口,它有两个已有的具体实现:本地实现 委派实现

每个 Method 实例的第一次反射调用都会生成一个委派实现,它所委派的具体实现便是一个本地实现。 之所以采用委派实现,便是为了能够在本地实现以及动态实现中切换。

考虑到许多反射调用仅会执行一次,Java 虚拟机设置了一个阈值 15(可以通过 -Dsun.reflect.inflationThreshold= 来调整),当某个反射调用的调用次数在 15 之下时,采用本地实现;当达到 15 时,便开始动态生成字节码,并将委派实现的委派对象切换至动态实现,这个过程我们称之为 Inflation, Inflation 机制是可以通过参数(-Dsun.reflect.noInflation=true)来关闭的。在反射调用一开始便会直接生成动态实现

动态实现和本地实现相比,其运行效率要快上 20 倍 。这是因为动态实现无需经过 Java 到 C++ 再到 Java 的切换,但由于生成字节码十分耗时,仅调用一次的话,反而是本地实现要快上 3 到 4 倍

反射调用开销

  1. Class.formName需要调用本地方法 java->c++耗时 (可在应用程序中缓存 Class.forName 和 Class.getMethod 的结果)
  2. getMethod 需要遍历所有公共方法,如果没匹配到 ,还会继续遍历父类的方法
  3. Method.invoke 是一个变长参数方法,在字节码层面它的最后一个参数会是 Object 数组。Java 编译器会在方法调用处生成一个长度为传入参数数量的 Object 数组,并将传入参数一一存储进该数组中 Objects数组会对基本类型的参数自动装箱,如果为int,还需考虑是否命中缓存

查看字节码

javac -g xxx.java javap -verbose xxx.class