06-Java服务性能排障
Java 服务变慢怎样定位?从 CPU、GC 到内存保留链
服务响应越来越慢,是死循环、频繁 GC、堆外增长,还是等待数据库?一张内存曲线或一行 GC 日志无法回答这些问题。排障需要把系统现象、线程证据和对象保留关系放在同一条推理链上。
CPU、堆转储和 GC 的 Main.java 是三个独立示例,分别保存和运行,不把同名类合并。本文保留原稿实验的有限观察,诊断命令中的 PID 替换为待分析进程。
1. 先确认范围与资源含义
区分机器、进程、Java 堆与容器限制,避免从一个指标直接下结论。
先确定 CPU 指标的含义
先记录异常时间段、实例、请求量、错误率和延迟,再看 CPU。不同监控平台对百分比的定义不同:有的把一个逻辑核记为 100%,有的以整个机器为 100%;容器还受到 CPU 配额和限流影响。
因此,“CPU 100%”不能直接换算成“整台机器没有剩余计算能力”。需要确认指标的分母、机器核数、容器配额以及是否发生 CPU throttling。
如果进程 CPU 很低,但接口很慢,应优先检查网络调用、数据库等待、锁等待和任务排队。等待时间长,不一定消耗大量 CPU。
从进程定位到线程
在 Linux 上,可以先用 top 确认 Java 进程,再用 top -H -p PID 查看其线程。PID 替换为实际进程号。线程列表中的 TID 是操作系统线程号。
取得线程转储:
jcmd PID Thread.print -l
HotSpot 转储中的 nid 通常以十六进制显示本机线程号。Linux 上可以用下面的命令转换高 CPU 线程的十进制 TID,再搜索对应 nid:
printf '%x\n' TID
Thread.getId() 返回的 Java 线程 ID 与操作系统线程号不是同一个概念,不能直接把它转换为十六进制来匹配 nid。Windows 可用进程诊断工具查看线程 CPU;同样需要关联本机线程号与转储,而不是混用两种 ID。
jcmd 应针对目标 JVM,并满足操作系统访问权限。线程转储本身不提供完整的 CPU 时间排名,排名需要来自操作系统或线程 CPU 计时。
单份堆栈为什么不够?
一份堆栈表示采样瞬间的位置。如果线程刚好执行到日志、序列化或集合访问,不能马上认定这一行是根因。
在异常持续期间间隔采集多份转储,检查高 CPU 线程是否反复停留在同一调用路径。同时观察该线程的 CPU 时间是否继续增加。
RUNNABLE 也不等于“正在占满一个 CPU 核”。它描述 Java 线程状态,部分本机调用也可能显示为 RUNNABLE。线程状态、CPU 增量和重复堆栈共同出现,证据才更充分。
JVM 规范定义的主要区域
| 区域 | 主要作用 | 共享关系 |
|---|---|---|
| 程序计数器 | 记录当前线程执行位置;执行 native 方法时值未定义 | 每个线程独享 |
| Java 虚拟机栈 | 保存方法调用的栈帧 | 每个线程独享 |
| 本地方法栈 | 支持本地方法执行,具体组织方式依赖实现 | 通常与线程关联 |
| 堆 | 存放类实例与数组 | 所有线程共享 |
| 方法区 | 保存类相关结构、运行时常量池等 | 所有线程共享 |
操作数栈是栈帧中的组成部分,不是与 Java 虚拟机栈平级的另一个全局区域。运行时常量池也是方法区的一部分。JVM 规范第 2 章
共享意味着多个线程可以访问,不能推导出访问自动线程安全。两个线程修改同一个堆对象,仍要遵守同步规则。
内存不足如何对应到不同区域?
StackOverflowError 常见于递归过深;OutOfMemoryError: Java heap space 表示堆分配无法满足;OutOfMemoryError: Metaspace 指向类元数据内存问题。
但进程内存还有线程栈、直接内存、JIT 代码缓存、GC 数据结构和其他本机分配。-Xmx 限制的是最大 Java 堆,不能当作整个进程的内存上限。
容器内存不足时,进程也可能被操作系统终止,并没有机会抛出 Java OOM 或生成 heap dump。需要把 JVM 数据与容器事件、系统日志关联起来。
2. CPU 热点怎样落到具体代码
线程编号、转储与重复观察帮助识别持续热点。
一个可以直接运行的 CPU 热点示例
下面用一个有明确结束条件的循环制造计算负载。完整代码可复制到 Main.java,或支持 Java 17 的在线运行器;不需要下载配套文件。
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.util.Arrays;
import java.util.concurrent.CountDownLatch;
public class Main {
private static volatile boolean stop;
private static volatile long sink;
private static void busyLoop(CountDownLatch started) {
started.countDown();
long value = 1;
while (!stop) {
value = value * 1664525L + 1013904223L;
sink = value;
}
}
public static void main(String[] args) throws Exception {
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
if (!bean.isThreadCpuTimeSupported()) {
System.out.println("Thread CPU time is unsupported");
return;
}
bean.setThreadCpuTimeEnabled(true);
CountDownLatch started = new CountDownLatch(1);
Thread worker = new Thread(() -> busyLoop(started), "cpu-worker");
worker.start();
try {
started.await();
if (args.length > 0 && args[0].equals("probe")) {
System.out.println("READY pid=" + ProcessHandle.current().pid());
Thread.sleep(5000);
}
long before = bean.getThreadCpuTime(worker.getId());
Thread.sleep(300);
long after = bean.getThreadCpuTime(worker.getId());
ThreadInfo info = bean.getThreadInfo(worker.getId(), 32);
boolean found = info != null && Arrays.stream(info.getStackTrace())
.anyMatch(frame -> frame.getMethodName().equals("busyLoop"));
System.out.println("worker = " + worker.getName());
System.out.println("cpu increased = " + (after > before));
System.out.println("stack contains busyLoop = " + found);
} finally {
stop = true;
worker.join();
}
}
}
运行输出:
worker = cpu-worker
cpu increased = true
stack contains busyLoop = true
本地 HotSpot 实验观察到了上述结果。CPU 时间 API 需要 JVM 支持;计时值的精度与实际准确度也有区别。sleep(300) 只是留出采样窗口,不保证繁忙机器一定调度到工作线程,因此这不是应当用于所有环境的时间断言。ThreadMXBean 文档
把代码与外部线程转储对应起来
编译后运行 java Main probe。程序先打印 PID,提供五秒采集窗口,再完成检查并停止线程。在另一终端对该 PID 执行 jcmd PID Thread.print -l。
本地实验实际采到了 cpu-worker,其状态为 RUNNABLE,堆栈包含 Main.busyLoop。这里的循环没有业务阻塞点,同时线程 CPU 时间持续增加,能够解释计算负载的来源。
这只验证了定位方法,不能据此推算生产接口的吞吐量。示例中的 volatile 写入也参与了计算成本;真实业务需要分析实际调用路径。
根据热点选择下一步
| 重复出现的热点 | 应继续核对的证据 | 可能的处理方向 |
|---|---|---|
| 业务计算循环 | 输入规模、退出条件、算法复杂度 | 修复循环条件、减少重复计算 |
| 序列化或字符串处理 | 返回体大小、对象数量、分配速率 | 减少数据量和中间对象 |
| 大量重试 | 单请求调用次数、失败率、重试间隔 | 限制次数,增加退避,修复依赖故障 |
| GC 线程 | GC 日志、分配速率、回收后占用 | 排查分配压力或对象保留 |
持续热点适合进一步用采样剖析或 JFR 查看调用栈分布。火焰图的宽度与采用的采样事件有关;CPU 采样和墙钟时间采样回答的问题不同。不能把任何火焰图都解读成 CPU 消耗排名。
修复以后如何证明有效?
用可比较的流量、请求类型和机器资源重新观察 CPU、吞吐量、延迟与错误率。CPU 降低但吞吐量也明显下降,可能只是处理的请求变少。
对算法问题,还应验证边界输入与结果正确性;对重试问题,应观察依赖异常时的调用放大倍数。修复需要同时解释性能变化和业务行为。
3. 堆增长怎样追到保留者
直方图告诉我们对象多,引用链告诉我们为什么不能回收。
可达性比引用计数多解决了什么?
可达性分析从一组根引用出发,沿对象之间的引用关系寻找可访问对象。仍然处在有效强引用链上的对象,不能作为普通垃圾回收。
两个对象互相引用,并不必然造成内存泄漏。如果这一整组对象都无法从根访问到,循环本身不会阻止基于可达性的回收。
反过来,对象只有一个引用,也可能长期无法释放。只要那个引用来自长期存活的拥有者,就已经足够。Java 17 垃圾回收说明
一个静态缓存如何保留业务数据?
下面每份报表保存 256 KiB 字节数据,缓存保存四份。payloadBytes 仅统计数组数据长度,不包括对象头、Map 节点等开销。
import java.util.HashMap;
import java.util.Map;
public class Main {
private static final Map<String, Report> CACHE = new HashMap<>();
private static final class Report {
final byte[] content = new byte[256 * 1024];
}
private static void loadReports() {
for (int i = 0; i < 4; i++) {
CACHE.put("report-" + i, new Report());
}
}
private static long payloadBytes() {
return CACHE.values().stream()
.mapToLong(report -> report.content.length).sum();
}
public static void main(String[] args) {
loadReports();
System.out.println("entries = " + CACHE.size());
System.out.println("payload bytes = " + payloadBytes());
CACHE.remove("report-0");
System.out.println("after remove = " + CACHE.size());
CACHE.clear();
System.out.println("after clear = " + CACHE.size());
}
}
运行输出:
entries = 4
payload bytes = 1048576
after remove = 3
after clear = 0
loadReports() 返回后,其方法调用已经结束,缓存中的对象仍然有强引用链。clear() 切断了缓存到条目的引用,但这个输出不证明数组已经被 GC,也不证明进程内存立刻下降。
对象需要同时不存在其他有效强引用,并等待实际回收。回收后,JVM 还可能保留内存用于后续分配,而不会立即把全部空间归还操作系统。
浅大小与保留大小为什么不同?
Shallow Size 表示对象本身占用的空间;Retained Size 表示如果该对象不可达,随之能够释放的对象集合所占空间。
一个 Map 本身可能很小,却通过条目持有大量业务对象和数组。只按 Map 的浅大小排序,容易错过问题。
保留大小还取决于共享关系。如果某数组同时被其他独立路径持有,移除当前 Map 不足以使它不可达。查看支配树和通向 GC Roots 的路径,可以判断拥有关系。MAT 支配树说明
常见的长期保留路径
| 持有者 | 为什么持续保留 | 应检查什么 |
|---|---|---|
| 无容量限制的缓存 | 新键持续累积 | 上限、淘汰、过期清理 |
| 监听器集合 | 注册后没有移除 | 注销时机、组件生命周期 |
| 任务队列 | 消费速度低于提交速度 | 队列容量、负载与背压 |
| 存活线程的 ThreadLocal | 线程复用而值没有清理 | finally 中的 remove |
| 长期存活的类加载器 | 持有类及关联状态 | 加载器是否能够解除引用 |
这些路径也可能是正常业务需要。内存泄漏的判断还需要说明:数据已经失去业务用途,却因为错误的生命周期继续被持有。
先确认发生的是哪种内存问题
Java heap space、Metaspace、直接缓冲区内存不足、无法创建本机线程,涉及的资源不同。进程被容器 OOM Kill 终止,也不等于 JVM 抛出了 Java 堆 OOM。
先保存异常全文、启动参数、容器内存限制、故障时间段的 GC 日志和系统事件。没有区分错误类型,就调大 Java 堆,可能压缩其他本机内存的可用空间。
提前配置自动转储
HotSpot 可以在堆 OOM 时生成转储:
java -Xmx32m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=heap-oom.hprof Main
这里的 32 MiB 是用于下面本地故障示例的小堆,不是生产配置建议。目录需要存在、可写,并有足够磁盘空间。重复实验应更换文件名,已有同名文件会影响转储创建。Java 17 启动参数
存活进程也可以使用 jcmd PID GC.heap_dump 文件路径。生成转储可能带来明显停顿和磁盘写入,应结合实例状态和可用资源安排采集。转储包含业务对象内容,实际故障处理要按业务数据的访问规则保存。
完整复现一个无界持有问题
下面先保存 16 个对象,再等待回车继续分配。每个对象持有 512 KiB 数据,静态 List 始终保留它们。代码完整放在正文,复制到 Main.java 即可。
import java.util.ArrayList;
import java.util.List;
public class Main {
private static final List<Payload> RETAINED = new ArrayList<>();
private static byte[] emergencySpace = new byte[1024 * 1024];
private static final class Payload {
final byte[] data = new byte[512 * 1024];
}
public static void main(String[] args) throws Exception {
for (int i = 0; i < 16; i++) {
RETAINED.add(new Payload());
}
System.out.println("retained before growth = " + RETAINED.size());
System.out.println("pid = " + ProcessHandle.current().pid());
System.out.println("Press Enter to continue allocating");
System.in.read();
try {
while (true) {
RETAINED.add(new Payload());
}
} catch (OutOfMemoryError error) {
emergencySpace = null;
System.err.println(error.getClass().getName());
System.err.println(error.getMessage());
System.exit(1);
}
}
}
先执行 javac Main.java,再使用上一节的小堆参数启动。IDE 中则把参数填入 VM options,随后在控制台回车。普通在线运行器若不能配置 JVM 参数或不提供标准输入,不适合完整验证本实验。
程序会打印初始持有量与 PID。继续分配后,本地实验打印 java.lang.OutOfMemoryError 和 Java heap space,进程以失败状态退出,并生成 HPROF 文件。这是故障复现的预期结果,不是程序应当正常完成的业务行为。
示例预留了 1 MiB 空间,OOM 后解除其引用,给诊断输出留出余地,再主动失败退出。它没有尝试继续执行业务。内存耗尽时,普通异常打印也可能失败,预留空间不能保证所有环境都能完整输出;自动转储仍由 JVM 参数控制。
OOM 之前先观察对象直方图
在等待回车时,对程序打印的 PID 执行:
jcmd PID GC.class_histogram
本地采集结果中,Main$Payload 实例数为 16,字节数组类型 [B 位于主要占用对象之中。这与程序此时保存的 16 份数据相符。
直方图给出类的实例数和浅大小总和,不提供每个对象的完整持有路径。[B 还包括 JVM 与其他对象创建的字节数组,不能把这一行的全部大小都算到示例业务中。
默认直方图采集可能触发 GC,命令也有运行成本。需要知道采集的是回收前还是回收后数据,才能解释变化。jcmd 诊断命令
如何用 MAT 分析转储?
用 Eclipse Memory Analyzer 打开 .hprof 后,先查看 Histogram,找到数量或总占用异常的类型,再查看 Dominator Tree 和 Path to GC Roots。
本例按代码能够推导出的路径是:
Main 的静态字段 RETAINED
→ ArrayList
→ elementData 数组
→ Main$Payload
→ data 字节数组
分析时应在转储中核对这条路径,而不是仅因看到大量 byte[] 就认定缓存泄漏。MAT 的支配树表示支配关系,父子项不一定是源码中的直接引用字段。
本文本地验证了直方图、OOM 和转储文件生成;上述 MAT 步骤是打开转储后的分析方法,没有把未执行的图形界面分析当作实测结果。MAT 基础教程
区分泄漏、容量不足与瞬时大分配
| 情况 | 需要寻找的证据 | 处理方向 |
|---|---|---|
| 错误地长期保留对象 | 同类数据持续积累,业务已不再需要 | 修复清理、容量、生命周期 |
| 正常存活数据超过容量 | 保留内容合理,业务规模增长 | 重新评估容量或拆分存储 |
| 瞬时大分配 | 大请求、全量导出、巨大数组 | 分页、流式处理、限制单次规模 |
| 排队造成堆积 | 队列长度与对象占用同步增长 | 有界队列、限流、提升有效消费能力 |
一次 dump 是一个时间截面。判断持续增长,应结合多次采样、请求量、缓存键数和回收后堆占用,而不是只靠某个大对象。
修复要改变哪条引用关系?
示例的直接问题是不断向长期存活的 List 添加数据,没有上限也没有释放。增大堆只会让它更晚失败。
真实业务可以按所有权选择策略:缓存设置容量和失效规则;导出改成分批处理;队列设置上限并定义拒绝行为。修复后重复相同负载,观察回收后的占用是否稳定,并确认数据完整性。
不能把“异常暂时没有再出现”作为唯一依据。较大的堆、较低的流量或较短的观察时间,都可能掩盖持续保留问题。
4. GC 日志怎样支持或推翻假设
暂停、回收趋势和吞吐需要结合负载解释。
先知道自己在读哪个收集器的日志
不同收集器的事件与阶段不同。本文显式选择 Java 17 HotSpot 的 G1,不依赖“当前机器默认一定使用某个收集器”的假设。
G1 将堆划分为 Region,按其角色组织年轻代与老年代。Young GC 与 Mixed GC 处理的范围不同;并发标记期间应用线程可以继续运行,但仍包含需要暂停应用的阶段。
所以“并发收集”不能理解为“从来没有 Stop-The-World”。G1 工作机制
打开统一日志
在 Java 17 中可以使用:
java -Xms32m -Xmx32m -XX:+UseG1GC -Xlog:gc*,safepoint:file=gc.log:time,uptime,level,tags Main
gc* 选择 GC 相关日志,safepoint 帮助观察安全点停顿;time 是时间戳,uptime 是 JVM 启动后的时间。日志写入当前工作目录的 gc.log。Java 17 统一日志参数
32 MiB 堆是为了让短小实验容易触发回收。生产日志应按磁盘和保留周期配置滚动,例如在文件输出参数后追加 :filecount=5,filesize=20M。采集时间范围也要覆盖异常请求。
完整的短生命周期分配示例
程序反复分配 128 KiB 数组,仅通过静态字段保留最近一次分配。它会制造分配压力,却不会把所有数组都放入一个长期存活集合。
public class Main {
private static volatile byte[] latest;
public static void main(String[] args) {
long checksum = 0;
for (int i = 0; i < 2000; i++) {
byte[] data = new byte[128 * 1024];
data[0] = (byte) i;
latest = data;
checksum += data[0];
}
System.out.println("allocations = 2000");
System.out.println("latest bytes = " + latest.length);
System.out.println("checksum = " + checksum);
}
}
运行输出:
allocations = 2000
latest bytes = 131072
checksum = 152
把完整代码保存为 Main.java,执行 javac Main.java,再使用上一节参数运行。IDE 也可以把参数填入 VM options。代码无需下载,但 GC 日志实验需要能配置 JVM 参数并读取输出文件的环境。
checksum 使用 Java 的有符号 byte 求和,结果与是否发生 GC 无关。程序没有报告吞吐量;短循环中的 GC 行为不能直接代表业务系统的性能。
一行暂停日志包含什么?
本地生成日志后,可找到带有 Pause Young 的结束行,其主体形如:
GC(编号) Pause Young (Normal) (G1 Evacuation Pause) 回收前M->回收后M(堆容量M) 停顿毫秒ms
这里用字段名表示可变数值,避免把本机某次停顿当作固定输出。
下面是本地 Java 17.0.1 实验实际采集的一行,省略了日期装饰项:
[0.047s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 13M->0M(32M) 1.254ms
它表示这次暂停前的占用约为 13 MiB,暂停后的占用按整数 MiB 显示为 0,堆容量为 32 MiB,暂停约 1.254 ms。0M 不表示 JVM 中已经没有对象,只是这个显示精度下的结果。下一次实验的数值会变化,不能把 1.254 ms 当作 G1 的性能承诺。
GC(编号) 用于关联同一次事件的多行;箭头两边描述此次事件前后的堆占用,括号里的容量不是对象总分配量。Pause 结束行中的毫秒数描述该暂停事件,也不等于整个请求的执行时间。
日志中的 User、Sys、Real 也有不同含义:前两者是 CPU 时间,Real 是经过的墙钟时间。并行线程的 CPU 时间相加,可能超过经过时间。
回收频繁与泄漏如何区分?
本例分配了约 250 MiB 数组数据,最大堆却只有 32 MiB。能够完成,是因为绝大部分数组短期内失去引用,空间被重复使用。
本地小堆运行实际出现多次 G1 Young Pause,程序正常退出。它说明较高分配量可以伴随频繁回收,不等于全部数据一直存活。
本次完整日志记录了 16 次带有回收前后占用的 Young Pause 结束行。事件数同样只描述这一次实验,受 JVM 状态与分配行为影响,不是代码必须产生的固定数量。
长期保留问题更需要关注回收后占用的趋势。若可比较负载下,回收后基线持续上升,就应结合对象直方图和 dump 检查引用关系。不过,年轻代回收并不回收全部老年代,不能只拿连续两次 Young GC 的变化判断泄漏。
把停顿与慢请求连接起来
先对齐日志时间和请求追踪时间,确认慢请求是否跨过暂停窗口。随后检查事件频率、暂停分布、回收后空间和相应阶段。
还要区分到达安全点所花的时间与安全点内执行操作的时间。长停顿可能涉及进入安全点的延迟,并不一定全部用于对象回收。
仅看到一条较长的 Pause,不能解释全天延迟。需要覆盖代表性负载,结合 CPU、队列、数据库与网络数据,判断 GC 对尾延迟的贡献。
参数调整应该依据什么?
先减少不必要的分配和错误保留,再评估堆容量是否覆盖正常存活数据与突发负载。容量扩大可能降低某些回收频率,也会改变回收工作量和容器内存预算。
G1 的 MaxGCPauseMillis 是目标,不是每次暂停都不能超过的承诺。把目标调小也不是免费的优化:收集器可能更频繁地工作,影响吞吐量。
调整时一次改变少量因素,在可比较负载下观察延迟分布、吞吐量、CPU 与内存。不要用某次特别短的暂停证明一组参数“最优”。G1 调优指南
磁盘空间与 I/O 是两类问题
df -h
df -i
du -sh /var/log/myapp
容量耗尽与 inode 耗尽都可能导致写入失败。路径使用实际应用日志目录,避免对整台机器无差别扫描。I/O 延迟可进一步使用环境已安装的 iostat、pidstat 等工具观察。
文件删除后,若进程仍打开它,空间可能尚未释放。用 lsof 检查对应打开文件,但不要直接删除或截断未知数据文件。数据库文件和日志的恢复规则不同。
日志增长过快时,先查异常循环、日志级别与轮转配置。清空日志只能暂时缓解,不能修复持续写入原因。
网络从监听到连接逐层查
ss -lntp
ss -tan
curl --connect-timeout 3 --max-time 10 -I https://api.example.com/health
检查进程是否监听正确地址与端口,然后判断能否建立连接、TLS 是否成功、HTTP 是否有响应。HEAD 请求仅用于支持该方式的健康接口,不证明完整业务可用。
大量连接状态需要结合进程、连接池和流量解释。TIME_WAIT 多不自动代表故障;长时间连接无法建立、连接数持续接近限制才需要深入核对。ss 手册。
如果应用等待数据库或模型服务,用请求编号与下游日志确认耗时。不要把网络可达当作下游业务健康。
5. 设计判断与适用边界
先保存现场,再决定恢复动作。CPU 高但线程栈不稳定,可能只是短期负载;老年代回收后仍持续上升,才需要结合对象增长和保留路径排查。堆很小而 RSS 高时,检查线程、直接内存与本地分配,不把所有内存问题归为堆泄漏。
采集本身有成本,转储、直方图与日志需要控制频率和访问权限。修复后比较相似负载下的延迟、资源与错误,保留未解释的现象。重启恢复可用性,不自动形成根因结论。
6. 问答
问:CPU 高就增加线程数吗?
先确认任务类型。计算资源已经紧张时,增加线程可能增加切换与竞争,不能创造更多 CPU 配额。
问:RUNNABLE 能证明线程正在消耗 CPU 吗?
不能。还要看 CPU 时间增量、操作系统指标以及多次堆栈。
问:heap dump 和线程转储有什么区别?
heap dump 保存堆对象与引用关系;线程转储主要描述线程栈、状态与锁。它们针对不同问题。
问:直方图显示某类最多,就说明它泄漏了吗?
不说明。还要看业务用途、拥有者和增长趋势。
问:Young GC 很多,就说明内存泄漏吗?
不说明。短生命周期对象分配很快也会造成频繁年轻代回收。
问:回收后占用升高,就一定泄漏吗?
要看回收范围、业务规模和持续趋势,并通过引用链确认。
参考资料
- Java 17 jcmd:线程转储与诊断命令
- Java 17 ThreadMXBean:线程 CPU 时间与堆栈
- Java 17 Thread.State
- JVM 17 规范:运行时数据区与栈帧
- JEP 122:移除永久代
- Java 17 HotSpot:逃逸分析与标量替换
- Java 17 启动参数:堆与线程栈
- Java 17:垃圾回收与可达对象
- Java 17:引用类型
- Java 17 WeakHashMap:值对键的引用
- Eclipse Memory Analyzer:堆分析工具
- MAT:支配树与对象保留关系
- MAT 基础教程:直方图、支配树与 GC Roots 路径
- Java 17:G1 工作机制