TT Lab
博客

한국어 English 日本語 中文

开始
博客

博客

Java 调试:断点调试、不停机观察、记录后观察

状态:草稿 (2026-10-26),已反映实测结果。 实测环境有两个。① 在笔记本电脑的容器(预先下载的 eclipse-temurin:21 镜像,JDK 21.0.12,无网络)中亲自动手制作并运行了五个示例程序。没有重新下载或安装任何东西。② 在 TT Lab 集群中运行的 Keycloak Pod(Red Hat 构建的 OpenJDK 21.0.11)上执行了 仅读取命令(jcmd 1 VM.version 类的操作。没有在生产 Pod 中打开调试端口、启动 JFR 记录、生成堆转储或重启。`async-profiler` 需要下载,故为 未实测。官方文档于当天查阅了原文进行核实。 编写规则:以实测数据为基础,未能测量的内容标注为「未实测」,无法确认的内容标注为「无法确认」,推测标注为「推测」。

目录

1. 三种观察方法

当 Java 服务出现异常时,通常可以采取以下三种方法。

本文首先通过图示了解 JVM 与外部工具通信的两条途径(第 2 节),随后依次介绍调试器(第 3-4 节)、诊断命令(第 5 节)、三个案例(第 6-8 节)、JFR(第 9 节)以及生产环境中的操作顺序与注意事项(第 10 节)。

2. 深入 JVM 内部的两条途径

JVM 안을 들여다보는 두 길: 디버거(JPDA)와 진단 명령(Attach) ① 디버거 — 멈추고, 한 줄씩, 값을 바꾼다 IDE · jdb JDI (자바 API) 프런트엔드 JDWP 에이전트 JVM TI 로 JVM 조작 -agentlib:jdwp JDWP (TCP 소켓, 인증 없음) 포트포워드로 내 PC 에서만 ② 진단 명령 — 멈추지 않고(또는 아주 잠깐) 상태를 읽는다 jcmd · jstack jmap · jfr 같은 사용자 · 같은 호스트 Attach Listener 첫 요청에 생기는 스레드 명령을 실행해 돌려줌 유닉스 소켓 (.java_pid<N>) 네트워크 포트 없음 대상 JVM 스레드 · 락 Thread.print 데드락 탐지 힙 · GC GC.heap_info 힙 덤프 · GC 로그 JFR 버퍼 JFR.start → .jfr → jfr view 실측: 디버거 없이 JDWP 만 열어 둔 JVM 은 속도 차이 없음(−0.4%, 잡음 수준). 그러나 포트에 닿는 누구나 「JDWP-Handshake」 14바이트로 붙는다.
图:调试器通过 JDWP 套接字连接到 JDWP 代理的途径,以及 `jcmd` 通过 Unix 套接字连接到 Attach Listener 的途径

调试器的途径:JPDA。 Java 平台调试架构(JPDA)分为三层(官方文档)。

层 位置 职责
JVM TI 目标 JVM 内部的原生接口 JVM 提供调试服务,如“暂停此线程”、“在此方法设置断点”、“获取局部变量值”
JDWP 通信协议 调试器与目标 JVM 之间的二进制协议,通过套接字或共享内存
JDI 调试器端 Java API IDE 和 `jdb` 使用的高级接口

-agentlib:jdwp=… 在目标 JVM 中加载 JDWP 代理(原生库),调用 JVM TI 并通过 JDWP 进行通信。将其常用的命令行展开如下(JDK 21 文档)。

-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005
               │                   │        │         └ 어디서 기다리나. 호스트를 비우면 루프백만, * 면 모든 인터페이스
               │                   │        └ 시작하자마자 멈추고 디버거를 기다릴까(기본 y!)
               │                   └ JVM 이 듣는 쪽(서버)이 될까(기본 n: JVM 이 디버거로 걸어 나간다)
               └ TCP 소켓

suspend 的默认值是 y ,如果忘记这一点,仅添加调试选项启动的服务 将在调试器连接之前完全无法启动。 如果是 Kubernetes,就绪探针将持续失败。该地址在 JDK 9 中发生了更改。过去,只需提供端口即可在所有接口上监听,但从 JDK 9 开始,它仅在回环地址上监听,如果需要监听所有接口,则必须附加 *: 。JDK 9 发行说明中提到,绑定到 * 是「不安全且不推荐的」。

诊断命令的途径:Attach。 jcmd·jstack·jmap 不使用网络。它会连接到目标 JVM 临时目录中的 Unix 套接字(.java_pid<PID>),JVM 在收到第一个请求时会启动一个名为 Attach Listener 的线程来执行命令。这仅限于同一用户、同一主机(如果是容器则为同一容器)。因此,在 Kubernetes 中,需要通过 kubectl exec 进入该容器内部执行。

3. 使用调试器断点调试:`jdb` 初体验

编写了一个每秒钟对某项得分进行一次计分的的小程序(Rally.point(boolean leftWins, int rallyNo)),使用 -g 进行了编译,在开启 JDWP 的情况下启动,然后从同一容器中通过 `jdb` 进行连接(2026-10-26)。

$ jdb -attach 5005 -sourcepath /w
> stop in Rally.point
Breakpoint hit: "thread=main", Rally.point(), line=6 bci=0
6            if (leftWins) left++; else right++;
main[1] where
  [1] Rally.point (Rally.java:6)
  [2] Rally.main (Rally.java:13)
main[1] locals
Method arguments:
leftWins = false
rallyNo = 6
main[1] print Rally.left           ← 정적 필드는 클래스 이름을 붙여야 한다(그냥 left 는 「Name unknown」)
 Rally.left = 4
main[1] set leftWins = !leftWins
 leftWins = !leftWins = true
main[1] clear Rally.point
main[1] cont

结合时间戳查看程序端的输出,可以清楚地了解调试器所做的事情。

时间(秒) 输出 说明
:34.03 回合 5 → 4:1 按常态 1 秒间隔
:39.54 回合 6 → 5:1 在断点处 暂停了 4.5 秒,并将原本应为右侧得分(4:2)的回合改为了左侧得分
:42.05 Listening for transport dt_socket at address: 5005 结束 `jdb` 时 恢复监听

调试器不仅可以暂停执行,还能 修改变量值。 并且,即使调试器离开,端口依然在等待下一个访客。这两个事实导致了下一节中的安全问题。

IDE 远程调试的原理也是相同的。IntelliJ 的「Remote JVM Debug」和 VS Code 的 "request": "attach" 配置会在上述 `jdb` 的位置通过 JDI 连接到相同的 JDWP 端口。

调试端口没有认证

JDWP 连接始于双方交换 JDWP-Handshake 14 字节数据。这就是全部。经过了亲自验证。连接到端口并发送了 14 字节,然后发送了一个请求获取「VM 版本」的命令数据包(11 字节)。

JDWP-Handshake
…Java Debug Wire Protocol (Reference Implementation) version 21.0
JVM Debug Interface version 21.0
JVM version 21.0.12 (OpenJDK 64-Bit Server VM, mixed mode, sharing)…

既没有询问密码,也没有询问证书。这意味着任何能够触及该端口的人都可以执行调试器能做的事情(调用任意方法、修改值)。虽然没有找到明文规定 JDWP 没有认证的官方文档语句(无法确认),但上述实测结果、JDK 9 发行说明中的警告以及 OpenJDK 邮件列表中关于「允许远程代码执行」的说明都指向同一方向。在实测容器中, address=*:5005 的套接字在 /proc/net/tcp6 的所有地址(::)的 5005 端口上处于运行状态。

开销如何? 如果没有连接调试器,仅仅加载 JDWP 代理的开销小到无法测量(第 9 节表格:较基准 −0.4%,在噪声范围内)。正因如此,它「嫌麻烦懒得关」而很容易留在生产镜像中,这也正是其危险所在。

4. 如何对 Kubernetes Pod 进行远程调试

原则上不应在生产 Pod 中进行。如果断点暂停了服务线程,请求就会在此期间堆积,导致存活探针(liveness)失败,进而被 kubelet 杀死容器。在开发或测试环境中进行时的安全做法如下:

  1. 单独创建调试副本。 不要修改生产 Deployment,而是使用相同的镜像创建一个专门用于调试的 Pod(kubectl debug <파드> --copy-to=<사본> 或单独的清单)。如果在环境变量 JAVA_TOOL_OPTIONS 中加入 JDWP 选项,JVM 就会自动读取,无需修改镜像的启动脚本。
  2. 仅绑定到回环地址。 address=127.0.0.1:5005 配置为仅在 Pod 内部的回环地址监听(如 ),且不为 Pod 外部创建 Service。
  3. 通过 port-forward 仅暴露给本地电脑。 kubectl port-forward pod/<사본> 5005:5005 默认仅在本地电脑的 localhost 上打开(官方文档: --address 默认值为 localhost),且仅支持 TCP。由于它需要经过 API 服务器的认证与授权(RBAC 的 pods/portforward),相当于由 Kubernetes 代为完成了 JDWP 本身所不具备的认证。
  4. 关闭或延长探针。 在副本中移除 liveness 探针,并将 IDE 的断点设置为仅暂停「该线程」而非「所有线程」。
  5. 结束后删除副本。

本节的步骤遵循本文不在生产 Pod 中打开调试端口的原则,故为 未实测。关于 port-forward 可以连接到 Pod 内部回环地址这一点,仅通过 Kubernetes 文档中的示例(转发到 Pod 端口)进行了确认。

5. 不停机观察:`jcmd`

jcmd <PID> help 展示了该 JVM 接收的所有命令。在此一并记录了常用命令及其 对 JVM 造成的负担(负担根据实测和文档进行判断)。

命令 显示内容 负担
VM.version · VM.uptime · VM.flags 版本、运行时间、实际生效的 JVM 选项 几乎没有
Thread.print (= jstack) 所有线程的栈、锁及死锁检测 短暂暂停(safepoint)。实测 89ms(命令完整往返)
GC.heap_info 各堆区域使用量 几乎没有
GC.class_histogram 按类统计的对象数及字节数 会触发 Full GC(第 8 节实测)
GC.heap_dump <파일> 将整个堆导出为 HPROF 文件 JVM 暂停,占用与堆大小相等的磁盘空间
JFR.start · JFR.dump · JFR.stop 开始、保存、结束飞行记录 默认设置下约 1% 左右(第 9 节)
VM.native_memory summary 堆外内存(线程栈、元空间等) 仅在启动时开启了 -XX:NativeMemoryTracking 时有效
Thread.dump_to_file 包含虚拟线程在内的转储(支持 JSON) 见于 JEP 444 示例,JDK 21 `jcmd` 手册中没有,但在 JDK 25 手册中

实测:容器内的 JVM 如何识别内存

JVM 会读取容器的内存限制(cgroup)来决定默认堆大小。默认值为该限制的 25%(-XX:MaxRAMPercentage 默认值,官方文档)。我们为笔记本容器分配了内存限制,并通过 -XX:+PrintFlagsFinal 读取了最大堆内存。

容器内存 无选项 -XX:MaxRAMPercentage=70
512MiB 128MiB 360MiB
2GiB 512MiB 1,434MiB

如果在无任何选项的情况下将其运行在 2GiB 的 Pod 中,堆内存将仅有 512MiB,其余 1.5GiB 将被闲置。反之,如果将比例调得太高,堆外内存(线程栈、元空间、直接缓冲区)将不足,导致容器被 `OOMKilled`。此时既不会产生 Java 的 `OutOfMemoryError`,也不会生成堆转储,Pod 会直接消失。

实测:仅读取集群的 Keycloak Pod

通过 kubectl exec 进入 TT Lab 集群的 Keycloak Pod 并执行了 `jcmd`(2026-10-26)。该镜像使用的是 Red Hat 的 headless JRE,其中也包含 jcmd (没有jstack·jmap )。由于一个 `jcmd` 就能干两个工具的活,因此没有任何问题。该 JVM 的 PID 为 1。

命令 结果
VM.version OpenJDK 64-Bit Server VM 21.0.11+10-LTS
VM.uptime 711,384秒(约 8.2 天)
VM.flags(部分) MaxRAM=2147483648(Pod 限制为 2GiB)、 MaxRAMPercentage=70 → MaxHeapSize=1503657984(1,434MiB)、 UseG1GC, ExitOnOutOfMemoryError, FlightRecorderOptions=stackdepth=512
GC.heap_info G1 堆预留 177MiB,已使用 157MiB,Metaspace 141MiB
Thread.print 摘要 95 个线程(处于 Java 状态的有:RUNNABLE 40、TIMED_WAITING 15、WAITING 14)。无死锁。最多的是 vert.x-eventloop-thread-N 22 个
JFR.check 无正在进行的记录
VM.native_memory summary 「Native memory tracking is not enabled」

读取到几项内容。由于镜像开启了 ExitOnOutOfMemoryError ,因此如果堆溢出,JVM 会立即终止,由 Kubernetes 重新启动。不过,并没有留下原因分析的堆转储选项。最大堆为 1,434MiB,而实际使用量仅为 157MiB,因此余量非常充足。还可以确认,上表中 2GiB 与 70% 的计算结果(1,434MiB)与生产 Pod 的值完全一致。

请记住,每执行一条命令,就会创建一个 Attach Listener 线程。即使是「读取命令」,也是在目标 JVM 内部创建一个线程的工作。

6. 案例 1:卡顿与死锁

증상에서 도구로: 무엇을 먼저 보나 멈춤 · 응답 없음 jcmd Thread.print 5–10초 간격 세 번 같은 자리에 BLOCKED? 「Found one Java-level deadlock」 확인 실측: 2초 만에 두 스레드의 고리 CPU 가 높다 top -H 로 뜨거운 TID jstack 의 nid 와 맞추기 (JDK 21 은 10진수) JFR 10초 기록 jfr view hot-methods 실측: 460 샘플 100% 한 메서드 메모리 · OOM GC 로그: Full GC 뒤 줄지 않는가 GC.heap_info OOM 때 힙 덤프 (힙 크기만큼 디스크) 실측: 64MB 힙이 5.5초에 OOM 운영 파드에서 조심할 것 · GC.class_histogram 은 Full GC 를 일으킨다(실측으로 확인) · 힙 덤프는 그동안 JVM 을 멈추고, 파일에 개인정보·토큰이 그대로 담긴다 · JFR 파일에는 환경 변수 값이 들어간다(실측) — 밖으로 보내기 전에 scrub · 디버그 포트(JDWP)는 운영 이미지에 켜 두지 않는다
图:卡顿、CPU 过热、内存增长这三种症状下应优先查看的工具与实测结果

运行一个两个线程以相反顺序获取两个锁的经典示例(rally-A 为 table → net, rally-B 为 net → table),并在 2 秒后拍摄了 jstack 1 。

Found one Java-level deadlock:
=============================
"rally-A":
  waiting to lock monitor 0x0000ffff18001d30 (object 0x000000008bb13c80, a java.lang.Object),
  which is held by "rally-B"
"rally-B":
  waiting to lock monitor 0x0000ffff1c000f00 (object 0x000000008bb13c70, a java.lang.Object),
  which is held by "rally-A"
…
"rally-A":
	at Deadlock.grab(Deadlock.java:10)
	- waiting to lock <0x000000008bb13c80> (a java.lang.Object)
	- locked <0x000000008bb13c70> (a java.lang.Object)
…
Found 1 deadlock.

JVM 会自行找出环路(形状与官方故障排除指南中的示例相同)。两个线程的 CPU 时间分别为 1.01ms 和 0.60ms,这也表明卡住的服务并没有消耗 CPU。在现实中,卡顿比死锁更常见的情况是 等待某个慢速操作(如数据库连接池耗尽、外部 HTTP 调用没有超时设置)。因此,转储通常会 以 5–10 秒的间隔拍摄三次 ,以观察相同的线程是否持续停留在同一行。仅凭单张快照,无法判断它是否只是刚好在那一瞬间停留在此处。

7. 案例 2:CPU 使用率过高

这是一个示例,其中一个线程(referee-worker)在进行计算,而其他四个线程处于休眠状态。顺序是 哪个线程 → 该线程在做什么。

  1. 在容器内部使用 top -H -p 1 查看每个线程的 CPU 情况。实测结果:TID 24 占比 90.9%,名称为 referee+(被截断)。
  2. jstack 1 在 nid 中查找该 TID。线程行中的
"referee-worker" #20 [24] … cpu=3922.52ms elapsed=3.96s … nid=24 runnable
   java.lang.Thread.State: RUNNABLE
	at Hot.scoreRally(Hot.java:7)

就是 OS 线程编号。 nid=24 实测使用的 JDK 21 像 一样用十进制 [24] 记录,并同时显示 nid=0x18 。JDK 8 和 11 时代的指南使用的是十六进制(如 ),要求将 TID 转换为十六进制再进行查找,因此需要根据对应的版本进行读取。

  1. 单张线程栈只代表一个瞬间。如果要了解「大部分时间花在了哪里」,可以使用 JFR。在运行中的 JVM 上 无需重启 即可开始记录。
$ jcmd 1 JFR.start name=hot duration=10s filename=/w/hot.jfr
$ jfr view hot-methods /w/hot.jfr
Method                  Samples Percent
Hot.scoreRally(long)        460 100.00%

在 10 秒内收集的 460 个执行样本(默认设置约为 20ms 间隔)全部指向同一个方法。同一记录中的 jfr view cpu-load 显示 JVM 用户态 CPU 平均为 9.02%,但这 是相对于整台机器(10 个核心)而言的 数值,其含义相当于「跑满了一个核心」。务必确认百分比所对应的基数是什么。

async-profiler(最新 v4.6)是一个利用 JVM 内部 API(AsyncGetCallTrace)和 Linux perf_events 的工具,它能够无 safepoint 偏向地同时捕获 Java 和本地线程栈(详见官方 README)。它能直接生成火焰图,对排查 CPU 问题特别有效,但由于需要下载并放入 Pod 中,因此本次 未实测。

8. 案例 3:内存持续增长

我们启动了一个最大堆为 64MB 的服务,其中包含一个只往列表中添加 64KB 帧却从不移除的「缓存」。相关选项采用了生产环境中推荐的组合。

java -Xmx64m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/w/heap.hprof \
     -XX:+ExitOnOutOfMemoryError "-Xlog:gc*:file=/w/gc.log:uptime,level,tags" -cp classes Leak

-Xlog:gc* 是由 JDK 9 的统一日志(JEP 158)与统一 GC 日志(JEP 271)结合而形成的的格式。结果如下。

时间 标准输出 / GC 日志
1.9 秒 Pause Young (Normal) 23M->22M(64M) 8.5ms — 清空年轻代,但几乎没有减少
2.3 秒 Pause Full (Heap Inspection Initiated GC) 27M->26M — 我执行的 GC.class_histogram 引发的 Full GC
3.2–5.3 秒 Young 与 Concurrent Mark 交替进行,使用量从 36M 持续上升至 59M
5.45–5.55 秒 Evacuation Failure,接着是三次 Pause Full (G1 Compaction Pause) 59M->59M — 即使执行 Full GC,连一个字节都没有减少
5.5 秒 OutOfMemoryError: Java heap space,以 0.073 秒生成 65,304,426 字节的堆转储,退出码 3

读取方法只有一个: 如果在 Full GC 后使用量仍未下降,说明有存活的对象正在死死抓住内存不放。 这是区分单纯的分配过多与内存泄漏的标准。在 2 秒时的类直方图甚至显露出了罪魁祸首的模样: [B(字节数组)9,728 个,共 26,419,600 字节,排名第一。不过,实测记录也表明该命令触发了 Full GC(2.3 秒处)。在拥有大堆的生产环境中,这绝不是一条可以「轻描淡写看一眼」就能随便执行的命令。

在堆转储中查找「究竟是谁抓住了这些数组」(支配树)需要使用 Eclipse MAT 或 VisualVM 等分析工具,因此本次 未实测。从实测中确认的生产环境事实有两点:转储文件 与堆大小相当(64MB 堆对应 65MB 转储),因此如果是 1.4GB 堆的 Pod,就需要相应大小的可写卷。此外,转储文件中包含了内存中的所有内容,即用户信息、令牌和密码,因此必须像处理机密数据一样谨慎对待。

9. JFR:常驻开启的黑匣子

JFR(JDK Flight Recorder)在 JDK 11 中成为了开源项目(JEP 328),其默认设置的目标开销在 1% 以下。JDK 21 的 default.jfc 自述「在生产环境中持续使用也很安全,通常低于 1%」,而 profile.jfc 则自述「通常在 2% 左右」。为了验证是否确实如此,我们运行并测量了包含哈希计算和短生命周期对象分配的混合任务固定次数(400 万次)。四种设置交替运行 7 次,并绑定到 2 个容器 CPU 上。

设置 主循环时间中位数(7 次) 相比基准的比例
基准(无选项) 1,734ms —
JFR default 1,750ms +0.9%
JFR profile 1,753ms +1.1%
仅 JDWP 代理(未附加调试器) 1,727ms −0.4%

由于基准 7 次的范围在 1,713–1,784ms 之间,本身就有约 ±2% 的波动,因此上述差异只能说明「在文档标称的 1%·2% 范围之内」。由于它会根据任务的性质(分配极多、锁竞争激烈等)而有所不同,建议在您自己的服务上亲自测量一次。

JFR 文件中包含环境变量

上述实测记录的 jfr summary 中包含了 11 条 jdk.InitialEnvironmentVariable 和 15 条 jdk.InitialSystemProperty ,使用 jfr print 打开后, 键和值会原封不动地显示出来 。Kubernetes Pod 经常通过环境变量接收数据库密码或 API 密钥,因此在将 JFR 文件附加到工单或发送到外部之前,必须将其擦除。JDK 21 的 jfr scrub 正是用于此项工作。

$ jfr scrub --exclude-events jdk.InitialEnvironmentVariable,jdk.InitialSystemProperty hot.jfr hot-clean.jfr

实测:清理后的文件中上述两个事件归零,而 460 个执行样本保持不变(大小从 211,772 字节变为 201,397 字节)。

在生产环境中使用 JFR 的形态

10. 生产 Pod 中的排查顺序与注意事项

按症状分类的优先查看项总结如图 2 所示。顺序的原则是 从开销最小的开始。

  1. 现成留存的日志:应用日志、GC 日志、指标、常驻开启的 JFR。切勿修改任何内容。
  2. 轻量级读取: VM.flags, GC.heap_info, VM.uptime。在此首先排除堆大小设置错误的情况(第 5 节)。
  3. 短暂暂停: Thread.print 带着时间间隔拍摄三次。
  4. 记录: JFR.start duration=….
  5. 重型操作:堆转储、类直方图。应将流量引流到其他 Pod(将其从就绪状态中剔除),或者配置 -XX:+HeapDumpOnOutOfMemoryError 以便仅在 OOM 发生的瞬间自动触发。

此外,应避免以下做法。

11. 总结

Titi 的寄语。 如果用调试器暂停了拉力赛,球、对手和观众都会停下来。比赛期间最好只用 jcmd 拍个照,录像(JFR)则常驻开启为好。啊,对了,录像里拍到了更衣室密码(环境变量),分享前可一定要擦除哦!

参考

登录后即可点赞

评论

还没有评论。

登录后即可发表评论