深入理解Java虚拟机系列(二):虚拟机执行子系统

目录

一、类文件结构

1.无关性

2.Class文件结构

二、虚拟机类加载机制

1.类加载时机

主动引用

1)new、静态字段、静态方法

2)反射

3)子类初始化先进行父类初始化

4)main函数所在的类

5)MethodHander的方法句柄所对应的类

被动引用

2.类加载过程

1)加载

2)验证

3)准备

4)解析

5)初始化

3.类加载器

1)加载器介绍

2)双亲委派模型

3)三次破坏双亲委派模型

三、虚拟机字节码执行引擎

1.运行时栈帧结构

1)局部变量表

2)操作数栈

3)动态连接

4)方法返回地址

2.方法调用

1)解析

2)分派

3)动态语言支持

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

四、参考文章


一、类文件结构

1.无关性

1)字节码(ByteCode)是构成平台无关性的基石(class文件由JVM编译执行,与具体的操作系统无关,由JVM开发者去实现不同平台的JVM)

2)语言无关性的基础仍然是虚拟机和字节码存储格式(Scala、Groovy等编译成class文件在JVM上运行)

2.Class文件结构

1)Class文件是一组以8位字节为基础单位的二进制流(可以是文件存储,也可以是数据流——二进制字节流),各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。当遇到需要占用8位字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8位字节进行存储。

2)Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表,后面的解析都要以这两种数据类型为基础。

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

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

3)魔数:每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。Class文件的魔数值为:0xCAFEBABE(咖啡宝贝?)

二、虚拟机类加载机制

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期

1.类加载时机

加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)

主动引用

有且只有(对类进行主动引用,引发类的初始化)5种立即对类进行初始化的时机:

1)new、静态字段、静态方法

遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。

2)反射

使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化

3)子类初始化先进行父类初始化

当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

4)main函数所在的类

当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

5)MethodHander的方法句柄所对应的类

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

被动引用

除主动引用外的所有引用类的方式都不会触发初始化,称为被动引用

1)通过子类引用父类的静态字段,不会导致子类初始化

2)通过数组定义来引用类,不会触发此类的初始化

3)常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化

2.类加载过程

1)加载

将class文件加载到内存区域,主要完成三件事

a.通过类的全限定名获取该类的二进制字节流(class文件)

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

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

加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序

2)验证

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

验证经历四个阶段,第一种基于二进制字节流进行的操作,后面三种基于方法区的存储结构进行的操作

a.文件格式验证

内容:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理

目的:保证输入的字节流能正确地解析并存储于方法区之内

b.元数据验证

内容:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求

目的:对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息

c.字节码验证

内容:对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件

目的:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。

d.符号引用验证

发生时机:在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生

内容:对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,

目的:确保解析动作能正常执行,如果无法通过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError异常的子类

3)准备

正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。

注意:

a.这个阶段这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。

b.这里所说的初始值“通常情况”下是数据类型的零值; 特殊情况”:如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值,即 public static final int value=123;

4)解析

虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行

符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可 以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。

直接引用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量或是 一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引 用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目 标必定已经在内存中存在。

5)初始化

前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。
到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)。

过程:在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程

<clinit>()方法

a.由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问

b.与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。

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

接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法

d.多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。(从锁的角度出发就是多个线程去竞争对象锁,只有一个线程可以成功,参考Java并发编程的艺术中的双重检查锁定单例模式的类初始化解决指令重排序问题的解决方法)

3.类加载器

1)加载器介绍

类加载器:类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,实现这个动作的代码模块称为“类加载器”。

类的唯一性:对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间,即两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等

四种类加载器:
a. 启动类加载器(Bootstrap ClassLoader)用来加载 java 核心类库,无法被 java 程序直接引用。
b. 扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。
c. 系统类加载器(system class loader):它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过ClassLoader.getSystemClassLoader()来获取它。
d. 用户自定义类加载器,通过继承 java.lang.ClassLoader 类的方式实现。

2)双亲委派模型

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先会去缓存查找是否存在该类,找不到的话该加载器不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此(先查找缓存再交给父类加载器,直到根加载器缓存找不到自己尝试自己加载这个类,加载不了就往子加载器返回),因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(子加载器根据这个继承关系链先尝试加载然后没加载成就往下、一层层的加载,直到提出加载的这个类加载器,都没有加载成功就抛出类找不到异常),若是上述流程有问题看下面的代码(另外注意启动类加载器是C++编写的在加载器中是null值,所以扩展类加载器不设置父加载器)

protected Class<?> loadClass(String name, boolean resolve)
      throws ClassNotFoundException
  {
      synchronized (getClassLoadingLock(name)) {
          // 先从缓存查找该class对象,找到就不用重新加载
          Class<?> c = findLoadedClass(name);
          if (c == null) {
              long t0 = System.nanoTime();
              try {
                  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
                  // 如果都没有找到,则通过自定义实现的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;
      }
  }

3)三次破坏双亲委派模型

a.继承java.lang.ClassLoader重写loadClass()方法直接破坏双亲委派模型的继承关系,建议把自己的类加载逻辑写到findClass()方法中

b.越基础的类由越上层的加载器进行加载、总是作为被用户代码调用的API,但如果基础类又要调用回用户的代码则无法实现(例如JNDI服务、JDBC、JCE、JAXB、JBI)

引入ThreadContextClassLoader线程上下文类加载器(违背了双亲委派模型的一般性原则):通过java.lang.Thread类的setContextClassLoaser()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器

c.用户对程序动态性的追求而导致:例如OSGI(同级加载器查找)

动态性:代码热替换(HotSwap)、模块热部署(HotDeployment)等

OSGi实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(OSGi中称为Bundle)都有一个自己的类
加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。

OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构

收到类加载请求时,OSGi将按照下面的顺序进行类搜索:

1)将以java.*开头的类委派给父类加载器加载。
2)否则,将委派列表名单内的类委派给父类加载器加载。
3)否则,将Import列表中的类委派给Export这个类的Bundle的类加载器加载。
4)否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。
5)否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。
6)否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
7)否则,类查找失败。

上面的查找顺序中只有开头两点仍然符合双亲委派规则,其余的类查找都是在平级的类加载器中进行的

三、虚拟机字节码执行引擎

从概念模型的角度来讲解虚拟机的方法调用和字节码执行

1.运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)[1]的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址、附加信息

1)局部变量表

一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。

2)操作数栈

操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的 max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和 double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。在方法执行的任 何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。

3)动态连接

Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接

4)方法返回地址

方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息

2.方法调用

方法调用唯一的任务:确定被调用方法的版本(即调用哪一个方法)

1)解析

调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)

解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。

2)分派

分派(Dispatch)调用则可能是静态的也可能是动态的

静态分派(静态多分派):单个类中方法重载,发生在编译阶段

动态分派(动态单分派):子类对父类的方法重写,运行时不根据静态类型而根据实际类型去调用方法

3)动态语言支持

动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期;JDK 7的字节码指令集添加新成员——invokedynamic指令,这条新增加的指令是JDK 7实现“动态类型语 言”(Dynamically Typed Language)支持而进行的改进之一,也是为JDK 8可以顺利实现 Lambda表达式做技术准备

JDK7中根据invokedynamic指令的引入,加入java.lang.invoke包提供一种新的动态确定目标方法的机制,称为MethodHandle

Reflection和MethodHandle对比:

a.从本质上讲,Reflection和MethodHandle机制都是在模拟方法调用,但Reflection是在模拟 Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。在 MethodHandles.lookup中的3个方法——findStatic()、findVirtual()、findSpecial()正是 为了对应于invokestatic、invokevirtual&invokeinterface和invokespecial这几条字节码指令的执 行权限校验行为,而这些底层细节在使用Reflection API时是不需要关心的。

b.Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象所包含的信息多。前者是方法在Java一端的全面映像,包含了方法的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含执行权限等的运行期信息。而后者仅仅包含与执行该方法相关的信息。用通俗的话来讲,Reflection是重量级,而MethodHandle是轻量级。

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

1) 基于栈的指令集与基于寄存器的指令集

基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供[2],程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。例如,现在32位80x86体系的处理器中提供了8个32位的寄存器,而ARM体系的CPU(在当前的手机、PDA中相当流行的一种处理器)则提供了16个32位的通用寄存器。如果使用栈架构的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更加简单一些。栈架构的指令集还有一些其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。

2)基于栈的解释器执行过程

四、参考文章

[1] 深入理解Java虚拟机(第二版)周志明

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值