虚拟机执行子系统

虚拟机执行子系统,第1张

虚拟机执行子系统

        书中该部分内容主要介绍Java程序是如何存储的、如何载入的、以及如何执行的这三个问题。本文章按照这个顺序简单记载一些重要的内容。

类文件结构

        类文件(Class文件)是Java虚拟机执行引擎的数据入口。本章主要介绍的是Class文件结构中各个组成部分、以及每部分的定义、数据结构和使用方法

  1. Class文件也称字节码文件,由源代码经编译器编译获得。存放的都是编译期间可以确定的数据。
  2. 字节:1Byte=8bit。Class文件是由一系列类似于 3A C8...(十六进制)这样的8位字节码为基础单位的二进制流,中间没有任何分隔符。
  3. Java虚拟机不仅能够实现平台无关性,也能实现语言无关性。而Class文件就是基石。Java虚拟机只与Class文件关联。任何一门编程语言都可以通过相应的编译器编译成Java虚拟机能够识别的Class文件。
  4. Class结构中的字节代表什么含义、长度是多少、先后顺序如何都是严格限定的,不允许改变。比如开头的四个字节为0xCAFEBABE(魔数)用来表示该文件是Class文件;接下来依次是两个字节的次版本号和2个字节的主版本号(0x 00 00 00 32,即50.0,主.次),版本号的作用是让当前版本的JDK判断自己是否支持该版本的Class文件,如果不可以,就需要升级JDK版本(向下兼容)。
  5. 除了魔数和版本号以外,还有常量池、访问标志、接口、字段表(field_info)、方法表(method_info)和属性表(attribute_info)等等。
  6. 常量池主要存放两大类常量:
    1. 字面量:类似于Java语言层面的常量,文本字符串,声明为final的常量值等。
    2. 符号引用:
      1. 类和接口的全限定名:类名全称(包+类名)中的.换成/:org/fenixsoft/clazz/TestClass;
      2. 字段的名称和描述符:
        1. 简单名称:没有类型和参数修饰:inc()的简单名称是inc
        2. 描述符:描述字段的数据类型,方法的参数列表(数量、类型及顺序)和返回值。
      3. 方法的名称和描述符:
  7. 在Class文件、字段表、方法表都可以携带自己的属性表集合,用于描述某些场景专有的信息。
  8. 类或接口的全限定名、方法和字段的名称的英文字符长度不能超过65535(2的16次幂-1),超过的话将无法编译。原因是都需要引用CONSTANT_Utf8_info型常量,该常量结构由三部分构成:其中length占两个字节,所以能表示的最大长度限定为65535,64KB。
    1. tag:u1(1个字节),数量为1,标志该项目为CONSTANT_Utf8_info类型的常量
    2. length:u2,数量为1,UTF-8编码的字符串长度是多少字节
      1. 'u0001' 到 'u007f' 之间的字符:相当于1~127的ASCII码,一个字符占1个字节。
      2. 'u0080' 到 'u07ff' 之间的字符:一个字符占两个字节。
      3. 'u0800' 到 'u7fff' 之间的字符:一个字符占3个字节。
    3. bytes:u1,代表字符的字节,连续length个字节。
  9. 在Class文件中,方法可以通过访问标志、名称索引、描述符索引表达清楚,方法体的代码编译后字节码存放在方法表的属性表中的Code属性中。
    1. Code属性表中包括
      1. max_stack: *** 作数栈的最大栈深度,u2。运行时根据此值分配。
      2. max_locals:u2,局部变量表的最大Slot数(所需存储空间):方法参数(this作为隐藏的方法参数默认存在)和局部变量。Slot是虚拟机为局部变量分匹配内存的最小单位,32位以下的局部变量占1个slot,像long和double这种64位的数据类型需要连续两个Slot存放。Slot可以重用,所以Slot数目并不等于参数个数+局部变量个数。
      3. code_length:字节码指令的数量,u4
      4. code:字节码指令,u1;一共code_length个字节码指令
      5. ...
  10. 只有返回值类型不同的两个方法无法构成重载:方法重载(能够区分具体使用的哪个方法)的前提是与原方法具有相同的简单名称,以及与原方法不同的方法签名。方法签名中不包含方法的返回值。所以只有返回值不同的两个方法,无法区分。
    1. 方法签名:方法中各个参数在(Class文件中的)常量池中的字段的符号引用的集合。
  11. 在属性表中,虚拟机规范中预定义了21项属性(Java SE 7版本),除了Code属性以外还有很多有意思的属性:
    1. LineNumberTable属性:用于记录Java源码与字节码行号(字节码的偏移量)之间的的对应关系。
      1. 编译完成后,对应关系就确定了,记录在Class文件当中(具体位置就在LineNumberTable属性中)。
      2. 可以在Javac中通过-g:none或-g:lines选项来取消或者要求生成这项信息。
      3. 如果选择不生成:凡是涉及到Java源码行号的功能都会受到影响。比如异常信息中不会显示出错的行号,debug时,无法按照源码行设置断点。
    2. LocalVariableTable属性:记录栈帧的局部变量表中的局部变量与源码中方法的局部变量的对应关系。
      1. 可以在Javac中通过-g:none或-g:vars来取消或者要去生成这项信息。
      2. 如果选择不生成,最大的影响就是无法获取参数名称信息。比如,IDE中调用方法时提示的参数列表用arg0、arg1之类的占位符代替参数名,虽然不影响程序运行,但是影响代码的编写;在debug时也无法根据参数名称从上下文中获得参数值。
    3. SourceFile属性:记录Class文件对应的源文件的名称。
      1. 需要单独记录的原因是,不是所有的类名和文件名都是一致的,比如内部类。
      2. 如果选择不生成,设计类名的地方将不显示。比如出现异常时,将不会显示出错代码所属的文件名。
虚拟机类加载机制

        Class文件中描述的各种信息都需要加载到虚拟机中之后才能运行和使用。所以这部分内容介绍虚拟机的类加载机制中的一些重要知识。

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

类加载时机
  1. 类加载过程:加载、连接(验证、准备、解析)、初始化;
  2. 加载、验证、准备、初始化这四个阶段开始的顺序是确定的,开始的时间依次靠后,解析阶段不一定,有时会在初始化之后开始。除了开始的时间有一定的先后顺序,这些阶段在进行的过程中是互相交叉地混合式进行的。
  3. 静态代码块:随着类加载过程而执行,而且只执行一次。
类加载过程 加载

        在加载阶段,虚拟机需要完成三件事:

  1. 通过类的全限定名获取定义此类的二进制字节流(不一定从Class文件获取)。(在虚拟机外部,由类加载器完成,类加载器可以自己定义)
  2. 将字节流所代表的的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
验证

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

        Java语言本身很安全,但Class文件不是只能由Java源码通过编译得到,可以使用任何途径产生,包括自己直接编写,在字节码语言层面,可以实现Java代码无法做到的是事情。所以不能完全信任,需要必要的验证措施。

        验证阶段大致完成四方面的检验动作:

  1. 文件格式验证:Class文件的存储格式,通过这个阶段的验证,二进制字节流才能进去方法区进行存储,所以这个阶段在加载阶段进行时就已开始。
  2. 元数据验证:对字节码描述的信息进行语义分析,保证其描述的信息符合Java语言规范。
  3. 字节码验证:通过数据流和控制流分析,确定方法体的字节码指令是合法的、符合逻辑的。
  4. 符号引用验证:在解析阶段发生,确保解析(符号引用转为直接引用)动作能正常执行。
准备 

        为类变量(静态变量)在方法区分配内存并设置变量初始值。此时的初始值是数据类型的默认值(0,null,false)。(这一阶段就还么开始执行Java代码,所以赋值 *** 作对应的指令还没执行)有一种特殊情况是,当类字段的字段属性表中存在ConstantValue属性,那么在准备阶段,类变量就会被初始化为ConstantValue属性指定的值。比如被final修饰的类变量,编译时javac将会为该类变量生成ConstantValue属性(public static final int value = 123;)。 

解析

         解析阶段是将常量池中的符号引用替换为直接引用的过程。

  • 符号引用:用一组符号来描述所引用的目标,符号可以是任意形式的字面量。(引用对象未必已经加载到内存中)。
  • 直接引用:直接引用的引用是直接指向目标的指针、相对偏移量或者能间接定位到目标的句柄。
初始化

        初始化阶段,真正开始执行类中定义的Java代码(字节码)。

        在准备阶段,类变量已经赋过一次系统默认初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其他资源。

        换种角度表达:初始化阶段是执行类构造器()方法的过程。

  • 类构造器不等同于类构造函数(实例构造器());
  • 父类的()在子类的()方法开始之前执行完。
  • ()方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并而成(在初始化阶段执行)
类加载器

        类加载器:是一个代码模块,不是物理实体。负责加载过程中的“通过一个类的全限定名来获取描述此类的二进制字节流”。这个动作在虚拟机外部实现。

        类本身和加载该类的类加载器共同确立其在java虚拟机中的唯一性。(只要类加载器不同,两个类一定不相同)。

双亲委派模型

        从Java开发人员的角度来看,绝大部分Java程序都会使用以下三种系统提供的类加载器:

  1.  启动类加载器(Bootstrap ClassLoader):负责将下列虚拟机能够识别的类库加载进内存:
    1. /lib目录中的类库
    2. -Xbootclasspath参数所指定的路径中的类库
  2. 扩展类加载器(Extension ClassLoader):
    1. /lib/ext目录中的类库
    2. 被java.ext.dirs系统变量所指定的路径中的所有类库
  3. 应用程序类加载器(Application ClassLoader):
    1. 负载加载用户类路径(ClasPath)上所指定的类库
    2. 程序中默认的类加载器是通过ClassLoader的getSystemClassLoader()方法获得的。

        我们的应用程序都是由这3种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器,类加载器之间的层次关系称为双亲委派模型。

         双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层的类加载器都如此。因此,所有类加载请求最终搜应该传送至顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(他的搜索范围没有找到所需的类),子类加载器才会尝试自己去加载。

        双亲委派机制的好处:

  • 避免类被重复加载:父类已经加载该类时,子类不会再加载一次
  • 避免Java的核心API被篡改:保证JDK核心类的优先加载,父类不会去加载自己写的核心类的同名类。即使自己在其他位置定义一个叫java.lang.System的类,因为双亲委派机制不会去加载自己写的,只会去加载系统提供的System;要想达到这个需求,可以自己定义一个类加载器(重写loadClass方法,打破双亲委派)。
 虚拟机字节码执行引擎

        Java源代码通过编译器编译成Class文件,然后经过类加载过程,二进制字节码经历了验证、准备、解析和初始化四个阶段的层层处理,已经具备执行的条件。

        本部分主要包括虚拟机的方法调用以及字节码执行。在此之前,先介绍一个用于支持方法调用和字节码执行的的一种数据结构,即虚拟机运行时数据区的虚拟机栈中的栈元素——栈帧。

运行时栈帧结构

        栈帧存储了方法的局部变量表、 *** 作数栈、动态链接、方法返回地址和一些额外的附加信息。

        局部变量表的容量和 *** 作数栈的深度在编译期间就已经确定,并写入到方发表的Code属性当中(max_locals,max_stacks)。

        一个线程的方法调用链可能会很长,只有位于栈顶的栈帧才是有效的,称为当前栈帧,当前栈帧对应的方法称为当前方法。

 局部变量表
  •  存放方法参数和方法内定义的局部变量
  • 局部变量表以变量槽Slot为最小单位,一个Slot可以存放32位以内的数据类型。对于64位的long和double,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。
  • long和double的读和写都不是原子 *** 作。但作为局部变量的long和double类型数据不会引起线程安全问题,因为局部变量表是线程私有的。相反,成员变量和类变量无法保证。
  • 局部变量表的第0位索引的Slot默认用于传递方法所属对象实例的引用(通过this访问这个隐含的参数)。
  • 恰当的变量作用域是控制变量回收时间的最优雅的解决方法(赋null值的 *** 作在经过JIT编译优化后会被消除掉,起不到作用)
  • 类变量存在准备阶段,有默认值;但局部变量不存在准备阶段,没有系统默认值,所以必须显式赋值才能被使用,在编译阶段就会进行提示。
  • 成员变量也有默认值:创建实例对象时,调用构造方法进行默认初始化或指定初始化;实例对象一般是在方法中作为局部变量出现,所以一定会显式赋值。
*** 作数栈
  • 后入先出的栈结构,32位数据类型所占栈容量为1,64位占2。
  • 算术运算通过 *** 作数栈进行,调用其他方法也是通过 *** 作数栈进行参数传递(被调用的方法与下一个栈帧关联,为了减轻额外的复制 *** 作,两个栈帧会有一部分重叠区域,上一个栈帧的局部变量表共享区域和下一个栈帧的 *** 作数栈共享区域重叠)。
动态连接
  • 栈帧中的有一部分区域用于存储一个指向运行时常量池中该栈帧所属方法的符号引用。
  • 静态解析:常量池中的符号引用一部分在类加载阶段或者第一次使用的使用(字段)转化为直接引用(类加载阶段的解析)
  • 动态连接:常量池中另一部分符号引用将在第一次运行期间(方法才会运行)转化为直接引用。栈帧中保存的对应方法的符号引用(一个)就用来支持方法调用过程中的动态连接。
方法返回地址
  • 栈帧中有一部分区域用于存储方法返回的地址(下一时刻执行的指令地址)
  • 正常完成出口:PC计数值
  • 异常完成出口:栈帧中没有,要通过异常处理表来确定
方法调用

        介绍完基础数据结构后,开始介绍虚拟机的方法调用 

  • 方法调用阶段的任务是:确定被调用方法的版本,Class文件的编译过程不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都是符号引用。需要在类加载期间甚至运行期间才能去确定目标方法的直接引用
  • (静态)解析:
    • 常量池中一部分符号引用(包括静态方法和私有方法)在类加载的解析阶段转化为直接引用。这部分方法的程序代码在编译期间都可以确定下来,即编译器可知,运行期不可变。
  • 分派:
    • 分派调用的过程可以揭示多态性特征的的一些具体体现,比如“重载”和“重写”,即虚拟机如何在多个同名方法中确定正确的目标方法。
    • 变量的两种类型:
      • 静态类型:编译器可以确定
      • 实际类型:运行期间才能确定
    • 静态分派:编译期间依赖变量的静态类型来定位方法版本。编译期间已经确定目标方法版本,典型应用:方法重载。
    • 动态分派:运行期根据变量的实际类型来定位方法执行版本,典型应用:重写(父类与子类之间)。
基于栈的字节码解释执行引擎

        虚拟机是依靠 *** 作数栈执行方法中的字节码指令的。

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/zaji/5070712.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-11-16
下一篇 2022-11-16

发表评论

登录后才能评论

评论列表(0条)

保存