JVM简介—3.JVM的执行子系统
技术分享
12小时前
0
999+
大纲
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一直保持着三层类加载器、双亲委派的类加载架构。三层类加载器如下所示: