码猿笔记

V1

2022/05/11阅读:29主题:全栈蓝

JVM运行时数据区

前言

java引以为豪的就是内存自动化管理,不需要像C、C++等一样需要开发者手动获取内存、释放内存,对内存进行操作等,java在这方面做的非常好、非常方便。所以,了解java内存区域是怎么划分的是非常有必要的,面试的时候也是经常会问到的。

运行时数据区

Java 虚拟机定义了在程序执行期间使用的各种运行时数据区域,有些区域是随着虚拟机的创建而创建,随着虚拟机的退出而销毁。有些区域是随着线程的创建而创建,随着线程的退出而销毁。

运行时数据区
运行时数据区

运行时数据区分为线程共享区和线程私有区。
线程共享区是所有线程共享的内存区域包括方法区和堆区。
线程私有区是每个线程独有的一份内存区域,分为虚拟机栈、本地方法栈、程序计数器。

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响, 独立存储, 我们称这类内存区域为“线程私有”的内存。

因为JVM内部有完整的指令与执行的一套流程,所以在运行 Java 方法的时候需要使用程序计数器(记录字节码执行的地址或行号),如果是遇到本地方法(native方法),这个方法不是JVM来具体执行,所以程序计数器不需要记录了,这个是因为在操作系统层面也有一个程序计数器,这个会记录本地代码的执行的地址,所以在执行native方法时,JVM 中程序计数器的值为空(Undefined)。 另外程序计数器也是JVM中唯一不会 OOM(OutOfMemory)的内存区域。

虚拟机栈

虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。虚拟机栈是一个栈结构,属于后进先出(FILO)的数据结构。
JVM规范允许Java虚拟机堆栈具有固定大小或根据计算要求动态扩展和收缩。如果Java虚拟机堆栈具有固定大小,则每个Java虚拟机堆栈的大小可以在创建堆栈时独立选择。
Java虚拟机实现可以让程序员或用户控制Java虚拟机堆栈的初始大小,以及在动态扩展或收缩Java虚拟机堆栈的情况下,控制最大和最小大小。 以下异常情况与 Java 虚拟机堆栈相关:

  • 如果线程中的计算需要比允许的更大的Java虚拟机堆栈,则Java虚拟机会抛出一个StackOverflowError.
  • 如果Java虚拟机堆栈可以动态扩展,并且尝试进行扩展,但没有足够的内存来实现扩展,或者如果没有足够的内存来为新线程创建初始Java虚拟机堆栈,则Java Virtual机器抛出一个OutOfMemoryError.
虚拟机栈
虚拟机栈

栈帧

栈帧用于存储局部变量表、操作数栈、动态连接、返回地址等信息。栈帧中需要多大的局部变量表,需要多深的操作数栈在编译期间就已经被分析计算出来,并且写入到方法表的Code属性之中不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

局部变量表

栈帧包含一个变量数组,称为局部变量表。局部变量表顾名思义就是局部变量的表,局部变量表存放着8大基本类型、对象引用和returnAddress类型。

局部变量表是通过索引来寻址的,索引从0开始。基本类型long和double占用局部变量表中的两个局部变量,也就是局部变量数组中的连续两个空间,这样的值是通过使用最小的一个索引来寻址的。比如double存储的下标是n,但实际上它是占用了索引为n和n+1两个坑位的,通过索引n来寻址。索引n+1处的局部变量也能够加载并重新存入值,但这样索引n处的局部变量就无效了。

局部变量表存储的是基本类型、对象引用和returnAddress类型,所以在编译期局部变量表需要多大的内存空间都是确定的,在运行期不会改变局部变量表大小。一般来说,局部变量表32位长度已经完全够用,但是这个长度会随着处理器、 操作系统或虚拟机实现的不同而发生变化,如果是64位的话,虚拟机会采用高低位对齐的让64位看起来像32位一样。

Java虚拟机使用局部变量在方法调用时传递参数。在类方法调用中,任何参数都在从局部变量0开始的连续局部变量中传递。在实例方法调用时,局部变量0始终用于传递对正在调用实例方法的对象的引用(this在Java编程语言中)。随后在从局部变量1开始的连续局部变量中传递任何参数。

操作数栈

每个栈帧都包含一个后进先出 (LIFO) 栈,称为其操作数栈。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。操作数栈上的每个条目都可以保存任何Java虚拟机类型的值,操作数栈中的值必须以适合其类型的方式进行操作,例如整数加法的字节码指令iadd。它在执行时,最接近栈顶的两个元素的数据类型必须为int型,不能出现一个long和一个float使用iadd命令相加的情况。
操作数栈本质上是JVM执行引擎的一个工作区,也就是方法在执行时才会对操作数栈进行操作,如果代码不不执行,操作数栈其实就是空的。

动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。

返回地址

方法调用完成分为两种方式:方法正常调用完成和方法异常调用完成。

  • 方法正常调用完成:如果直接从Java虚拟机或作为执行显式语句的结果,该调用不会导致抛出异常,则该方法调用正常完成。如果当前方法的调用正常完成,则可能会向调用方法返回一个值。这发生在被调用的方法执行返回指令之一时,选择的返回指令必须适合返回值的类型(如果有)。在这种情况下,当前帧栈用于恢复调用者的状态,包括其局部变量和操作数栈,调用者的程序计数器会适当增加以跳过方法调用指令。然后在调用方法的帧中正常继续执行,并将返回值(如果有)推送到该帧栈的操作数栈中。

  • 方法异常调用完成:如果在方法内执行Java虚拟机指令导致Java虚拟机抛出异常,并且该异常不在方法内处理,则方法调用会突然完成。执行athrow指令 也会导致显式抛出异常,如果当前方法未捕获到异常,则会导致方法调用突然完成。突然完成的方法调用永远不会向其调用者返回值。

方法完成的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:

  • 恢复上层方法的局部变量表和操作数栈
  • 把返回值(如果有的话)压入调用者栈帧的操作数栈中
  • 调整PC计数器的值以指向方法调用指令后面的一条指令等

栈的优化技术——栈帧之间数据的共享

在一般的模型中,两个不同的栈帧的内存区域是独立的,但是大部分的 JVM 在实现中会进行一些优化,使得两个栈帧出现一部分重叠(主要体现在方法中有参数传递的情况),让下面栈帧的操作数栈和上面栈帧的部分局部变量重叠在一起,这样做不但节约了一部分空间,更加重要的是在进行方法调用时就可以直接公用一部分数据,无需进行额外的参数复制传递了。

栈帧之间数据的共享
栈帧之间数据的共享

本地方法栈

本地方法栈和虚拟机栈的作用差不多是一样的,只不过虚拟机栈是为java方法提供服务,而本地方法栈是为除了java外的native方法提供服务。你甚至可以认为虚拟机栈和本地方法栈是同一个区域,JVM规范无强制规定,各版本虚拟机自由实现,HotSpot直接把本地方法栈和虚拟机栈合二为一 。

JVM规范允许本地方法堆栈具有固定大小或根据计算要求动态扩展和收缩,如果本地方法堆栈具有固定大小,则每个本地方法堆栈的大小可以在创建该堆栈时独立选择。

Java虚拟机实现可以为程序员或用户提供对本地方法堆栈的初始大小的控制,以及在不同大小的本地方法堆栈的情况下,控制最大和最小方法堆栈大小。
以下异常情况与本机方法堆栈相关:

  • 如果线程中的计算需要比允许的更大的本机方法堆栈,Java 虚拟机将抛出一个StackOverflowError.
  • 如果本地方法堆栈可以动态扩展并尝试本地方法堆栈扩展,但内存不足,或者如果内存不足,无法为新线程创建初始本地方法堆栈,Java 虚拟机将抛出OutOfMemoryError.

堆区

Java虚拟机有一个在所有Java虚拟机线程之间共享的堆,堆是为所有类实例和数组分配内存的运行时数据区域,它是在虚拟机启动的时候创建的。 Java堆是虚拟机所管理的内存中最大的一块,我们常说的垃圾回收操作的区域就是堆。堆是为所有类实例和数组分配内存的运行时数据区域,如果是普通对象并且是局部变量,那么在局部变量表中存放的只是对象的引用,也就是存储的是对象的地址,实例还是存放在堆区。

堆可以是固定大小的,也可以根据计算的需要进行扩展,如果不需要更大的堆,则可以收缩。堆的内存在物理上不需要是连续的,逻辑上是连续的即可,可通过参数-Xms(设置堆内存初始值或最小值)和-Xmx(设置堆内存最大值)来对堆内存大小进行扩展。
Java虚拟机实现可以让程序员或用户控制堆的初始大小,如果堆可以动态扩展或收缩,还可以控制最大和最小堆大小。以下异常情况与堆相关联:

  • 如果对象没有足够的内存去分配的话,Java虚拟机会抛出一个OutOfMemoryError.

由于现代垃圾回收器大部分采用分代收集,所以jdk8堆空间也是采用分代结构设计的。

堆空间分代划分
堆空间分代划分

方法区

方法区是可供各条线程共享的运行时内存区域。它存储了每一个类的结构信息,例如运行时常量池字段和方法数据、构造函数和普通方法的字节码内容、还包括一些在类、实例、接口初始化时用到的特殊方法。虚拟机的规范中方法区是堆中的一个逻辑部分,但是它却拥有一个叫做非堆(Non-Heap)的别名。 方法区是JVM对内存的“逻辑划分” ,在 JDK1.7 及之前很多开发者都习惯将方法区称为“永久代”,是因为在HotSpot虚拟机中,设计人员使用了永久代来实现了JVM 规范的方法区。在JDK1.8 及以后使用了元空间来实现方法区。
如果方法区域中的内存无法满足分配请求,Java 虚拟机将抛出一个OutOfMemoryError(OOM).

元空间

元空间用了储存类的元数据,它是方法区的实现。

在HotSpot虚拟机、Java7版本中已经将永久代的静态变量和运行时常量池转移到了堆中,其余部分则存储在JVM的非堆内存中,而Java8 版本已经将方法区中实现的永久代去掉了, 并用元空间代替了之前的永久代, 并且元空间的存储位置是本地内存,因为元空间占用的是本地内存,所以只要本地内存足够大,就不会出现OutOfMemoryError,但是也可以通过-XX:MetaspaceSize和-XX:MaxMetaspaceSize来分别配置元空间初始值和最大值,例如:-XX:MaxMetaspaceSize=256m

Java8 为什么使用元空间替代永久代,这样做有什么好处呢? 官方给出的解释是:移除永久代是为了融合HotSpot JVM与JRockit VM 而做出的努力,因为 JRockit 没有永久代,所以不需要配置永久代。 永久代内存经常不够用或发生内存溢出, 抛出异常 java.lang.OutOfMemoryError: PermGen。 这是因为在 JDK1.7 版本中, 指定的 PermGen 区大小为8M, 由于PermGen中类的元数据信息在每次FullGC的时候都可能被收集,回收率都偏低,成绩很难令人满意;还有为PermGen分配多大的空间很难确定, PermSize的大小依赖于很多因素,比如,JVM 加载的 class 总数、 常量池的大小和方法的大小等。

运行时常量池

运行时常量池(Runtime Constant Pool)是每一个类或接口的常量池(Constant_Pool)的运行时表示形式, 它包括了若干种不同的常量:从编 译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。
运行时常量池是方法区的一部分,它相对于 Class 常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生, 也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常 量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。

如果构建运行时常量池需要的内存超出JVM方法区域可用的内存,JVM将抛出OutOfMemoryError.

再理解

上面都是些概念,看起来枯燥不容易记住,接下来通过工具和字节码加深对常用的堆和虚拟机栈部分的理解。

虚拟机栈再理解

下面通过3个简单的例子再深入了解一下虚拟机栈区域。

1. 虚拟机栈的出入栈过程

public class JVMStack {

    public static void main(String[] args) {
        methodA();
    }

    public static void methodA(){
        methodB();
    }

    public static void methodB(){
        methodC();
    }

    public static void methodC(){
        System.out.println("methodC()");
    }
}

这段代码很简单,在main方法中调用methodA方法、methodA调用methodB、methodB调用methodC,因为每个方法在运行期间在内存中都是以栈帧的形式表示,所以启动的时候虚拟机栈入栈过程如下: main方法是线程中运行的,运行时先把main方法栈帧压入栈底,接着再陆续把methodA方法、methodB方法、methodC方法的栈帧压入虚拟机栈。

入栈过程
入栈过程

因为虚拟机栈后进先出,所以出栈顺序是相反的,methodC运行完出栈,接着就是methodB、methodA,直至main方法运行结束。

出栈过程
出栈过程

2. 栈帧执行流程

先看一段简单的代码:

public class FrameStacks {

    public static void main(String[] args) {
        FrameStacks frameStacks = new FrameStacks();
        frameStacks.add();
    }

    public int add(){
        int a = 2;
        int b = 3;

        int c = (a + b)*4;
        return c;
    }
}

这段代码很简单,就是a和b相加的结果乘以2,然后返回。那这段代码在JVM是怎么运行的呢。先看下栈帧的结构图

栈帧内存结构
栈帧内存结构

因为add是个实例方法,所以局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。也可以通过javap -v FrameStacks.class 来看下局部变量表结构,可以看到第0位索引存放的是this。

局部变量表
局部变量表

再通过javap -c FrameStacks.class命令看下add方法的字节码:

public int add();
    Code:
       0: iconst_2
       1: istore_1
       2: iconst_3
       3: istore_2
       4: iload_1
       5: iload_2
       6: iadd
       7: iconst_4
       8: imul
       9: istore_3
      10: iload_3
      11: ireturn

先简单了解下这几个字节码的意思:

  1. iconst_ :将一个int类型常量加载到操作数栈,n为将要操作的数值或者常量池行号
  2. istore_ :将一个int类型数值从操作数栈存储到局部变量表,n为局部变量的位置序号
  3. iload_ :将一个局部变量加载到操作栈,n为局部变量的位置序号
  4. iadd :int类型加法指令,运算后的结果自动入操作数栈
  5. imul : int类型乘法指令,运算后的结果自动入操作数栈
  6. ireturn :返回

再解释一下这段字节码的执行步骤:

0: iconst_2 -> 将a=2加载到操作数栈
1: istore_1 -> 把a从操作数栈出栈并储存到局部变量表下标为1的位置
2: iconst_3 -> 将b=3加载到操作数栈
3: istore_2 -> 把b从操作数栈出栈并储存到局部变量表下标为2的位置 
4: iload_1  -> 把a从局部变量表加载到操作数栈
5: iload_2  -> 把b从局部变量表加载到操作数栈
6: iadd     -> 把操作数栈栈顶的两个值a和b相加,相加的动作是在执行引擎做的,加完之后自动入操作数栈
7: iconst_4 -> 把常量4加载到操作数栈栈顶
8: imul     -> 把a和b的和乘以4,同样在执行引擎计算,计算之后自动入栈顶
9: istore_3 -> 把imul得到的结果c从操作数栈出栈并存储到局部变量表下标为3的位置
10: iload_3 -> 把c从局部变量表加载到操作数栈
11: ireturn -> 把操作数栈中的结果c返回

通过这些执行步骤可以发现,变量会频繁的出入操作数栈,一些运算操作也是在执行引擎进行的,操作数栈只是暂存变量。其实操作数栈就类似于我们说的缓存,出入栈就是删除和添加缓存,操作数栈是线程级别的缓存,随着线程的结束操作数栈也就over了。

3. 栈帧优化

先介绍一个工具JHSDB,JHSDB是一款基于服务性代理(Serviceability Agent,SA)实现的进程外调试工具。服务性代理是 HotSpot虚拟机中一组用于映射Java虚拟机运行信息的、主要基于Java语言(含少量JNI代码)实现的 API集合。通过JHSDB可以更好的理解栈帧优化。

3.1 JHSDB的启动 要使用必须要把sawindbg.dll复制一份到jre的bin目录下,我的jdk安装目录如下图,你的可能不一样: jdk文件夹 在jdk1.8.0_152\jre\bin目录下找到sawindbg.dll文件,复制一份到jre\bin目录下。
进入jdk1.8.0_152\lib目录,通过命令行执行java -cp .\sa-jdi.jar sun.jvm.hotspot.HSDB ,执行完时候会出现如下窗口
HSDB 3.2 修改上面例子的代码

public class FrameStacks {

    public static void main(String[] args) throws InterruptedException {
        FrameStacks frameStacks = new FrameStacks();
        frameStacks.add(4);
    }

    public int add(int c) throws InterruptedException {
        int a = 2;
        int b = 3;

        int d = (a + b)*c;
        Thread.sleep(Integer.MAX_VALUE);
        return d;
    }
}

修改后启动main方法

3.3 JHSDB的使用
在cmd命令行窗口输入jps 命令,找到进程ID

jps
jps

打开JHSDB窗口

HotSpot process
HotSpot process

打开之后可以看到有好几个线程启动,我们只要选择main线程就行,然后选择左上角Stack Memory查看栈内存

Stack Memory
Stack Memory

一个栈帧的开始是从Interpreted frame部分开始的。
第一个栈帧是当前正在执行的栈帧,在这里是Thread.sleep方法的栈帧,sleep方法是native方法,因此当前是本地方法栈,也从侧面证明了Hotspot虚拟机的本地方法栈和虚拟机栈是合二为一的。
第二个是方法add方法的栈帧、第三个是main方法的栈帧,可以看到add方法栈帧的局部变量表(locals area)部分和main方法栈帧的操作数栈(expression stack)有重合,也就是蓝色方框部分,这段区域就是共享部分,也是Hotspot虚拟机对栈帧的优化。

栈帧优化
栈帧优化

堆区再理解

下面通过JHSDB工具来再理解一下堆区的内存布局。
新建一个类HeapObject

package jvm;

public class HeapObject{

    public static void main(String[] args) throws InterruptedException {

        Student studentSun = new Student();
        studentSun.setUsername("sun");
        studentSun.setAge(18);

        System.gc();

        Student studentArron = new Student();
        studentArron.setUsername("Arron");
        studentArron.setAge(17);

        Thread.sleep(Integer.MAX_VALUE);
    }
}

class Student{

    private String username;

    private Integer age;

    // 省略get/set方法
}

在idea中Edit Configurations中添加虚拟机启动参数-XX:+UseConcMarkSweepGC -XX:-UseCompressedOops -Xmx10m,如图:

VM option
VM option

-XX:+UseConcMarkSweepGC的作用是使用CMS垃圾收集器。这样能更好的查看堆的分代情况,关于CMS垃圾收集器可自行了解,这里不做过多解释。
-XX:-UseCompressedOops 禁止指针压缩,JHSDB对指针压缩存在缺陷,建议关闭指针压缩
-Xmx10m 设置堆的最大内存为10M,在这里是为了JHSDB加快在内存中搜索对象的速度

然后在通过jps命令查看HeapObject进程ID
jps 进程id获取到之后通过JHSDB查看具体信息,在Tools -> Object Histogram中查看类的描述信息,通过类的全限定名搜索Student类。

Object Histogram
Object Histogram

找到之后双击查看类的描述,这里new 了两个Student对象,会看到两个对象信息。

Object of Type
Object of Type

然后通过下方的Inspect 按钮分别查看两个对象地址对应的哪个对象

Inspector
Inspector

从Inspector中我们可以看到
studentSun对象的内存地址是0x0000000013832558
studentArron对象的内存地址是0x0000000013400000

再在Tools -> Heap Parameters中查看堆内存分代情况

Heap Parameters
Heap Parameters
eden区的起始地址:[0x0000000013400000 ~ 0x00000000136b0000)

from 起始地址:[0x00000000136b0000 ~ 0x0000000013700000)

to 起始地址:   [0x0000000013700000 ~ 0x0000000013750000)

Tenured 起始地址: [ 0x0000000013750000 ~ 0x0000000013e00000)

对比一下studentArron对象的内存地址是在Eden区的范围内的,所以studentArron对象在Eden区,studentSun对象内存地址在Tenured区老年代的范围内,所以studentSun在Tenured区。

为什么这两个对象不在一个区呢?
这是因为在代码中显示调用了System.gc(),studentSun对象的分代年龄变大了又因为studentSun对象的引用还在被使用,所以就把它放到了Tenured区。

从这个例子中我们可以看到Hotspot堆内存结构目前使用的是分代划分,内存空间也是连续的,并且虽然Student类对象虽然是局部变量,但是实例还是在堆区分配的。

总结

本文主要简单介绍了JVM运行时数据区的一些概念,并通过工具和示例来加深一下理解。其实除了JVM运行时数据区还有一个直接内存的概念,本地内存不属于运行时数据区也不受JVM的内存限制,受本机内存限制,感兴趣的可以了解一下。

参考书籍 《深入了解JVM虚拟机》

能力一般,水平有限,如有错误,请多指出。 如果对你有用点个关注给个赞呗 更多文章可以关注一下我的微信公众号suncodernote

分类:

后端

标签:

Java

作者介绍

码猿笔记
V1