Java虚拟机:JVM内存布局

JVM内存布局规定了Java在运行过程中内存申请、分配和管理的策略,保证了JVM的高效稳定运行。

结合JVM规范,来探讨一下经典的JVM内存布局,下面的内存布局基于Jdk1.8,JVM是HotSpot

 

1.Heap(堆区)

Heap是OOM故障主要的发源地,它存储几乎所有的实例对象,堆由垃圾回收器自动回收,堆区各子线程共享

由图所示,堆区由新生代和老年代组成,而 新生代 = 1个Eden区 + 2个Survivor 区。

绝大部分对象在Eden区生成,当它满了之后,会触发Young GC(也成为Minor GC)。垃圾回收的时候,Eden区实现清除算法(标记-复制算法),没有被引用的对象会被直接回收,而存活下来的对象会被移送到Survivor区。

每次YoungGC的时候,这些存活的对象都会被复制到未被使用的那块空间,然后将正在使用的那块survivor区清空,即交换两块空间的使用状态

如果YGC移送的对象survivor都放不下了,那么就直接移送到Old区。

每个对象都有一个计数器,用于记录经历YGC的次数,每次YGC都会+1。当达到了一个阈值之后,这个对象就会被移到老年代。这个阈值默认为15,可以通过-XX:MaxTenuringThreshold参数能够配置。对象分配与简要GC流程图如下:

 若不同的JVM实现及不同的回收机制中,堆内存划分的方式是不一样的。

 参数:

  • -Xms:-X表示JVM的运行参数,ms是memory start,表示初始的堆空间大小,如-Xms128M
  • -Xmx:mx是memory max,表示堆空间的最大可用内存。
  • 在生产环境中,一般设置此两个参数相同,避免对空间不断扩容和回缩。
  • -XX:MaxTenuringThreshold:配置对象YGC时从新生代晋升为老年代的计数器阈值。
  • -XX:HeapDumpOnOutOfMemoryError:遇到OOM时能够输出堆内信息

2.Metaspace(元空间)

在jdk8中,元空间的前身Perm区已经被淘汰。在jdk7及之前的版本中,只有Hotspot才有Perm区,它启动时固定大小,很难调优,如果动态加载类过多,容易产生Perm区的OOM。

Metaspace在本地内存中分配。jdk8中,Perm区中的所有内容中字符串常量移到了堆内存,其他内容包括类元信息、字段、方法、常量等移到元空间

其中,图中的CodeCache也是存在于元空间的

各线程共享metaspace。

 3.JVM Stack(虚拟机栈)

JVM是基于栈结构的运行环境。JVM的虚拟机栈是描述Java方法执行的内存区域它是线程私有的,每个线程都有自己独立的栈帧

每个方法从开始调用到执行结束,就是栈帧从入栈到出栈的过程。在活动线程中,只有栈顶的帧才是有效的,成为当前栈帧。栈帧是方法运行的基本结构。

操作栈的压栈与出栈如下图所示:

 虚拟机栈通过压栈和出栈的方式,堆每个方法对应的活动栈帧进行运算处理,方法正常执行结束会跳到另一个栈帧,如果出现异常,会进行异常回溯,返回地址通过异常处理表确定。

栈帧在整个JVM体系的地位颇高,包括局部变量表、操作栈、动态链接、方法返回地址等。

(1)局部变量表

存放方法参数和局部变量的区域。局部变量没有准备阶段,必须显式初始化。如果是非静态方法,在局部变量表位置第一个位置index[0]上存放的是方法所属对象的实例引用,也就是this。随后存储的是参数和局部变量。

字节码指令STORE就是将操作栈中计算完成的局部变量写回局部变量表中。

(2)操作栈

JVM执行引擎是基于栈的执行引擎,其中的栈就是指操作栈。它的初始状态为空的桶式栈结构。栈的深度可以在方法元信息的stack属性中看到。

下面通过简单的代码说明操作栈与局部变量表的交互:

public int inc() {
    int x = 13;
    int y = 14;
    int z = x + y;
    return z;
}

字节码:

public int inc();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1    // 最大栈深度为2,局部变量表4个,参数个数1(这里指的是index[0]中的this)
         0: bipush        13         // 常量压入栈
         2: istore_1                 // 保存到局部变量表的slot_1
         3: bipush        14         // 常量压入栈
         5: istore_2                 // 保存到局部变量表的slot_2
         6: iload_1                   // 把slot_1压入栈
         7: iload_2                   // 把slot_2压入栈
         8: iadd                     // 把两个上方两个数取出来,在cpu加一下,并压栈
         9: istore_3                // 结果保存到局部变量表的slot_3
        10: iload_3                  // slot_3压入栈
        11: ireturn                  // 返回
      LineNumberTable:
        line 25: 0
        line 26: 3
        line 27: 6
        line 28: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      12     0  this   Lchapter5/TestClass;
            3       9     1     x   I
            6       6     2     y   I
           10       2     3     z   I

局部变量表就像一个中药柜,里面有很多抽屉,依次编号为0,1,2,。。,n,字节码istore_1就是打开1号抽屉,把13放进去。

栈是一个很深的竖桶,每次只能对桶口的元素操作。像上面的字节码,每次要对变量进行操作,都要先从常量池或者变量表把数据压入栈,有些指令是可以直接在变量表操作的,不需要压入栈,比如iinc指令。

这里就给出了i++和++i在字节码层面上的解释:

a=i++

0:iload_1      // 从1号抽屉取编号为1的变量(局部变量表),压入栈顶

1:iinc 1,1  // 对局部变量表(抽屉)中编号为1的变量,自增1

4:istore_2    // 把栈顶元素保存到局部变量表的slot_2(2号抽屉),此时,a是i为自增前的值

a=++i

0:iinc 1,1  // 对局部变量表slot_1自增1

3:iload_1      // 把slot_1压栈

4:istore_2     // 栈顶元素保存到slot_2

(3)动态链接

每个栈帧中包含一个在常量池中对当前方法的引用,目的是支持方法调用过程的动态链接。

(4)方法返回地址

方法执行退出有两种情况:第一,正常退出RETURN、IRETURN、ARETURN等。第二,异常退出。

无论哪种退出,都将会返回至方法当前被调用的位置。方法退出相当于弹出当前栈帧,退出可能有三种形式:

  • 返回值压入上层调用栈帧
  • 异常信息抛给能处理的栈帧
  • PC计数器指向方法调用后的下一条指令

4.Native Method Stacks(本地方法栈)

它是线程私有的。线程开始调用本地方法时,会进入不受JVM约束的世界,本地方法可以通过JNI(Java Native Interface)访问虚拟机运行时的数据区。

当大量本地方法出现时,会削弱JVM的控制力,而且,它的出错信息比较黑盒,难以发现和调试。

5.Program Counter Register(程序计数器)

Register命名来源于CPU寄存器,CPU只有把数据装载到寄存器才可以运行,寄存器存储指令相关的现场信息,由于CPU时间片轮限制,众多线程在并发执行过程中,任一个确定时刻,一个处理器或多核处理器中的一个内核只能执行某个线程的指令,这样必然导致经常中断和恢复。

因此,每个线程创建后,都会有自己的程序计数器和栈帧,PC用于存放执行指令的偏移量和行号指示器等,线程执行和恢复都需要依赖PC。此区域不会发生内存溢出。

从线程共享的角度来看,堆内存(OutofMemoryError:Java heap space)和元空间(OutofMemoryError:Metaspace)是线程线程共享的,而PC、本地方法栈(StackOverflowError)、虚拟机栈(StackOverflowError)是线程私有的。

原文地址:https://www.cnblogs.com/yn-huang/p/10759353.html