通过Arthas在线诊断K8S中的内存及JVM等使用情况

在我们的项目中如果出现了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(不影响目标进程)
相关推荐
分布式存储与RustFS1 天前
MinIO 官方 Docker 镜像被移除:依赖它的项目该怎么办
docker·云原生·devops·对象存储·minio·分布式存储
此冬歌咏1 天前
K8s 节点故障实战:优雅驱逐 31 秒,硬故障 331 秒,以及那个永远 Pending 的 Pod
运维·k8s
xing-xing1 天前
Docker容器中Nginx站点根目录网页配置访问
nginx·docker
guo_wen_qiang1 天前
上传本地镜像到harbor中
docker·容器·持续部署
流烟默1 天前
K8s StatefulSet 详解:有状态应用的“身份证”与“固定住址”
k8s·statefulset
羑悻的小杀马特1 天前
Dockerfile 全景指南:从入门编写dockerfile到生产环境镜像优化实战,一步步带你从啃透制作指令到镜像制作落地!
docker·dockerfile·镜像制作
BianHuanShiZhe1 天前
docker常见命令
docker
cg.family1 天前
K8S集群手动巡检
k8s
正经教主1 天前
【FDE系列】阶段2:Day 43:Docker Compose — 多容器一键编排
人工智能·docker·fde