程序员Java

JVM 运行时数据区 - 多图预警、万字内存模型解读

2020-12-11  本文已影响0人  吃井不忘挖水人呢

运行时数据区概述

本文所有代码和介绍,基于 JDK 1.8.0.25

放上这个总结性的图,这个针对 hotspot 虚拟机运行时数据区所绘制的简图:

JVM 运行时数据区 - 多图预警、万字内存模型解读

本文要介绍的就是这个图中的 运行时数据区 ,也就是常说的内存模型。

对于 java 程序员来说,在虚拟机自动内存管理机制的帮助下,不容易出现内存泄漏和内存溢出。

有虚拟机管理内存,这一切看起来都很美好。但是,也正因为java把内存控制的权力给了java虚拟机,一旦出现内存泄漏和溢出方面的问题。

如果不了解虚拟机是怎么样使用内存的,那么排查错误将会成为一项异常艰难的工作

先把几个重要概念放上:

JVM 配置参数如下:

线程与内存模型

在 Hotspot JVM 里,每个线程都与操作系统的本地线程直接映射。

在运行时数据区区分了线程共享和线程私有。

至于原因嘛,后面会写到,到这先明确 虚拟机栈、本地方法栈、程序计数器 是线程私有的,所以生命周期和线程相同。

PC 寄存器

PC 寄存器(Program Counter Register),也就是上图中的程序计数器。

这个叫法更顺口,因为 Register 的命名源自 CPU 的寄存器,它存储指令相关的现场信息。

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

JVM 运行时数据区 - 多图预警、万字内存模型解读

比如在字节码反编译文件中:

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2    // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3    // String Hello World!
         5: invokevirtual #4    // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 8: 0
        line 9: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
}
复制代码

上述中左侧序号 0、3、5、8 就是指令地址,这些就是 PC 寄存器中存储的结构。

右侧则是虚拟机栈内的指令,这个以后再说。。。

PC 寄存器常见问题

  1. PC 寄存器没有 GC 和 OOM

PC 寄存器是唯一没有 OOM 的内存区域,没有 GC 的除了它还有虚拟机栈和本地方法栈。

  1. 使用 PC 寄存器存储字节码指令地址有什么用呢?为什么使用 PC 寄存器记录当前线程的执行地址呢?

因为 CPU 需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。

JVM 的字节码解释器就需要通过改变 PC寄存器 的值来明确下一条应该执行什么样的字节码指令。

  1. PC 寄存器为什么会被设定为线程私有?

由于 CPU 时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。

每个线程在创建后,都会产生自己的程序计数器和栈帧;这样的话,在线程中断或恢复中,程序计数器在各个线程之间可以互不影响。

虚拟机栈

首先看下总体性的概念:

JVM 运行时数据区 - 多图预警、万字内存模型解读

虚拟机栈运行原理

在一条活动的线程中,一个时间点上,只会有一个活动的栈帧。

其实从上面那个图中很容易可以理解。

Java 方法有两种返回函数的方式(正常函数返回,使用 return 指令;抛出异常),不管****那种****方式,都会导致栈帧将执行结果返回上一个栈帧,并且当前栈帧被弹出。

下面开始分别讲解栈帧的内部结果。

局部变量表

局部变量表(local variables)也叫做局部变量数据、本地变量表。

它是一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量;这些数据类型包括各类基本数据类型、对象引用(reference),以及 retuenAddress 类型。

到这里,就可以解释以前在多线程部分的一个提问:为什么局部变量不会存在线程安全问题?

太详细的就不解释了,写几个关键词示意一下:

虚拟机栈是线程私有、一个方法对应一个栈帧、方法内局部变量保存在虚拟机栈的局部变量表中、不同线程的栈不允许相互通信。

好嘞,然后把需要记的内容列一下,全是概念性的东西:

操作数栈

操作数栈(Operand Stack,其实就是一个数组),在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈和出栈。

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

关于操作数栈的知识要点:

动态链接

动态链接(Dynamic Linking)是指:每一个栈帧内部都包含一个指向 运行时常量池 中 该栈帧所属方法的引用 。比如:invokedynamic 指令。

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

写个测试代码:

public class DynamicLinkingTest {
    int num ;
    public static void main(String[] args) {
        DynamicLinkingTest dy = new DynamicLinkingTest();
        dy.test();
    }
    public void test(){
        dyTest();
    }
    public void dyTest(){
        num++;
    }
}
复制代码

编译后使用 javap -v DynamicLinkingTest.class 命令,显示:

Classfile /E:/test-demos/target/classes/jvm/DynamicLinkingTest.class
  Last modified 2020年10月17日; size 645 bytes
  MD5 checksum a4548dfdcf2a9d748f4e603d3bc7676a
  Compiled from "DynamicLinkingTest.java"
public class jvm.DynamicLinkingTest
  minor version: 0
  major version: 52
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #2                          // jvm/DynamicLinkingTest
  super_class: #7                         // java/lang/Object
  interfaces: 0, fields: 1, methods: 4, attributes: 1
Constant pool:
   #1 = Methodref          #7.#26         // java/lang/Object."<init>":()V
   #2 = Class              #27            // jvm/DynamicLinkingTest
   #3 = Methodref          #2.#26         // jvm/DynamicLinkingTest."<init>":()V
   #4 = Methodref          #2.#28         // jvm/DynamicLinkingTest.test:()V
   #5 = Methodref          #2.#29         // jvm/DynamicLinkingTest.dyTest:()V
   #6 = Fieldref           #2.#30         // jvm/DynamicLinkingTest.num:I
   #7 = Class              #31            // java/lang/Object
   #8 = Utf8               num
   #9 = Utf8               I
  #10 = Utf8               <init>
  #11 = Utf8               ()V
  #12 = Utf8               Code
  #13 = Utf8               LineNumberTable
  #14 = Utf8               LocalVariableTable
  #15 = Utf8               this
  #16 = Utf8               Ljvm/DynamicLinkingTest;
  #17 = Utf8               main
  #18 = Utf8               ([Ljava/lang/String;)V
  #19 = Utf8               args
  #20 = Utf8               [Ljava/lang/String;
  #21 = Utf8               dy
  #22 = Utf8               test
  #23 = Utf8               dyTest
  #24 = Utf8               SourceFile
  #25 = Utf8               DynamicLinkingTest.java
  #26 = NameAndType        #10:#11        // "<init>":()V
  #27 = Utf8               jvm/DynamicLinkingTest
  #28 = NameAndType        #22:#11        // test:()V
  #29 = NameAndType        #23:#11        // dyTest:()V
  #30 = NameAndType        #8:#9          // num:I
  #31 = Utf8               java/lang/Object
{
  int num;
    descriptor: I
    flags: (0x0000)

  public jvm.DynamicLinkingTest();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 6: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Ljvm/DynamicLinkingTest;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #2                  // class jvm/DynamicLinkingTest
         3: dup
         4: invokespecial #3                  // Method "<init>":()V
         7: astore_1
         8: aload_1
         9: invokevirtual #4                  // Method test:()V
        12: return
      LineNumberTable:
        line 11: 0
        line 12: 8
        line 13: 12
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      13     0  args   [Ljava/lang/String;
            8       5     1    dy   Ljvm/DynamicLinkingTest;

  public void test();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokevirtual #5                  // Method dyTest:()V
         4: return
      LineNumberTable:
        line 16: 0
        line 17: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Ljvm/DynamicLinkingTest;

  public void dyTest();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #6                  // Field num:I
         5: iconst_1
         6: iadd
         7: putfield      #6                  // Field num:I
        10: return
      LineNumberTable:
        line 20: 0
        line 21: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  this   Ljvm/DynamicLinkingTest;
}
SourceFile: "DynamicLinkingTest.java"
复制代码

方法返回值

方法返回值实际并不是一个值,而是这个方法返回地址,指向存放调用该方法的 PC寄存器的值。

它的作用就是回到调用方法位置,继续往下执行。

异常退出时,不会给他的上层调用者产生任何的返回值。

虚拟机栈两种异常

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

所以,这分别将导致以下两种常见异常:

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

使用递归方法的时候,如果出现问题将进入死循环,每一次调用将会进行压栈,最后就会出现这个异常。

测试代码如下:

public class StackOverflowTest {
    public static void main(String[] args) {
        int i = 0;
        recursion(i);
    }
    private static void recursion(int i){
        System.out.println(i);
        recursion(++i);
    }
}
复制代码

抛出异常:

Exception in thread "main" java.lang.StackOverflowError
    at sun.nio.cs.ext.DoubleByte$Encoder.encodeLoop(DoubleByte.java:617)
    at java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:579)
    at sun.nio.cs.StreamEncoder.implWrite(StreamEncoder.java:271)
    at sun.nio.cs.StreamEncoder.write(StreamEncoder.java:125)
    at java.io.OutputStreamWriter.write(OutputStreamWriter.java:207)
    at java.io.BufferedWriter.flushBuffer(BufferedWriter.java:129)
    at java.io.PrintStream.write(PrintStream.java:526)
    at java.io.PrintStream.print(PrintStream.java:597)
    at java.io.PrintStream.println(PrintStream.java:736)
    at jvm.StackOverflowTest.recursion(StackOverflowTest.java:15)
复制代码

可以根据修改 -Xss 来对比输出值的大小。

  1. 采用动态扩展的虚拟机栈在尝试扩展的时候无法申请到足够的内存,或者在创建新线程的时候没有足够的内存去创建对应的虚拟机栈,那么将会抛出 OutOfMemoryError 异常。

本地方法栈

在将本地方法栈之前,先简单介绍下几个概念:

简单来说,就是为了和 JVM 所在操作系统交互,或者和硬件交互。

本地方法栈(Native Method Stack)是管理本地方法的调用; 和管理 Java 方法的虚拟机栈相似。

本地方法栈的异常和虚拟机栈相同,工作原理也相同,就略过了。

执行过程:

  1. 在调用本地方法是,在本地方法栈压入本地方法;
  2. 由动态链接指向本地方法库;
  3. 由执行引擎进行调用执行。

堆 Heap

堆是 Java 内存结构中最重要的部分,也是知识点最多的一部分。

这里涉及到垃圾回收将会讲的比较简要(因为还没有学到),以后开专题再详细讲。

JVM 运行时数据区 - 多图预警、万字内存模型解读

还是一样,关于 JVM 部分都是先放概念:

堆空间设置

Java 堆在 JVM 启动时就已经创建了,可以通过相关指令设置其大小:

年轻代和老年代

堆区的进一步划分可以分为如下结构:

JVM 运行时数据区 - 多图预警、万字内存模型解读

年轻代和老年代默认占比分配为 1 : 2 ,默认配置为 -XX:NewRatio=2 。若修改为 3,则表示 老年代/年轻代 = 3.

一般情况下是不会修改这个比例的,只有我们明确知道对象的生命周期,才会针对进行更改。

而在年轻代中,Eden 和两个 Survivor 区的默认占比为 8 : 1 : 1

Survivor 0 和 1 区的因为需要相互复制,所以它们的空间大小是相同的。

修改年轻代和老年代空间占比的指令为 -XX:SurvivorRatio=8 ,相当于 Eden 区/一个Survivor区

对象在堆中的生命周期

对象在堆中的流程大致分为如下几个步骤:

  1. new 的对象先放 Eden 区。此区有大小限制。
  2. 当 Eden 区的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对 Eden 区进行垃圾回收(Minor GC),将 Eden 区中的不再被其他对象所引用的对象进行销毁,再加载新的对象放到 Eden 区。
  3. 然后将 Eden 区中的剩余对象移动到 Survivor 0区。
  4. 如果再次触发垃圾回收,此时上次幸存下来的放到 Survivor 0区的,如果没有回收,就会 放到 Survivor 1区。
  5. 如果再次经历垃圾回收,此时会重新放回 Survivor 0区,接着再去 Survivor 1区。 每一次在 Survivor 0 和 1区转移,都会为该对象的标志位 age 加一。 到达默认次数15后,下一次就可以晋升到老年代。 最大转移次数可以通过 -XX :MaxTenuringThreshold=<N> 进行设置。
  6. 在老年代,相对悠闲。当老年代内存不足时,再次触发GC: Major GC, 进行老年代的内存清理。
  7. 若老年代执行了 Major GC 之后发现依然无法进行对象的保存,就会产生 OOM 异常
JVM 运行时数据区 - 多图预警、万字内存模型解读

可得记住了,这是最正常的流程,后面还有其他特殊情况。。。再给你们放个示意图

不同颜色对应的区域参考上面的区域划分

JVM 运行时数据区 - 多图预警、万字内存模型解读

以上步骤是正常情况下,当然不可能所有包含所有情况,也存在一些特殊情况。

最后看下这个流程图,里面涉及的判断应该可以理解了。

JVM 运行时数据区 - 多图预警、万字内存模型解读

最后提两句,各个 GC 之间的差别,详细的以后再讲:

所谓的调优,就是让 GC 触发的次数尽量少,避免占用用户线程的资源。

TLAB - 线程本地分配缓存区

首先要弄明白的是,什么是 TLAB( Thread Local Allocation Buffer )?为什么要有 TLAB ?

JVM 运行时数据区 - 多图预警、万字内存模型解读

这玩意儿就是 JVM 自带的,它设计了我们学就是了嘛。。。。

基本的原因和情况如下:

  1. 堆区是线程共享区域,并发环境下从堆区中划分内存空间是线程不安全的。
  2. 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。
  3. JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 区。
  4. 多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为 快速分配策略

然后把 TLAB 的流程图放上:

JVM 运行时数据区 - 多图预警、万字内存模型解读

关于 TLAB 的其他知识点如下:

方法区

先看一眼栈、堆、方法区的关系:

JVM 运行时数据区 - 多图预警、万字内存模型解读

方法区的概念性知识和堆差不多:

永久代和元空间的本质区别是: 元空间不在虚拟机设置的内存中,而是使用本地内存。

啥是本地内存呢?

就是我们口语上的 8G、16G,这要是还能溢出我也是懵了。

所以对比元空间,永久代将更容易使 Java 应用产生 OOM 异常,即超过 -XX:MaxPermSize 的上限。

方法区参数设置

JDK7 及以前

JDK8 及以后

方法区内部结构

方法区内部结构包括:类型信息、域(字段)信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等。

方法区内还有一个非常重要的结构:运行时常量池

在 class 文件中,就有一个常量池表,包含了类名、方法名、参数类型、字面量等类型的符号引用。

字节码中直接调用常量池的信息,避免相同信息重复创建。

还有就是关于 StringTable 的位置:

上一篇下一篇

猜你喜欢

热点阅读