走进JVM Java内存布局

原文来自《码出高效 Java开发手册》

写的非常好,推荐购买

内存是非常重要的系统资源 , 是硬盘和 CPU 的中 间仓库及桥梁 , 承载着操作系统和应用程序的实时运行。 JVM 内存布局规定了 Java 在运行过程中内存申请、分配、管理的策略 ,保证了 JVM 的高效稳定运行。不同的 JVM 对于内存的划分方式和管理机制存在着部分差异。结合 JVM 虚拟机规范 , 来探讨一下经典的 NM 内存布局 , 如图所示。

1. Heap (堆区)

Heap 是 OOM 故障最主要的发源地,它存储着几乎所有实例对象,堆由垃圾收集器自动回收,堆区由各子线程共享使用。通常情况下,它占用的空间是所有内存区域中最大的,但如果无节制地创建大量对象,也容易消耗完所有的空间。堆的内存空间即可以固定大小,也可以在运行时动态地调整,通过如下参数设定初始值和最大值,比如 -Xms256M -Xmx1024M,其中 -X 表示它是 JVM 运行参数, ms 是 memory start 的简称, mx 是 memory max 的简称,分别代表最小堆容量和最大堆容量。但是在通常情况下,服务器在运行过程中,堆空间不断地扩容与回缩,势必形成不必要的系统压力 , 所以在线上生产环境中 , JVM 的 Xms 和 Xmx 设置成一样大小,避免在 GC 后调整堆大小时带来的额外压力 。

堆分成两大块 新生代和老年代。对象产生之初在新生代 , 步入暮年时进入老年 代 , 但是老年代也接纳在新生代无法容纳的超大对象。新生代= 1 个 Eden 区+ 2 个 Survivor 区。绝大部分对象在 Eden 区生成 , 当 Eden 区装填满的时候 , 会触发 Young Garbage Collection , 即 YGC。垃圾回收的时候 , 在 Eden 区实现清除策略 , 没有被引用的对象则直接回收。依然存活的对象会被移送到 Survivor 区 , 这个区真是名副其实的存在。 Survivor 区分为 S0 和 S1 两块内存空间 ,送到哪块空间呢?每次 YGC 的 时候, 它们将存活的对象复制到未使用的那块空间,然后将当前正在使用的空间完全清除 , 交换两块空间的使用状态。如果 YGC 要移送的对象大于 Survivor 区容量的上限 ,则直接移交给老年代。假如一些没有进取心的对象以为可以一直在新生代的 Survivor 区交换来交换去,那就错了。每个对象都有一个计数器,每次 YGC 都会加 1 。 -XX:MaxTenuringThreshold 参数能配置计数器的值到达某个阀值的时候 , 对象从新生代晋升至老年代。如果该参数配置为 1,那么从新生代的 Eden 区直接移至老年代。 默认值是 15 , 可以在 Survivor 区交换 14 次之后 , 晋升至老年代。 对象晋升流程图如图所示。

图 中,如果 Survivor 区无法放下, 或者超大对象的闹值超过上限, 则尝试在老年代中进行分配 ; 如果老年代也无法放下, 则会触发 Full Garbage Collection , 即 FGC。 如果依然无法放下, 则抛出 OOM。 堆内存出现 OOM 的概率是所有内存耗尽 异常中最高的。 出错时的堆内信息对解决问题非常有帮助 , 所以给 JVM 设置运行参数 -XX:+HeapDumpOnOutOfMemoryError ,让 JVM 遇到 OOM 异常时能输出堆内信息,特别是对相隔数月才出现的 OOM 异常尤为重要。

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

2. Metaspace (元空间)

本书源码解析和示例代码基本采用 JDK11 版本, JVM 则 为 Hotspot。 早在 JDK8 版本中, 元空间的前身 Perm 区已经被淘汰。 在 JDK7 及之前的版本中, 只有 Hotspot 才有 Perm 区,译为永久代 , 它在启动时固定大小, 很难进行调优, 并且 FGC 时会移动类元信息。 在某些场景下, 如果动态加载类过多, 容易产生 Perm 区的 OOM。 比如某个实际 Web 工程中, 因为功能点比较多, 在运行过程中, 要不断动态加载很多的类, 经常出现致命错误:

1
"Exception in thread 'dubbo client x.x connector' java.lang.OutOfMemoryError: PermGenspace"

为了解决该问题 ,需要设定运行参数 -XX:MaxPermSize=1280m ,如果部署到新机器上, 往往会因为 JVM 参数没有修改导致故障再现。 不熟悉此应用的人排查问题时往往苦不堪言, 除此之外, 永久代在垃圾回收过程中还存在诸多问题。 所 以, JDK8 使用元空间替换永久代。 在 JDK8 及以上版本中, 设定 MaxPermSize 参 数, JVM 在启动时并不会报锚, 但是会提示 : Java HotSpot 64Bit Server VM warning: ignoring option MaxPem1Size=2560m; support was removed in 8.0。

区别于永久代 , 元空间在本地内存中分配。 在 JDK8 里, Perm 区中的所有内容中字符串常量移至堆内存, 其他内容包括类元信息、字段、静态属性、方法、常量等都移动至元空间内, 比如图 4-10 中的 Object 类元信息、静态属性 System.out、整型常量 10000000 等。图 4-10 中显示在常量池中的 Strirg, 其实际对象是被保存在堆内 存中的。

3. JVM Stack ( 虚拟机栈)

栈( Stack )是一个先进后出的数据结构 , 就像子弹的弹夹 , 最后压入的子弹先发射 , 压在底部的子弹最后发射 , 撞针只能访问位于顶部的那一颗子弹。

相对于基于寄存器的运行环境来说 ,JVM 是基于栈结构的运行环境。栈结构移植性更好 ,可控性更强。JVM 中的虚拟机栈是描述 Java 方法执行的内存区域,它是线程私有的。栈中的元素用于支持虚拟机进行方法调用 , 每个方法从开始调用到执行完成的过程 , 就是栈帧从入栈到出栈的过程。在活动线程中 , 只有位于栈顶的帧才是有效的 , 称为当前栈帧。正在执行的方法称为当前方法 , 栈帧是方法运行的基本结构。 在执行引擎运行时 , 所有指令都只能针对当前栈帧进行操作。而 StackOverflow Error 表示请求的栈溢出 , 导致内存耗尽 , 通常出现在递归方法中。 JVM 能够横扫干军 , 虚拟机栈就是它的心腹大将 , 当前方法的栈帧 , 都是正在战斗的战场 , 其中的操作栈是参与战斗的士兵。操作栈的压栈与出栈如图所示。

虚拟机栈通过压栈和出栈的方式, 对每个方法对应的活动栈帧进行运算处理, 方法正常执行结束, 肯定会跳转到另一个栈帧上。 在执行的过程中, 如果出现异常, 会进行异常回溯, 返回地址通过异常处理表确定。 栈帧在整个 JVM 体系中的地位颇高, 包括局部变量表、操作栈、动态连接、方法返回地址等。

(1)局部变量表

局部变量表是存放方法参数和局部变量的区域。 相对于类属性变量的准备阶段和初始化阶段来说, 局部变量没有准备阶段, 必须显式初始化。 如果是非静态方法, 则在 index[0]位置上存储的是方法所属对象的实例引用, 随后存储的是参数和局部变量。 字节码指令中的 STORE 指令就是将操作栈中计算完成的局部变量写回局部变量表的 存储空间内。

###(2 )操作栈

操作栈是一个初始状态为空的桶式结构栈。在方法执行过程中,会有各种指令往栈中写入和提取信息。JVM 的执行引擎是基于栈的执行引擎,其中的栈指的就是操作栈。字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的 stack 属性中,下面用一段简单的代码说明操作栈与局部变量表的交互:

1
2
3
4
5
6
7
public int simpleMethod() {
int x= 13;
int y = 14;
int z = x + y;

return z;
}

详细的字节码操作顺序如下 :

第1处说明:局部变量表就像一个中药柜 , 里面有很多抽屉 , 依次编号为 0, 1, 2, 3, … , n , 字节码指令 ISTORE_1 就是打开 1 号抽屉 , 把栈顶中的数 13 存进去。栈是一个很深的竖桶, 任何时候只能对桶口元素进行操作 ,所以数据只能在栈顶进行存取。某些指令可以直接在抽屉里进行 , 比如 iinc 指令 , 直接对抽屉里的数值进行 +1 操作。 程序员面试过程中 , 常见的 i++ 和 ++i 的区别 ,可以从字节码上对比出来 ,如 表 4-1 所示。

在表 4-1 左列中 ,iload l 从局部变量表的第 1 号抽屉里取出一个数 ,压入栈顶 , 下一步直接在抽屉里实现 +1 的操作 , 而这个操作对栈元素的值没有影响。 所以 istore_2 只是把栈顶元素赋值给 a ,表格右列 , 先在第 1 号抽屉里执行 +1 操作 , 然后通过 iload_1 把第 1 号抽屉里的数压人栈顶, 所以 istore_2 存人的是 +1 之后的值。

这里延伸一个信息 , i++ 并非原子操作。 即使通过 volatile 关键字进行修饰 , 多个线程同时写的话 , 也会产生数据互相覆盖的问题。

(3 )动态连接

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

(4)方法返回地址

方法执行时有两种退出情况:

第一,正常退出,即正常执行到任何方法的返回字节码指令,如RETURN、 IRETURN 、 ARETURN等;

第二,异常退出。无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式:

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

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

本地方法栈( Native Method Stack )在 JVM 内存布局中 ,也是线程对象私有的,但是虚拟机栈“主内 ”, 而本地方法栈”主外“。这个“内外 ”是针对 JVM 来说的,本地方法栈为 Native 方法服务。线程开始调用本地方法时,会进入一个不再受 JVM 约束的世界。本地方法可以通过 JNI ( Java Native Interface )来访问虚拟机运行时的数据区 ,甚至可以调用寄存器,具有和 JVM 相同的能力和权限。当大量本地方法出现时 势必会削弱 JVM 对系统的控制力,因为它的出错信息都比较黑盒。对于内存不足的情况 ,本地方法栈还是会抛出 native heap OutOfMemory。

重点说一下 JNI 类本地方法 , 最著名的本地方法应该是 System. currentTimeMillis() , JNI 使 Java 深度使用操作系统的特性功能,复用非 Java 代码。 但是在项目过程中 , 如果大量使用其他语言来实现 JNI , 就会丧失跨平台特性 ,威胁到程序运行的稳定性。假如需要与本地代码交互,就可以用中间标准框架进行解耦, 这样即使本地方法崩溃也不至于影响到 JVM 的稳定。当然, 如果要求极高的执行效率、 偏底层的跨进程操作等,可以考虑设计为 JNI 调用方式。

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

在程序计数寄存器( Program Counter Register, PC )中, Register 的命名源于 CPU 的寄存器, CPU 只有把数据装载到寄存器才能够运行。寄存器存储指令相关的现场信息,由于 CPU 时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。 这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器用来存放执行指令的偏移量和行号指示器等, 线程执行或恢复都要依赖程序计数器。程序计数器在各个线程之间互不影响,此区域也不会发生内存溢出异常 。

最后 ,从线程共享的角度来看,堆和元空间是所有线程共享的,而虚拟机栈、本地方法栈、程序计数器是线程内部私有的,从这个角度看下 Java 内存结构,如图所示。

打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2015-2023 高行行
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信