0%

Java 内存管理 - SRE 的必修课

在过去三年 SRE 的经历中,遇到过多起因为 JVM OOM 导致的线上故障。其中印象最深的一次排查经历:收到故障外呼后,几个大男人现场梳理业务链路,大眼瞪小眼,最后发现根因竟然是部分网关机器动态加载数据库中的 groovy 脚本,导致 Metaspace out of memory 报错,影响了部分 XX 商户的代扣业务,最终落了一个 P4 故障 T…T

但是之后很长一段时间内,都不太明白 Metaspace 是什么,为什么会耗尽?和 perm 区的关系是?不同线程本地变量和全局对象的关系?

正好趁这次机会,系统性的整理和分享一下 :)

一、走近 Java

首先预热一下,简单解释几个常见名词:JVM -> JRE -> JDK

  • JVM(Java Virtual Machine):Java 虚拟机,它实现了一次编译到处运行,例如 HotSpot 等
  • JRE(Java Runtime Environment),JRE 是支持 Java 程序运行的标准环境。包含 Java SE API 子集 / 虚拟机
  • JDK(Java Development Kit):Java 程序开发的最小环境。包含 程序语言 / 虚拟机 / 基础类库等,例如 OpenJDK 等

参考大图:

书中有一段总结挺有意思的,分享一下:“Oracle 收购 Sun 是 Java 发展历史上一道明显的分界线。在 Sun 掌舵的前十几年里,Java 获得巨大成功,同时也渐渐显露出来语言演进的缓慢与社区决策的老朽;而在 Oracle 主导 Java 后,引起竞争的同时也带来新的活力,Java 发展的速度要显著高于 Sun 时代。Java 的未来是继续向前、再攀高峰,还是由盛转衰、锋芒挫缩,你我拭目以待”

二、自动内存管理

进入正文!

Java 内存区域

网上很多文章因为 java 版本的问题,存在不同程度的过时。

所以花了一点时间,尝试通过「堆」和「栈」两个视角,将 java8 的内存分布重新绘制一遍加深理解:

(p.s. 如果有不对的地方辛苦帮忙指正)

关键点说明

1. 关于 Perm 区 & Metaspace

为了解决 持久代内存溢出 & 不同虚拟机融合等目的,持久代(PermGen)在 1.8 以后被 Metaspace 取代。

我个人理解最大不同在于:1.8 之前,持久代与 Heap & Stack 都归属 虚拟机内存 ,而 Metaspace 侧使用的 本地内存 (native memory), 默认不做限制

既然没有限制,文章开头故障为什么还会发生呢??
因为通常还是习惯设置 -XX:MaxMetaspaceSize 参数。。所以如果代码编写不当,类占据的空间还是很可能超过指定的空间大小,造成java.lang.OutOfMemoryError: Metaspace 异常 :(

2. 关于栈帧(Stack Frame)

程序运行本质上是方法的套娃调用,也就是不断入栈与出栈的过程。

而每个栈帧(Stack Frame)中,本地变量(Local Variables)与 Heap 的关系如下:

3. 关于运行时常量池(Run-Time Constant Pool)

1)首先理解 class 文件的常量池(Constant Pool)& 符号应用

参考下面的例子,通过 javac + javap查看编译后的 .class 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class Scratch {
int num = 10;
public void methodA(){
System.out.println("methodA()....");
}
public void methodB(){
System.out.println("methodB()....");
methodA();
num++;
}
}

// 1. javac Scratch.java
// 源代码转化为字节码(byte code = 1111_1111),
// 2. javap -v Scratch.class
// The `javap` tool is used to get the information of any class or interface.
➜ test git:(master) ✗ javap -v Scratch
Warning: Binary file Scratch contains test.Scratch
Classfile /Users/henry/IdeaProjects/Head-First-Design-Patterns/src/test/Scratch.class
Last modified Aug 15, 2021; size 554 bytes
MD5 checksum 1dac5a22a5ccc66bfd64ee3185a1587e
Compiled from "Scratch.java"
public class test.Scratch
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #9.#20 // java/lang/Object."<init>":()V
#2 = Fieldref #8.#21 // test/Scratch.num:I
#3 = Fieldref #22.#23 // java/lang/System.out:Ljava/io/PrintStream;
#4 = String #24 // methodA()....
#5 = Methodref #25.#26 // java/io/PrintStream.println:(Ljava/lang/String;)V
#6 = String #27 // methodB()....
#7 = Methodref #8.#28 // test/Scratch.methodA:()V
#8 = Class #29 // test/Scratch
#9 = Class #30 // java/lang/Object
#10 = Utf8 num
#11 = Utf8 I
...

可以看到 class 文件包含一段 Constant pool 区域,用于存放编译期生成的各种字面量( Literal )和 符号引用(Symbolic References)。不难理解,在编译阶段,并不知道所引用类 / 方法的地址(实际地址),所以将 符号引用 保存至变量池(Constant pool)

  1. 其中第一列 #1#2 等等代表 符号引用(symbolic references)
  2. methodB 调用 methodA 对应的指令是 9: invokevirtual #36 // Method methodA:()V
2)所以 Run-Time Constant Pool 是什么?

先来回顾 jvm 加载一个类时,会经历 加载 -> 连接(验证 | 准备 | 解析) -> 初始化 三个阶段。

首先在第一步 加载阶段:虚拟机加载 Class 文件后,会在内存方法区中生成这个类的 java.lang.Class 对象,供外部访问。同时将上文常量池中的符号引用(字段 / 方法 / 类的引用)转移至 Run-Time Constant Pool 中。

然后将对应的「符号引用」转化为「直接引用」(实际运行时内存布局中的入口地址),这个过程叫做“方法调用”,而它又分为以下两种:

  1. 解析调用 :在 连接 最后一步的 解析 阶段,完成直接引用的转化。
    例如静态方法、私有方法、实例构造器、父类方法,以及被 final 修饰的实例方法,在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的,所以在类加载时就能完成直接引用的转化。
  2. 分派调用(Dispatch):每一次运行期间确认直接引用
    1. 静态分派:重载(Oveload)- 根据静态类型决定重载的版本
    2. 动态分派:重写(Override)- 根据对象的实际类型,选择重写的方法
3)总而言之

运行时常量池(Run-Time Constant Pool)保存的是 class 文件常量池构建的符号引用,同时包含翻译后真实内存地址的直接引用。

p.s. 我们常说的 动态连接(Dynamic Linking):指的是在开头内存分布大图中,栈帧 (Stack Frame) 存在一个指向 Run-Time Constant Pool 的连接

三、垃圾收集器与内存分配策略

  1. 对象是否存活?
    • 引用计数算法:引用为 0 的对象可以被当作垃圾收集(循环引用 & 线程安全等问题)
    • 可达性分析法:从 gc roots 开始,引用关系遍历对象图,能被遍历到的对象就判定为存活的,其余的对象判定为死亡。
      gc roots 是什么?
      例如全局引用(例如静态变量)& 执行的上下文(栈帧中的本地变量)
  2. 分代收集理论:
    • 对象初始化 -> Eden
    • Eden 空间不足 -> Minor GC(YGC) - 标记 + 复制
      • (从 Eden&S0 复制到 S1 或 老年代,然后交换 S0 与 S1,同时年龄 +1)
    • 老年代空间不足 -> Major GC - 标记 + 整理
      • (避免碎片的情况)
    • heap 满了 -> Full GC - metaspace & 整个 heap 进行回收

关于垃圾回收相关的知识网上遍布都是,就简单 copy 了一下自己的读书笔记,暂时不展开班门弄斧了。

The End

java 小白历险记,文中如有错误请多包涵,欢迎指正交流。
3FB01AAE-67BF-4755-B6ED-0A301FFB3B36_1_105_c

参考

  1. 《深入理解 JVM 虚拟机》
  2. 《解析与分派》
  3. JEP 122: Remove the Permanent Generation
  4. ...