漫游JVM(一):JVM内存结构

271 阅读13分钟

关于Java虚拟机的概念有很多,我们先从JVM的内存结构说起,说起内存结构,可能会有些小伙伴会和内存模型混淆,两者虽然都是在说Java虚拟机的内存管理,但又有各自侧重的点:

  • JVM的内存结构:
关注单线程环境下,对象的整个生命周期,与程序执行,垃圾回收相关。
  • JVM的内存模型
关注多线程的工作内存与主内存的关系,与并发编程相关。

本节我们只讨论JVM的内存结构,关于JVM的内存模型,欢迎阅读: 漫游JVM(二):JVM内存模型

1 内存划分

JVM主要包含三大核心部分,【运行时数据区】、【类装载系统】和【执行引擎】分为,而本节要是说的内存结构,则是【运行时数据区】

运行时数据区,主要包含这五个主要区域:

  • 【堆】
  • 【栈】
  • 【方法区】
  • 【本地方法栈】
  • 【程序计数器】

JVM_memory_2

此外,还有【直接内存】,它不属于虚拟机【运行时数据区】的一部分,也不是Java虚拟机规范中定义的内存区域,主要通过NIO的方法直接使用计算机的内存。

2 五个主要区域

2.1 堆(Heap)

堆是线程共享部分,所有的线程都可以访问的区域,几乎所有的对象、数组,都在此分配内存,在JVM内存中占比最大,同时也是GC回收的主要目标。

  • 新生代 :包括一个Eden 和 两个Survivor(分别为from 和 to 区),在JVM默认的内存划分中,Eden与两个Survivor的比例为8:1:1。
  • 老年代:老年代中存放的就是较大或经历多次Minnor GC仍然存活的对象。老年代和新生代的比例是2:1。

JVM_heap_2

垃圾回收

JVM的垃圾回收,主要的回收目标,正是内存结构中的堆,而针对回收的不同区域划分,回收由分为:

  • Minnor GC :针对新生代的垃圾回收
  • Full GC:针对老年代的垃圾回收

对象在堆中

  1. 新建的对象,大部分情况下会直接入住Eden(伊甸园),但当新建的对象过大时(对象动态年龄判断),则会直接进入老年代。
  2. 程序一直在运行,不停的有新的对象进入到Eden中,当Eden中的空间已经增大至无法容纳新的对象时,则会触发一次Minnor GC,初次清理只清理【Eden区】的对象,并将清理后仍然存活的对象放入【From区】,并对这部分对象的年龄+1。
  3. 清理后的程序持续产生新的对象,又将触发一次Minnor GC,此时的清理将会清理【Eden区和From区】,清理完成后,将仍然存活的对象放入【To区】,对象年龄再+1。
  4. 如此重复执行2和3,【From区】和【To区】交替被清理以及使用,仍然存活的对象年龄持续增长,直到某些对象年龄到达15的时候(-XX:MaxTenuringThreshold=X X,默认15),该对象将进入老年代。
  5. 随着程序持续运行,不停有新的对象进入老年代,直到老年代空间已无法容纳新的对象时,则会触发一次Full GC。
  6. 此后,程序又讲继续运行,再经历了很多次的Full GC之后,仍然有对象无法被清理,直到出现【JVM花费了98%的时间进行垃圾回收,而只得到2%可用的内存,频繁的进行内存回收(最起码已经进行了5次连续的垃圾回收)】时,程序则会出现下面的异常:java.lang.OutOfMemoryError:GC overhead limit exceeded。遇到这个报错程序并不会直接崩溃,但表面程序马上要OOM了。这一步不一定会出现,也有可能直接到达7。
  7. 堆无法再容纳新的对象,JVM报出OOM异常,程序结束。

2.2 方法区(Method Area)/元空间(Metaspace)

参考:JVM学习——元空间(Metaspace)

  • 在 Java 虚拟机中,方法区( Method Area) 是可供各条线程共享的运行时内存区域。它存储了每一个类的结构信息,例如运行时常量池( Runtime Constant Pool)、字段和方法数据、构造函数和普通方法的字节码内容、还包括一些在类、实例、接口初始化时用到的特殊方法。
  • 由于此部分数据大部分都会常驻内存,生命周期非常长,所以又叫永久代。
  • 在JDK1.8之后,已经将永久带移除了,替换上场的是:元空间(Metaspace)。

2.3 虚拟机栈(VM Stack)

本段内容参考,感谢博主:JVM 系列 - 内存区域 - Java 虚拟机栈(三)

虚拟机栈,是线程独占的区域,每创建一个线程,虚拟机都会为这个线程创建一个虚拟机栈。既然是栈,它同样拥有栈的特性:先进后出,而“进”“出”的单位元素,就是栈帧(Stack Frame)

虚拟机栈和线程相关,而栈帧则和方法相关,每一个方法从调用开始到执行完成的过程,都是一个栈帧在虚拟机栈里面从入栈到出栈的过程。

在 Running 的线程,只有当前栈帧有效(Java 虚拟机栈中栈顶的栈帧),与当前栈帧相关联的方法称为当前方法。每调用一个新的方法,被调用方法对应的栈帧就会被放到栈顶(入栈),也就是成为新的当前栈帧。当一个方法执行完成退出的时候,此方法对应的栈帧也相应销毁(出栈)

从下图可以看出,每个【栈帧】都包含:

  • 【局部变量表】
  • 【操作数栈】
  • 【动态链接】
  • 【方法出口信息】

Stack Frame

局部变量表(Local Variable Table)

  • 每个栈帧中都包含一组称为局部变量表的变量列表,用于存放方法参数和方法内部定义的局部变量。在 Java 程序编译成 Class 文件时,在 Class 文件格式属性表中 Code 属性的 max_locals(局部变量表所需的存储空间,单位是 Slot) 数据项中确定了需要分配的局部变量表的最大容量。
  • 局部变量无初始值(实例变量和类变量都会被赋予初始值),类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予开发者定义的值。因此即使在初始化阶段开发者没有为类变量赋值也没有关系,类变量仍然具有一个确定的默认值。但局部变量就不一样了,如果一个局部变量定义了但没有赋初始值是不能使用的。

操作数栈(Operand Stack)

  • 与局部变量表一样,均以字长为单位的数组。不过局部变量表用的是索引,操作数栈是弹栈/压栈来访问。操作数栈可理解为java虚拟机栈中的一个用于计算的临时数据存储区。
  • Java 虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的 “栈” 就是操作数栈。
  • 当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,通过一些字节码指令从局部变量表或者对象实例字段中复制常量或者变量值到操作数栈中,也提供一些指令向操作数栈中写入和提取值,及结果入栈,也用于存放调用方法需要的参数及接受方法返回的结果。

局部变量表操作数栈密不可分,我们可以通过javap -c xxx.class的指令将字节码文件解析成有一定可读性的汇编语言,再通过指令集可理解JVM是如何执行代码的。

参考:深入理解Java虚拟机 - 字节码指令集

javap

执行流程简述:
0:iconst_1 将a压入【操作数栈的栈顶】
1:istore_1 将【操作数栈的栈顶】弹出的a存入【局部变量表】索引为1的Slot
2:iconst_2 将b压入【操作数栈的栈顶】
3:istore_2 将【操作数栈的栈顶】弹出的b存入【局部变量表】索引为2的Slot
此时,已通过【操作数栈】的“压入弹出”,将数据存入【局部变量表】中
4:iload_1 将a从【局部变量表】中加载进【操作数栈】
5:iload_2 将b从【局部变量表】中加载进【操作数栈】
此时,操作数栈中已经有a和b两个数据了
6:iadd 从【操作数栈】顶弹出两个变量(正好是a和b)执行加法运算,结果放在栈顶
7:bipush 将一个常量(10)压入【操作数栈】栈顶
9:imul 从【操作数栈】顶弹出两个变量(6步的结果和7步的常量)执行乘法运算,结果放在栈顶
10:istore_3 将【操作数栈的栈顶】弹出的8的结果,并存入【局部变量表】索引为3的Slot
11:iload 将c从【局部变量表】中加载进【操作数栈】
12:ireturn 将【操作数栈的栈顶】弹出并存作为结果返回

动态链接(Dynamic Linking)

  • 【符号引用】和【直接引用】在运行时进行解析和链接的过程,叫动态链接。
  • 【符号引用】:相当于名称(1.类的全限定名,2.字段名和属性,3.方法名和属性)
  • 【直接引用】:相当于地址(指向目标的指针、相对偏移量或者是一个能够直接定位到目标的句柄)
  • 在运行的过程中,将【符号引用】转换为【直接引用】,供程序调用的过程。
  • 运行时常量池(存储字面量和符号引用)中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载过程的解析阶段的时候转化为【直接引用】,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为【直接引用】,这部分称为动态连接。
人话:
1 字节码中方法的调用,是以【符号引用】也就是方法名或字段名来做参数的
2 这部分【符号引用】总要转化为【直接引用】,才能真正被识别,根据识别的时机和方式分为:
    A:静态解析:在类加载的解析阶段就做好了转换
    B:动态解析(连接):在运行时期才做转换
  • 每个栈帧都包含一个指向运行时常量池(JVM 运行时数据区域)中该栈帧所有的【属性】和【方法】的引用,持有这个引用是为了支持方法调用过程中的动态连接。
  • 编译时期做的静态解析:
从下面字节码指令看出【0: getstatic #2 // Field i:I】这行字节码指令指向 【Constant pool 中的 #2】,
而【#2 中指向了 #3 和 #20】 为【符号引用】,在类加载过程的解析阶段会被转化为【直接引用】(指向方法区的指针)。

// java 代码
 public Test test() {
    return new Test();
 }

// 字节码指令
Constant pool:
   #1 = Methodref          #4.#19         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#20         // com/alibaba/uc/Test.i:I
   #3 = Class              #21            // com/alibaba/uc/Test
   #4 = Class              #22            // java/lang/Object
   
   备注:上面记录的是父类(#4),类(#3),字段【符号引用】(#2),方法【符号引用】(#1)
        下面的是普通的以Utf8为字符集的字符串(组合起来就是【符号引用】)
        
   #5 = Utf8               i
   #6 = Utf8               I
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcom/alibaba/uc/Test;
  #14 = Utf8               test
  #15 = Utf8               ()I
  #16 = Utf8               <clinit>
  #17 = Utf8               SourceFile
  #18 = Utf8               Test.java
  #19 = NameAndType        #7:#8          // "<init>":()V
  #20 = NameAndType        #5:#6          // i:I
  #21 = Utf8               com/alibaba/uc/Test
  #22 = Utf8               java/lang/Object

public int test();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: getstatic     #2                  // Field i:I
         3: areturn
      LineNumberTable:
        line 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       4     0  this   Lcom/alibaba/uc/Test;

方法出口信息

当一个方法开始执行后,只有两种方式可以退出这个方法。

  1. 第一种方式是执行引擎遇到任意一个方法返回的字节码指令(例如:return),这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)
  2. 另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)
  • 无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。这块区域就是记录这些信息。
  • 方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整程序计数器的值以指向方法调用指令后面的一条指令等。

2.4 本地方法栈(Native Method Stack)

  • 和虚拟机一样,都是线程独占的区域,本地方法栈的功能和特点类似于虚拟机栈,均具有线程隔离的特点以及都能抛出StackOverflowError和OutOfMemoryError异常。 不同的是,本地方法栈服务的对象是JVM执行的native方法,而虚拟机栈服务的是JVM执行的java方法。

  • 与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

2.5 程序计数器(Program Counter Register)

  • 程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  • 由于Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

  • 此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

3 小结

本节主要的内容都是围绕JVM的内存结构:

  • JVM
    • 类装载系统
    • 执行引擎
    • 运行时数据区
      • 堆(Heap)
      • 方法区(Method Area)/元空间(Metaspace)
      • 虚拟机栈(VM Stack)
        • 局部变量表(Local Variable Table)
        • 操作数栈(Operand Stack)
        • 动态链接(Dynamic Linking)
        • 方法出口信息
      • 本地方法栈(Native Method Stack)
      • 程序计数器(Program Counter Register)