Java虚拟机执行子系统(class文件结构与字节码指令、类加载机制、执行引擎)

一入编程深似海,照望头发竟还在!!!
在这里插入图片描述
这一路上走走停停,(顺着少年漂流的痕迹)今天终于把JVM的面试基础过完一遍了,先总的描述一下Java代码(当然不只是Java语言)在JVM中的执行过程:

  • Java代码首先被Javac编译器编译为字节码文件,再通过类加载器子系统加载进内存(运行时数据区)并同时完成对数据的校验解析转换等。由于操作系统本身是不认识字节码的,所以这个时候就需要执行引擎扮演翻译者的角色(但又不只是翻译者那么简单),将字节码翻译为本地机器指令并最终执行。可能在程序执行过程中需要用到以其他语言编写的方法啥的,这时就需要引入本地库接口了。

  • 这就是JVM概念上的四大组成部分:类加载器子系统、运行时数据区、执行引擎、本地库接口。
    在这里插入图片描述


所谓双亲委派模型…嗯就这
在这里插入图片描述

一.Class文件结构与字节码指令

(1)概述

  • Java虚拟机从严格意义上讲不应该只叫做Java虚拟机。虚拟机不关心是用什么语言生成的class文件,它只关心class文件本身,即语言无关性而实现语言无关性的基础是虚拟机和字节码存储格式。

一个Java对全世界做出的霸气承诺:“Write once,Run anywhere!”

在这里插入图片描述

  • class文件是一组以8个字节为基础单位的二进制流,无添加任何分隔符,因此class文件中存储的数据几乎全是程序运行时的必要数据。容易想到,正因为class文件的这种无分隔符的特点,文件内部数据项的排列、大小等一定是严格定义不能改变的

  • class文件格式采用一种类似于结构体的伪结构存储数据,包含无符号数两种数据类型。

无符号数:基本数据类型,以u1、u2、u4、u8来分别代表对应字节的无符号数。

表:复合数据类型,由无符号数或表构成,class文件本身就可以看作是一个表。在习惯上,通常对表的命名都以“_info”结尾。

  • 当描述同一类型但数量不定的多个数据时,通常会在数据项的前面加上一个容量计数器,该数据形式整体就称作一个集合

(2)Class文件的首八字节(魔数+版本号)

  • class文件最前面的四个字节被称作为“魔数”,存在的唯一作用是用来确定该文件是否是一个能被虚拟机接受的class文件。Class文件的魔数值取名叫做“咖啡宝贝”,即十六进制表示为0xCAFEBABE。

  • 在魔数之后的四个字节是class文件的版本号信息(第5、6个字节是次版本号,第7、8个字节是主版本号),Java版本号是从45开始的,高版本号JDK可向下兼容低版本号。
    在这里插入图片描述

  • 从该十六进制class文件上可以看到,头四个字节十六进制表示即是0xCAFEBABE,次版本号为0,主版本号为十进制的57。(在JDK12以后,次版本号通常用来标识技术预览性功能,如果没有则设置为0,有则设置为最大65535)

(3)常量池

  • 常量池在class文件中位于主版本号之后的位置,由于常量池的大小是不固定的,所以需要在常量池的起始位置存放一个占两个字节大小(u2类型)的容量计数器。值得注意的是,常量池的容量计数是从1开始的,空余出来的0位置是有特殊情况下的考虑的。从这里我们也可以合理推测出常量池的最大容量为2^16-1。

常量池通常是class文件结构中与其他项目关联最多的数据;
同时也是占用class文件空间最大的数据项目之一;
还是class文件中第一个出现的表类型数据项目。

常量池中存放两类常量:

1.字面量
2.符号引用

  • 字面量接近于Java语言层面的常量概念,包括文本字符串、final修饰的常量值等。符号引用接近于编译原理方面的概念,包括Package、全限定名、字段名与描述符、方法名与描述符等。
  • 常量池中的每一项常量都是表类型,截至目前共有17种结构各不相同的表结构数据。表结构起始的第一位都是一个u1类型的标志位tag(1-17),对应着如图所示的不同结构类型。

(4)访问标志

  • 在常量池结束之后紧接着的2个字节代表的就是访问标志,用于识别一些类或接口层次的访问信息。在访问标志之后,紧跟在其后的是类索引、父类索引与接口索引集合等。

(5)字节码指令

  • Java虚拟机的指令由两部分组成:一部分叫做操作码(opcode),代表着某种特定操作含义的数字;另一部分叫做操作数(operand),代表着执行此操作所需的参数。由于Java虚拟机采用的是面向操作数栈的架构,所以大多数指令都只包含有操作码,而操作数存放于操作数栈中。
  • 操作码只占有一个字节大小,也就意味着所能表示的最大的操作类型数为2^8=256个,这显然不能完全表示出实际场景中用到的所有操作。解决的办法是故意将指令集设计成非完全独立的,某些指令代码用于将一些数据类型转化为可支持的数据类型。

大多数的对于boolean、byte、short、char等数据类型的操作一般都是将其转化为int类型作为实际运算类型的

二.类加载机制

  • 虚拟机将类的数据从Class文件加载到内存,同时对数据进行验证、准备、解析和初始化,最终形成可以被虚拟机直接使用的Java类型的过程叫做类加载机制

  • 类加载的过程是在程序运行期间完成的,这也是Java语言实现动态可拓展性的基础。

类的生命周期:
加载->验证->准备->解析->初始化->使用->卸载(验证、准备、解析阶段合称为连接)

  • 该顺序表明的是各个阶段的开始时间(注意:开始时间的先后并不代表着实际结束时间的先后。事实上,以上过程是彼此交叉混合执行的)其中解析过程可依实际情况在初始化过程开始之后进行。

(1)初始化的时机(类加载的时机)

《Java虚拟机规范》定义了有且只有以下六种情况需要立即对类进行初始化过程(同时意味着加载验证准备阶段自然也得在这之前进行):

1.遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。
2.使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
3.当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4.当虚拟机启动时,用户需要指定一个要执行的主类( 包含main()方法的那个类),虚拟机会先初始化这个主类。
5.当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄, 并且这个方法句柄所对应的类没有进行过初始化, 则需要先触发其初始化。
6.如果一个接口中定义了默认方法,而该接口的实现类发生了初始化,那么该接口需要在这之前率先进行初始化。

以上行为称为对一个类型进行主动引用,除此之外的行为都不会触发初始化,称之为被动引用

代码实例演示:

/**
 * Demo01 通过子类调用父类的static字段时只会发生父类的初始化
 */
public class Main {
    
    
    public static void main(String[] args) {
    
    
        System.out.println(SonClass.value);
    }
}

class SonClass extends SuperClass {
    
    
    static {
    
    
        System.out.println("SonClass init!");
    }
}

class SuperClass {
    
    
    static {
    
    
        System.out.println("SuperClass init!");
    }

    public static int value = 0;
}

输出结果为:
在这里插入图片描述

/**
 * Demo02 数组定义引用类时不会引发该类的初始化
 */
public class Main {
    
    
    public static void main(String[] args) {
    
    
        SuperClass[] superClasses = new SuperClass[10];
    }
}

class SuperClass {
    
    
    static {
    
    
        System.out.println("SuperClass init!");
    }
}

输出结果为:
在这里插入图片描述
可以看到,并没有输出“SuperClass init!”字段,即意味着该类并没有被初始化。

/**
 * 常量value在编译阶段会存入class文件的常量池中
 * 引用该变量时并不会引用到定义该变量的类,即不发生类的初始化
 * */
public class Main {
    
    
    public static void main(String[] args) {
    
    
        System.out.println(SuperClass.value);
    }
}

class SuperClass {
    
    
    static {
    
    
        System.out.println("SuperClass init!");
    }

    public static final int value = 0;
}

输出结果为:
在这里插入图片描述

(2)类加载的过程

1. 加载

1.1 类加载器

加载阶段主要需要完成三件事情:

a.通过全限定类名获取该类的二进制字节流
b.将该字节流中的静态存储结构转化为运行时数据结构存放在方法区中
c.在Java堆中生成一个该类对应的java.lang.Class对象,以提供访问该类型数据的外部接口

  • 类加载器:《Java虚拟机规范》并没有规定从哪里去获取二进制字节流(这可以是运行时动态生成的,也可以是数据库中读取的,还可以是从网络中获取的等等)。而具体决定这一选择的代码就被称为“类加载器”。

  • 对于任意一个类,它的唯一性都是由这个类本身与它的类加载器共同确定的。即使两个类来自于同一个Class文件,只要他们的类加载器不同,那么这两个类必不相等。

注意:数组类型是由虚拟机直接在内存中动态构建出来的,本身不通过类加载器创建,但是数组类的元素类型仍然是通过类加载器创建的。

以下是类的唯一性决定因素的验证代码:

package jvm;

import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.InvocationTargetException;

/**
 * 验证类的唯一性确定因素
 */
public class ClassLoaderTest {
    
    
    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException {
    
    
        /*
         *自定义类加载器
         */
        ClassLoader myloader = new ClassLoader() {
    
    
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
    
    
                String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                InputStream resourceAsStream = getClass().getResourceAsStream(fileName);
                if (resourceAsStream == null) {
    
    
                    return super.loadClass(name);
                }
                try {
    
    
                    byte[] b = new byte[resourceAsStream.available()];
                    resourceAsStream.read(b);
                    return defineClass(name, b, 0, b.length);
                } catch (IOException e) {
    
    
                    throw new ClassNotFoundException(name);
                }
            }
        };
        //采用自定义类加载器加载对象并实例化
        Object o = myloader.loadClass("jvm.ClassLoaderTest").getDeclaredConstructor().newInstance();
        /**
         * 输出结果为:
         * class jvm.ClassLoaderTest
         * false
         * 对象o是由自定义加载器加载的,而instanceof时是由内置的应用程序类加载器加载的
         */
        System.out.println(o.getClass());
        System.out.println(o instanceof jvm.ClassLoaderTest);
    }
}

1.2 双亲委派模型(Jdk9之前)

从虚拟机的角度来看,类加载器可分为两类:

a.启动类加载器,作为虚拟机自身的一部分,采用非Java语言编写(在Jdk9之后启动类加载器是由Java虚拟机内部和Java类库共同实现的),不能被用户程序直接使用
b.非启动类加载器,由Java语言编写,全都继承自java.lang.ClassLoader

从开发人员的角度来看,类加载器可分为三层:

a.启动类加载器(引导类加载器)
b.扩展类加载器,可被应用程序直接引用,做为对Java系统类库的一种扩展
c.应用程序类加载器(系统类加载器),负责加载用户类路径下的所有类库
补:自定义类加载器

  • 双亲委派模型描述的是类加载器之间的协作关系,一个类加载请求将逐步上升提交到启动类加载器,如果启动类加载器无法加载,则由下一级的扩展类加载器加载,以此类推。
    在这里插入图片描述
    双亲委派模型的简单代码演示:
package java.lang;

/**
 * 编写一个与Java类库中的Math类重名的类
 */
public class Math {
    
    
    public static void main(String[] args) {
    
    
        System.out.println("验证双亲委派模型");
    }
}

控制台结果:
在这里插入图片描述

  • 造成结果的原因是类加载时将按照双亲委派模型将请求逐步提交到启动类加载器,而原Java.lang包中已有与该类同名的类但却没有main方法可供执行,故编译器出现了运行错误信息。

查看JDK源码找到ClassLoader类中的loadClass方法,也可以发现双亲委派模型的实现逻辑:

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    
    
        synchronized(this.getClassLoadingLock(name)) {
    
    
            Class<?> c = this.findLoadedClass(name);
            //首先保证该类未被加载过
            if (c == null) {
    
    
                long t0 = System.nanoTime();

                try {
    
    
                    //如果父加载器不为空(非启动类加载器)
                    if (this.parent != null) {
    
    
                        //将加载请求向上提交到父加载器
                        c = this.parent.loadClass(name, false);
                    } else {
    
    
                        //如果无父加载器(即为启动类加载器),则由启动类加载器加载
                        c = this.findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException var10) {
    
    
                }
                //执行完上述代码之后该类仍然未被加载
                if (c == null) {
    
    
                    long t1 = System.nanoTime();
                    //调用自身的findClass方法尝试加载
                    c = this.findClass(name);
                    PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    PerfCounter.getFindClasses().increment();
                }
            }

            if (resolve) {
    
    
                this.resolveClass(c);
            }

            return c;
        }
    }

1.3 Java模块化系统(Jdk8之后)

  • Java模块化系统从JDK9开始引入,模块化的关键目标是可配置的封装隔离机制(在模块化下,一个public对象不再意味着完全可访问)。在模块化系统下,Module替代了原始的Jar包,各个module之间可随时组合构建出新的功能类库,提供了天然的可拓展性。(因此,扩展类加载器被平台类加载器所取代自然也是顺理成章的事)

Jdk8->Jdk13下内容目录的变化:
在这里插入图片描述
在这里插入图片描述
模块化下的双亲委派模型:

  • 当底层的类加载器收到一个类加载请求时,不会立即将请求发送给父加载器,而是先判断该类能否归属到某一个系统模块中。如果可以,则优先交由对应的类加载器加载。

2. 验证

  • 由于字节码并不一定是由Java语言编译而来,甚至可以在二进制编辑器上直接敲出Class文件,因此源Class文件中可能会包含许多不符合《Java虚拟机规范》的代码和行为。验证阶段的任务就是为了确保Class文件的字节流中包含的信息完全符合《Java虚拟机规范》的要求。

该阶段大致分为四个子阶段的动作:

  • 文件格式验证:验证字节流是否符合Class文件格式的规范。
  • 元数据验证:对字节流所描述的信息进行语义分析,保证其符合《Java虚拟机规范》的要求。(确保元数据正确)
  • 字节码验证:通过数据流分析和控制流分析确定程序语义合法、符合逻辑。(确保方法行为正确)
    注意:字节码验证并不能百分百保证程序逻辑的无害性

JDK6之后的版本在程序方法体(class 文件中的code属性)的属性表中新添了“StackMapTable”属性,描述了方法体所有基本块开始时的本地变量表和操作栈应有的状态。字节码验证时就只负责检查StackMapTable属性是否合法。
即完成了类型推导->类型检查的转变

  • 符号引用验证:验证该类是否缺少或拥有禁止其依赖和访问的其他外部类、方法、字段等。(解析阶段中发生)

3. 准备

  • 准备阶段将为静态变量分配内存并初始赋值,通常情况下赋值为对应数据元素的零值。但是当类字段的字段属性表中存在ConstantValue属性时将初始化为ConstantValue属性所指定的初始值。(例如:final关键字)

4. 解析

  • 解析阶段将把常量池内的符号引用替换为直接引用(此仅为静态解析),除了invokedynamic指令外虚拟机可以对实现解析结果的缓存。

  • 解析动作主要面向类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符(包含访问限定符、abstract、const、event、extern、override、sealed、static、virtual等)。

5. 初始化

  • 初始化阶段将按照程序员的主观计划初始化类变量和其他资源,我们可以这样描述该阶段:执行类构造器方法()方法的过程。

  • clinit方法是JVM自动生成的,按照语句在源文件中出现的顺序自动收集类中所有的类变量赋值动作和静态代码块中的语句。静态代码块只能访问到定义在static块之前的变量,而对于定义在其后的变量只可以赋值但不能访问。
    在这里插入图片描述

  • JVM在执行子类的clinit方法之前会首先执行父类的clinit方法,但是在接口中却不会先执行父接口的clinit方法,同样在实现类中也是同样的道理。

  • clinit方法不是必须的。当一个类中没有赋值语句和静态代码块或接口中没有赋值操作时,允许不存在clinit方法。

  • 多线程环境下只会有一个线程去执行类的clinit方法,在该线程完成之前其余线程都需要阻塞等待。

补:clinit方法与init方法的区别

clinit方法是类构造器方法,在类加载过程中的初始化阶段会调用该方法;而init方法是对象构造器方法,在显式调用类构造器时才会执行该方法。

三.字节码执行引擎

  • 不同虚拟机的执行引擎在执行字节码做出的选择是不一定相同的,但是至少在这些方面是一致的:
    输入的都是字节码二进制流,处理过程是字节码解析的等效过程,输出的是执行结果。

(1)运行时栈帧结构

  • 栈帧存储了方法的局部变量表、操作数栈、动态连接、方法返回地址和额外的其他附加信息
  • 执行引擎所运行的所有字节码指令都只对当前的栈帧进行操作。

局部变量表

  • 局部变量表用来存放方法参数和方法内部定义的局部变量,建立在线程堆栈当中,属于线程私有的数据。局部变量表的最大容量在编译期间就在方法的code属性上确定了。
  • 局部变量表以局部变量槽为基本单位,其大小应满足能存放一个boolean、byte、short、int、float、char、reference或returnAddress类型即可(4个字节及以下)

对于8个字节的数据类型来说,JVM会以高位对齐方式为其分配两个连续的变量槽空间

  • 变量槽是可以被重用的。如果当前PC计数器的值已经超出了某个变量的作用域,那么这个变量对应的变量槽就将被交给其他变量重用。
    在这里插入图片描述
  • 在这里的程序中bytes数组对象被成功回收,原因是在代码离开bytes对象的作用域后,在变量槽中又加入了新的变量a,替代了已经存在的bytes对象的引用,这时就完全解除了局部变量槽对bytes引用的关联。
  • 特别要知道的是,局部变量不存在有准备阶段,也就是不存在默认赋值!
    还是上面那段代码,如果未对a变量初始赋值那么整个过程a变量都不会被赋值!bytes对象引用将不会被替代
    在这里插入图片描述

(2)基于操作数栈的字节码解释执行引擎

1. 为什么说Java程序的编译是半独立的实现

  • 许多的Java虚拟机执行引擎在执行Java代码时都有解释执行和编译执行两种选择。编译过程自然也是不同:
    在这里插入图片描述
  • 对于Javac编译器来说,它只完成了词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。这一部分不包含在JVM内部,所以Java程序的编译就是半独立的实现。

2. 为什么Javac编译器基本上采用基于栈的指令集架构

  • 基于栈的指令集依赖于操作数栈进行工作,其指令流中的指令不带有参数,只对数据进行相应的入栈出栈操作。
  • 主要优点是由于不依赖于硬件,可移植性强;由于不带有参数,代码更加紧凑;由于无需额外空间用来操作(全都在栈上),编译器实现更加简单。
  • 主要缺点是在解释执行的条件下执行速度会相对寄存器架构来说要慢一些,因为频繁的出栈入栈操作对应着频繁的内存访问。

(3)方法调用

方法调用阶段:

  • 确定被调用方法的版本,不涉及方法内部的具体运行过程。

1. 解析调用

  • 方法在编译期间就已经确定下来且不会在运行期间被改变,该类方法的调用就叫做(静态)解析。
  • 静态方法、私有方法、实例构造器、父类方法和final修饰的方法就满足“编译期可知,运行期不可变”的原则,在类加载阶段就会将符号引用解析为直接引用。

2. 分派调用

2.1 静态分派(方法重载的实现)

/**
 * 静态分派
 */
public class StaticDispatch {
    
    
    static abstract class Human {
    
    
    }

    static class Man extends Human {
    
    
    }

    static class Woman extends Human {
    
    
    }

    public void hello(Human human) {
    
    
        System.out.println("hello human!");
    }

    public void hello(Man man) {
    
    
        System.out.println("hello man!");
    }

    public void hello(Woman woman) {
    
    
        System.out.println("hello woman!");
    }

    public static void main(String[] args) {
    
    
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch staticDispatch = new StaticDispatch();
        staticDispatch.hello(man);
        staticDispatch.hello(woman);
    }
}

在这里插入图片描述
对输出结果的解释:

  • 当执行hello方法时,编译器将以参数的静态类型做为判定依据进行重载。且静态类型在编译期就确定了,所以在编译阶段Javac就确定了重载方法的版本(human)。
  • 所有依赖静态类型来决定方法执行版本的分派动作都称为静态分派
  • 静态方法是在编译期就确定的,在类加载期进行解析,但是静态方法也是可以拥有重载版本的,选择重载版本的过程就是通过静态分派完成的。

2.2 动态分派(方法重写的实现)

/**
 * 动态分派
 */
public class DynamicDispatch {
    
    

    static abstract class Human {
    
    
        protected abstract void hello();
    }

    static class Man extends Human {
    
    

        @Override
        protected void hello() {
    
    
            System.out.println("hello man!");
        }
    }

    static class Woman extends Human {
    
    

        @Override
        protected void hello() {
    
    
            System.out.println("hello woman!");
        }
    }

    public static void main(String[] args) {
    
    
        Human man = new Man();
        Human woman = new Woman();
        man.hello();
        woman.hello();
        man = new Woman();
        man.hello();
    }
}

在这里插入图片描述
对结果的解释:
在这里插入图片描述

  • L0和L1阶段在创建对象引用并执行初始化,L2和L3在执行方法的重写。两个地方的字节码完全一样,似乎并没有什么区别。但实际上区别是在INVOKEVIRTUAL指令中体现出来的。
  • INVOKEVIRTUAL指令的执行过程:
    1.找到操作数栈顶元素指向对象的实际类型,记作C
    2.用C去匹配方法,找到后进行权限校验,若通过,则直接返回该方法的直接引用;不通过则返回异常
    3.若未匹配成功,则按照继承关系从下往上继续匹配
    4.若始终未找到,返回异常
  • 这种根据方法接受者的实际类型来确定执行方法版本的分派过程叫做动态分派。

猜你喜欢

转载自blog.csdn.net/m0_46550452/article/details/111589601