在我们的项目中如果出现了JVM异常信息,除了常规的Java自带的工具,我们还可以通过Arthas在线工具去观察内存使用情况,如图:

通过图片我们可以看到各种指标数据,如老年代,年轻代等使用情况,我目前介绍的是在dockers desktop里面配置k8s的情况,如果没有好的项目部署,我们可以使用下面的测试代码
java
package com.smartcs.chaos;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
/**
* 故障模拟控制器(仅用于测试环境)
* <p>
* 提供内存泄漏、CPU 飙高、GC 压力等模拟能力,用于验证监控告警与排查演练。
* 严禁在生产环境启用。
*/
@Slf4j
@RestController
@RequestMapping("/chaos")
public class ChaosController {
/** 内存泄漏容器:static 引用,GC 无法回收 */
private static final List<byte[]> LEAK_HOLDER = Collections.synchronizedList(new ArrayList<>());
/** CPU 燃烧线程池 */
private volatile ExecutorService cpuBurnPool;
/** GC 压力线程 */
private volatile Thread gcPressureThread;
/** 状态标记 */
private final AtomicBoolean memoryLeaking = new AtomicBoolean(false);
private final AtomicBoolean cpuBurning = new AtomicBoolean(false);
private final AtomicBoolean gcPressing = new AtomicBoolean(false);
/** 统计信息 */
private final AtomicLong totalLeakedBytes = new AtomicLong(0);
private final AtomicLong totalGcAllocatedBytes = new AtomicLong(0);
/**
* 启动内存泄漏
*
* @param mb 每次泄漏的 MB 数,默认 10
* @param interval 间隔毫秒,默认 1000
*/
@PostMapping("/memory/leak")
public Map<String, Object> startMemoryLeak(
@RequestParam(defaultValue = "10") int mb,
@RequestParam(defaultValue = "1000") long interval) {
if (!memoryLeaking.compareAndSet(false, true)) {
return Map.of("status", "already_running", "leakedMB", totalLeakedBytes.get() / 1024 / 1024);
}
Thread t = new Thread(() -> {
log.warn("[Chaos] 内存泄漏模拟启动: {}MB/{}ms", mb, interval);
while (memoryLeaking.get()) {
try {
// 分配指定大小的数组并持有引用,阻止 GC 回收
byte[] chunk = new byte[mb * 1024 * 1024];
Arrays.fill(chunk, (byte) 1); // 触碰每个页,确保真正占用物理内存
LEAK_HOLDER.add(chunk);
totalLeakedBytes.addAndGet(chunk.length);
long leakedMB = totalLeakedBytes.get() / 1024 / 1024;
if (leakedMB % 100 == 0) {
log.warn("[Chaos] 已泄漏 {} MB", leakedMB);
}
Thread.sleep(interval);
} catch (OutOfMemoryError e) {
log.error("[Chaos] 触发 OOM,泄漏总量: {} MB", totalLeakedBytes.get() / 1024 / 1024);
memoryLeaking.set(false);
break;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
log.warn("[Chaos] 内存泄漏模拟停止");
}, "chaos-memory-leak");
t.setDaemon(true);
t.start();
return Map.of("status", "started", "mbPerRound", mb, "intervalMs", interval);
}
/** 停止内存泄漏并释放内存 */
@PostMapping("/memory/stop")
public Map<String, Object> stopMemoryLeak() {
memoryLeaking.set(false);
long freed = totalLeakedBytes.get() / 1024 / 1024;
LEAK_HOLDER.clear();
totalLeakedBytes.set(0);
System.gc();
return Map.of("status", "stopped", "freedMB", freed);
}
/**
* 启动 CPU 燃烧
*
* @param threads 并发线程数,默认等于 CPU 核心数
* @param duration 持续秒数,0 表示直到手动停止
*/
@PostMapping("/cpu/burn")
public Map<String, Object> startCpuBurn(
@RequestParam(defaultValue = "0") int threads,
@RequestParam(defaultValue = "0") long duration) {
if (!cpuBurning.compareAndSet(false, true)) {
return Map.of("status", "already_running");
}
int n = threads > 0 ? threads : Runtime.getRuntime().availableProcessors();
cpuBurnPool = Executors.newFixedThreadPool(n, r -> {
Thread t = new Thread(r, "chaos-cpu-burn");
t.setDaemon(true);
return t;
});
for (int i = 0; i < n; i++) {
cpuBurnPool.submit(() -> {
long endTime = duration > 0 ? System.currentTimeMillis() + duration * 1000 : Long.MAX_VALUE;
double x = 0;
while (cpuBurning.get() && System.currentTimeMillis() < endTime) {
// 纯计算循环,避免 JIT 优化掉
x += Math.sqrt(Math.random() * 1000) * Math.sin(Math.random());
if (x > 1e15) x = 0;
}
});
}
// 定时自动停止
if (duration > 0) {
new Timer(true).schedule(new TimerTask() {
@Override public void run() { stopCpuBurn(); }
}, duration * 1000);
}
log.warn("[Chaos] CPU 燃烧启动: {} 线程, 持续 {}s", n, duration == 0 ? "手动停止" : duration);
return Map.of("status", "started", "threads", n, "durationSec", duration);
}
/** 停止 CPU 燃烧 */
@PostMapping("/cpu/stop")
public Map<String, Object> stopCpuBurn() {
cpuBurning.set(false);
if (cpuBurnPool != null) {
cpuBurnPool.shutdownNow();
cpuBurnPool = null;
}
log.warn("[Chaos] CPU 燃烧停止");
return Map.of("status", "stopped");
}
/**
* GC 压力:高频分配短命对象,触发频繁 Young GC
*
* @param rate 每秒分配的 MB 数,默认 100
*/
@PostMapping("/gc/pressure")
public Map<String, Object> startGcPressure(@RequestParam(defaultValue = "100") int rate) {
if (!gcPressing.compareAndSet(false, true)) {
return Map.of("status", "already_running");
}
gcPressureThread = new Thread(() -> {
log.warn("[Chaos] GC 压力模拟启动: {}MB/s", rate);
int chunkSize = 1024 * 1024; // 每次分配 1MB
int chunksPerSecond = rate;
long intervalNs = 1_000_000_000L / chunksPerSecond;
while (gcPressing.get()) {
long start = System.nanoTime();
// 分配后立即丢弃引用,制造大量垃圾
byte[] garbage = new byte[chunkSize];
garbage[0] = 1;
totalGcAllocatedBytes.addAndGet(chunkSize);
long elapsed = System.nanoTime() - start;
if (elapsed < intervalNs) {
try {
Thread.sleep((intervalNs - elapsed) / 1_000_000,
(int) ((intervalNs - elapsed) % 1_000_000));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
log.warn("[Chaos] GC 压力模拟停止");
}, "chaos-gc-pressure");
gcPressureThread.setDaemon(true);
gcPressureThread.start();
return Map.of("status", "started", "rateMBPerSec", rate);
}
/** 停止 GC 压力 */
@PostMapping("/gc/stop")
public Map<String, Object> stopGcPressure() {
gcPressing.set(false);
if (gcPressureThread != null) {
gcPressureThread.interrupt();
gcPressureThread = null;
}
return Map.of("status", "stopped",
"totalAllocatedMB", totalGcAllocatedBytes.get() / 1024 / 1024);
}
/** 查看当前模拟状态与 JVM 内存概况 */
@GetMapping("/status")
public Map<String, Object> status() {
Runtime rt = Runtime.getRuntime();
long usedMB = (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024;
Map<String, Object> result = new LinkedHashMap<>();
result.put("memoryLeaking", memoryLeaking.get());
result.put("cpuBurning", cpuBurning.get());
result.put("gcPressing", gcPressing.get());
result.put("leakedMB", totalLeakedBytes.get() / 1024 / 1024);
result.put("gcAllocatedMB", totalGcAllocatedBytes.get() / 1024 / 1024);
result.put("heapUsedMB", usedMB);
result.put("heapMaxMB", rt.maxMemory() / 1024 / 1024);
result.put("availableProcessors", rt.availableProcessors());
return result;
}
/**
* 测试连接是否成功
* @return
*/
@GetMapping("/health")
public Map<String,String> health(){
return Map.of("status", "healthy");
}
}
同时,先编写Dockfile进行项目打包到dockers环境,Dockerfile配置如下:
XML
# 构建阶段,注意我这里使用的是jre,不是jdk,后面在部署arthas的时候因为不是使用jdk,所以需要临时使用引入jdk环境,如果直接使用jdk环境就没有这么麻烦,但是安装包会变大,影响资源使用
FROM eclipse-temurin:21-jre AS builder
WORKDIR /app
COPY target/*.jar app.jar
# 分层提取
RUN java -Djarmode=layertools -jar app.jar extract
# 运行阶段
FROM eclipse-temurin:21-jre
# 时区与用户设置(Ubuntu 使用 apt-get)
RUN apt-get update \
&& apt-get install -y tzdata \
&& rm -rf /var/lib/apt/lists/* \
&& ln -fs /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& dpkg-reconfigure -f noninteractive tzdata \
&& groupadd -r appgroup && useradd -r -g appgroup appuser
WORKDIR /app
# 从构建阶段复制分层文件
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
# 创建日志目录,并赋予 appuser 完全的读写权限
RUN mkdir -p /app/logs && chown -R appuser:appgroup /app
# 切换到普通用户
USER appuser
EXPOSE 8090
# 启动命令(如果项目未启用虚拟线程,可去掉 -XX:+UseVirtualThreads)
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
接下来是配置k8s文件
XML
apiVersion: apps/v1
kind: Deployment
metadata:
name: chaos-service
labels:
app: chaos-service
spec:
replicas: 1
selector:
matchLabels:
app: chaos-service
template:
metadata:
labels:
app: chaos-service
spec:
containers:
- name: chaos-app
image: chaos-service:1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8090
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 1000m # 限制 1 核,CPU 飙高不会拖垮节点
memory: 1Gi # 限制 1GB,内存泄漏会触发 OOMKilled,便于观察
env:
# JVM 参数:开启 GC 日志,便于分析
- name: JAVA_OPTS
value: >-
-Xms256m -Xmx768m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-Xlog:gc*:file=/app/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=10M
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/app/logs/heapdump.hprof
---
apiVersion: v1
kind: Service
metadata:
name: chaos-service
spec:
selector:
app: chaos-service
ports:
- port: 8090
targetPort: 8090
项目启动后,如果想观察内存泄漏情况,我们可以直接访问如下地址,其他端口访问也如法炮制:
XML
#所有终端最好通过Ctrl+X的方式选择终端(管理员),免得出现错误
# 转发端口出来
kubectl port-forward service/chaos-service 8090:8090
# 新开终端进行接口访问
curl.exe -X POST "http://localhost:8090/chaos/memory/leak?mb=20&interval=500"
还有的情况就是,有时候我们拉取不到jdk到容器中,这个时候我们可以手动先拉取jdk
XML
# 拉取jdk到dockers
docker pull docker.1ms.run/library/eclipse-temurin:21-jdk
# 重新命名
docker tag docker.1ms.run/library/eclipse-temurin:21-jdk eclipse-temurin:21-jdk
安装好jdk后,我们就可以启动jdk进行观察内存使用情况
XML
kubectl debug -it chaos-service-5bfccd6848-wc9ft --image=eclipse-temurin:21-jdk --target=chaos-app -- sh
直接使用Java自带工具
XML
# 查看 Java 进程
jps
# 查看 CPU 热点线程
top -H -p 1
# 查看堆概况
jhsdb jmap -heap 1
通过查看到的数据如下:
XML
using thread-local object allocation. # 启用了 TLAB(Thread-Local Allocation Buffer):每个线程在 Eden 区有独立的分配缓冲区,减少多线程分配时的锁竞争。
Mark Sweep Compact GC # Full GC 算法为"标记-清除-压缩"。这通常对应 Serial Old 或 Parallel Old 收集器,说明当前未使用 CMS、G1 等更现代的收集器。
Heap Configuration: # 堆内存的启动配置(由 JVM 参数或默认值决定)
MinHeapFreeRatio = 40 # 堆最小空闲比例。当堆空闲率低于 40% 时,JVM 会尝试扩展堆(前提是未达到 -Xmx)。
MaxHeapFreeRatio = 70 # 堆最大空闲比例。当堆空闲率高于 70% 时,JVM 会尝试收缩堆(前提是未低于 -Xms)。
MaxHeapSize = 268435456 (256.0MB) # 最大堆大小,即 -Xmx256m。堆总容量不能超过此值。
NewSize = 5570560 (5.3125MB) # 新生代初始大小。通常由 -Xmn 或 NewRatio 计算得出。
MaxNewSize = 89456640 (85.3125MB) # 新生代最大大小。默认约为最大堆的 1/3(256MB / 3 ≈ 85.3MB)。
OldSize = 11206656 (10.6875MB) # 老年代初始大小。
NewRatio = 2 # 老年代与新生代的比例:NewRatio=2 表示 老年代:新生代 = 2:1,即老年代占堆的 2/3,新生代占 1/3。
SurvivorRatio = 8 # Eden 与单个 Survivor 区的比例:8 表示 Eden:Survivor = 8:1。两个 Survivor 共占新生代的 2/10,Eden 占 8/10。
MetaspaceSize = 22020096 (21.0MB) # 元空间初始大小(触发首次 Metaspace GC 的阈值)。
CompressedClassSpaceSize = 1073741824 (1024.0MB) # 压缩类空间大小,用于存放类元数据,默认 1GB。
MaxMetaspaceSize = 17592186044415 MB # 元空间最大大小。这个巨大数字表示无上限(默认不限制,受系统内存限制)。
Heap Usage: # 堆内存当前使用情况
New Generation (Eden + 1 Survivor Space): # 新生代 = Eden + 1 个 Survivor(From Space)
capacity = 80543744 (76.8125MB) # 新生代总容量(Eden + 一个 Survivor)
used = 80091464 (76.38117218017578MB) # 已使用 76.38MB
free = 452280 (0.43132781982421875MB) # 空闲仅 0.43MB
99.43846663000916% used # 新生代使用率 99.44%,几乎完全占满
Eden Space: # Eden 区(新对象首先分配在这里)
capacity = 71630848 (68.3125MB) # Eden 容量 68.3MB
used = 71630848 (68.3125MB) # 已用 68.3MB
free = 0 (0.0MB) # 空闲 0MB
100.0% used # Eden 使用率 100%,完全填满,下一次 Young GC 即将触发
From Space: # From Survivor 区(上一次 Young GC 后存活对象移入的区域)
capacity = 8912896 (8.5MB) # 容量 8.5MB
used = 8460616 (8.068672180175781MB) # 已用 8.07MB
free = 452280 (0.43132781982421875MB) # 空闲 0.43MB
94.92555506089154% used # 使用率 94.93%,接近满
To Space: # To Survivor 区(本次 Young GC 时用来接收存活对象的区域)
capacity = 8912896 (8.5MB) # 容量 8.5MB
used = 0 (0.0MB) # 已用 0MB(正常,因为 To Space 在 Young GC 时才使用)
free = 8912896 (8.5MB) # 空闲 8.5MB
0.0% used # 使用率 0%
tenured generation: # 老年代(长期存活对象、大对象晋升到此)
capacity = 178978816 (170.6875MB) # 老年代容量 170.7MB
used = 176446928 (168.2729034423828MB) # 已用 168.27MB
free = 2531888 (2.4145965576171875MB) # 空闲仅 2.41MB
98.58537001384566% used # 老年代使用率 98.59%,几乎占满
接下来我们直接使用arthas工具,同样的我们还是在这个环境下
XML
kubectl debug -it chaos-service-5bfccd6848-wc9ft --image=eclipse-temurin:21-jdk --target=chaos-app -- sh
通过以下命令安装arthas
XML
# 下载 Arthas
curl -O https://arthas.aliyun.com/arthas-boot.jar
# 附加到 PID 为 1 的 Java 进程
java -jar arthas-boot.jar 1
启动后我们可以看到如下图

通过dashboard命令得到如图

这个是在容器中看到的图,如果想通过页面终端访问,我们还需要进行端口转换
XML
kubectl port-forward chaos-service-5bfccd6848-wc9ft 8563:8563
这个时候访问页面就会看到这样的图

常用 Arthas 命令速查
| 命令 | 作用 |
|---|---|
dashboard |
实时面板:线程、内存、GC、运行时信息 |
thread |
查看所有线程;thread -n 3 查看 CPU 最高的 3 个线程 |
thread -b |
查找阻塞其他线程的线程(死锁排查) |
heapdump /tmp/dump.hprof |
生成堆转储文件 |
jvm |
查看 JVM 详细信息(内存、GC、线程数等) |
sysprop |
查看系统属性 |
sysenv |
查看环境变量 |
sc -d <类名> |
查看类的详细信息(类加载器、方法等) |
watch <类名> <方法名> '{params, returnObj}' |
观察方法入参和返回值 |
trace <类名> <方法名> |
追踪方法内部调用路径和耗时 |
profiler start / profiler stop |
生成 CPU 火焰图 |
stop / exit |
退出 Arthas(不影响目标进程) |