本文聚焦 Java 并发编程的「最后一公里」------问题排查与诊断。当你写的代码在测试环境跑得好好的,一到线上就出现各种「疑难杂症」:线程阻塞、内存持续增长、CPU 飙升、响应超时......怎么办?靠猜是不行的,你需要掌握一系列排查工具和方法。本文会从最基础的 jstack 讲起,教你如何分析线程状态和死锁;再介绍 heap dump(堆转储)的获取和分析方法,帮助你定位内存泄漏;最后介绍阿里巴巴开源的诊断神器 Arthas,让你体验「在线调试」的强大能力。读完本文,你将掌握 Java 排查的「三板斧」,面对线上问题时不再手足无措。
1. 为什么排查能力很重要
1.1 线上问题有多棘手
很多同学在开发阶段写得一手好代码,但一到线上就「翻车」:
- 线程池耗尽,请求堆积
- 内存持续增长,最终 OOM 崩溃
- CPU 100%,服务响应缓慢
- 死锁导致部分功能完全不可用
这些问题往往有以下特点:
- 难以复现:测试环境正常,线上才出问题
- 随机性强:同样的代码,有时正常有时异常
- 影响范围大:线上问题直接影响用户体验和业务
如果没有排查能力,你只能:
- 重启服务(治标不治本)
- 加日志、重新部署(影响业务)
- 干着急(等待「大腿」支援)
1.2 排查的核心思路
Java 排查的核心思路是「看穿表象,找到根因」:
- 表象:线程卡死、内存爆满、CPU 飙升
- 根因:死锁、内存泄漏、死循环、锁竞争
排查工具的作用,就是帮你从「表象」一步步追溯到「根因」。
排查三板斧:
- jstack:看线程在做什么
- heap dump:看对象在哪里
- Arthas:动态观测和干预
2. jstack:线程分析神器
2.1 jstack 是什么
jstack 是 JDK 自带的线程堆栈分析工具,位于 $JAVA_HOME/bin/jstack。它可以:
- 打印指定 Java 进程的线程堆栈信息
- 检测死锁(Deadlock)
- 分析线程状态(RUNNABLE、BLOCKED、WAITING 等)
2.2 基本用法
获取 Java 进程 PID
首先需要找到目标 Java 进程的 PID:
bash
# 方式一:jps 命令
jps -l
# 方式二:ps 命令
ps -ef | grep java
# 方式三:top 命令
top -b -n 1 | grep java
使用 jstack 打印堆栈
bash
# 打印线程堆栈(推荐:-l 参数打印更多锁信息)
jstack -l <pid>
# 打印堆栈并输出到文件
jstack -l <pid> > jstack.log
# 强制打印(即使 jstack 被阻塞)
jstack -F -l <pid> > jstack.log
2.3 线程状态解读
jstack 输出的线程堆栈中,每个线程都有状态标记:
| 状态 | 含义 | 常见原因 |
|---|---|---|
| NEW | 新建状态 | 线程刚创建,还未启动 |
| RUNNABLE | 可运行状态 | 正在 CPU 上运行或等待 CPU 时间片 |
| BLOCKED | 阻塞状态 | 等待获取 monitor 锁 |
| WAITING | 等待状态 | 无限期等待(Object.wait、Lock.await) |
| TIMED_WAITING | 限时等待状态 | 限时等待(Thread.sleep、Lock.await(timeout)) |
| TERMINATED | 终止状态 | 线程已执行完毕 |
RUNNABLE 状态
java
"Thread-1" #15 prio=5 os_prio=0 tid=0x00007f8a5c01e800 nid=0x7a2c runnable [0x00007f8a5a7e7000]
java.lang.Thread.State: RUNNABLE
at com.example.demo.DemoApplication.lambda$main$0(DemoApplication.java:18)
at com.example.demo.DemoApplication$$Lambda$1/1175965214.run(Unknown Source)
at java.lang.Thread.run(Thread.java:748)
RUNNABLE 表示线程正在 CPU 上执行,可能是正常计算,也可能是死循环。
BLOCKED 状态
java
"Thread-2" #16 prio=5 os_prio=0 tid=0x00007f8a5c020800 nid=0x7a2d waiting for monitor entry [0x00007f8a5a8f0000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.demo.DemoApplication.lambda$main$1(DemoApplication.java:25)
- waiting to lock <0x00000000d5f8b828> (a java.lang.Object)
- locked <0x00000000d5f8b818> (a java.lang.Object)
BLOCKED 表示线程在等待获取对象锁(synchronized),常见于锁竞争场景。
WAITING 状态
java
"Thread-3" #17 prio=5 os_prio=0 tid=0x00007f8a5c022800 nid=0x7a2e in Object.wait() [0x00007f8a5a9ff000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
- waiting on <0x00000000d5f8b838> (a java.lang.Object)
at java.lang.Object.wait(Object.java:502)
at com.example.demo.DemoApplication.lambda$main$2(DemoApplication.java:32)
WAITING 表示线程在无限期等待,通常是调用了 Object.wait() 或 Lock.await()。
2.4 死锁检测
jstack 可以自动检测死锁(Deadlock),输出中会包含 "Found one Java-level deadlock" 信息:
bash
$ jstack -l <pid>
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock object 0x00000000d5f8b828 (a java.lang.Object),
which is held by "Thread-2"
"Thread-2":
waiting to lock object 0x00000000d5f8b818 (a java.lang.Object),
which is held by "Thread-1"
Java stack information for the threads listed above:
===================================================
"Thread-1":
at com.example.demo.DemoApplication.lambda$main$0(DemoApplication.java:18)
- waiting to lock <0x00000000d5f8b828> (a java.lang.Object)
- locked <0x00000000d5f8b818> (a java.lang.Object)
...
"Thread-2":
at com.example.demo.DemoApplication.lambda$main$1(DemoApplication.java:25)
- waiting to lock <0x00000000d5f8b818> (a java.lang.Object)
- locked <0x00000000d5f8b828> (a java.lang.Object)
...
死锁示例代码:
java
public class DeadlockDemo {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
// 线程 1:先锁 lock1,再锁 lock2
new Thread(() -> {
synchronized (lock1) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
synchronized (lock2) {
System.out.println("线程 1 完成");
}
}
}, "Thread-1").start();
// 线程 2:先锁 lock2,再锁 lock1
new Thread(() -> {
synchronized (lock2) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
synchronized (lock1) {
System.out.println("线程 2 完成");
}
}
}, "Thread-2").start();
}
}
2.5 实战:分析线程阻塞问题
问题场景:线上服务响应变慢,CPU 使用率不高,但请求堆积。
排查步骤:
- 查看进程状态
bash
# 查看 Java 进程
$ jps -l
12345 com.example.demo.DemoApplication
# 查看进程 CPU 和内存占用
$ top -p 12345
- 打印线程堆栈
bash
$ jstack -l 12345 > jstack.log
- 分析堆栈内容
打开 jstack.log,搜索关键信息:
- BLOCKED 线程:大量 BLOCKED 线程说明锁竞争激烈
- WAITING 线程:大量 WAITING 线程说明在等待某些条件
- 死锁:搜索 "deadlock" 关键字
- 线程数量:统计 "Thread-" 开头的线程数量
- 定位问题代码
根据堆栈信息,定位到具体的代码行:
"http-nio-8080-exec-10" #50 daemon prio=5 os_prio=0 tid=0x00007f8a5c0a2800 nid=0x7a45 waiting for monitor entry [0x00007f8a5a9bf000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.demo.service.OrderService.getOrder(OrderService.java:45)
- waiting to lock <0x00000000d5f8b828> (a com.example.demo.service.OrderService)
- locked <0x00000000d5f8b818> (a com.example.demo.service.OrderService)
从堆栈可以看出:
- 线程在
OrderService.java:45行等待获取锁 - 锁被
OrderService类的另一个实例持有
常见阻塞原因:
- synchronized 锁竞争:多个线程竞争同一把锁
- 数据库连接池耗尽:等待从连接池获取连接
- 外部服务超时:调用外部接口超时
- 死锁:两个或多个线程相互等待对方释放锁
3. heap dump:内存分析利器
3.1 什么是 heap dump
heap dump(堆转储)是 Java 堆内存的快照,包含了堆中所有对象的信息:对象类型、字段值、引用关系等。通过分析 heap dump,可以找到:
- 内存泄漏的根本原因
- 占用内存最多的对象
- 对象的创建来源(谁创建了这些对象)
3.2 获取 heap dump 的方式
方式一:jmap 命令
bash
# 获取 heap dump(推荐:-dump:format=b 表示二进制格式)
jmap -dump:format=b,file=heap.hprof <pid>
# 导出到指定目录
jmap -dump:format=b,file=/tmp/heap.hprof <pid>
方式二:JVM 参数自动导出
在启动参数中添加:
bash
# OOM 时自动导出 heap dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heap.hprof
这样当发生 OOM 时,会自动导出 heap dump。
方式三:jcmd 命令
bash
jcmd <pid> GC.heap_dump /tmp/heap.hprof
方式四:JMX / JConsole
通过 JMX 连接 Java 进程,手动触发 heap dump。
3.3 分析 heap dump 的工具
工具一:jhat(已废弃)
jhat 是 JDK 自带的 heap dump 分析工具,但功能有限,已被官方标记为废弃。
bash
# 启动 jhat 服务器
jhat heap.hprof
# 访问 http://localhost:7000
工具二:Eclipse MAT(推荐)
Eclipse Memory Analyzer Tool(MAT)是功能最强大的 heap dump 分析工具,完全免费:
- 下载地址:https://www.eclipse.org/mat/
- 支持大文件(GB 级别)
- 提供 Leak Suspects、Top Components 等自动化分析
工具三:Visual VM
Visual VM 是 JDK 自带的可视化工具:
bash
# 启动 Visual VM
jvisualvm
可以加载 heap dump 文件,提供对象视图、OQL 查询等功能。
工具四:JProfiler(商业)
功能强大的商业工具,提供实时监控、CPU 采样、内存分析等功能。
3.4 使用 Eclipse MAT 分析 heap dump
步骤一:打开 heap dump
启动 MAT,打开 heap.hprof 文件。
步骤二:查看 Leak Suspects
MAT 会自动运行 Leak Suspects 分析,报告可能的内存泄漏:
Leak Suspects
-------------
1. One instance of "com.example.demo.service.CacheService"
loaded by "sun.misc.Launcher$AppClassLoader @ 0x..."
occupies 512,345,678 bytes (45.23%) of heap memory.
2. The stack trace of the thread that created this
"CacheService" instance:
at com.example.demo.service.CacheService.<init>
at com.example.demo.config.CacheConfig.createCache
...
步骤三:查看 Histogram
Histogram 视图显示所有类的实例数量和内存占用:
Class Name | Objects | Shallow Heap
----------------------------------------------|---------|-------------
java.lang.String | 123,456 | 2,967,744
java.util.HashMap$Node | 98,765 | 3,160,480
com.example.demo.service.OrderService | 45,678 | 5,840,784
...
步骤四:查看 Dominator Tree
Dominator Tree 显示哪些对象持有大量内存:
Dominator Tree
--------------
Name | Shallow Heap | Retained Heap
----------------------------------------|--------------|---------------
com.example.demo.service.CacheService | 1,024 | 512,345,678
|- cache (HashMap) | 48 | 512,000,000
|- key1 -> OrderEntity | 56 | 100,000,000
|- key2 -> OrderEntity | 56 | 100,000,000
...
步骤五:使用 OQL 查询
MAT 提供 OQL(Object Query Language)查询:
sql
-- 查询大于 10MB 的 byte 数组
SELECT * FROM byte[] WHERE length > 10485760
-- 查询特定类的所有实例
SELECT * FROM com.example.demo.service.OrderService
-- 查询被大量引用的 String
SELECT * FROM java.lang.String WHERE count > 1000
3.5 常见内存问题分析
问题一:内存泄漏
内存泄漏是指对象被错误地持有,导致无法被 GC 回收:
java
// 典型内存泄漏:静态集合持有对象引用
public class CacheService {
private static final Map<String, Object> cache = new HashMap<>();
public void put(String key, Object value) {
cache.put(key, value); // 永远不会被清除
}
}
在 heap dump 中,表现为:对象实例数量持续增长,Retained Heap 很大。
问题二:内存溢出(OOM)
OOM 的常见原因:
- Heap OOM:对象太多,堆内存不足
- Metaspace OOM:类太多,元空间不足
- Stack Overflow:递归调用太深
- Direct Memory OOM:NIO 使用了太多直接内存
问题三:内存碎片
频繁创建和销毁大对象会导致内存碎片:
java
// 频繁创建大对象
for (int i = 0; i < 100000; i++) {
byte[] buffer = new byte[10 * 1024 * 1024]; // 10MB
// 处理 buffer
}
在 heap dump 中,表现为:大量 byte[] 对象,内存不连续。
4. Arthas:在线诊断神器
4.1 Arthas 是什么
Arthas(阿尔萨斯)是阿里巴巴开源的 Java 诊断工具,被誉为「Java 界的 Chrome DevTools」。它的特点是:
- 无需重启:在线.attach 到运行中的 Java 进程
- 功能丰富:支持方法监控、调用链追踪、热点方法分析等
- 上手简单:命令行交互式界面
4.2 安装和使用
方式一:下载启动脚本
bash
# 下载 arthas-boot.jar
curl -O https://arthas.aliyun.com/arthas-boot.jar
# 启动 Arthas(会列出 Java 进程供选择)
java -jar arthas-boot.jar
方式二:使用 as.sh(Linux/macOS)
bash
# 一键安装
curl -L https://arthas.aliyun.com/install.sh | sh
# 启动
./as.sh <pid>
方式三:IDEA 插件
在 IDEA 插件市场搜索 "Arthas",安装后可以右键选择 "Start Arthas"。
4.3 核心功能
功能一:dashboard------实时面板
bash
$ dashboard
显示实时数据面板,包括:
- 线程信息(CPU、状态、堆栈)
- 内存信息(堆内存、各区域使用量)
- 运行信息(JVM 版本、启动时间)
功能二:thread------线程分析
bash
# 查看所有线程
$ thread
# 查看最忙的 N 个线程
$ thread -n 5
# 查看线程堆栈
$ thread 1
# 查看阻塞的线程
$ thread -b
# 查看死锁
$ thread --state BLOCKED
功能三:watch------方法监控
bash
# ��控方法返回值
$ watch com.example.demo.service.OrderService getOrder return-value
# 监控方法入参和返回值
$ watch com.example.demo.service.OrderService getOrder "{params,returnObj}"
# 监控方法执行时间
$ watch com.example.demo.service.OrderService getOrder "{params,returnObj}" -x 3
# 监控异常
$ watch com.example.demo.service.OrderService getOrder "{params,throwExp}" -e
功能四:trace------方法调用链
bash
# 追踪方法调用链
$ trace com.example.demo.service.OrderService getOrder
# 跳过 JDK 方法
$ trace com.example.demo.service.OrderService getOrder '#cost > 10'
# 追踪并输出耗时
$ trace com.example.demo.service.OrderService getOrder -n 5
功能五:monitor------方法统计
bash
# 统计方法调用次数和耗时
$ monitor -c 5 com.example.demo.service.OrderService getOrder
# 输出:
# timestamp class method total success fail avg-rt(ms) fail-rate
# 2024-01-01 12:00 com.example.demo.service.OrderService getOrder 100 99 1 12.34 1.00%
功能六:jad------反编译
bash
# 反编译类
$ jad com.example.demo.service.OrderService
# 反编译并保存到文件
$ jad com.example.demo.service.OrderService > OrderService.java
功能七:ognl------执行表达式
bash
# 查看静态字段
$ ogl @com.example.demo.config.AppConfig@maxRetry
# 调用静态方法
$ ogl @com.example.demo.service.CacheService@getInstance().get("key")
# 修改对象属性
$ ogl @com.example.demo.service.OrderService@order.setStatus("CANCELLED")
功能八:sc------查看类信息
bash
# 查看类的详细信息
$ sc -d com.example.demo.service.OrderService
# 查看类的字段
$ sc -f com.example.demo.service.OrderService
功能九:sm------查看方法信息
bash
# 查看类的方法
$ sm com.example.demo.service.OrderService
# 查看方法的详细信息
$ sm -d com.example.demo.service.OrderService getOrder
4.4 实战案例
案例一:排查方法执行慢
bash
# 追踪方法调用,找出耗时点
$ trace com.example.demo.controller.OrderController createOrder
# 输出:
# `---ts=2024-01-01 12:00:00;thread_name=http-nio-8080-exec-1;---`
# `---[12.3456ms]com.example.demo.service.OrderService:createOrder() #28`
# ` `---[10.1234ms]com.example.demo.service.OrderService:validateOrder() #35`
# ` `---[1.2345ms]com.example.demo.service.OrderService:saveOrder() #42`
# ` `---[0.9876ms]com.example.demo.service.NotificationService:send() #50`
# 从输出可以看出,validateOrder() 耗时最长(10ms)
案例二:排查接口返回 null
bash
# 监控方法返回值
$ watch com.example.demo.service.OrderService getOrder "{params,returnObj}" -x 2
# 输出:
# params: [1001]
# returnObj: null
# 说明传入 ID=1001 时,返回了 null
案例三:动态修改日志级别
bash
# 查看当前日志级别
$ ogl @org.slf4j.LoggerFactory@getLogger("com.example.demo").getLevel()
# 修改日志级别为 DEBUG
$ ogl @ch.qos.logback.classic.Logger@setLevel(ch.qos.logback.classic.Level@DEBUG)
案例四:查看 JVM 信息
bash
# 查看 JVM 信息
$ jvm
# 输出:
# JVM information:
# JVM version: 17.0.5+9-LTS-191
# JVM args: -Xms512m -Xmx1024m -XX:+UseG1GC
# Java home: /usr/lib/jvm/java-17
# ...
4.5 Arthas 进阶技巧
技巧一:火焰图
bash
# 生成 CPU 火焰图
$ profiler start
# 停止并生成火焰图
$ profiler stop --format html > flamegraph.html
技巧二:热更新代码
bash
# 编译修改后的类
$ mc /path/to/ModifiedClass.java
# 重新加载类
$ retransform /path/to/ModifiedClass.class
技巧三:保存结果
bash
# 将结果保存到文件
$ watch com.example.demo.service.OrderService getOrder "{params,returnObj}" > watch.log
5. 排查流程最佳实践
5.1 常见问题的排查步骤
问题一:CPU 100%
- 使用
top -p <pid>找到最耗 CPU 的线程 - 将线程 ID 转换为十六进制:
printf "%x\n" <tid> - 使用
jstack <pid> | grep <hex-tid>找到线程堆栈 - 分析堆栈,找出死循环或密集计算
问题二:内存持续增长
- 使用
jstat -gc <pid>观察 GC 情况 - 如果 Full GC 频繁且内存持续增长,可能是内存泄漏
- 使用
jmap -dump导出 heap dump - 使用 MAT 分析,找出泄漏对象
问题三:线程阻塞
- 使用
jstack -l <pid>查看线程状态 - 搜索 BLOCKED、WAITING 状态的线程
- 分析锁竞争或等待条件
- 如果有死锁,jstack 会自动报告
问题四:响应超时
- 使用 Arthas 的
trace追踪方法调用链 - 找出耗时最长的方法
- 检查是 CPU 密集还是 I/O 密集
- 如果是外部调用超时,检查网络和下游服务
5.2 排查工具对比
| 工具 | 用途 | 优点 | 缺点 |
|---|---|---|---|
| jstack | 线程分析 | JDK 自带,零依赖 | 信息有限 |
| jmap | 堆内存分析 | JDK 自带 | 需要导出文件 |
| jstat | JVM 统计 | 实时监控 | 信息有限 |
| Arthas | 全面诊断 | 功能强大,无需重启 | 需要学习成本 |
| MAT | 内存分析 | 功能强大,可视化 | 需要导出文件 |
| VisualVM | 综合分析 | 可视化 | 功能一般 |
5.3 排查注意事项
注意一:生产环境谨慎操作
- jstack、jmap 等命令会暂停 JVM(STW),大内存堆可能影响服务
- 建议先在测试环境验证
- 优先使用 Arthas 的非侵入式操作
注意二:保留现场
- 排查前先保存现场:jstack、jmap 输出
- 记录时间点、现象、复现步骤
- 方便后续分析和回溯
注意三:不要只看表面
- 线程多不一定有问题,可能是正常业务
- 内存高不一定泄漏,可能是缓存
- 找到根因再修复,不要「头痛医头」
6. 综合示例:线上问题排查实战
6.1 问题描述
线上服务在高峰期出现响应超时,CPU 使用率正常,但请求堆积。
6.2 排查过程
步骤一:初步诊断
bash
# 查看进程状态
$ jps -l
12345 com.example.demo.DemoApplication
# 查看线程数
$ jstack 12345 | grep "Thread-" | wc -l
200
# 查看 CPU 和内存
$ top -p 12345
发现线程数达到 200,偏高。
步骤二:分析线程堆栈
bash
$ jstack -l 12345 > jstack.log
$ grep -c "BLOCKED" jstack.log
150
发现 150 个线程处于 BLOCKED 状态!
步骤三:定位阻塞点
bash
$ grep -A 10 "BLOCKED" jstack.log | head -50
发现大量线程在等待 com.example.demo.service.OrderService 的锁。
步骤四:分析锁持有者
bash
$ grep -B 5 "locked <" jstack.log | grep -A 5 "Thread-"
发现 Thread-1 持有锁且处于 RUNNABLE 状态,查看其堆栈:
"Thread-1" #15 prio=5 os_prio=0 tid=0x00007f8a5c01e800 nid=0x7a2c runnable [0x00007f8a5a7e7000]
java.lang.Thread.State: RUNNABLE
at com.example.demo.service.OrderService.getOrder(OrderService.java:45)
- locked <0x00000000d5f8b828> (a com.example.demo.service.OrderService)
at com.example.demo.service.OrderService.getOrder(OrderService.java:30)
at com.example.demo.controller.OrderController.getOrder(OrderController.java:20)
步骤五:定位根因
查看 OrderService.java 第 45 行:
java
public Order getOrder(Long id) {
synchronized (this) { // 问题:锁粒度太大
Order order = orderRepository.findById(id).orElse(null);
// 模拟耗时操作
try {
Thread.sleep(5000); // 问题:同步块内调用 sleep!
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return order;
}
}
根因 :同步块内调用了 Thread.sleep(5000),导致其他线程阻塞 5 秒!
步骤六:修复问题
java
public Order getOrder(Long id) {
// 只在需要同步的地方加锁
Order order = orderRepository.findById(id).orElse(null);
if (order != null) {
// 处理订单(不需要锁)
processOrder(order);
}
return order;
}
6.3 总结
这个案例展示了完整的排查流程:
- 发现异常:线程数多、BLOCKED 线程多
- 定位阻塞点:jstack 分析
- 找到根因:同步块内 sleep
- 修复问题:减小锁粒度
小结
- jstack 是线程分析的核心工具,可以查看线程状态、定位死锁、分析阻塞原因
- heap dump 是内存分析的核心,通过 MAT 等工具可以找到内存泄漏的根因
- Arthas 是功能最强大的在线诊断工具,支持方法监控、调用链追踪、热更新等高级功能
- 排查的核心思路是「看穿表象,找到根因」:CPU 高 → 死循环?内存涨 → 泄漏?线程卡 → 锁竞争?
- 排查时要注意生产环境的特殊性,优先使用非侵入式工具,保留现场便于分析
P1 阶段小结
从第 013 篇到第 032 篇,我们完成了 Java 语言 + JVM + 并发的全部 20 篇文章。这个阶段我们学习了:
- Java 基础:内存模型、基本类型、String、集合、泛型、异常、IO、反射
- JVM 原理:运行时数据区、对象创建、GC、类加载
- 并发编程:volatile、synchronized、锁、线程池、j.u.c、并发设计
- 排查工具:jstack、heap dump、Arthas
这些知识是 Java 开发者的核心能力,也是后续 Spring Boot、分布式、微服务的基础。
P2 阶段预告:从第 033 篇开始,我们将进入 P2 工程化与协作阶段,包括 Maven/Gradle、Git、单元测试、CI/CD 等主题。这些是每个 Java 工程师必备的工程能力。