深入理解Java虚拟机第三版——虚拟机类加载与字节码执行机制

Chapter6 类文件结构

无关性的基石

Java"一次编写,到处运行"是怎么做到的呢?

Java代码编译后的结果是从本地机器码转变为字节码。Java虚拟机不与某种特定语言绑定,而是和".class"文件绑定,Class文件中包含了Java虚拟机指令集,符号表等。

因此,JVM不需要关心字节码的源语言。

什么是.class

是一组以8个字节为基础单位的二进制字节流。

文件格式如下:

magic[4字节] 魔数,用来判断是否可以被虚拟机使用。固定值为0xCAFEBABE(咖啡宝贝)
minor_version[2字节] 次版本号
major_version[2字节] 主版本号,低版本的jdk无法执行高版本的class文件。
constant_pool_count[2字节] 常量池里的项目个数
constant_pool 常量池里每一个项目类型都用一个tag标示。从1开始取值,比如取值为1时,表示info里存放的是utf8的字符串
  tag[1字节] 不同的取值,决定了其下info的结构不同
  info
access_flags[2字节] 类的访问标识位,用来标识类是否具有pulbic/abstract/interface/final等修饰符。用其中的bit位标识是否存在。例如,如果是public的class,其值为0x0001
this_class[2字节] 两个字节的数值,指向常量池里的某一个项目。这里指向的是当前类的全名称
super_class[2字节] 指向常量池里的当前类的父类全名称
interfaces_count[2字节] 当前类实现的接口个数
interfaces 每一个指向常量池里的接口的全名称
fields_count[2字节] 当前类的成员变量个数
fields 成员变量信息
  access_flags[2字节] 成员变量的访问标识,与上边access_flags相似
  name_index[2字节] 指向常量池里当前字段的名字
  desc_index[2字节] 指向常量池里当前字段的描述。例如字符串类型对应的描述是’Ljava.lang.String;’
  attribute_count[4字节] 字段的属性表个数,跟类的属性表类似。在下面介绍
  attributes 存放字段的属性信息
methods_count[2字节] 当前类的成员方法个数
mehtods 成员方法信息
  access_flags[2字节] 成员方法的访问标识,与上边access_flags相似
  name_index[2字节] 指向常量池里当前方法的名字
  desc_index[2字节] 指向常量池里当前方法的签名。比如 public String test(Object o) 方法对应描述是(Ljava.lang.Object;)Ljava.lang.String;
  attributes_count[4字节] 方法的属性表个数,跟类的属性表类似。在下面介绍
  attributes 存放方法的属性信息,最重要的属性就是Code,存放了方法的字节码指令
attributes_count[2字节] 类的属性表个数
attributes 类的属性信息
  attribute_name_index[2字节] 指向常量池里属性的名称
  attribute_length[4字节] 下边info内容的长度
  info 属性的内容。不同的属性,内容结构不同

常量池

class文件里的资源仓库,class文件结构里和其他项目关联最多的数据。

那常量池存放着什么呢?

  • 字面量
    文本字符串,被声明为final的常量等

  • 符号引用

java代码进行编译的时候,在虚拟机加载class文件的时候进行动态链接,将会从常量池中获得对应的符号引用,再在类创建或者运行的时候解析和翻译到内存入口地址。否则这些符号引用是无法被虚拟机直接使用的

Chapter7 虚拟机类加载机制

类加载机制:Java虚拟机把表述的类的数据从Class文件加载到内存,并且对数据进行校验,转换解析和初始化,最终形成可以直接被JVM使用的加载类型。

类型的加载,链接和初始化都是在程序运行期间完成的。举个例子,在编写一个面向接口的程序的时候,可以等到运行的时候再指定实际的实现类,用户可以通过Java预置的或者自定义类加载器,让某个本地的应用程序再运行的时候从网络或者其他地方加载一个二进制流作为其程序代码的一部分。

类生命周期

深入理解Java虚拟机第三版——虚拟机类加载与字节码执行机制_第1张图片

类加载的时机

JVM 规范没有强制约束类加载过程的第一阶段(加载)什么时候开始,但对于“初始化”阶段,有着严格的规定。

主动引用

以下六种情况必须对类进行初始化:

1.遇到new,getstatic,putstatic,invokestatic这四条字节码指令并且类型没有初始化:

  • 如 new 一个新对象
  • 调用一个类型的静态方法
  • 读取或者设置一个类型的静态字段(被final修饰,已在编译期把结果放入常量池的静态字段)

2.对类型进行反射调用的时候

3.初始化类的时候,如果发现父类还没有进行过初始化,先初始化父类

4.虚拟机启动的时候,要指定一个需要执行的主类(包括main方法的类),虚拟机会先初始化这个类

5.当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类还没初始化,则需要先触发初始化。

6.定义了JDK8新加入的默认方法的时候,如果这个接口的实现类发生了初始化,则这个接口先要被初始化。

这 6种场景中的行为称为对一个类进行主动引用,除此之外,其它所有引用类的方式都不会触发初始化,称为被动引用

被动引用

  • 通过子类引用父类的静态字段,不会导致子类初始化。对于静态字段,只有直接定义这个字段的类才会被初始化。
class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }
    public static int value = 123;
}

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

public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
        // SuperClass init!
    }
}
  • 通过数组定义来引用类,不会触发此类的初始化。
class SuperClass2 {
    static {
        System.out.println("SuperClass init!");
    }
    public static int value = 123;
}

public class NotInitialization2 {
    public static void main(String[] args) {
        SuperClass2[] superClasses = new SuperClass2[10];
    }
}
  • 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
class ConstClass {
    static {
        System.out.println("ConstClass init!");
    }

    public static final String HELLO_BINGO = "Hello Bingo";
}

public class NotInitialization3 {
    public static void main(String[] args) {
        System.out.println(ConstClass.HELLO_BINGO);
    }
}

编译通过之后,常量存储到 NotInitialization 类的常量池中,NotInitialization 的 Class 文件中并没有 ConstClass 类的符号引用入口,这两个类在编译成 Class 之后就没有任何联系了。

接口加载

当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,当真正用到父接口的时候才会初始化。

类加载的过程

加载阶段

JVM完成三件事情:

  • 通过一个类的全限定名获取定义该类的二进制字节流
    怎么获取这个二进制字节流呢
    1.从编译好的.class文件获取
    2.从zip包中获取,如jar,war等
    3.从网络中获取,web applet
    等等

  • 将二进制字节流所代表的静态结构转化为方法区的运行时数据结构
    加载到的字节流存放在哪里?
    方法区

  • 在堆内存中创建一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

Java方法区和堆一样,方法区是一块所有线程共享的内存区域,他保存系统的类信息。
比如类的字段、方法、常量池等。方法区的大小决定系统可以保存多少个类。如果系统
定义太多的类,导致方法区溢出。虚拟机同样会抛出内存溢出的错误。方法区可以理解为永久区。

加载过程,既可以使用虚拟机内置的类加载器来完成,也可以用用户自定义的类加载器来完成,开发人员通过定义自己的类加载器来控制字节流的获取方式。但对于数组类来说,本身是通过JVM在内存中动态构造出来的。

加载完成后,二进制字节流会按照虚拟机所设定的格式存储在方法区之中,在堆内存中创建一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

连接阶段

验证

验证是链接的第一步,目的是确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

准备

为类的静态变量(static filed)在方法区分配内存,并赋默认初值(0值或null值)

对于一般的成员变量是在类实例化时候,随对象一起分配在堆内存中。

面试可以问
类的静态变量分配在哪里?一般的成员变量呢?

前者可以理解为这个变量是属于类的,那当然跟随着类存放在方法区

后者可以理解为这个变量是属于对象的,那当然跟随着对象存储在堆

解析

虚拟机将常量池内的符号引用替换为直接引用。会把该类所引用的其他类全部加载进来( 引用方式:继承、实现接口、域变量、方法定义、方法中定义的本地变量)

那什么是符号引用,直接引用呢?

  • 符号引用

一个 java 文件会编译成一个class文件。在编译时,java 类并不知道所引用的类的实际地址,因此只能使用符号引用来代替,所引用的对象不一定被加载。

  • 直接引用

直接指向目标的指针(指向方法区,Class 对象)、指向相对偏移量(指向堆区,Class 实例对象)或指向能间接定位到目标的句柄。所引用的对象已经被加载,在内存中

类的初始化

加载阶段可以通过自定义类加载器,其他步骤都是由JVM来主导控制的,直到初始化阶段,JVM才开始真正执行类中编写的代码,主导权交回给应用程序。

类的初始化的主要工作是为静态变量赋程序设定的初值。

如static int a = 100;在准备阶段,a被赋默认值0,在初始化阶段就会被赋值为100

至于类初始化的时机,上文已经提到了

类加载器

类加载器作用:

.java文件编译后转换为.class文件,类加载器负责读取.class文件,并转换成java.lang.class类的一个实例,每个这样的实例都用来表示一个Java类

对于任意一个类,都需要加载它的类加载器和这个类本身来确定这个类在Java虚拟机中的唯一性,每一个类加载器都有一个独立的类名称空间

双亲委派模型

Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象,而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式即把请求交由父类处理,它是一种任务委派模式。

双亲委派模式要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。

工作过程:

如果一个类加载器收到了类加载的请求,首先不会自己加载这个类,而是先把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此。因此所有请求都会传送到最顶层的启动类加载器。

只有在父类加载失败的时候,子加载器才会去处理这个加载请求。

深入理解Java虚拟机第三版——虚拟机类加载与字节码执行机制_第2张图片

好处

Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系,例如类java.lang.Object,存在于rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器。
保证了Object类在程序各种类加载器环境中都能保证是同一个类。

通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次

三层类加载器

  • 启动类(Bootstrap)加载器

  • 扩展类加载器

  • 系统类加载器

面试题

1、什么是类加载?什么时候进行类加载?

类加载:在代码编译后,就会生成JVM(Java虚拟机)能够识别的二进制字节流文件(*.class)。而JVM把Class文件中的类描述数据从文件加载到内存,并对数据进行校验、转换解析、初始化,使这些数据最终成为可以被JVM直接使用的Java类型,

类加载无需等到程序中“首次使用”的时候才开始,JVM预先加载某些类也是被允许的

2、什么是类初始化?什么时候进行类初始化?

类的初始化的主要工作是为静态变量赋程序设定的初值。

什么时候进行类初始化:

1.遇到new,getstatic,putstatic,invokestatic这四条字节码指令并且类型没有初始化:

  • 如 new 一个新对象
  • 调用一个类型的静态方法
  • 读取或者设置一个类型的静态字段(被final修饰,已在编译期把结果放入常量池的静态字段)

2.对类型进行反射调用的时候

3.初始化类的时候,如果发现父类还没有进行过初始化,先初始化父类

4.虚拟机启动的时候,要指定一个需要执行的主类(包括main方法的类),虚拟机会先初始化这个类

5.当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类还没初始化,则需要先触发初始化。

6.定义了JDK8新加入的默认方法的时候,如果这个接口的实现类发生了初始化,则这个接口先要被初始化。

3、什么时候会为变量分配内存?

准备阶段:为类的静态变量赋予默认初值,分配内存
至于一般的成员变量,在对象实例化的时候随对象分配到内存中。

4、什么时候会为变量赋默认初值?什么时候会为变量赋程序设定的初值?

链接阶段的准备阶段。
类初始化阶段。

5、类加载器是什么?

类加载器负责加载所有的类(根据全限定名获得其.class),其为所有被载入内存(方法区)中的类生成一个java.lang.Class实例对象(堆内存)。一旦一个类被加载到JVM中,同一个类就不会被再次载入了。正如一个对象有一个唯一的标识一样,一个载入JVM的类也有一个唯一的标识。

6.类用什么来标识?

在Java中,一个类用其全限定类名(包括包名和类名)作为标识;但在JVM中,一个类用其全限定类名和其类加载器作为其唯一标识

7.双亲委派模型是什么?

工作原理是当一个类加载器收到了类加载的的请求,会委派给父类加载器递归下去,直到父类加载器无法加载。然后再给子加载器加载。

保证了不同类加载器环境运行的类最终都是由顶端的启动类加载器来进行加载的,保证了是相同的一个类。

另外,避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。

同时,考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。

Chapter8 虚拟机字节码执行引擎

执行引擎是Java虚拟机核心的组成部分之一。

输入:字节码二进制流
输出:执行结果

虚拟机和物理机的执行引擎有什么区别?

物理机的执行引擎是直接建立在处理器,缓存,指令集与操作系统上的。

虚拟机的执行引擎是由软件自行实现的,因此可以不受物理条件制约定制指令集与执行引擎的结构体系。

栈帧

栈帧包含:

  • 局部变量表
    是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量
    局部变量表以变量槽为最小单位,每个变量槽能够存放boolean,byte,char,short,int,float,reference,或者returnAddress 类型的数据.(不超过32b)

reference类型指的是对一个对象实例的引用。JVM通过reference查找到对象在堆中数据存放的起始地址或者索引。

查找到对象所属数据类型在方法区中的存储类型信息。

而对于64bit的long与double来说,JVM会以高位对齐方式为其分配两个连续的变量槽空间。

JVM通过索引定位的方法使用局部变量表

当一个方法被调用的时候,JVM会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。

如果执行的是实例方法(没有static修饰的方法),那么第0位索引的变量槽默认是用于传递方法所属对象实例的引用。在方法中可以通过关键词"this"来访问这个隐含参数,其他参数从1开始排列。

  • 操作数栈

栈中每一个栈的容量不超过32b,即64位数据类型所占栈容量为2

  • 动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。

.class文件中常量池有大量的符号引用。那么字节码中的方法调用就以常量池中的符号引用作为参数,这些符号引用一部分会在类加载阶段或者第一次使用的时候转为直接引用。这叫做静态解析

另外一部分在每一次运行期间都直接转化为直接引用,称为动态链接

  • 方法返回地址

方法开始执行之后,只有两种方式可以退出这个方法:

1.执行引擎遇到任意一个方法返回的字节码指令,可能会有返回值传递给上层的方法调用者。

2.方法执行过程中遇到了异常,使用异常完成出口的方式退出,不会给上层调用者提供任何返回值。

方法正常退出的时候,主调方法得PC计数器的值可以作为返回地址,栈帧中可能会保存这个计数器值,方法异常退出的时候,返回地址就是通过异常处理器表来决定的,栈帧中不会保存这部分信息

方法退出相当于把当前栈帧出栈,操作包括回复上层方法的局部变量表和操作数栈,如果有返回值的话就把返回值压入调用者栈帧的操作数栈中,调整PC计数器以指向方法调用指令后面的一条指令等。

  • 额外附加信息等

每一个方法从调用开始到执行结束,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程

对于执行引擎来说,在当前活动线程中,只有位于栈顶的栈帧才是生效的,这个栈帧也称为当前栈帧,与这个栈帧关联的方法称为当前方法。

方法调用

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

  • 解析
    所有方法调用的目标方法在.class文件中都是一个常量池的符号引用,在类加载的解析阶段,会把其中一部分符号引用转化为直接引用,这一部分指的是调用目标在程序代码写好,编译器进行编译那一刻就已经确定了。这类方法的调用称为解析。

例如静态方法和私有方法不可以被通过继承或其他方式重写出其他版本,因此适合在类加载阶段进行解析。

能够直接在类加载阶段就把符号引用解析为直接引用的方法有:

静态方法,私有方法,实例构造器,父类方法以及被final修饰的方法。以上方法也称为非虚方法

  • 分派

分派是针对方法而言的,指的是方法确定的过程,通常发生在方法调用的过程中。分派根据方法选择的发生时机可以分为静态分派和动态分派。根据类型确定发生在运行期还是编译期以及依据实际类型还是静态类型,可以将Dispatch分为动态分配Dynamic Dispatch和静态分配Static Dispatch两类

1.静态分派
典型应用:overload重载
发生时候:编译期间进行方法选择。
通常以方法
名称,方法接收者和方法参数的静态类型来作为方法选择的依据。

静态分派的方法一般都具有“签名唯一性”的特点(签名只考虑参数的静态类型而不管参数的实际类型)

非虚方法(主要包括静态方法,私有方法,final方法等,这些方法一般不可重写,故而不会有相同签名的情况出现)通常仅需要静态分派就可以实现方法的最终确定。

public class StaticDispatch{
static abstract class Human{
}
static class Man extends Human{
}
static class Woman extends Human{
}
    public void sayHello(Human guy){
        System.out.println("hello,guy!");
    }
    public void sayHello(Man guy){
        System.out.println("hello,gentleman!");
    }
    public void sayHello(Woman guy){
        System.out.println("hello,lady!");
    }
    public static void main(String[]args){
        Human man=new Man();
        Human woman=new Woman();
        StaticDispatch sr=new StaticDispatch();
        sr.sayHello(man);
        sr.sayHello(woman);
}
}
 Human man=new Man();

Human是静态类型,Man是实际类型,前者是编译器就可知的,而后者只有在运行期才可知。

因此,虚拟机在重载的时候是通过参数的静态类型而不是实际类型作为判断依据的。

2.动态分派

典型应用:override重写
发生时候:运行期间进行方法选择。
使用动态分派来实现方法确定的方法一般在编译期间都是一些“不明确”的方法(比如一些重写方法,拥有相同的方法签名并且方法接收者的静态类型可能也相同),因此只能在运行时期根据方法接收者和方法参数的实际类型最终实现方法确定。

Java中的虚方法(主要指实例方法) 通常需要在运行期采用动态分派来实现方法确定。

public class DynamicDispatch{
    static abstract class Human{
        protected abstract void sayHello();
    }
    static class Man extends Human{
        @Override
        protected void sayHello(){
        System.out.println("man say hello");
        }
    }
    static class Woman extends Human{
        @Override
        protected void sayHello(){
        System.out.println("woman say hello");
        }
    }
    public static void main(String[]args){
        Human man=new Man();
        Human woman=new Woman();
        man.sayHello();
        woman.sayHello();
        man=new Woman();
        man.sayHello();
    }
}

运行结果:

man say hello
woman say hello
woman say hello

静态多分派,动态单分派

宗量:方法的接收者与方法的参数。

”静态多分派“概念: 由于java的静态分派需要同时考虑方法接收者和方法参数的静态类型,某种层度上而言是考虑了两种宗量。

动态单分派:唯一可以影响虚拟机选择方法的版本因素为接收者的实际类型。

动态类型语言与静态类型语言

如Python,PHP,Lua等,类型检查的主体过程是在运行期而不是在编译期完成的。

而如C++,Java都是静态类型语言。类型检查是在编译期进行的。

public static void main(String[] args) {
	int [][][]array = new int[1][0][-1];
}

如上段代码,Java代码能够正常编译,但是运行的时候会抛出运行时异常(Runtime Exception),即代码不执行到这一行的时候就不会抛出异常。

ref

深入理解JAVA虚拟机

https://blog.csdn.net/qq_32534441/article/details/86241632

你可能感兴趣的:(JAVA)