JVM入门-运行时数据区概述及线程

Posted by 小拳头 on Wednesday, January 27, 2021

回顾上一讲, class文件被类加载器加载之后, 会使用run engine去执行.

Running Data Area具体内容如下图, 红色部分是多个线程共享的, 灰色部分是线程私有的. 一个JVM只有一个Area(只有一个Runtime实例).

线程

在HotSpot JVM, 每个线程都与操作系统的本地线程直接映射. 当一个java线程准备好执行以后,此时一个操作系统的本地线程也同时创建. java线程执行终止后. 本地线程也会回收. 操作系统负责所有线程的安排调度到任何一个可用的CPU上, 一旦本地线程初始化成功, 它就会调用java线程中的run()方法.

主要的后台线程有: 虚拟机线程, 周期任务线程, GC线程, 编译线程和信号调度线程.

Program Counter Register(PC寄存器)

PC寄存器的作用是存储指向下一条指令的地址, 由执行引擎读取下一条指令. 从字节码的角度看如下图, PC寄存器读取到5, 被执行引擎读取后来操作栈结构, 把机器码指令翻译成机器指令, 机器指令就可以让对应的CPU做运算.

那么使用PC寄存器存储字节码指令地址有什么用呢? 为什么使用PC寄存器记录当前线程的执行地址呢? 因为CPU在不同的线程之间切换, 切换回来后要知道从哪里开始继续执行该线程, JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令. PC寄存器为什么会设定为线程私有? 道理相同, 每个线程要知道在自己线程中执行到那一个字节码指令, PC寄存器能够准确地记录各个线程正在执行的当前字节码指令地址. 和操作系统中的进程切换是一个道理, 只是进程的信息保存在PCB中.

可以用java -v XXX.class反编译, 但是最方便的还是直接用idea中的view-show bytecode with jclasslib.

JVM Stack(虚拟机栈)

由于跨平台性的设计, java的指令都是根据栈来设计的. 优点是跨平台, 指令集小, 编译器容易实现; 缺点是性能下降, 实现同样的功能需要更多的指令.

栈是运行时的单位, 而堆是存储的单位. 一般来讲, 对象主要放在堆空间. 栈空间存放基本数据类型的局部变量和引用数据类型的对象的引用. JVM Stack生命周期和线程是一致的, 主管java程序的运行, 它保存方法的局部变量(8种基本数据类型, 对象的引用地址), 部分结果, 并参与方法的调用和返回. 内部保存的是Stack Frame, 因为只需要出栈入栈操作, 所以它的速度仅次于PC寄存器, 并且不需要GC.

Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的, 相对的如果JVM栈固定, 线程请求分配的栈容量超过JVM栈允许的最大容量, 就会出现熟悉的StackOverFlowError. 如果JVM栈可以动态拓展, 但是在尝试拓展的时候无法申请到足够的内存, 就会出现熟悉的OutOfMemoryError. 对于栈的大小可以通过-Xss来设置, 可以通过下面的代码来测试, 改变Xss, 在StackOverFlowError出现后看count的值也会跟着变化. 比如可以试试-Xss256k. macos默认的栈大小是1024KB.

public class StackErrorTest {
    private static int count = 1;
    public static void main(String[] args) {
        System.out.println(count);
        count++;
        main(args);
    }
}

在一条活动线程中, 一个时间点上, 只会有一个活动的栈帧, 即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的, 这个栈帧被称为当前栈帧(Current Frame), 与当前栈帧对应的方法就是当前方法(Current Frame). Java方法有两种返回函数的方式, 一种是正常的函数返回, 使用return指令, 另外一种是抛出异常, 不管使用哪种方式, 都会导致栈帧被弹出.

虚拟机设置在Run-Edit Configuration-VM option中.

Stack Frame(栈帧)

栈帧的内部存储了

  • 局部变量表 Local Variables
  • 操作数栈 Operand Stack
  • 动态链接 Dynamic Linking
  • 方法返回地址 Return Address
  • 一些附加信息

局部变量表

局部变量表定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量. 这些数据类型包括各类基本数据类型, 对象引用, 以及returnAddress类型; 线程私有; 容量大小是在编译期确定下来的; 可以在idea中通过查看字节码看方法中的Maximum Local Varialbe来查看具体大小.

public static void main(String[] args) {
    LocalVariablesTest test = new LocalVariablesTest();
    int num = 10;
    test.test1();
}

局部变量表最基本的存储单元是Slot. 在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot. 可以从Index看出.

  • byte, short, char, float在存储前被转换为int.
  • boolean也被转换为int, 0表示false, 非0表示true;

当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个slot上. 如果需要访问局部变量表中一个64bit的局部变量值时, 只需要使用前一个索引即可. (比如访问long或者double类型变量)

如果当前帧是由构造方法或者实例方法创建的, 那么该对象引用this将会存放在index为0的slot处, 其余的参数按照参数表顺序排列.

slot是可以重复利用的. 如下图c的index用了之前b的slot, 所以都为2. 因为变量b的作用域已经结束了.

变量分类

数据类型分: 基本数据类型, 引用数据类型.

按照在类中声明的位置分:

1.成员变量(在使用前都经过默认初始化赋值): static类变量(linking的prepare阶段默认赋值, initial阶段显式赋值); 实例变量(随着对象创建, 在堆空间中分配实例变量空间, 并进行默认赋值).

2.局部变量: 在使用前必须显式赋值.

局部变量表中的变量也是重要的垃圾回收根节点, 只要被局部变量表中直接或间接引用的对象都不会被回收; 在栈帧中, 与性能调优关系最为密切的部分就是局部变量表.

Operand Stack(操作数栈)

可以使用数组或者链表来实现. 操作数栈在方法执行过程中根据字节码指令, 往栈中写入数据(push)或提取(pop)数据. 也就是说操作数栈主要用于保存计算过程的中间结果, 同时作为计算过程中变量临时的存储空间. 32bit的类型占用一个栈单位深度, 64bit的类型占用两个栈深度单位.

如果被调用的方法带有返回值, 其返回值将会被压入当前栈帧的操作数栈中, 并更新PC寄存器中下一条需要执行的字节码指令. 我们说JVM的解释引擎是基于栈的执行引擎, 其中的栈指的就是操作数栈.

下图就是代码的一系列操作对应的字节码治指令及栈的相关操作.

public void testAddOperation {
    byte i = 15;
    int j = 8;
    int k = i + j;
}

这里我们发现push到操作数栈的时候, 都是基于byte的bipush, 因为数字都在byte范围内, 但是存储到局部变量表之后就变成了int, 因为store都是istore, i就是指int. 还有一点值得注意, 即使我们用int j = 8;声明了一个int型局部变量, 在push到操作数栈的时候, 依然是bipush, 因为8在byte的范围内.

Dynamic Linking(动态链接)

每一个栈帧内部都包含一个指向运行时常量池或该栈帧所属方法的引用. 在Java源文件被编译成字节码文件中时, 所有的变量和方法引用都作为符号引用(symbolic Refenrence)保存在class文件的常量池里. 常量池提供了一些符号和常量, 便于指令的识别.

方法的调用

  • 静态链接: 被调用的目标方法在编译期可知, 且运行期保持不变; 对应早期绑定.
  • 动态链接: 被调用的方法在编译期无法被确定下来, 只能够在程序运行期将调用方法的符号引用转换为直接引用. 对应晚期绑定.
  • 非虚方法指方法在编译器就确定了具体的调用版本. 这个版本在运行时是不可变的. 静态方法, 私有方法, final方法, 实例构造器, 父类方法都是非虚方法. 其实可以看出这些非虚方法都是可以确定我们调用的是到底是哪一个, 不存在多态(重写)的影响.

非虚方法调用指令包括invokestatic(调用静态方法), invokespecial(调用init方法, 私有及父类方法). 虚方法调用指令是invokevirtual. 调用接口方法指令是invokeinterface.

以上都是普通调用指令, 而动态调用指令指需要动态解析出需要调用的方法. 指令是invokedynamic, 是Java为了实现动态类型语言支持而做的一种改进.

为了提高性能, jvm采用在类的方法区建立一个虚方法表. 避免每次虚方法指令都需要在类的方法元数据中搜索合适的目标.

动态类型语言和静态类型语言两者的却别就在于对类型的检查是在编译期还是在运行期, 满足前者就是静态类型语言, 反之则是动态类型语言. 也就是说静态语言是判断变量自身的类型信息; 动态类型语言是判断变量值的类型信息.

Return Address(方法返回地址)

方法返回地址, 动态链接, 附加信息也叫做帧数据区. 方法返回地址存储调用该方法的pc寄存器的值, 也就是说这个方法跑完了, 调用它的方法就能知道接着从哪里开始跑. 方法正常返回时, 在字节码指令中, 返回指令包含ireturn(boolena, byte, char, short, int, lreturn, freturn, dreturn, areturn, return(声明为void的方法, 实例初始化方法, 类和接口的初始化方法).

5个问题:

  1. 举例栈溢出的情况(StackOverflowError). 递归调用等, 通过-Xss设置栈的大小.
  2. 调整栈的大小, 就能保证不出现溢出么? 不能, 如递归无限次数肯定会溢出, 调整栈大小只能保证溢出的时间晚一些.
  3. 分配的栈内存越大越好么? 不是, 会挤占其他线程的空间.
  4. 垃圾回收是否会涉及到虚拟机栈? NO!
  5. 方法中定义的局部变量是否线程安全? 看情况, 看是否变量是这一个线程独享(内部产生内部消亡)的.

本地方法栈

Java虚拟机栈用于管理Java方法的调用, 而本地方法栈用于管理本地方法的调用. 在代码中native method就是一些本地方法. 同样有StackOverFlowError和OutOfMemoryError. HotSpot JVM中直接将本地方法栈和虚拟机栈合二为一.

参考

  1. 尚硅谷最新版宋红康JVM教程
  2. The Java® Virtual Machine Specification

comments powered by Disqus