从一次 OOM 事故说起:打造生产级的 JVM 健康检查组件

0 阅读4分钟

本文皆为Derek_Smart个人原创,请尊重创作,未经许可不得转载。

引言

2026马年第一篇文章,复盘一下年前的重大问题。年前线上出现过一次线程卡死,整个项目直接挂了,就我一人忙。后面一查,OOM。整个生产线,没有监控,只能根据dump文件和普通日志文件进行排查。所以在生产环境中,及时察觉 JVM 的异常状态(如线程卡死、内存泄漏、死锁)对保障服务稳定性至关重要。许多团队会在业务代码中嵌入轻量级的健康检查任务,定期采集 JVM 指标并记录日志,以便在故障发生前获得预警。

本文将以一个实际项目中使用的 HealthCheckTask 为例,分析其设计思路、潜在问题以及优化方案,构建一个既安全又高效的健康检查组件。这个类也将在生产环境真正实施部署。


一、健康检查任务的典型设计

1.1 核心功能

  • 定期采集线程状态(RUNNABLE、BLOCKED、WAITING 等)
  • 采集堆内存使用量、GC 次数与耗时
  • 检测死锁与“卡死”征兆(如 BLOCKED 线程过多)
  • 在可疑情况下 dump 部分线程堆栈,辅助问题定位

1.2 代码概览

@Component
@Slf4j
@ConditionalOnProperty(name = "health.check.enabled", havingValue = "true", matchIfMissing = true)
public class HealthCheckTask {

    // 阈值配置
    private static final int WARN_BLOCKED_THREAD = 5;
    private static final int WARN_TOTAL_THREAD = 500;
    // ...

    // JMX Bean
    private final ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
    private final MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
    // ...

    @Scheduled(fixedDelayString = "${health.check.interval:300000}")
    public void healthCheck() {
        long start = System.currentTimeMillis();
        try {
            // 1. 采集数据
            ThreadStats stats = collectThreadStats();
            MemoryUsage heap = memoryMXBean.getHeapMemoryUsage();
            // 2. 记录日志
            log.info("健康检查指标:线程={} ...", stats);
            // 3. 判断是否异常
            if (isSuspectHang(stats)) {
                suspectCount++;
                if (suspectCount >= CONTINUOUS_SUSPECT_LIMIT && canDump()) {
                    dumpSuspectThreads();   // 采样线程堆栈
                    suspectCount = 0;
                }
            } else {
                suspectCount = 0;
            }
        } catch (Throwable t) {
            log.error("健康检查执行异常", t);
        } finally {
            long cost = System.currentTimeMillis() - start;
            if (cost > 100) log.warn("健康检查自身耗时={}ms", cost);
        }
    }
}

该任务通过 Spring 的 @Scheduled 定期执行,默认间隔 5 分钟,可通过配置文件调整。关键设计亮点:

  • 可开关、可配置:通过 @ConditionalOnProperty 控制是否启用,执行间隔支持占位符。
  • 分级日志:正常指标输出 INFO,可疑征兆输出 WARN,触发采样输出 ERROR。
  • 自我保护:连续 3 次可疑才采样,采样后冷却 10 分钟,避免频繁 dump 影响业务。
  • 轻量采集:常规检查不获取线程堆栈,仅统计状态;采样时也限制堆栈深度(5层)和可疑线程数(5条)。

二、运行时的潜在问题

尽管上述设计已考虑性能影响,但在实际生产环境中,仍可能遇到以下问题:

2.1 阈值过于刚性

代码中硬编码了 WARN_BLOCKED_THREAD = 5WARN_TOTAL_THREAD = 500。这个是根据自己项目定制的,因为本项目核心模块就是netty通信。不同应用的线程模型差异巨大:

  • 一个 Netty 服务器可能轻松拥有上千个线程,500 线程的阈值会频繁触发警告。
  • 某些系统 BLOCKED 线程短暂出现 5 个以上可能是正常现象(如数据库连接池等待)。

后果:误报频发,导致真正的问题被淹没,甚至触发不必要的线程采样。

2.2 线程统计的微小偏差

collectThreadStats() 方法中,总线程数取自 ThreadInfo[] 的长度,但 threadMXBean.getThreadInfo(ids, 0) 在获取瞬间,若某些线程已终止,返回的数组中对应位置为 null。后续虽然遍历时过滤了 null,但 total 仍包含了这些已消失的线程,导致总数略微偏大。

2.3 “卡死”判定逻辑的精度问题

if (s.total > WARN_TOTAL_THREAD && s.runnable < s.total * 0.1) {
    // 线程很多但几乎不运行
}

浮点数乘法可能因精度产生边界误判,且 0.1 的比例是否适用于所有场景值得商榷。例如,大量线程处于 TIMED_WAITING(如业务线程池空闲)时,系统依然健康。

本文皆为Derek_Smart个人原创,请尊重创作,未经许可不得转载。

2.4 死锁检测的局限性

threadMXBean.findDeadlockedThreads() 仅能检测由 synchronized 引起的 JVM 级别死锁,无法检测 java.util.concurrent 包中的 ReentrantLock 死锁。如果应用大量使用显式锁,该方法会漏报。

2.5 配置过短导致性能开销

如果运维人员将 health.check.interval 误设为 1 秒,那么每秒钟都会遍历全部线程状态,当线程数上万时,CPU 开销会显著上升,甚至影响业务。


三、优化方案与实践

针对上述问题,可以对健康检查任务进行增强,使其更健壮、更通用。

3.1 阈值可配置化

将硬编码的常量改为从配置文件读取,并提供合理的默认值:


@Value("${health.warn.blocked-thread:5}")
private int warnBlockedThread;

@Value("${health.warn.total-thread:500}")
private int warnTotalThread;

@Value("${health.warn.runnable-ratio:0.1}")
private double warnRunnableRatio;

这样,不同团队可以根据自身应用特点调整阈值,避免误报。

3.2 精确的线程总数

使用 threadMXBean.getThreadCount() 获取准确的总线程数,避免数组中的 null 干扰

int totalThreads = threadMXBean.getThreadCount();
// 或者继续使用数组方式,但手动计数非 null 元素
int nonNullCount = 0;
for (ThreadInfo info : infos) {
    if (info != null) nonNullCount++;
}

3.3 优化判定逻辑

  • 将浮点比较改为整数比较,避免精度问题:
    s.runnable * 10 < s.total
  • 增加对 TIMED_WAITING 的容忍度,例如要求 RUNNABLE 线程数低于阈值且 WAITING 类线程占比过高才告警。

3.4 增强死锁检测

可以同时调用 findMonitorDeadlockedThreads()(检测对象监视器死锁)和 findDeadlockedThreads()(检测 JVM 全局死锁,包括 java.util.concurrent 锁),两者结合覆盖更全面。

long[] deadlocked = threadMXBean.findDeadlockedThreads();
if (deadlocked == null) {
    deadlocked = threadMXBean.findMonitorDeadlockedThreads();
}

注意:findDeadlockedThreads() 从 Java 6 开始支持 java.util.concurrent 锁的死锁检测,但需要 JVM 支持,通常可用。

3.5 执行间隔的保护

在代码中增加最小间隔限制,防止配置错误:

@Scheduled(fixedDelayString = "${health.check.interval:300000}")
public void healthCheck() {
    long interval = healthCheckInterval; // 注入的值
    if (interval < 10000) { // 小于10秒则强制设为10秒
        log.warn("健康检查间隔过短({}ms),重置为10000ms", interval);
        // 可通过动态修改下一次执行时间,但 Spring Scheduled 不支持动态修改
        // 这里只是记录警告,建议在配置校验层面解决
    }
    // ...
}

或者在配置中心限制最小值为 30 秒。

3.6 线程堆栈采样的保护

  • 采样前检查当前 JVM 负载(如 SystemLoadAverage),过高时跳过采样,避免雪上加霜。
  • 采样线程使用独立的线程池异步执行,避免阻塞调度线程。

3.7 增加内存与 GC 的详细监控

  • 当老年代使用率持续超过 80% 且 GC 频繁时,输出更详细的 GC 日志。
  • 监控非堆内存(MetaSpace)使用量,防止类加载过多导致的内存泄漏。

四、生产环境部署建议

  1. 默认关闭,按需开启:通过 health.check.enabled=false 默认关闭,仅在需要监控的实例上开启。
  2. 配置合理的阈值:在压测环境下观察正常指标,设定合适的告警阈值。
  3. 结合监控系统:将日志中的关键指标(如 BLOCKED 线程数、GC 耗时)通过日志采集工具发送到监控系统(如 Prometheus + Grafana),实现可视化告警。
  4. 定期复盘:检查因健康检查引发的误报或漏报,持续优化阈值和逻辑。

五、总结

一个优秀的 JVM 健康检查任务应当在“足够感知异常”和“尽可能小影响业务”之间取得平衡。本文分析的 HealthCheckTask 已经具备了良好的基础框架,但通过阈值配置化、统计精确化、死锁检测增强等优化,可以使其更加可靠。

在实际生产中,没有一劳永逸的监控脚本,只有不断根据系统表现调整的守护程序。希望本文能为你设计和优化自己的健康检查组件提供一些启发。

本文皆为Derek_Smart个人原创,请尊重创作,未经许可不得转载。

完整源码

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.lang.management.*;
import java.time.LocalDateTime;
import java.util.*;

/**
 * JVM Health Check Task
 * @Author:Derek_Smart
 * @Date:2026/2/27 15:18
 */
@Component
@Slf4j
@ConditionalOnProperty(name = "health.check.enabled", havingValue = "true", matchIfMissing = true)
public class HealthCheckTask {

    /* =================== 阈值配置 =================== */

    private static final int WARN_BLOCKED_THREAD = 5;
    private static final int WARN_TOTAL_THREAD = 500;
    private static final int WARN_RUNNABLE_THREAD = 5;
    private static final int MAX_STACK_DEPTH = 5;
    private static final int CONTINUOUS_SUSPECT_LIMIT = 3;
    private static final long DUMP_COOLDOWN_MS = 10 * 60 * 1000; // 10分钟

    /* =================== 状态 =================== */

    private int suspectCount = 0;
    private volatile long lastDumpTime = 0;

    /* =================== MXBean =================== */

    private final ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
    private final MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
    private final RuntimeMXBean runtimeMXBean = ManagementFactory.getRuntimeMXBean();
    private final OperatingSystemMXBean osMXBean = ManagementFactory.getOperatingSystemMXBean();
    private final ClassLoadingMXBean classLoadingMXBean = ManagementFactory.getClassLoadingMXBean();
    private final List<GarbageCollectorMXBean> gcMXBeans = ManagementFactory.getGarbageCollectorMXBeans();

    /* =================== 主任务 =================== */

    @Scheduled(fixedDelayString = "${health.check.interval:300000}")
    public void healthCheck() {

        long start = System.currentTimeMillis();

        try {
            long uptimeSeconds = runtimeMXBean.getUptime() / 1000;
            ThreadStats stats = collectThreadStats();

            MemoryUsage heap = memoryMXBean.getHeapMemoryUsage();
            long heapUsed = heap.getUsed() / 1024 / 1024;
            long heapMax = heap.getMax() / 1024 / 1024;

            long gcCount = 0, gcTime = 0;
            for (GarbageCollectorMXBean gc : gcMXBeans) {
                gcCount += gc.getCollectionCount();
                gcTime += gc.getCollectionTime();
            }

            log.info(
                    "【健康检查】运行={}s | 线程={} (RUNNABLE={}, BLOCKED={}, WAITING={}, TIMED_WAITING={})"
                            + " | Heap={}MB/{}MB | GC={}次 {}ms | 类={} | Load={}",
                    uptimeSeconds,
                    stats.total,
                    stats.runnable,
                    stats.blocked,
                    stats.waiting,
                    stats.timedWaiting,
                    heapUsed,
                    heapMax,
                    gcCount,
                    gcTime,
                    classLoadingMXBean.getLoadedClassCount(),
                    osMXBean.getSystemLoadAverage()
            );

            if (isSuspectHang(stats)) {
                suspectCount++;
                log.warn("【异常征兆】疑似卡死指标触发,第 {} 次", suspectCount);
            } else {
                suspectCount = 0;
            }

            if (suspectCount >= CONTINUOUS_SUSPECT_LIMIT && canDump()) {
                log.error("【严重告警】连续 {} 次异常,开始线程采样", suspectCount);
                dumpSuspectThreads();
                suspectCount = 0;
            }

        } catch (Throwable t) {
            log.error("健康检查执行异常", t);
        } finally {
            long cost = System.currentTimeMillis() - start;
            if (cost > 100) {
                log.warn("【健康检查】自身执行耗时={}ms", cost);
            }
        }
    }

    /* =================== 线程统计 =================== */

    private ThreadStats collectThreadStats() {

        int runnable = 0, blocked = 0, waiting = 0, timedWaiting = 0;

        ThreadInfo[] infos = threadMXBean.getThreadInfo(threadMXBean.getAllThreadIds(), 0);

        for (ThreadInfo info : infos) {
            if (info == null) continue;
            switch (info.getThreadState()) {
                case RUNNABLE -> runnable++;
                case BLOCKED -> blocked++;
                case WAITING -> waiting++;
                case TIMED_WAITING -> timedWaiting++;
            }
        }

        return new ThreadStats(infos.length, runnable, blocked, waiting, timedWaiting
        );
    }

    /* =================== 卡死判定 =================== */

    private boolean isSuspectHang(ThreadStats s) {

        if (s.blocked > WARN_BLOCKED_THREAD) {
            log.warn("BLOCKED 线程过多:{}", s.blocked);
            return true;
        }

        if (s.total > WARN_TOTAL_THREAD && s.runnable < s.total * 0.1){
            log.warn("线程很多但几乎不运行:total={}, runnable={}", s.total, s.runnable);
            return true;
        }

        return false;
    }

    /* =================== dump 控制 =================== */

    private boolean canDump() {
        long now = System.currentTimeMillis();
        if (now - lastDumpTime < DUMP_COOLDOWN_MS) {
            return false;
        }
        lastDumpTime = now;
        return true;
    }

    /* =================== 核心采样 =================== */

    private void dumpSuspectThreads() {

        log.error("【线程采样时间】{} | JVM已运行 {} 秒",
                LocalDateTime.now(),
                runtimeMXBean.getUptime() / 1000
        );

        long[] deadlocked = threadMXBean.findDeadlockedThreads();
        if (deadlocked != null && deadlocked.length > 0) {
            log.error("【死锁】检测到死锁线程数={}", deadlocked.length);
            ThreadInfo[] infos = threadMXBean.getThreadInfo(deadlocked, MAX_STACK_DEPTH);
            for (ThreadInfo info : infos) {
                log.error(formatThread(info));
            }
            return;
        }

        ThreadInfo[] all =
                threadMXBean.getThreadInfo(threadMXBean.getAllThreadIds(), MAX_STACK_DEPTH);

        List<ThreadInfo> suspects = Arrays.stream(all)
                .filter(Objects::nonNull)
                .filter(t -> !isJvmThread(t.getThreadName()))
                .filter(t ->
                        t.getThreadState() == Thread.State.BLOCKED
                                || t.getThreadState() == Thread.State.RUNNABLE)
                .sorted(Comparator
                        .comparingLong((ThreadInfo t) -> Math.max(t.getBlockedTime(), 0))
                        .reversed())
                .limit(5)
                .toList();


        log.error("【线程采样】可疑线程 Top{}", suspects.size());
        for (ThreadInfo info : suspects) {
            log.error(formatThread(info));
        }
    }

    /* =================== JVM 线程过滤 =================== */

    private boolean isJvmThread(String name) {
        return name.startsWith("GC")
                || name.startsWith("Finalizer")
                || name.startsWith("Reference Handler")
                || name.startsWith("VM Thread")
                || name.startsWith("VM Periodic Task")
                || name.startsWith("Attach Listener")
                || name.startsWith("Signal Dispatcher")
                || name.startsWith("Common-Cleaner");
    }

    /* =================== 栈格式 =================== */

    private String formatThread(ThreadInfo info) {

        StringBuilder sb = new StringBuilder(256);
        sb.append("\n【线程】")
                .append(info.getThreadName())
                .append(" | 状态=").append(info.getThreadState())
                .append(" | 阻塞=").append(info.getBlockedTime()).append("ms")
                .append(" | 等待=").append(info.getWaitedTime()).append("ms")
                .append("\n");

        for (StackTraceElement ste : info.getStackTrace()) {
            sb.append("    at ").append(ste).append("\n");
        }

        return sb.toString();
    }

    /* =================== DTO =================== */

    private record ThreadStats(int total,int runnable,int blocked,int waiting,int timedWaiting) {
    }
}

本文皆为Derek_Smart个人原创,请尊重创作,未经许可不得转载。