本文皆为Derek_Smart个人原创,请尊重创作,未经许可不得转载。
引言
2026马年第一篇文章,复盘一下年前的重大问题。年前线上出现过一次线程卡死,整个项目直接挂了,就我一人忙。后面一查,OOM。整个生产线,没有监控,只能根据dump文件和普通日志文件进行排查。所以在生产环境中,及时察觉 JVM 的异常状态(如线程卡死、内存泄漏、死锁)对保障服务稳定性至关重要。许多团队会在业务代码中嵌入轻量级的健康检查任务,定期采集 JVM 指标并记录日志,以便在故障发生前获得预警。
本文将以一个实际项目中使用的 HealthCheckTask 为例,分析其设计思路、潜在问题以及优化方案,构建一个既安全又高效的健康检查组件。这个类也将在生产环境真正实施部署。
一、健康检查任务的典型设计
1.1 核心功能
- 定期采集线程状态(RUNNABLE、BLOCKED、WAITING 等)
- 采集堆内存使用量、GC 次数与耗时
- 检测死锁与"卡死"征兆(如 BLOCKED 线程过多)
- 在可疑情况下 dump 部分线程堆栈,辅助问题定位
1.2 代码概览
java
@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 = 5、WARN_TOTAL_THREAD = 500。这个是根据自己项目定制的,因为本项目核心模块就是netty通信。不同应用的线程模型差异巨大:
- 一个 Netty 服务器可能轻松拥有上千个线程,500 线程的阈值会频繁触发警告。
- 某些系统 BLOCKED 线程短暂出现 5 个以上可能是正常现象(如数据库连接池等待)。
后果:误报频发,导致真正的问题被淹没,甚至触发不必要的线程采样。
2.2 线程统计的微小偏差
collectThreadStats() 方法中,总线程数取自 ThreadInfo[] 的长度,但 threadMXBean.getThreadInfo(ids, 0) 在获取瞬间,若某些线程已终止,返回的数组中对应位置为 null。后续虽然遍历时过滤了 null,但 total 仍包含了这些已消失的线程,导致总数略微偏大。
2.3 "卡死"判定逻辑的精度问题
java
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 阈值可配置化
将硬编码的常量改为从配置文件读取,并提供合理的默认值:
java
@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 干扰
java
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 锁),两者结合覆盖更全面。
java
long[] deadlocked = threadMXBean.findDeadlockedThreads();
if (deadlocked == null) {
deadlocked = threadMXBean.findMonitorDeadlockedThreads();
}
注意:
findDeadlockedThreads()从 Java 6 开始支持java.util.concurrent锁的死锁检测,但需要 JVM 支持,通常可用。
3.5 执行间隔的保护
在代码中增加最小间隔限制,防止配置错误:
java
@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)使用量,防止类加载过多导致的内存泄漏。
四、生产环境部署建议
- 默认关闭,按需开启 :通过
health.check.enabled=false默认关闭,仅在需要监控的实例上开启。 - 配置合理的阈值:在压测环境下观察正常指标,设定合适的告警阈值。
- 结合监控系统:将日志中的关键指标(如 BLOCKED 线程数、GC 耗时)通过日志采集工具发送到监控系统(如 Prometheus + Grafana),实现可视化告警。
- 定期复盘:检查因健康检查引发的误报或漏报,持续优化阈值和逻辑。
五、总结
一个优秀的 JVM 健康检查任务应当在"足够感知异常"和"尽可能小影响业务"之间取得平衡。本文分析的 HealthCheckTask 已经具备了良好的基础框架,但通过阈值配置化、统计精确化、死锁检测增强等优化,可以使其更加可靠。
在实际生产中,没有一劳永逸的监控脚本,只有不断根据系统表现调整的守护程序。希望本文能为你设计和优化自己的健康检查组件提供一些启发。
本文皆为Derek_Smart个人原创,请尊重创作,未经许可不得转载。
完整源码
java
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个人原创,请尊重创作,未经许可不得转载。