天天看点

JVM知识点总结二类加载机制运行时栈帧结构编译器优化技术

类加载机制

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程称为虚拟机的类加载机制

类加载的生命周期

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和 卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。

JVM知识点总结二类加载机制运行时栈帧结构编译器优化技术

加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的;

而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始, 这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。

类加载的过程

1.加载

  • 通过全限定名(包名加类名)获取二进制字节流
  • 将静态结构转化为方法区运行时数据结构
  • 生成一个java.lang.Class对象,作为方法区这个类的各种数据的访问入口

2.连接(验证,准备,解析)

  • 验证
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流包含的信息符合<<Java虚拟机规范>>的全部约束要求,保证这些信息被当做代码运行后不会危害虚拟机自身的安全
- 文件格式验证 验证是否是Class文件流
- 元数据验证 元数据信息进行语义校验
- 字节码验证 具体代码的语义校验
-  符号引用验证 发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在解析阶段实现
           
  • 准备
    • 准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配
    • 初始化为零值(被final修饰的值,该是什么就是什么)
  • 解析
    • 将符号引用转换为直接引用\
      • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可
      • 直接引用(Direct References):直接引用是可以直接指向目标的指针,相对偏移量或者是一个能间接定位到目标的句柄

初始化

  • 类的初始化阶段是类加载过程的最后一个步骤
  • 把静态变量赋值为相应的值
  • 普通的先赋值为零值
  • 遇到以下六种情况必须立即对类进行“初始化”
    • 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时
      • 能够生成这四条指令的典型Java代码场景有:
      • 使用new关键字实例化对象的时候
      • 读取或设置一个类型的静态字段(被final修饰,已在编译期把结果放入常量池的静态字段除外)的时候
      • 调用一个类型的静态方法的时候
    • 反射调用的时候
    • 如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
    • 先初始化包含main()方法的那个主类
    • 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解 析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句 柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
    • 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有 这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

类加载器

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

比较两个类是否“相 等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

1.双亲委派模型

站在Java虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),是虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。
自JDK 1.2以来,Java一直保 持着三层类加载器、双亲委派的类加载架构(注意,1.1是没有的)

1.三层类加载器

  • 启动类加载器
    • 这个类加载器负责加载存放在 <JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。
    • 启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理
  • 拓展类加载器
    • 这个类加载器是在类sun.misc.Launcher$ExtClassLoader 中以Java代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所 指定的路径中所有的类库。
    • 这是一种Java系统类库的扩展机制。
  • 应用程序类加载器
    • 有些场合中也称它为“系统类加载器”
    • 它负责加载用户类路径 (ClassPath)上所有的类库
    • 如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
JDK 9之前的Java应用都是由这三种类加载器互相配合来完成加载的,如果用户认为有必要,还可 以加入自定义的类加载器来进行拓展
JVM知识点总结二类加载机制运行时栈帧结构编译器优化技术

2.双亲委派模型

  • 上图展示的各种类加载器之间的层次关系被称为类加载器的双亲委派模型
  • 工作过程:
    • 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
  • 好处:
    • 一个是避免重复加载,提升性能
    • 避免了核心类被用户篡改

3.破坏性双亲委派模型

  • 三次破坏
    • 第一次破坏是发生在JDK1.2之前,由于双亲委派模型在JDK 1.2之后才被引入,但是类加载器的概念和抽象类 java.lang.ClassLoader则在Java的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代码,Java设计者们引入双亲委派模型时不得不做出一些妥协,为了兼容这些已有代码,无法再以技术手段避免loadClass()被子类覆盖的可能性,只能在JDK 1.2之后的java.lang.ClassLoader中添加一个新的 protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在 loadClass()中编写代码。
    • 第二次“被破坏”是由这个模型自身的缺陷导致的,双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以被 称为“基础”,是因为它们总是作为被用户代码继承、调用的API存在,但程序设计往往没有绝对不变的完美规则,如果有基础类型又要调用回用户的代码,那该怎么办呢?
      • 典型代表就是JNDI服务(简单来讲就是父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则)。
      • JDBC:原生的JDBC中Driver驱动本身只是一个接口,并没有具体的实现,具体的实现是由不同数据库类型去实现的。例如,MySQL的mysql-connector-.jar中的Driver类具体实现的。 原生的JDBC中的类是放在rt.jar包的,是由启动类加载器进行类加载的,在JDBC中的Driver类中需要动态去加载不同数据库类型的Driver类,而mysql-connector-.jar中的Driver类是用户自己写的代码,那启动类加载器肯定是不能进行加载的,既然是自己编写的代码,那就需要由应用程序启动类去进行类加载。于是乎,这个时候就引入线程上下文件类加载器(Thread Context ClassLoader)。有了这个东西之后,程序就可以把原本需要由启动类加载器进行加载的类,由应用程序类加载器去进行加载了。
  • 双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的,这里所说的“动态性”指的是一些非常“热”门的名词:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等。
    • 案例:OSGi实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi中称为 Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构。

运行时栈帧结构

Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。
每 一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程
栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址和一些额外的附加信息。
JVM知识点总结二类加载机制运行时栈帧结构编译器优化技术

这里我们主要要了解局部变量表

  • 局部变量表

局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数 和 方法内部定义的局部变量。

局部变量表的容量以变量槽(Variable Slot)为最小单位。

一个变量槽可以存放一个32位以内的数据类型,Java中占用不超过32位存储空间的数据类型有boolean、byte、char、short、int、 float、reference和returnAddress(目前很少见)这8种类型。

- reference:reference类型表示对一个对象实例的引用,根据引用直接或间接地查找到对象在Java堆中的数据存放的起始地址或索引。

- ⭐Java虚拟机通过索引定位的方式使用局部变量表。

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

- 对于方法内来讲,变量名字并不重要

  • 操作数栈
    • 操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出栈。
    • 当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。
    • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配
  • 动态链接
    • 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
    • 这些符号 引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。 另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。
  • 方法返回地址
  • 附加信息(具体请详细参考深入理解Java虚拟机第三版)

编译器优化技术

几种优化技术包括有:方法内联、逃逸分析、公共子表达式消除以及数组边界检查消除。

  • 方法内联(优化之母)
    • 去除方法调用的成本
    • 为其他优化建立良好的基础
  • 逃逸分析
    • 基本原理:
      • 分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部 方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;
    • 如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径 访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化 :
      • 栈上分配
        • 在Java虚拟机中,对象的内存空间基本上都是在堆上分配的。
        • Java堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问到堆中存储的对象数据。虚拟机的垃圾收集子系统会回收堆中不再使用的对象,但回收动作无论是标记筛选出可回收对象,还是回收和整理内存,都需要耗费大量资源。如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。
        • 栈上分配可以支持方法逃逸,但不能支持线程逃逸。
      • 标量替换
        • 若一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据 就可以被称为标量。相对的,如果一个数据可以继续分解,那它就被称为聚合量(Aggregate),Java中的对象就是典型的聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。
        • 对逃逸程度的要求更高,它不允许对象逃逸出方法范围内
      • 同步消除
        • 线程同步本身是一个相对耗时的过程,如果逃逸分析 能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以安全地消除掉。