JVM简介—3.JVM的执行子系统

大纲

1.Class文件结构

2.Class文件格式概述

3.Class文件格式详解

4.字节码指令

5.类的生命周期和初始化

6.类加载的全过程

7.类加载器

8.双亲委派模型

9.栈桢详解

11.方法调用详解

12.基于栈的字节码解释执行引擎

1.Class文件结构

(1)Java跨平台的基础

字节码是各种不同平台虚拟机与所有平台都能统一使用的程序存储格式,所以字节码(ByteCode)是构成平台无关性的基石,是语言无关性的基础。

Java虚拟机不和包括Java在内的任何语言绑定,Java虚拟机只与Class文件这种特定的二进制文件格式所关联。Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息。

(2)Class文件的本质

Class文件本质是一组以8位字节为基础单位的二进制流,或者说整个Class文件本质上就是一张表,类似于C语言结构体的伪结构来存储数据。

只有两种数据类型:无符号数和表。无符号数属于基本的数据类型,u1、u2、u4、u8,表是由多个无符号数或其他表作为数据项构成的复合数据类型。

任何一个Class文件都对应着唯一一个类或接口的定义信息。但反过来说,类或接口并不一定都得定义在Class文件里。比如类或接口也可以动态生成,直接送入类加载器中。所以Class文件不一定以磁盘文件的形式存在。

2.Class文件格式概述

各个数据项严格按顺序紧凑排列在Class文件中,中间没有任何分隔符。这使Class文件中存储的内容几乎全是程序运行的必要数据,没有空隙。

Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表。

无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个、2个、4个和8个字节的无符号数,无符号数可用来描述数字、索引引用、数量值或按UTF-8构成字符串值。

表是由多个无符号数或其他表作为数据项构成的复合数据类型,所有表都习惯性地以"_info"结尾,表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表。

3.Class文件格式详解

(1)魔数与Class文件的版本

(2)常量池

(3)访问标志

(4)类索引、父类索引与接口索引集合

(5)字段表集合

(6)方法表集合

(7)属性表集合

Class的结构不像XML等描述语言,由于它没有任何分隔符号,所以在其中的数据项,无论是顺序还是数量,都是被严格限定的。哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变。

按顺序分别包括:

(1)魔数与Class文件的版本

每个Class文件的头4个字节称为魔数(Magic Number),它的作用是确定这个文件是否为一个能被虚拟机接受的Class文件。其实不仅仅是Class文件,很多文件格式标准中都有使用魔数来标识身份的习惯。

使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过同时又不会引起混淆即可。

紧接着魔数的4个字节存储的是Class文件的版本号,第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。

Java的版本号是从45开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1。高版本JDK能兼容低版本Class文件,但不能运行以后版本的Class文件。即使文件格式未发生变化,JVM也拒绝执行超过其版本号的Class文件。

(2)常量池

紧接着次、主版本号之后的是常量池入口,常量池可以比喻为Class文件里的资源仓库,常量池是Class文件结构中与其他项目关联最多的数据项目。

常量池通常是占用Class文件空间最大的数据项目之一,也是Class文件中第一个出现的属于表类型的数据项目。

常量池中常量的数量是不固定的,所以在常量池的入口需放置一项u2类型的数据,代表常量池容量计数值。与Java中语言习惯不一样的是,这个容量计数是从1而不是0开始的。

常量池中主要存放两大类常量:字面量和符号引用。字面量比较接近于Java语言层面的常量概念,如声明为final的常量值等。符号引用则属于编译原理方面的概念,包括了三类常量:类和接口的全限定名(Fully Qualified Name)、字段的名称和描述符(Descriptor)、方法的名称和描述符。

如何观看字节码,可以通过使用javap工具的-verbose参数输出Class文件字节码:

$ javap -verbose ShowByteCode.class

(3)访问标志

在常量池结束之后,紧接着的2个字节代表访问标志。这个访问标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口、是否定义为public类型、是否定义为abstract类型、如果是类则是否被声明为final等。

(4)类索引、父类索引与接口索引集合

Class文件由这三项数据来确定这个类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由于Java语言不允许多重继承,所以父类索引只有一个。除了java.lang.Object之外,所有的Java类都有父类。因此除了java.lang.Object外,所有Java类的父类索引都不为0。接口索引集合就用来描述这个类按顺序实现了哪些接口。

(5)字段表集合

字段表用于描述接口或者类中声明的变量,字段(field)包括类级变量以及实例级变量。

字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。

字段表集合中不会列出从父类或者父接口中继承而来的字段,但有可能列出原本Java代码之中不存在的字段。比如在内部类中为了保持对外部类的访问性,编译器就会自动添加指向外部类实例的字段。

(6)方法表集合

方法表用于描述方法的定义。但方法里的Java代码经过编译器编译成字节码指令后,存放在属性表集合中的方法属性表集合中一个名为Code的属性里。

与字段表集合相类似的,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。

但同样的,有可能会出现由编译器自动添加的方法,最典型的便是类构造器<clinit>方法和实例构造器<init>方法。

(7)属性表集合

存储Class文件、字段表、方法表都可以携带自己的属性表集合,用于描述某些场景专有的信息,如方法的代码就存储在Code属性表中。

4.字节码指令

Java虚拟机的指令由一个字节长度的、代表某种特定操作含义的数字,以及跟随其后的零至多个代表此操作所需要的参数而构成。其中的数字称为操作码Opcode,需要的参数称为操作数Operands。

由于限制了Java虚拟机操作码的长度为一个字节(即0~255),所以这意味着指令集的操作码总数不可能超过256条。

大多数的指令都包含了其操作所对应的数据类型信息,例如:iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据到操作数栈中。

大部分的指令都没有支持整数类型byte、char和short,甚至没有任何指令支持boolean类型。

大多数对于boolean、byte、short和char类型数据的操作,都是使用相应的int类型作为运算类型。

(1)加载和存储指令

用于将数据在栈帧中的局部变量表和操作数栈之间来回传输,这类指令包括如下内容:

一.将一个局部变量加载到操作数栈

iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>。

二.将一个数值从操作数栈存储到局部变量表

istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>。

三.将一个常量加载到操作数栈

bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>。

(2)运算指令

对两个操作数栈上的值进行某种特定运算,并把结果重新存入操作栈顶。加法指令:iadd、ladd、fadd、dadd;减法指令:isub、lsub、fsub、dsub;乘法指令:imul、lmul、fmul、dmul等等。

(3)类型转换指令

可以将两种不同的数值类型进行相互转换。Java虚拟机支持以下数值类型的宽化类型转换,也就是小范围类型向大范围类型的安全转换:int类型到long、float或者double类型,long类型到float、double类型,float类型到double类型。处理窄化类型转换(Narrowing Numeric Conversions)时,必须显式地使用转换指令来完成,这些转换指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。

(4)创建类实例指令

new。

(5)创建数组指令

newarray、anewarray、multianewarray。

(6)访问字段指令

getfield、putfield、getstatic、putstatic。

(7)数组存取相关指令

把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload。将一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore。取数组长度的指令:arraylength。

(8)检查类实例类型的指令

instanceof、checkcast。

(9)操作数栈管理指令

如同操作一个普通数据结构中的堆栈那样,Java虚拟机提供了一些用于直接操作操作数栈的指令。

将操作数栈的栈顶一个或两个元素出栈:pop、pop2。复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2。将栈最顶端的两个数值互换:swap。

(10)控制转移指令

控制转移指令可以让Java虚拟机有条件或无条件地:从指定的位置指令而不是控制转移指令的下一条指令继续执行程序,可以认为控制转移指令就是在有条件或无条件地修改PC寄存器的值。

控制转移指令之条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge等。控制转移指令之复合条件分支:tableswitch、lookupswitch。控制转移指令之无条件分支:goto、goto_w、jsr、jsr_w、ret。

(11)方法调用指令

invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。

invokeinterface指令用于调用接口方法,会在运行时搜索一个实现了该接口方法的对象,找出适合的方法来调用。

invokespecial指令用于调用一些要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。

invokestatic指令用于调用类静态方法。

invokedynamic指令用于在运行时动态解析出方法并执行该方法。

前面4条调用指令的分派逻辑都固化在JVM内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的,方法调用指令与数据类型无关。

(12)方法返回指令

根据返回值的类型区分,包括ireturn、lreturn、freturn、dreturn和areturn。另外还有一条return指令供声明为void的方法、实例初始化方法以及类和接口的类初始化方法使用。

(13)异常处理指令

在Java程序中显式抛出异常的操作(throw语句)都由athrow指令来实现。

(14)同步指令

有monitorenter和monitorexit两条指令支持synchronized关键字的语义。

5.类的生命周期和初始化

(1)类的生命周期

(2)会触发类进行初始化的主动引用

(3)不会触发类进行初始化的被动引用

(1)类的生命周期

类从被加载到虚拟机内存中开始,到卸载出内存为止。类的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段。其中验证、准备、解析3个部分统称为连接。

(2)会触发类进行初始化的主动引用

初始化阶段规定了有且只有6种情况必须立即对类进行初始化,当然对类进行初始化之前,加载、验证、准备自然也需要完成。

一.遇到new、getstatic、putstatic或invokestatic这4条字节码指令时

如果类没有进行初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:

场景1:使用new关键字实例化对象

场景2:读取或设置一个类的静态字段

场景3:调用一个类的静态方法

二.用java.lang.reflect包的方法对类进行反射调用

如果类没有进行过初始化,则需要先触发其初始化。

三.初始化一个类时发现父类还没有进行过初始化

那么就需要先触发其父类的初始化。

四.当虚拟机启动时用户需要指定一个要执行的主类

比如包含main()方法的那个类,虚拟机就会先初始化这个主类。

五.如果某些方法句柄所对应的类没有进行过初始化

如果一个java.lang.invoke.MethodHandle实例最后的解析结果是REF_getStatic、REF_putStatic、REF_invokeStatic等方法句柄,且这个方法句柄所对应的类没有进行过初始化,则要先触发其初始化。

六.当接口中定义了被default关键字修饰的方法时

如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

**注意:**以上六种场景的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。

(3)不会触发类进行初始化的被动引用

一.通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。

//被动引用类字段演示一:
//通过子类引用父类的静态字段, 不会导致子类初始化
public class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }
    public static int value = 123;
}

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

//输出结果:
//SuperClass init!
//123
public class Main {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}

二.通过数组定义来引用类不会触发此类的初始化。

//被动引用类字段演示二:
//通过数组定义来引用类,不会触发此类的初始化
public class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }
    public static int value = 123;
}

//运行输出结果没有"SuperClass init"
public class Main {
    public static void main(String[] args) {
        SuperClass[] sca = new SuperClass[10];
    }
}

三.常量在编译阶段会存入调用类的常量池中(编译阶段常量传播优化),本质上没直接引用到定义常量的类,故不会触发定义常量的类的初始化。

//被动引用类字段演示三:
//常量在编译阶段会存入调用类的常量池中, 本质上没有直接引用到定义常量的类,
//因此不会触发定义常量的类的初始化
public class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }
    public static int value = 123;
}

public class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init!");
    }
    public static final String HELLO = "hello";
}

//运行输出结果: 只有hello, 没有输出"SubClass init!"
public class Main {
    public static void main(String[] args) {
        System.out.println(SubClass.HELLO);
    }
}

6.类加载的全过程

(1)加载阶段

(2)验证阶段

(3)准备阶段

(4)解析阶段

(5)类初始化阶段

Java虚拟机中类加载的全过程,即加载、验证、准备、解析和初始化这5个阶段所执行的具体动作:

(1)加载阶段

虚拟机需要完成以下3件事情:

一.通过一个类的全限定名来获取定义这个类的二进制字节流。

二.将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

三.在内存中生成一个代表这个类的java.lang.Class对象,代表这个类的对象会作为方法区这个类的各种数据的访问入口。

(2)验证阶段

这是连接阶段的第一步,主要有如下两个目的。

目的一:确保Class文件的字节流中包含的信息符合当前虚拟机的要求。

目的二:不会危害虚拟机自身的安全。

从整体上看,验证阶段大致上会完成下面4个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

(3)准备阶段

这是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段中有两个容易混淆的概念需要强调一下。

首先,此时进行内存分配的只是类变量(被static修饰),不包括实例变量。实例变量将会在对象实例化时随着对象一起分配在Java堆中。

其次,这里所说的初始值"通常情况"下是数据类型的零值。假设一个类变量的定义为:public static int value=123,那变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何Java方法。而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。

假设上面类变量value的定义变为:public static final int value=123。编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

(4)解析阶段

这是虚拟机将常量池内的符号引用替换为直接引用的过程,比如有:类或接口的解析、字段解析、方法解析、接口方法解析等。

(5)类初始化阶段

这是类加载过程的最后一步。在前面的类加载过程中:除了在加载阶段用户应用程序可以通过自定义类加载器参与外,其余动作完全由虚拟机主导和控制。

到了初始化阶段,才真正开始执行类中定义的Java程序代码。在准备阶段,变量已经赋过一次系统要求的初始值。而在初始化阶段,则根据初始化方法去初始化类变量和其他资源,或者说初始化阶段是执行类构造器<clinit>()方法的过程。

<clinit>()方法是由编译器自动收集类中所有类变量的赋值动作,以及静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。

<clinit>()方法对于类或接口来说并不是必需的。如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。

JVM会保证一个类的<clinit>()方法在多线程环境中被正确加锁、同步。如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕为止。所以如果在一个类的<clinit>()方法中有一些耗时很长的操作,那么就有可能会导致多个线程阻塞。

7.类加载器

(1)类加载器的定义及其用途

(2)类加载器与类是否相等

(3)如何重写类的加载方法

(4)自定义类加载器对业务类进行加密解密

(1)类加载器的定义及其用途

类加载的5个阶段中:除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。

一.类加载器的定义

类加载的阶段通过一个类的全限定名来获取描述该类的二进制字节流,这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类,实现这个动作的代码被称为类加载器(Class Loader)。

二.类加载器的用途

类加载器的用途有:热加载、代码保护和加解密、类层次划分、OSGi等。

(2)类加载器与类是否相等

对于任意一个类,都必须由加载它的类加载器和这个类本身,一同确立其在Java虚拟机中的唯一性。每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否"相等",只有在这两个类是由同一个类加载器加载的前提下才有意义。否则即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

这里所指的"相等",包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。

(3)如何重写类的加载方法

在自定义ClassLoader的子类时,常见的会有两种做法。一种是重写loadClass()方法,另一种是重写findClass()方法。

其实这两种方法本质上差不多,毕竟loadClass()也会调用findClass(),但是从逻辑上讲最好不要直接修改loadClass()的内部逻辑,建议只在findClass()里重写自定义类的加载方法。

loadClass()这个方法是实现双亲委托模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。因此最好在双亲委托模型框架内进行小范围改动,不破坏原有的结构,同时也避免了重写loadClass()方法的过程中必须写双亲委托的重复代码。从代码的复用性来看,不直接修改loadClass()方法始终是比较好的选择。

(4)自定义类加载器对业务类进行加密解密

下面自定义类加载对类进行加密和解密,比如对DemoUser加密和解密。

//类说明:加密和解密的服务类
public class XorEncrpt{
    //异或操作并写回磁盘, 可以进行加密和解密(一个数异或两次会变回它自己)
    private void xor(InputStream in, OutputStream out) throws Exception {
        int ch;
        while (-1 != (ch = in.read())) {
            ch = ch ^ 0xff;
            out.write(ch);
        }
    }
    
    //加密方法, 由于class文件是二进制流, 所以采用流的形式读入源文件
    //@param src 加密前的文件
    //@param des 加密后的文件
    public void encrypt(File src, File des) throws Exception {
        InputStream in = new FileInputStream(src);
        OutputStream out = new FileOutputStream(des);
        xor(in, out);
        in.close();
        out.close();
    }
    
    //解密方法, 加密后的class文件通过二进制流进行输出, 输出后转会为二进制数组
    //@param src 加密后的class文件
    public byte[] decrypt(File src) throws Exception {
        InputStream in = new FileInputStream(src);
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        xor(in, bos);
        byte[] data = bos.toByteArray();;
        return data;
    }
    
    public static void main(String[] args) throws Exception {
        File src = new File("/Users/demo/deencrpt/DemoUser.class");
        File dest = new File("/Users/demo/deencrpt/encrypt/DemoUser.class");
        XorEncrpt demoEncryptUtil = new XorEncrpt();
        demoEncryptUtil.encrypt(src,dest);
        System.out.println("加密完成!");
    }
}

如果要想在下面的DemoRun类里使用DemoUser,那么就需要在自定义的类加载器里进行解密:

public class DemoRun {
    public static void main(String[] args) throws Exception {
        CustomClassLoader demoCustomClassLoader = new CustomClassLoader("My ClassLoader");
        demoCustomClassLoader.setBasePath("/Users/demo/Desktop/code/jvm/bin/");
        Class<?> clazz = demoCustomClassLoader.findClass("com.demo.class.deencrpt.DemoUser");
        System.out.println(clazz.getClassLoader());
        Object o = clazz.newInstance();
        System.out.println(o);
    }
}

对加密过的Class二进制流进行解密,虚拟机才能加载DemoUser这个类:

//类说明:自定义的类加载器
public class CustomClassLoader extends ClassLoader {
    private final String name;
    private String basePath;
    private final static String FILE_EXT = ".class";
    
    public CustomClassLoader(String name) {
        super();
        this.name = name;
    }
    
    public void setBasePath(String basePath) {
        this.basePath = basePath;
    }
    
    //实际解密, 解密完成后返回字节数组
    private byte[] loadClassData(String name) {
        byte[] data = null;
        XorEncrpt demoEncryptUtil = new XorEncrpt();
        try {
            String tempName = name.replaceAll("\\.","\\\\");
            data = demoEncryptUtil.decrypt(new File(basePath+tempName+FILE_EXT));
        } catch (Exception e) {
            e.printStackTrace();
        }
        return data;
    }
    
    //覆盖findClass方法可以拿到类的全限定名
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] data = this.loadClassData(name);
        return this.defineClass(name, data, 0, data.length);
    }
}

8.双亲委派模型

(1)JVM角度的两种类加载器

(2)开发者角度的三层类加载器

(3)自定义加载器的作用

(4)双亲委派模型

(1)JVM角度的两种类加载器

从Java虚拟机的角度来看,只存在两种类加载器。第一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分。第二种是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。

(2)开发者角度的三层类加载器

从Java开发人员的角度来看,类加载器应该划分得更细致些。Java一直保持着三层类加载器、双亲委派的类加载架构。三层类加载器如下所示:

(3)自定义加载器的作用

一.增加除了磁盘位置外的Class文件来源

二.实现类的隔离

三.实现类的重载

(4)双亲委派模型

一.双亲委派模型各个类加载器之间的关系

二.双亲委派模型的工作过程

三.双亲委派模型的好处

四.双亲委派模型的实现

五.双亲委派模型被破坏的情形

一.双亲委派模型各个类加载器之间的关系

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

这里类加载器之间的父子关系不会以继承的关系来实现,而是使用组合关系(委托)来复用父加载器的代码。

二.双亲委派模型的工作过程

如果一个类加载器收到了类加载请求,它首先不会自己尝试加载这个类,而是把这个请求委派给父类加载器去完成,每个层次的类加载器都如此。

因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时(即搜索不到所需的类),才会让子加载器尝试自己去完成加载。

三.双亲委派模型的好处

使用双亲委派模型来组织类加载器之间的关系,有个显而易见的好处是:Java类随着它的类加载器一起具备了一种带有优先级的层次关系。

例如类java.lang.Object,它存放在rt.jar之中。无论哪个类加载器要加载这个类,最终都委派给启动类加载器进行加载。因此Object类在程序的各种类加载器环境中都是同一个类。

相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话。如果用户自己编写了一个java.lang.Object类并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,应用程序将会变得一片混乱。

四.双亲委派模型的实现

双亲委派模型的实现就放在loadClass()方法里,所以自定义的类加载器最好不要覆盖loadClass(),而是覆盖findClass()。

//代码逻辑为:
//先检查请求加载的类是否已经被加载过, 若没有则调用父加载器的loadClass()方法;
//若父加载器为空则默认使用启动类加载器作为父加载器;
//假如父加载器加载失败, 抛出ClassNotFoundException异常, 才调用自己的findClass()方法尝试进行加载
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { 
    synchronized (getClassLoadingLock(name)) {
        //首先检查请求的类是否已经被加载过
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                //如果父类加载器抛出ClassNotFoundException异常, 说明父类加载器无法完成加载请求
            }
            if (c == null) {
                //在父类加载器无法加载时, 再调用本身的findClass方法来进行类加载
                long t1 = System.nanoTime();
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

五.双亲委派模型被破坏的情形

双亲委派很好解决了各个类加载器协作时基础类的一致性问题,越基础的类由越上层的加载器进行加载。

基础类型之所以被称为基础,是因为它们总是作为被用户代码继承、调用的API,但如果有基础类又要调用回用户的代码,该如何处理。

为了解决该困境,Java引入了线程上下文类加载器。线程上下文类加载器可通过Thread.setContextClassLoader()方法设置。如果创建线程时还未设置,它将会从父线程中继承一个。如果在应用程序的全局范围内都没有设置过的话,那这个线程上下文类加载器默认是应用程序类加载器。

有了线程上下文加载器,JNDI服务就可使用它去加载所需的SPI服务代码。这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,这种行为已经违背了双亲委派模型的一般性原则。

Java中涉及SPI的加载基本上都是采用线程上下文类加载器来完成,例如:JNDI、JDBC、JCE、JAXB等。

9.栈桢详解

(1)运行时的栈帧结构

(2)栈桢的内容详解

(1)运行时的栈帧结构

Java虚拟机以方法作为最基本的执行单元。栈帧是用于支持虚拟机进行方法调用和方法执行背后的数据结构,栈帧也是虚拟机运行时数据区中的虚拟机栈的栈元素。

栈帧存储了方法的局部变量表、操作数栈、动态连接和返回地址等信息。每个方法从调用开始至调用结束,都对应一个栈帧从入栈到出栈的过程。

编译Java程序源码时,栈帧中需要多大的局部变量表、多深的操作数栈,就已经被分析计算出来,并且会写入到方法表的Code属性中。

所以一个栈帧要分配多少内存,并不会受程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。

(2)栈桢的内容详解

一.局部变量表

二.操作数栈

三.动态连接

四.方法返回地址

一.局部变量表

局部变量表用于存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽为最小单位。局部变量表建立在线程堆栈中,属于线程私有的数据。

读写两个连续变量槽是否为原子操作,都不会有数据竞争和线程安全问题。Java虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变量槽数量。

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

为了节省栈帧的内存空间,局部变量表中的变量槽是可以重用的。方法体中定义的变量,其作用域并不一定会覆盖整个方法。如果当前字节码程序计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。不过,这种变量槽的重用在某些情况下会影响到系统的垃圾收集行为。

类的字段变量有两次赋初始值的过程:一次在准备阶段赋予系统初始值,一次是在初始化阶段赋予程序定义的初始值。因此即使在初始化阶段程序没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值,不会产生歧义。

但局部变量就与类的字段变量不同了;如果一个局部变量定义了但没有赋初始值,那它是不能使用的。

二.操作数栈

操作数栈也被称为操作栈,它是一个后进先出栈。同局部变量表一样,操作数栈的最大深度也在编译时被写入到Code属性。

Java虚拟机的解释执行引擎被称为基于栈的执行引擎,这里的栈指的就是操作数栈。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的。在方法的执行过程中,会有各种字节码指令在操作数栈中出栈和入栈。

比如在运行整数加法的字节码指令iadd这条指令时,会要求操作数栈中最接近栈顶的两个元素先存入两个int型的数值,然后把这两个int值出栈进行相加,接着将相加的结果重新入栈。

三.动态连接

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

我们知道Class文件的常量池中存在大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。

这些符号引用中:一部分会在类加载阶段或第一次使用时就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分转化就称为动态连接。

四.方法返回地址

当一个方法开始执行后,只有两种方式退出这个方法:第一种方式是执行引擎遇到任意方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者。第二种方式是方法执行的过程中遇到异常且没有妥善处理,这时候不会有返回值传递给上层的方法调用者。

方法退出的过程等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈、把返回值(如果有的话)压入调用者栈帧的操作数栈中、调整程序计数器的值以指向方法调用指令后面的一条指令等。

11.方法调用详解

(1)方法调用介绍

(2)解析调用

(3)静态分派

(4)动态分派

(1)方法调用介绍

这里的详情总结成3句话就是:

一.解析和静态分派都是在编译期间就能知道具体调用的是哪一个方法。

二.动态分派在运行时才知道具体调用哪个方法。

三.动态分派通过虚拟机的虚方法表机制来实现。

在Idea这些工具上,使用command点一下就能跳到对应的方法了。如果跳到的是具体的方法那就是静态分派,如果跳到的是接口方法那就是动态分派。

方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),因此方法调用阶段暂时还未涉及方法内部的具体运行过程。

在程序运行时,进行方法调用是最普遍、最频繁的操作之一。Class文件的编译过程中不包含传统程序语言编译的连接步骤。一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。

这个特性给Java带来强大的动态扩展能力,但也让方法调用过程变复杂。某些调用要在类加载期间,甚至运行期间才能确定目标方法的直接引用。

(2)解析调用

调用编译时就已经确定的目标方法,称为解析调用。

所有方法调用的目标方法在Class文件里都是常量池中的一个符号引用。在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用。

这种在解析阶段能转化为直接引用的符号引用,对应的方法具有的特点:

特点一:在程序运行前就有一个可确定的调用版本。

特点二:该方法的调用版本在运行期是不可改变的。

符合编译期可知 + 运行期不可变的方法主要是静态方法和私有方法。前者与类型直接关联,后者在外部不可被访问。这两方法的特点决定它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

被invokestatic和invokespecial指令调用的方法,也可以在解析阶段中确定唯一的调用版本。这些方法有静态方法、私有方法、实例构造器、父类方法。

被final修饰的方法,在类加载时会把符号引用解析为该方法的直接引用。

以上这些方法统称为非虚方法,与之相反,其他方法被称为虚方法。所以非虚方法(把符号引用解析为该方法的直接引用)有5种,分别是:静态方法、私有方法、实例构造器、父类方法、被final修饰的方法。

解析调用是一个静态过程,在编译期间就完全确定,在类加载的解析阶段会把涉及的符号引用全部转为明确的直接引用。

(3)静态分派

静态分派多见于方法的重载(也就是方法名相同,但是参数不同)。

//方法静态分派演示
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!");
    }
   
    //运行结果:
    //hello,guy!
    //hello,guy!
    public static void main(String[]args) {
        Human h1 = new Man();
        Human h2 = new Woman();
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(h1);
        sr.sayHello(h2);
    }
}

Human称为变量的静态类型,或者叫做的外观类型,后面的Man和Woman则称为变量的实际类型。

代码中定义了两个静态类型相同但实际类型不同的变量,但编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的,并且静态类型是编译期可知的。因此在编译阶段,编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标。

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,确定静态分派的动作不是由虚拟机来执行的。

(4)动态分派

动态分派多见于方法的重写(方法名和参数都相同,继承相同父类)。

//方法动态分派的演示
public class DynamicDispatch {
    static abstract class Human {
        protected abstract void sayHello();
    }
    
    static class Man extends Human {
        @Override
        protected void sayHello() {
            System.out.println("hello,man!");
        }  
    }
    
    static class Woman extends Human {
        @Override
        protected void sayHello() {
            System.out.println("hello,lady!");
        }
    }
    
    //运行结果:
    //hello,man!
    //hello,lady!
    //hello,lady!
    public static void main(String[]args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
        man = new Woman();
        may.sayHello();
    }
}

静态类型同样都是Human的两个变量man和woman,在调用sayHello()方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同。

动态分派的实现:

动态分派是执行非常频繁的动作,Java虚拟机会为类型在方法区中建立一个虚方法表,虚方法表中存放着各个方法的实际入口地址。

如果某个方法在子类中没有被重写,那子类的虚方法表里的地址入口和父类相同方法的地址入口是一致的。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。

12.基于栈的字节码解释执行引擎

JVM的执行引擎在执行Java代码时有解释执行和编译执行两种选择。解释执行就是通过解释器来执行,编译执行就是通过即时编译器产生本地代码来执行。

Java语言经常被人们定义为解释执行的语言。在Java1.0时代时,这种定义还算比较准确。但当主流的虚拟机中都包含了即时编译器后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事了。

Javac编译器输出的字节码指令流,基本上是一种基于栈的指令集架构,字节码指令流里的指令大部分是零地址指令,它们依赖操作数栈工作。

基于栈的解释器执行过程:

public int calc() {
    int a = 100;
    int b = 200;
    int c = 300;
    return (a + b) * c; 
}

这段代码在虚拟机中的执行情况如下:

相关推荐
程序员志哥20 小时前
JVM系列(十三) -常用调优工具介绍
jvm
后台技术汇20 小时前
JavaAgent技术应用和原理:JVM持久化监控
jvm
程序员志哥20 小时前
JVM系列(十二) -常用调优命令汇总
jvm
黄油饼卷咖喱鸡就味增汤拌孜然羊肉炒饭20 小时前
聊聊volatile的实现原理?
java·jvm·redis
_LiuYan_1 天前
JVM执行引擎JIT深度剖析
java·jvm
王佑辉1 天前
【jvm】内存泄漏的8种情况
jvm
工业甲酰苯胺1 天前
JVM简介—1.Java内存区域
java·jvm·python
yuanbenshidiaos2 天前
c++---------数据类型
java·jvm·c++