• JVM整体结构图

JVM内存 - 图1

类加载器子系统

详见 :字节码与类的加载

运行时数据区

JVM运行时数据区 -1-.svg

  • 按生命周期分类:
    • 进程类:Heap、Metaspace、CodeCache,一个进程只有一份,线程间数据共享;
    • 线程类:本地方法栈、虚拟机栈、程序计数器,每个线程单独一份,线程间数据无法共享。
      • 元数据区+JIT编译产物 就是JDK8以前的方法区。
  • 说明

    • 每个Java应用程序都有一个Runtime类的实例,该实例允许该应用程序与运行该应用程序的环境进行交互。 当前运行时可以从getRuntime方法获得。
    • 在HotSpot JVM,每个线程都与操作系统的本地线程直接映射。
      • 操作系统负责所有线程的安排调度到任何一个可用的CPU上,一旦本地线程初始化成功,它就会调用java线程中的run()方法。

        核心组成

        程序计数器

        JVM中的程序计数寄存器(Program Counter Register)简称PC寄存器,Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟,并非广义上所指的物理CPU寄存器。

  • 作用:

PC寄存器是用来存储指向下一条指令的地址,即 将要执行的指令代码。由执行引擎读取下一条指令。

  • 特点:
    • 它是一块很小的内存空间,几乎可以忽略不计,也是运行速度最快的存储区域;
    • 在jvm规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致;
    • 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefined),因为程序计数器不负责本地方法栈;
    • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成;
    • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令;
    • 它是唯一一个在java虚拟机规范中没有规定任何OOM(Out Of Memery)情况的区域,而且没有垃圾回收。
  • 举例说明

jdu.png
PC寄存器中记录了下一条执行指令的地址,执行引擎从PC寄存器中读取该数据,从而操作局部变量表、操作数栈,将指令翻译为机器码,交给CPU执行。

  • PC寄存器高频问
  1. 使用PC寄存器存储字节码指令地址有什么用呢?(为什么使用PC寄存器记录当前线程的执行地址呢)
    因为CPU并发执行多线程时,需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。 JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。
    所以,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。


虚拟机栈⭐️

栈是一种快速有效的分配存储方式,访问速度仅次于PC寄存器(程序计数器)。栈是运行时的单位,而堆是存储的单位

  1. 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪儿。
  2. 一般来讲,对象主要都是放在堆空间的,是运行时数据区比较大的一块;
  3. 栈空间存放 基本数据类型的局部变量,以及引用数据类型的对象的引用地址。
  • 作用

主管java程序的运行,它保存方法的局部变量、8种基本数据类型、对象的引用地址、部分结果,并参与方法的调用和返回。

  • 特点

    • 栈的生命周期与线程一致,访问速度仅次于PC寄存器(程序计数器);
    • 每个方法对应栈中的一个栈帧,JVM直接对Java栈的操作只有两个:
      • 每个方法执行,伴随着进栈(入栈,压栈);
      • 执行结束后的出栈工作。
    • 栈不存在GC问题。
  • 栈中可能出现的异常

java虚拟机规范允许Java栈的大小是动态的或者是固定不变的

  1. 如果采用固定大小的Java虚拟机栈,那每一个线程的java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过java虚拟机栈允许的最大容量,java虚拟机将会抛出一个 StackOverFlowError异常

    1. /**
    2. * 演示栈中的异常:StackOverflowError
    3. * 原因:不停的自己调用自己导致
    4. */
    5. class StaticErrorTest {
    6. public static void main(String[] args) {
    7. main(args);
    8. }
    9. }
  2. 如果java虚拟机栈可以动态拓展,并且在尝试拓展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那java虚拟机将会抛出一个 OutOfMemoryError异常

  • 设置栈内存大小

可以使用参数-Xss选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。(IDEA设置方法:Run-EditConfigurations-VM options 填入指定栈的大小,如:-Xss256k)

栈的存储结构 & 运行原理

JVM栈 -2-.svg
栈的基本单位是栈帧(Stack Frame)。一个线程对应一个栈,一个方法对应一个栈帧。

  • 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息;
  • JVM直接对java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循先进后出/后进先出的和原则;
  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧对应的方法就是当前方法(Current Frame)
  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作;
  • 不同线程中所包含的栈帧是不允许相互引用的,即不可能在另一个栈帧中引用另外一个线程的栈帧;
  • 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧;
  • Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。


栈帧

其中部分参考书目上,也称方法返回地址、动态链接、附加信息为帧数据区

⭐️局部变量表(Local Variables)

本质上是一个一维数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型在编译期可知,包括各类基本数据类型、对象引用(reference),以及returnAddressleixing。因此,局部变量表也被称之为局部变量数组或本地变量表。

  • 特点

    • 由于局部变量表是建立在线程的栈上,是线程私有的数据,因此不存在数据安全问题
    • 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的;
    • 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,他的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间;
    • 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
  • 变量槽slot

    局部变量表最基本的存储单元是Slot(变量槽), 参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束。

    • 在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。 byte、short、char、float在存储前被转换为int,boolean也被转换为int,0表示false,非0表示true。
    • JVM会为局部变量表中的每一个slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。
    • 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照声明顺序被复制到局部变量表中的每一个slot上。
    • 如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可,(比如:访问long或者double类型变量)。
    • 如果当前帧是由构造方法或者实例方法创建的(意思是当前帧所对应的方法是构造器方法或者是普通的实例方法),那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序排列;
    • 静态方法中不能引用this,是因为静态方法所对应的栈帧当中的局部变量表中不存在this;
    • 栈帧中的局部变量表中的槽位是可以重复利用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
      pgh.png
  • 补充说明:
    • 在栈帧中,与性能调优关系最为密切的部分就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递;
    • 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。

  • 查看帧的局部变量表
    • 方式一:Javap -v
      ntd.png
    • 方式二:jclasslib插件
      以main()方法为例
      cya.pngrrp.pngsmy.pngbvo.png

其中Length表示该变量在指令中的作用域范围。
参考视频

⭐️操作数栈(Operand Stack)

或称表达式栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)或出栈(pop)。

  • 作用:主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。

  • 特点

    • 操作数栈就是jvm执行引擎的一个工作区,当一个方法开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。
    • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译器就定义好了,保存在方法的code属性中,为max_stack的值。
    • 栈中的元素可以是任意的java数据类型
      • 32bit的类型占用一个栈单位深度;
      • 64bit的类型占用两个栈深度单位。
    • 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈push和出栈pop操作来完成一次数据访问。
    • 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
    • 操作数栈中的元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类验证阶段的数据流分析阶段要再次验证。
    • 另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
  • 栈顶缓存技术

基于栈式架构的虚拟机所使用的零地址指令(即不考虑地址,单纯入栈出栈)更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。
由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率.

动态链接(Dynamic Linking)

即指向运行时常量池的方法引用。

  • 运行时常量池

运行时常量池位于方法区(注意: JDK1.7 及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。)
每一个栈帧内部都包含一个指向运行时常量池Constant pool(该栈帧所属方法的引用)。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。比如invokedynamic指令。

  • 符号引用

在Java源文件被编译成字节码文件中时,所有的变量和方法引用都作为符号引用(symbolic Refenrence)保存在class字节码文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用(#)最终转换为调用方法的直接引用。

  • 符号引用的主要作用就是为了节省内存开销,数据复用。

方法返回地址(Return Address)

存放的是调用该方法的PC寄存器的值。

本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。

  • 一个方法的结束,有两种方式:
    • 正常执行完成
    • 出现未处理的异常,非正常退出

无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者(方法的调用者可能也是一个方法)的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出时,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
正常退出和异常退出的区别在于:异常退出的不会给他的上层调用者产生任何的返回值。

一些附加信息

栈帧中还允许携带与java虚拟机实现相关的一些附加信息,当然也可以没有。例如,对程序调试提供支持的信息。(很多资料都忽略了附加信息)

补充知识点

  • 方法调用

在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关

  • 静态链接

当一个 字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。

  • 动态链接

如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。

  • 虚方法表

动态分派,就是在方法调用时,会首先在本类中查找该方法,没有就去父类中找,层层追溯,直到找到该方法,或者找不到抛出异常。
在面向对象编程中,会很频繁期使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,jvm采用在类的方法区建立一个虚方法表(virtual method table)(非虚方法不会出现在表中)来实现。使用索引表来代替查找。
每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
那么虚方法表什么时候被创建? 虚方法表会在类加载的链接阶段被创建 并开始初始化,类的变量初始值准备完成之后,jvm会把该类的虚方法表也初始化完毕。

  • 方法绑定

绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。

  • 早期绑定

早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。

  • 晚期绑定

如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。

  • 虚方法 vs 非虚方法

    • 虚方法:编译期不能确定,运行时才能确定的方法;
    • 非虚方法:编译期就能确定的方法。
      • 静态方法、私有方法、final方法、实例构造器(实例已经确定,this()表示本类的构造器)、父类方法(super调用)都是非虚方法。其他所有体现多态特性的方法称为虚方法。
    • 虚拟机提供的几条方法调用指令:
      • 普通调用指令:
        • invokestatic:调用静态方法,解析阶段确定唯一方法版本;
        • invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本;
        • invokevirtual调用所有虚方法;
        • invokeinterface:调用接口方法。
      • 动态调用指令(Java7新增):

invokedynamic:动态解析出需要调用的方法,然后执行 。前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。

注意: 其中invokestatic指令和invokespecial指令调用的方法称为非虚方法; 其中invokevirtual(final修饰的除外,JVM会把final方法调用也归为invokevirtual指令,但要注意final方法调用不是虚方法)、invokeinterface指令调用的方法称称为虚方法。

  • 动态语言 vs 静态语言

动态类型语言和静态类型语言两者的却别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之则是动态类型语言。
直白来说 静态语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。
Java是静态类型语言(尽管lambda表达式为其增加了动态特性),js,python是动态类型语言。

本地方法栈

  1. Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法(一般非Java实现的方法)的调用;
  2. 本地方法栈,也是线程私有的;
  3. 允许被实现成固定或者是可动态拓展的内存大小。(和Java虚拟机栈在内存溢出方面情况是相同的);
  4. 本地方法是使用C语言实现的;
  5. 它的具体执行步骤是Native Method Stack中登记native方法,在Execution Engine执行时加载本地方法库;
  6. 当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限
    • 本地方法可以通过本地方法接口来 访问虚拟机内部的运行时数据区
    • 它甚至可以直接使用本地处理器中的寄存器;直接从本地内存的堆中分配任意数量的内存;
  7. 并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。

堆⭐️

JVM堆 图 -1-.svg
Java堆区分为年轻代(YoungGen)和老年代(OldGen)。其中年轻代可以分为Eden空间、Survivor0空间和Survivor1空间(空的S区也叫to区,非空S区叫from区)。
各区域默认占比如图示,需要指出的是官方文档上Eden空间和另外两个Survivor空间缺省所占的比例是8:1:1(实测却是6:1:1)

  • 核心概述
  1. Java堆区在JVM启动的时候即被创建,其空间大小也就确定了;
  2. 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的;
  3. 所有的线程共享jvm堆,在这里还可以划分线程私有的缓冲区(TLAB:Thread Local Allocation Buffer)(面试问题:堆空间一定是所有线程共享的么?不是,TLAB在堆中,但线程独有的) ;
  4. 《Java虚拟机规范》中对jvm堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。从实际使用的角度看,”几乎”所有对象的实例都在这里分配内存 (”几乎”是因为可能存储在栈上,另见逃逸分析);
  5. 数组或对象永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置;
  6. 方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除;
  7. 堆是GC的重点区域。
  • 分代思想

为什么要把JVM堆分代?不分代就不能正常工作了么?
经研究,不同对象的生命周期不同。70%-99%的对象都是临时对象。

  • 新生代:有Eden、Survivor构成(s0、s1 又称为from、to),to总为空;
  • 老年代:存放新生代中经历多次依然存活的对象。

其实不分代完全可以,分代的唯一理由就是优化GC性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描,而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。

对象分配策略与过程

JVM内存 - 图12

  • 说明
    • 如果 to区中相同年龄对象内存总和 >= to区总内存的一半,此时直接将大于等于该年龄的对象晋升到Old区,无需等到年龄阈值。
      • 之所以这么设计是因为,每次MinorGC会进行S0和S1区的相互复杂转换 ,如果S区存在一半以上的同龄对象,此时将出现大量的复杂转换,对IO开销较大,同时这些同龄对象也是相对稳定的,因此将其放入Old区是很合理的策略。
    • 对象晋升老年代的年龄阈值,可以通过选项 -XX:MaxTenuringThreshold来设置;
    • 要尽量避免程序中出现过多的大对象;
      • 长期存活的对象分配到老年代
      • 动态对象年龄判断
    • 在OOM前,如果JVM配置了动态调整内存空间,那么会扩容Old区,只有经过扩容后Old区还是不足,才会OOM。

TLAB

TLAB(Thread Local Allocation Buffer)是JVM为每个线程在Eden区分配的一个私有缓存区域,因此它是线程安全的。

所有OpenJDK衍生出来的JVM都提供了TLAB的设计。

  • 作用

多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。

  • 说明
    • 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM明确是将TLAB作为内存分配的首选;
    • 在程序中,开发人员可以通过选项“-XX:UseTLAB“ 设置是够开启TLAB空间;
    • 默认情况下,TLAB空间的内存非常小,仅占有整个EDen空间的1%,当然我们可以通过选项 ”-XX:TLABWasteTargetPercent“ 设置TLAB空间所占用Eden空间的百分比大小;
    • 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配了内存。

堆并非分配对象的唯一选择

在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
在JVM中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
此外,前面提到的基于OpenJDK深度定制的TaoBaoVM,其中创新的GCIH(GCinvisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。

逃逸分析

当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸;当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

  • 快速的判断是否发生了对象逃逸,就看new的对象实体是否有可能在方法外被调用。
  • 常见的发生逃逸的场景:给成员变量赋值、方法返回值、实例引用传递。
  • 作用

通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围,从而决定是否要将这个对象分配到堆上。当对象分配到堆上,随着栈帧出栈,对象就被释放,无需通过GC来释放内存了,减少开销,提高性能。

  • 结论

开发中能使用局部变量,就不要使用成员变量。
在JDK7之后,HostSpot默认就开启了逃逸分析。

  • 代码优化理论

使用逃逸分析,编译器可以对代码做如下优化:

  • 栈上分配

JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成之后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须机型垃圾回收了。
关闭逃逸分析(-XX:-DoEscapeAnalysi); 开启逃逸分析(-XX:+DoEscapeAnalysi)。

  • 同步省略

在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫 锁消除。

  • 分离对象(标量替换)

在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来替代。这个过程就是标量替换。

开启标量替换(-XX:+EliminateAllocations),默认是开启的。

  1. - 标量(Scalar):是指一个无法在分解成更小的数据的数据。Java中的原始数据类型就是标量。
  2. - 聚合量(Aggregate):指那些还可以分解的数据, Java中对象就是聚合量,因为它可以分解成其他聚合量和标量。
  • 辩证思维

关于逃逸分析的论文在1999年就已经发表了,但直到JDK1.6才有实现,而且这项技术到如今也并不是十分成熟的。
其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。

StringTable(字符串常量池)

  • 通过下列方式创建的字符串会放在StringTable中
  1. 字面值创建。 如:String str = “一野”;
  2. 常量与常量拼接形成的新字符串(在编译期间就会将其替换为方式1的形式)。 如:String str = “a”+”b”;
    • 注意:只要在拼接的过程中,有一个是变量,那么,最后结果会在堆中。如:String t= “a”; String str = t + “b”;此时的str指向的是位于堆中的新对象;
  3. 调用String的intern()方法(在StringTable中复制该对象(jdk1.6),或者复制该对象的引用地址(jdk1.7起));
  4. 调用Object的toString()方法(底层是通过方式1,创建字符串);
  5. new String(“a”),在堆中创建对象并返回引用,同时会在StringTable中创建一份该对象;
  6. new String(“a”)+new String(“b”),返回堆中的”ab”对象的地址。常量池中会创建”a”、”b”对象,但不会创建”ab”对象。
  • 字符串拼接时的几点疑问
  1. 为什么字符串拼接时,只要有一个是变量,最后结果会位于堆中?

lcm.png

  1. 使用final修饰的变量,拼接后为何却会存在于StringTable中?

    • 例子:

      1. final String s1 = "a";
      2. String str = s1 + "b"; //此时的str却位于常量池中
    • 解答:
      此时的s1使用final修饰后,就成为了一个常量,在后续的使用中无法对其重新赋值,那么在编译阶段就能确定下来,所以编译成字节码文件时,直接将其转换为了 String str = “a”+”b” ,再转换为了String str = “ab”,因此位于StringTable中。

  • String的基本特性
    1. String类是已经被声明为final的, 不可被继承。
    2. String实现了Serializable接口:表示字符串是支持序列化的。 实现了Comparable接口:表示String可以比较大小。
    3. String在jdk8及以前内部定义为“private final char value[]”用于存储字符串数据。jdk9时改为byte[]。
    4. 自jdk9以来,String再也不用char[] 来存储了,改成了byte[] 加上字符编码集的标识,节约了一些空间。基于字符串的内容,决定用何种编码去存储。特定的编码集如ISO-8859—1/Latin-1:一个character字符采用一个字节存储。其他编码集如UTF-16:一个character字符采用2个字节存储。中文字符也是采用2个字节存储。
    5. 通过字面量的方式(不同于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。字符串常量池当中的字符串不允许重复存放。
  • String底层是不会扩容的HashTable:

    • String的String Pool 是一个固定大小的Hashtable,默认值大小长度是1009。如果放进StringPool的String非常多, 就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用String. intern时性能会大幅下降。
    • 使用 -XX: StringTableSize 可设置StringTable的长度
    • 在jdk6中StringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快。StringTableSize设 置没有要求
    • 在jdk7中,StringTable的长度默认值是60013
    • jdk8开始,1009是StringTable长度可设置的最小值。
  • String的内存分配

    • 在Java语言中有8种基本数据类型和一种比较特殊的类型String。这些 类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。
    • 常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种。
    • 直接使用双引号声明出来的String对象会直接存储在常量池中(显示的调用toString()方法,底层也是用的改种方式,因此,也会将其字面值放入常量池)。
      • 比如: String info = “abc” ;
    • 如果不是用双引号声明的String对象,可以使用String提供的intern() 方法将String对象放在常量池中,并返回引用地址。
    • Java 6及以前,字符串常量池存放在永久代。
    • Java 7中Oracle的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到Java堆内。
    • StringTable为什么要调整?
      • 永久代permSize默认比较小;
      • 永久代的垃圾回收频率低;
  • intern()的使用

    • 例子中两种方式分别会创建几个对象?

      1. class StringNewTest {
      2. public static void main(String[] args) {
      3. String str = new String("ab");
      4. String str1 = new String("a")+new String("b");
      5. }
      6. }
      • new String(“ab”)会创建几个对象?
        看字节码,可知是两个。
        • 一个对象是:new关键字在堆空间创建的
        • 另一个对象是:字符串常量池中的对象” ab”。 字节码指令:ldc
        • 但注意:str此时指向的是堆空间当中的对象。
          feh.png
      • new String(“a”) + new String(“b”)会创建几个对象?
        对象1:new StringBuilder(),变量拼接“+”操作肯定有StringBuilder
        对象2: new String(“a”)
        对象3: 常量池中的”a”
        对象4: new String(“b”)
        对象5: 常量池中的”b”
        vxx.png

      • 深入剖析:StringBuilder的toString()
        对象6 :new String(“ab”),但这里的new String并没有在字符串常量池当中生成ab。

        • 强调一下,toString()的隐式调用(只是隐含在字节码中StringBuilder拼接new String(“a”) + new String(“b”)之后调用),表明其属于变量的拼接,结果ab存储在堆区
        • 因为没有出现字节码指令lcd,所以在字符串常量池中并没有生成”ab”。
    • 特别注意

      • jdk1.6中,将这个字符串对象尝试放入串池。
        • 如果字符串常量池中有,则并不会放入。返回已有的串池中的对象的地址
        • 如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址
      • Jdk1.7起,将这个字符串对象尝试放入串池。
        • 如果字符串常量池中有,则并不会放入。返回已有的串池中的对象的地址
        • 如果没有,则会把对象的引用地址复制一份,放入串池,并返回串池中的引用地址

intern()视频详解

方法区⭐️

方法区是JVM官方抽象出来的一个区域,是一个标准或规范,可以理解为接口,对于HotSpotJVM中对方法区的落地实现被称为 元空间(jdk1.8及以后)。
对于HotSpotJVM而言,方法区还有一个别名叫做 Non-Heap(非堆),目的就是要和堆分开。

  • 方法区(Method Area)是各个线程共享的内存区域;
  • 方法区在JVM启动时就会被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的;
  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可拓展;
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError:PermGen space 或者 java.lang,OutOfMemoryError:Metaspace(JDK8及以上),比如:
    • 加载大量的第三方jar包;
    • Tomcat部署的工程过多;
    • 大量动态生成反射类;
  • 关闭JVM就会释放这个区域的内存。

栈、堆、方法区的交互关系

堆、栈、方法区的交互关系 -1-.svg

方法区的演进过程

本质上,方法区和永久代并不等价。《java虚拟机规范》对如何实现方法区,不做统一要求。例如:BEA JRockit/IBM J9中不存在永久代的概念。其实可以将方法区看作是一个官方接口,永久代或者原空间就是hotspot对该接口的具体实现。

  • Hotspot中方法区的变化
    • jdk1.6及之前:有永久代(permanent generation) ,静态变量存放在 永久代上。
    • jdk1.7:有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移至堆中。
    • jdk1.8及之后: 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍留在堆空间。

方法区演进细节 图.svg

  • 永久代为什么要被元空间替换?

由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。 这项改动是很有必要的,原因有:

  1. 为永久代设置空间大小是很难确定的。 在某些场景下,如果动态加载类过多,容易产生Perm区(永久代)的OOM。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。 “Exception in thread’ dubbo client x.x connector java.lang.OutOfMemoryError: PermGenspace” 而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。
  2. 对永久代进行调优是很困难的。
  • StringTable 为什么要调整?

因为永久代的回收效率很低,在full gc的时候才会触发。而full GC 是老年代、永久代的空间不足时才会触发。这就导致了StringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。

设置方法区大小

  • jdk8及以后(元空间):
    • 元数据区大小可以使用参数-XX: MetaspaceSize和-XX: MaxMetaspaceSize指定;
      • 查询 jps -> jinfo -flag MetaspaceSize PID;
      • 设置 -XX:MetaspaceSize=100m -XX:MaxMetaspaceSize=100m
    • 默认值依赖于平台。windows下,-XX:MetaspaceSize=21M;-XX:MaxMetaspaceSize=-1, 即没有限制。
    • 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。 如果元数据区发生溢出,虚拟机一样会拋出异常OutOfMemoryError: Metaspace。

注意
-XX:MetaspaceSize: 设置初始的元空间大小。对于一个64位的服务器端JVM来说, 其默认的-XX :MetaspaceSize值为21MB.这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。
如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将- XX :MetaspaceSize设置为一个相对较高的值。

  • jdk7及以前(永久代):
    • -XX: PermSize来设置永久代初始分配空间。默认值是20.75M
    • -XX: MaxPermSize来设定永久代最大可分配空间。32位机器默认是64M,64位机器模式是82M
    • 当JVM加载的类信息容量超过了这个值,会报异常OutOfMemoryError : PermGen space

查询 jps -> jinfo -flag PermSize [进程id]; 设置 -XX:PermSize=100m -XX:MaxPermSize=100m

方法区的内部结构

ofn.png

类型信息

对每个加载的类型( 类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:

  1. 这个类型的全限定类名;
  2. 这个类型直接父类的全限定类名(对于interface或是java. lang.Object,都没有父类)
  3. 这个类型的修饰符(public, abstract, final的某个子集)
  4. 这个类型直接接口的一个有序列表(因为接口是多继承的,所以需要记录顺序来辨别是哪一个)
  • 域信息(成员变量)
    • JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序;
    • 域的相关信息包括:域名称、 域类型、域修饰符(public, private, protected, static, final, volatile, transient的某个子集)。
  • 方法信息(method)

JVM必须保存所有方法的以下信息,同 域信息一样包括声明顺序:

  • 方法名称;
  • 方法的返回类型(或void);
  • 方法参数的数量和类型(按顺序);
  • 方法的修饰符(public, private, protected, static, final, synchronized, native , abstract的一个子集);
  • 方法的字节码(bytecodes)、操作数栈、局部变量表及大小( abstract和native 方法除外);
  • 异常表( abstract和native方法除外),每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引。
    • 全局常量(static final)

被声明为final的类变量的处理方法则不同,如果该final修饰的静态变量在编译期就能确定下来,那么,它将在编译的时候就被分配。

运行时常量池

常量池:

一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

  • 作用:减小class文件的内存占用
    一个 java 源文件中的类、接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持(比如需知道当前类的所有父类),通常这种数据会很大以至于不能直接存到字节码里。因此,JVM将这些数据存储在常量池中供字节码文件引用。而字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池。
  • 总结
    字节码当中的常量池结构(constant pool),可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名,方法名,参数类型、字面量等信息。

运行时常量池:

硬盘中class文件的常量池通过类加载器加载到JVM内存的方法区后,存在于方法区的常量池就是运行时常量池。

  • 特性
    • 运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性,即支持动态添加常量到运行时常量池(比如通过String.intern()方法可以将字符串加载进运行时常量池)。
    • JVM为每个已加载的类型(类或接口)都维护一个运行时常量池。池中的数据项像数组项一样,是通过索引访问的。
    • 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
    • 运行时常量池类似于传统编程语言中的符号表(symbol table) ,但是它所包含的数据却比符号表要更加丰富一些。
    • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutOfMemoryError异常。

❌字符串常量池

之前,字符串常量池作为运行时常量池的一部分存在于方法区,但,从jdk1.7开始,字符串常量池已经放入到堆内存中了。

❌静态变量

即 非final的static变量。

  • 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分;
  • 类变量被类的所有实例所共享,即使没有类实例你也可以访问它。

注意:从jdk1.7开始,静态变量已经从方法区移到了堆中了。

方法区的垃圾回收

方法区的垃圾收集主要回收两部分内容:常量池中废奔的常量和不再使用的类型。

  1. 运行时常量池中废奔的常量

运行时常量池之中主要存放的两大类常量:字面量 和 符号引用。
字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念。

  • 常量池中包括下面三类常量:
    • 类和接口的全限定名
    • 字段的名称和描述符
    • 方法的名称和描述符

HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。回收废弃常量与回收Java堆中的对象非常类似。

  1. 不再使用的类型

对象创建详解

对象创建图解(1).svg
图解代码

  1. class Customer {
  2. int id = 1001;
  3. String name;
  4. Account account;
  5. {
  6. name = "一元";
  7. }
  8. public Customer() {
  9. account = new Account();
  10. }
  11. public static void main(String[] args) {
  12. Customer customer = new Customer();
  13. }
  14. }
  15. class Account {
  16. }

对象的实例化

创建对象的方式

  • new
  • 反射
    • Class的newInstance():只能调用空参的构造器,权限必须是public
    • Constructor的newInstance(Xxx):可以调用空参、带参的构造器,权限没要求
  • 克隆

不用调任何构造器,当前类需要实现Cloneable接口,实现clone()

  • 反序列化

从文件中、从网络中获取一个对象的二进制流

  • 第三方库Objenesis

创建对象的步骤

  1. 判断对象对应的类是否 加载、链接、初始化 完成

虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的运行时常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化( 即判断类元信息是否存在)。
如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为Key进行查找对应的.class文件。如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class类对象。

  1. 为对象分配内存

首先计算对象占用空间大小,接着在堆中划分一块内存给新对象。
除了 double和long类型变量占8个字节,其余类型变量占4个字节。

  • 内存规整, 使用指针碰撞 分配

如果内存是规整的,那么虚拟机将采用的是指针碰撞法(BumpThePointer)来为对象分配内存。意思是所有用过的内存在一边,空闲的内存在另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式。所以一般使用带有compact (整理)过程的收集器时,使用指针碰撞。

  • 内存不规整, 虚拟机需要维护一个列表,使用空闲列表 分配

如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虛拟机将采用的是空闲列表法来为对象分配内存。意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式成为“空闲列表(Free List) ”。

3.处理并发安全问题

在分配内存空间时,另外一个问题是及时保证new对象时候的线程安全性:创建对象是非常频繁的操作,虚拟机需要解决并发问题。虚拟机采用 了两种方式解决并发问题:

  • CAS ( Compare And Swap )失败重试、区域加锁:保证指针更新操作的原子性;
  • TLAB把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲区,(TLAB ,Thread Local Allocation Buffer) 虚拟机是否使用TLAB,可以通过一XX:+/一UseTLAB参数来 设定。jdk8开始 默认开启。

    4.初始化分配到的空间

    Java给对象的属性赋值的操作有如下四种:

  • 属性的默认初始化

  • 显式初始化
  • 代码块中初始化
  • 构造器中初始化

注意:

  • 这里提到的是第1种,所有属性设置默认值,保证了对象实例字段在不赋值时可以直接使用。
  • 第2、3种根据代码中的位置确认优先等级。
    5.设置对象的对象头
    将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。
    6.执行init方法进行显示初始化
    在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。 因此一般来说(由字节码中是否跟随有invokespecial指令所决定),new指令之 后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。

    对象的内存布局

    对象头(Header)

    包含 运行时数据和类型指针。如果是数组,还需记录数组长度。
  • 运行时数据(Mark Work)
    • HashCode
    • GC分代年龄
    • 锁状态标志
    • 线程持有的锁
    • 偏向线程ID
    • 偏向时间戳
  • 类型指针:指向类元数据InstanceKlass,确定该对象所属的类型

对象实例通过.getClass()方法就可以获得该实例的class对象,正是因为对象头中存在的类型指针。
注意,并非所有的对象的对象头中都保留 类型指针。

实例数据(Instance Data)

它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)

  • 规则
    • 相同宽度的字段总被分配在一起
    • 父类中定义的变量会出现在子类之前
    • 如果CompactFields参数为true(默认为true),子类的窄变量可能插入到父类变量的空隙

      对齐填充(Padding)

      不是必须的,也没特别含义,仅仅起到占位符作用

对象的访问定位

句柄访问

vcp.png

  • 缺点
    占用空间、间接指向对象实例。
  • 优点
    定位稳定:当对象实例发生移动(垃圾回收算法,内存整理算法),则不需要修改reference到句柄的定位地址,仅需要在句柄内修改间接地址,重新定位到对象实例即可。

    直接指针(Hotspot采用)

    堆、栈、方法区的交互关系 -1-.svg

  • 优点
    节省空间、访问速度快

  • 缺点
    栈空间的引用的内存地址发生改变,会导致栈空间的引用地址也要改变,不够稳定

    执行引擎

    如果想让一个Java程序运行起来、执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。

aoi.png

  • 执行引擎的工作过程:

宏观上看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。
执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器。每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址。
当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表(栈)中的对象引用准确定位到存储在Java堆区中的对象实例信息。
以及通过对象头(堆)中的元数据指针,定位到目标对象的类型信息(方法区)。

  • 设置程序执行方式:

缺省情况下HotSpot VM是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为Java虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。如下所示:

  • -Xint: 完全采用解释器模式执行程序;
  • -Xcomp: 完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行;
  • -Xmixed:采用解释器+即时编译器的混合模式共同执行程序。

    解释器

    当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。

    • 解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
    • 当一条字节码指令被解释执行完成后,接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作。
  • 优点
    响应速度快。当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行代码。
  • 缺点
    执行速度慢。

  • 组成

在HotSpot VM中,解释器主要由Interpreter模块和Code模块构成。

  • Interpreter模块:实现了解释器的核心功能;
  • Code模块:用于管理HotSpot VM在运行时生成的本地机器指令。

JIT编译器

为避免函数被解释执行从而导致效率低下,即时编译器将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。

  • 优点
    编译为本地代码后,执行效率非常高。
  • 缺点
    响应速度较慢,把代码编译成本地机器指令,需要有一定的执行时间。

热点代码及其探测方式

是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。关于那些需要被编译为本地代码的字节码,也被称之为“热点代码”。JIT编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升Java程序的执行性能。

  • 一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换,或简称为OSR (On StackReplacement)编译。
  • HotSpot VM采用基于计数器的热点探测,为每一个 方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter) 和回边计数器(BackEdge Counter) 。
    • 方法调用计数器用于统计方法的调用次数。
    • 回边计数器则用于统计循环体执行的循环次数。

热点代码探测方式

  • 方法计数器
    • 这个计数器就用于统计方法被调用的次数,它的默认阈值在Client 模式 下是1500 次,在Server 模式下是10000 次。超过这个阈值,就会触发JIT编译。
    • 这个阈值可以通过虚拟机参数-XX :CompileThreshold来人为设定。
      sby.png
  • 回边计数器
    它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边” (Back Edge)。显然,建立回边计数器统计的目的就是为了触发OSR编译。

热度衰减

  • 如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。
  • 当超过一定的时间限度, 如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay) ,而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)。
  • 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。
  • 另外, 可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

    其它编译器

  • AOT编译器

    • jdk9引入了AOT编译器(静态提前编译器,Ahead Of Time Compiler)
    • Java 9引入了实验性AOT编译工具jaotc。它借助了Graal 编译器,将所输入的Java 类文件转换为机器码,并存放至生成的动态共享库之中。
    • 所谓AOT编译,是与即时编译相对立的一个概念。我们知道,即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。而AOT编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。
    • 优点
      Java虚拟机加载已经预编译成二进制库,可以直接执行。不必等待即时编译器的预热,减少Java应用给人带来“第一次运行慢”的不良体验。
    • 缺点
      • 破坏了java”一次编译,到处运行”(提前干掉了能够跨平台的class文件),必须为每个不同硬件、oS编译对应的发行包。
      • 降低了Java链接过程的动态性,加载的代码在编译期就必须全部已知。
      • 还需要继续优化中,最初只支持Linux x64 java base。
  • Graal编译器

    • 自JDK10起,HotSpot又加入一个全新的即时编译器: Graal编译器
    • 编译效果短短几年时间就追评了C2编译器。未来可期。
    • 目前,带着“实验状态”标签,需要使用开关参数 -XX: +UnlockExperimentalVMOptions 一XX: +UseJVMCICompiler去激活,才可以使用。

      Java代码执行过程

  • HotSpot JVM的执行方式:

当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。并且随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率。

  • 宕机案例,热机状态切换:

wpn.png

本地方法库和接口

本地方法接口简称JNI(Java Native Interface)
简单来讲,一个Native Method就是一个java调用非java代码的接口,一个Native Method 是这样一个java方法:该方法的底层实现由非Java语言实现,比如C。这个特征并非java特有,很多其他的编程语言都有这一机制,比如在C++ 中,你可以用extern “C” 告知C++ 编译器去调用一个C的函数。
在定义一个native method时,并不提供实现体(有些像定义一个Java interface),因为其实现体是由非java语言在外面实现的。
本地接口的作用是融合不同的编程语言为java所用,它的初衷是融合C/C++程序。
标识符native可以与其他所有的java标识符连用,但是abstract除外。