深入理解JVM—Java虚拟机桟

1、虚拟机桟概述

由于跨平台性的设计,JVM的指令架构是基于桟的结构来设计的,这么做的优点:一是具有了跨平台性,其次使得指令集更小,编译器更容易实现,但缺点也很明显:实现同样的功能需要更多的指令和性能下降。
栈是运行时的单位,堆是存储的单位

栈解决的程序运行问题,即程序如何执行,或者说如何处理数据;堆解决的是数据存储问题,即就是数据如何放、放哪儿

在java中一个线程就会相应有一个线程栈与之对应,因为不同的线程执行逻辑有所不同,因此需要一个独立的线程栈。堆是所有线程共享的。栈因为是运行单位。因此里面存储的是和当前线程相关的数据。包括局部变量、程序运行状态、方法返回值等;而堆只负责存储对象信息。

堆中存什么,栈中存什么?

堆中存的是对象,栈中存的是基本数据类型和堆中对象的引用,一个对象的大小不可以估计,或者说是可以动态变化的,但是在栈中,一个对象只对应了一个4byte引用

对象,从某种意义上说,是由基本类型组成的。可以把一个对象看作为一棵树,对象的属性如果还是对象,则还是一颗树(即非叶子节点),基本类型则为树的叶子节点。程序参数传递时,被传递的值本身都是不能进行修改的,但是,如果这个值是一个非叶子节点(即一个对象引用),则可以修改这个节点下面的所有内容。
堆和栈中,栈是程序运行最根本的东西。程序运行可以没有堆,但是不能没有栈。而堆是为栈进行数据存储服务,说白了堆就是一块共享的内存。不过,正是因为堆和栈的分离的思想,才使得Java的垃圾回收成为可能。

1.1 Java虚拟机桟(Java Virtual Machine Stacks)的特点

(1) 桟内存线程私有的,它的生命周期与线程相同(即随着线程的创建而创建,随着线程的销毁而销毁)
(2)虚拟机桟描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个 桟帧 用于存储 局部变量(Local Variables)、操作数桟(Operand Stack)、动态链接(Dynamic Linking)、方法返回地址(Return Address) 等信息。新创建的桟帧会被保存到Java虚拟机栈的栈顶,方法执行完毕后自动将此桟帧出栈。一般我们把虚拟机桟栈顶的桟帧称作当前方法。


图1.1 Java虚拟机桟栈帧

(3)虚拟机桟没有GC,但是Java虚拟机规范中,对此区域定义了 两种异常情况

如果线程请求的桟深度大于虚拟机允许的深度,将抛出StackOverflowError异常
如果虚拟机的实现中允许虚拟机桟动态扩展,当需要扩展但是内存不够的时候将会抛出OutOfMemoryError异常

1.2 Java虚拟机桟相关参数调优

首先学习JVM调优我们还是应该主要参考官方文档 。打开官方文档后选择Main Tools to Create and Build Applications=>java,之后在页面使用快捷键Ctrl+F搜索-Xss就可以看到关于参数-Xss的说明和用法。


图1.2.1 Java虚拟机桟调优参数-Xss

根据官网的说明,-Xss就是用来设置线程桟(虚拟机桟)的大小的。在参数后面加上k/K表示KB,m/M表示MB,g/G表示GB。并且还说明了不同平台上的虚拟机桟默认的大小 ,Linux、macOS、Oracle Solaris是1024KB,Windows上默认值受虚拟内存的影响。

示例:写一个简单的测试代码验证一下这个-Xss参数对Java虚拟机桟的调节效果,测试代码如下:

package top.easyblog;

/**
 * @author HuangXin
 * @since 2020/2/12 19:24
 */
public class JVMStackErrorTest {

     private static int count=0;

    public static void main(String[] args) {
        System.out.println(count++);
        main(args);
    }
}

在没有设置桟大小使用默认值时,在我电脑上发生StackOverflowError异常时打印的count值为9544


图1.2.2 调参之前报错信息

在IDEA中设置虚拟机桟的大小
具体操作看图中的演示:


图1.2.3 选择Run-->Edit Configurations...

图1.2.4 设置VM options参数为-Xss256m
设置好后再次运行刚刚的程序,发现过了好久才报StackOverflowError异常,而且最后打印的count值为3893229,截图如下:

图1.2.5 调参之后报错信息

2、Java虚拟机桟的存储单位

2.1 Java虚拟机桟桟中存储的什么?
  • 每个线程都有自己的桟,Java虚拟机桟都是以桟帧(Stack Frame)为基本单位存在
  • 一个线程上每个被调用的方法都有一个桟帧。
  • 桟帧是一个内存区块,是一个数据集,维护着方法执行过程中的各种数据信息。
2.2 Java虚拟机桟的运行原理
  • 不同线程中所包含的桟帧是不允许存在相互引用的,即不可能在一个线程的某个桟帧中引用另外一个线程中某个桟帧。(线程可以共享同一个进程中的共享数据,但是线程内的数据无法共享
  • 如果当方法调用了其他方法,方法返回之际,当前桟帧会传回此方法的执行结果给前一个桟帧,接着JVM会丢弃当前桟帧,使得前一个桟帧重新成为当前桟帧。
  • Java的方法有两种返回方式:一种是方法正常执行结束返回,使用return命令;另一种是抛出异常,没有捕获导致虚拟机挂掉。两种返回方式都会导致桟帧被弹出。
2.3 桟帧的内部结构详解

每个桟帧的结构包括:局部变量表(Loval Variables)操作数桟(Operand Stack)动态链接(Dynamic Linking)方法返回地址(Return Address) 和一些其他的 一些附加信息


图2.3.1 JVM桟帧结构
2.3.1 局部变量表(Loval Variables)
  • 1、局部变量表也被称为局部变量数组或本地变量表。
  • 2、局部变量表本质是一个数组,主要用于存储方法参数和定义在方法体内的局部变量的值,可以存放的数据类型有boolean、byte、char、short、int、float、long、double、对象引用(reference)和returnAddress类型。(基本数据类型存储的是数值,引用类型存储的是引用

图2.3.2 局部变量表存储结构
  • 3、局部变量表中32位以内的类型只占用一个Slot(包括returnAddress),64位的类型(long和double)占用两个Slot。byte、short、char在存储的时候会被转换为int,boolean也会被转换为int,0 表示 false,非0 表示true

  • 4、由于局部变量表是建立在线程的桟帧上的,是线程私有的,因此不存在线程安全问题。

  • 5、局部变量表所需要的容量大小是在编译期间就可以确定下来的,并保存在方法的Code属性的locals数据项中,并且在方法运行期间是不会改变局部变量表的大小的。

图2.3.3 局部变量表大小
  • 6、在固定的虚拟机桟内存大小下,可以调用的方法的个数取决于桟帧的大小。对于一个方法而言,它的参数和局部变量越多,使得局部变量表膨胀,它的桟帧也会变大,以满足方法调用所需传递的信息增大的需求。
  • 7、局部变量表中的变量只有在当前方法调用中才有效,当方法调用结束后随着方法桟帧的销毁而销毁。
  • 8、JVM会为局部变量表中的每个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量表的值。

图2.3.4 局部变量表分析
  • 9、如果需要访问局部变量表中的一个64bit局部变量值时,使用的时候只需要使用其起始Slot索引即可。
  • 10、如果当前桟帧是由构造方法或者实例方法创建的,那么方法所属对象引用this将会作为局部变量放在局部变量表的index=0的Slot处,其余变量从Slot的index=1开始按定义的顺序存储。

图2.3.5 实例方法的第一个Slot存放的一定是this引用
  • 11、为了节约桟帧的空间,局部变量表的Slot是可以重复利用的

图2.3.6 局部变量表的Slot可以复用
2.3.2 操作数桟(Operand Stack)
  • 操作数桟就是JVM执行引擎的一个工作区,本质是一个由数组构成的桟,当一个方法开始执行的时候,一个新的操作数桟就会被创建。操作数桟初始是空的,主要用于保存计算过程的中间结果,同时作为计算过程变量的临时存储空间
  • 每一个操作数桟所需要的最大桟深度会在编译器就确定下来了,这个值保存在方法Code属性的max_stack选项中。
  • 桟中的任何一个元素可以是任意Java类型。32位的类型占用一个桟单位深度,64位的类型占用两个桟单位深度
  • 操作数桟并非采用访问索引的方式来进行数据访问,而是只能通过入栈和出栈操作完成一次数据访问

基本执行逻辑
主要是对变量值的入栈、出栈、运算、入栈…

例如将两个int类型的局部变量相加再将结果保存至第三个局部变量:

 public void test1(){
       int i=10;
       int j=20;
       int k=i+j;
}

使用JDK自带的javap反汇编器,可以查看java编译器为我们生成的字节码。通过它,我们可以对照源代码和字节码,从而了解很多编译器内部的工作。反汇编得到上面方法的字节码如下:

public void test1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
     //stack表示操作数桟最大深度,locals表示局部变量表的大小
      stack=2, locals=4, args_size=1    
         0: bipush        10     //将常量10将加载到操作数桟
         2: istore_1             //将操作数桟中的10存储到局部变量表的索引为1的位置(因为这个方法是实例方法,所以局部变量表的索引为0的Slot存储的是当前对象的引用this)
         3: bipush        20     //将常量10将加载到操作数桟
         5: istore_2             //将操作数桟中的20存储到局部变量表的索引为2的位置
         6: iload_1              //取出局部变量表中索引位置为1的数值放到操作数桟的栈顶
         7: iload_2              //取出局部变量表中索引位置为2的数值放到操作数桟的栈顶
         8: iadd                 //最顶部的两个数出栈有执行引擎翻译iadd指令为机器指令后交给CPU进行求和运算
         9: istore_3             //将计算的结果放到局部变量表索引为3的Slot中
        10: return               //方法执行结束,返回。
      LineNumberTable:           //源代码行号和字节码地址的对应关系(源代码行号:字节码地址)
        line 12: 0
        line 13: 3
        line 14: 6
        line 15: 10
      LocalVariableTable:       //局部变量表
  字节码起始地址          索引   变量   变量的数据类型
        Start  Length  Slot  Name   Signature
            0      11     0  this   Ltop/easyblog/OperandStackTest;
            3       8     1     i   I
            6       5     2     j   I
           10       1     3     k   I
}
  • 操作数栈中元素的类型必须与字节码指令的序列严格匹配,例如上面的iadd操作时,不能出现iadd操作需要的值第一个为long 第二个为 float 的情况。
2.3.3 动态链接(Dynamic Linking)

在Java源文件被编译到直接码文件时,所有的变量和方法引用都作为符号引用保存在class文件的常量池。而我们都知道每一个桟帧内部包含一个结构—动态链接,它就是指向运行时常量池中该桟帧所属方法的引用。每个桟帧持有一个引用就是为了将符号引用转换为调用方法的直接引用

可以用下面的图形象的解释:


图2.3.7 动态链接图解
2.3.4 方法返回地址(Return Address)

       当一个方法开始执行后,只有两种方式可以退出这个方法:
       第一种方式是执行引擎遇到方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method InvocationCompletion)。

       另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

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

       方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

3、方法调用

3.1 虚方法和非虚方法

       首先应该明确的是方法的调用不等于方法的执行,方法的调用阶段唯一的任务就是确定被调用的方法的版本,暂时还没有涉及到方法内部的具体运行过程。通过JVM类加载有关内容的学习,我们知道了在类加载的解析阶段会将class文件中的一部分符号引用转化为直接引用,可以这样做的前提是方法在编译的时候就能确定下来。
       在Java中符合“编译期可知,运行期不可变”的方法我们称作非虚方法,主要包括静态方法、私有方法、构造方法、父类方法以及被final修饰的方法。除此而外的方法都叫虚方法。与之对应的,在JVM规范中提供了5个有关方法调用的字节码指令,具体如下:

  1. invokestatic:调用静态方法,解析阶段可以确定唯一版本
  2. invokespecial:调用实例构造器<init>方法,私有方法和父类方法,也是解析阶段可以确定唯一版本
  3. invokevirtual :调用虚方法和final修饰的方法(但是final修饰的方法不是虚方法)
  4. invokeinterface:调用接口方法
  5. invokedynamic:动态解析出需要调用的方法,然后执行

invokestatic、invokespecial、invokevirtual、invokeinterface这4条方法调用字节码指令是伴随着Sun的第一款Java虚拟机问世以来就有的,直到JDK7才新增了invokedynamic指令,这条新增的指令是Java实现“动态类型语言”支持的改进之一,也就是为JDK8的Lamba表达式技术而准备的。它与前面4个指令的最大的区别是由前面4个指令调用的方法分派逻辑是固化到JVM内部的,而invokedynamic指令调用的方法分派逻辑完全是由程序员来控制的

3.2 静态分派

解析调用的过程一定是静态过程,在编译期间就可以完全确定,在类加载的解析阶段就会把涉及的符号引用全部转换为可以确定的直接引用。而分派调用则可分为静态分派和动态分派,具体的有静态单分派、静态多分派、动态单分派、动态多分派这4种组合。下面我们一起来看一下JVM中的方法分派是如何进行的。

首先我们来看一段代码:

package top.easyblog.methodinvoke;

/**
 * @author :huangxin
 * @modified By:
 * @Description: 方法静态分派演示
 * @since :2020/02/14 11:33
 */
public class StaticDispatch {

    static class Human{

    }

    static class Man extends Human{

    }

    
    static class Woman extends Human{

    }

    public void sayHello(Human human){
        System.out.println("hello,human");
    }

    public void sayHello(Man human){
        System.out.println("hello,man");
    }

    public void sayHello(Woman human){
        System.out.println("hello,woman");
    }

    public static void main(String[] args) {
        Human man=new Man();
        Human woman=new Woman();
        StaticDispatch staticDispatch = new StaticDispatch();

        staticDispatch.sayHello(man);
        staticDispatch.sayHello(woman);
    }
}

运行结果:

结论
这段代码对于学过Java的同学应该不难看出运行结果,主要考察的是对重载概念的理解。但你有想过为什么两次都执行了参数类型为Human的重载吗?在说原因之前我们需要知道两个概念:
Human man=new Man();这条语句来说,我们把Human称为静态类型或外观类型,Man我们称为实际类型。静态类型和实际类型在编译的时候都可发生变化,但是静态类型在编译期最终是可知的;而实际类型要在运行后才能确定。为在编译阶段,javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main方法的两条invokevirtual指令参数中。所有依赖静态类型来定位方法执行版本的分派动作称为静态分派,静态分派的典型应用就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机执行的。

重载方法的匹配优先级规律

首先还是看一段代码:

package top.easyblog.methodinvoke;

import java.io.Serializable;

/**
 * @author :huangxin
 * @modified By:
 * @Description:方法重载优先级匹配测试
 * @since :2020/02/14 15:32
 */
public class StaticDispatchPriority {

    public void sayHello(char obj) {
        System.out.println("char obj");
    }

    public void sayHello(int obj) {
        System.out.println("int obj");
    }

    public void sayHello(long obj) {
        System.out.println("long obj");
    }

    public void sayHello(float obj) {
        System.out.println("float obj");
    }

    public void sayHello(double obj) {
        System.out.println("double obj");
    }

    public void sayHello(Character obj) {
        System.out.println("Character obj");
    }

    public void sayHello(Serializable obj) {
        System.out.println("Serializable obj");
    }

    public void sayHello(Comparable obj) {
        System.out.println("Comparable obj");
    }

    public void sayHello(char... obj) {
        System.out.println("char... obj");
    }


    public static void main(String[] args) {
        new StaticDispatchPriority().sayHello('a');
    }
}

运行上面这段代码,会打印出char obj,这很好理解,a是一个char类型的数据,自然会寻找参数为char的重载方法,如果注释掉这个方法,那么运行结果又会变成int obj,这是由于a的Unicode值为97,进一步的会依次转型为long->float->double。最终把double形参的方法也注释掉后,会打印出Character obj ,发生了自动装箱,a被包装成了封装类型java.lang.Character,继续注释掉Character形参方法后,我们看到编辑器报错了此时匹配上了两个方法:sayHello(Serializable obj)和sayHello(Comparable obj)

为什么会匹配上Serializable 和Comparable 呢?这个不难解释,我们打开Character类一看就知道了,Character实现了Serializable 和Comparable这两个接口

至于会同时匹配上两个实现接口类型的重载方法,那是因为它两的优先级是一样的,这时就需要我们程序员通过强制类型转换显式的指定要调用那个方法了。

最后会匹配变长参数的方法sayHello(char… obj)。因此,从这个案例中我们可以总结出Java中方法重载的优先级匹配规则:
优先匹配基本数据类型(char->int->long->float->double),其次匹配它的包装类型,如果没有包装类型但是有包装类型的父类,那么将在继承关系中从下往上开始搜索,越接近上层的优先级约定,优先级最低的是变长参数重载

3.2 动态分派
3.2.1 Java语言方法重写的本质

首先我们还是看一段代码:

package top.easyblog.methodinvoke;

/**
 * @author :huangxin
 * @modified By:
 * @Description: 演示动态分配
 * @since :2020/02/14 18:08
 */
public class DynamicDispatch {

    static abstract class Human{
        protected abstract void sayHello();
    }

    static class Man extends Human{

        @Override
        protected void sayHello() {
            System.out.println("man say hello");
        }
    }

    static class Woman extends Human{

        @Override
        protected void sayHello() {
            System.out.println("woman say hello");
        }
    }

    public static void main(String[] args) {
        Human man=new Man();
        Human woman=new Woman();
        man.sayHello();
        woman.sayHello();
        man=new Woman();
        man.sayHello();
    }
}

运行结果 :

运行结果不会出乎人的意料,对于习惯了面向对象思维的Java程序员会觉得这个结果理所当然。但是还是那个问题,为啥会这样?虚拟机是如何知道要调用哪个方法的?
我们使用javap命令输出字节码来看看:


图 3.2.1 main方法字节码

0~15行的作用是创建对象、分配空间,并把对象的引用保存在局部变量表中。之后16~21行是事情本质的关键,我们看到字节码中调用方法使用了invokevirtual指令,原因就需要从incokevirtual指令的多态查找说起:

  1. 找到操作数栈顶的第一个元素所指向的对象的实例类型C
  2. 如果在C类型中找到与常量池中描述符合简单名称都相符的方法,则进行访问权限检查,如果通过就返回这个方法的直接引用,查找结束;如果没有通过,则返回java.lang.IllegalAccessError
  3. 否者,继续按照继承树从下往上对C的各个父类进行第二步的搜索和检查和验证。
  4. 如果始终没有找到合适方法,就抛出java.lang.AbstractMethodError异常。

所以,由于incokevirtual指令执行的第一步工作就是确定实际类型,所以两次调用中incokevirtual指令都把常量池中的类方法符号引用解析到了不同的直接引用上,而这也就是我们常说的“编译时看左边,运行时看右边”这句话或者Java中方法重写的本质。

3.2.2 虚方法表

通过上面的学习我们知道了方法重写的原理,那么由于动态分派是非常频繁的操作,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用了在类的方法区建立一个虚方法表(Virtual Method Table,vtable)与之对应的是在invokeinteface执行的时候也会用到接口方法表(Interface Method Table,itable),使用虚方法表的索引来代替元数据查找以提高性能方法表一般会在类加载的链接阶段完成初始化,准备了类的实例变量之后,虚拟机也会把该类的得到表初始化完毕。

留言区

还能输入500个字符