上一篇我们聊了心跳——那根维系客户端和服务端之间“生死契约”的细线。心跳回答的是“你还活着吗?”这个最基本的问题。但活着只是底线,运维真正关心的是“你活得怎么样?”。本篇将深入 Phoenix 的 JVM 信息采集机制,看它如何在不侵入一行业务代码的前提下,给每一个 Java 进程做一次全面的“体检”。
一、JVM 体检:你需要检查什么?
如果你是一名医生,给一个人做体检,至少要看这几项:心率、血压、血常规、体温。对应到 JVM 的世界,Phoenix 采集的“体检项目”也有五大维度:
| 体检项目 | JVM 对应维度 | 关键指标 |
|---|---|---|
| 血液系统 | 内存(Memory) | 堆内存、非堆内存、各内存池的 init/used/committed/max |
| 心率 | 线程(Thread) | 活跃线程数、峰值线程数、守护线程数、累计创建线程总数 |
| 代谢能力 | GC(Garbage Collector) | 各收集器的 GC 次数、GC 累计耗时 |
| 细胞活性 | 类加载(Class Loading) | 已加载类数、累计加载总数、已卸载类数 |
| 基本信息 | 运行时(Runtime) | VM 名称/版本/供应商、启动参数、运行时长、启动时间 |
这五项加在一起,就是一个 Java 进程的完整“健康档案”。堆内存涨到 90% 了?线程数突破 500 了?Full GC 频繁触发?这些问题不用你登上服务器手动 jstat、jmap,Phoenix 会定时帮你采集、传输、落库——你只需要打开 UI 页面看图表就行。
二、JvmTaskScheduler:体检的“闹钟”
所有定时采集任务都需要一个调度器来“定闹钟”。JVM 信息采集的调度器是 JvmTaskScheduler,它和心跳调度器的结构一脉相承,但多了一个关键差异——它可以被关掉。
public class JvmTaskScheduler {
private JvmTaskScheduler() {
}
public static void run() {
boolean jvmInfoEnable = ConfigLoader.getMonitoringProperties()
.getJvmInfo().getEnable();
if (jvmInfoEnable) {
long rate = ConfigLoader.getMonitoringProperties()
.getJvmInfo().getRate();
ThreadPoolAcquirer.getInstanceScheduledThreadPoolExecutor()
.scheduleWithFixedDelay(new JvmThread(), 45, rate, TimeUnit.SECONDS);
}
}
}
几个值得关注的设计点:
可选采集。心跳是“无开关”的——实例存活的唯一证明不允许关闭。但 JVM 采集不一样,它是配置驱动的:monitoring.jvm-info.enable 默认为 false,需要显式开启。这是务实的选择——不是所有场景都需要 JVM 级别的监控,有些轻量应用只需要心跳就够了。
延迟 45 秒。心跳延迟 35 秒,JVM 延迟 45 秒。这个 10 秒的差值不是随便写的——它确保心跳先于 JVM 包抵达服务端。为什么?因为服务端处理 JVM 包时,第一件事就是检查 MONITOR_INSTANCE 表中有没有这个实例的记录。如果 JVM 包先到、心跳包后到,服务端会因为找不到实例而直接拒绝这个 JVM 包。先让心跳“打前站”注册实例,再让 JVM 包跟上,时序上才是安全的。
频率下界 30 秒。配置加载阶段会校验:采集频率不能低于 30 秒,否则直接抛异常。这既是对服务端的保护(防止高频采集压垮数据库),也是对客户端的保护(JVM 指标采集虽然轻量,但毕竟涉及遍历所有线程、所有内存池)。
三、JvmThread:一次体检的完整过程
JvmThread 是每次“闹钟响起”时实际执行的任务。它的骨架和 HeartbeatThread 几乎一致,但核心动作从“构造心跳包”变成了“采集 JVM 全量信息”:
@Override
public void run() {
if (!DataExchanger.isReady()) {
return;
}
TimeInterval timer = DateUtil.timer();
try {
// 1. 采集JVM信息
Jvm jvm = JvmUtils.getJvmInfo();
// 2. 构建JVM信息包
JvmPackage jvmPackage = this.clientPackageConstructor
.structureJvmPackage(jvm);
// 3. 封装WebSocket包并发送
WebSocketPackage requestPackage = new WebSocketPackage();
requestPackage.setClassName(JvmPackage.class.getName());
requestPackage.setPayload(jvmPackage);
DataExchanger.sendMessage(requestPackage);
} catch (NetException e) {
log.error("获取网络信息异常!", e);
} catch (Exception e) {
log.error("其它异常!", e);
} finally {
String betweenDay = timer.intervalPretty();
int criticalValue = 5;
if (timer.intervalSecond() > criticalValue) {
log.warn("构建+发送Java虚拟机信息包耗时:{}", betweenDay);
} else {
if (log.isDebugEnabled()) {
log.debug("构建+发送Java虚拟机信息包耗时:{}", betweenDay);
}
}
}
}
整体流程是三步走:采集 → 封装 → 发送。其中“采集”这一步是重头戏——JvmUtils.getJvmInfo() 里到底做了什么?
四、JvmUtils:JDK 送你的“免费体检仪”
这是整篇文章最核心的部分。Phoenix 的 JVM 信息采集,没有用任何第三方字节码增强工具,没有 Agent 注入,没有 JVMTI——它用的是 JDK 自带的 java.lang.management 包,也就是所谓的 MXBean(Management Extension Bean)。
MXBean 是 JDK 从 1.5 开始就提供的 JVM 自省接口。你可以把它理解为 JVM 内置的“传感器阵列”——每个 MXBean 负责暴露一个维度的运行时数据。ManagementFactory 则是获取这些传感器的统一入口。
JvmUtils 在类加载时就通过静态字段拿到了所有需要的 MXBean:
private static final RuntimeMXBean RUNTIMEMX_BEAN
= ManagementFactory.getRuntimeMXBean();
private static final ThreadMXBean THREADMX_BEAN
= ManagementFactory.getThreadMXBean();
private static final ClassLoadingMXBean CLASS_LOADINGMX_BEAN
= ManagementFactory.getClassLoadingMXBean();
private static final MemoryMXBean MEMORYMX_BEAN
= ManagementFactory.getMemoryMXBean();
private static final List<MemoryPoolMXBean> MEMORY_POOLMX_BEANS
= ManagementFactory.getMemoryPoolMXBeans();
private static final List<GarbageCollectorMXBean> GARBAGE_COLLECTORMX_BEANS
= ManagementFactory.getGarbageCollectorMXBeans();
六个 MXBean,覆盖了 JVM 五大维度的所有信息。注意这些都是 static final 的——MXBean 本身是单例的,获取一次就够了,后续每次采集直接调用方法读取最新值即可。
下面逐一拆解五个采集方法。
4.1 内存采集:堆、非堆与内存池
public static MemoryDomain getMemoryInfo() {
MemoryUsage heapMemoryUsage = MEMORYMX_BEAN.getHeapMemoryUsage();
MemoryUsage nonHeapMemoryUsage = MEMORYMX_BEAN.getNonHeapMemoryUsage();
Map<String, MemoryDomain.MemoryUsageDomain> memoryPoolDomainMap
= new HashMap<>(6);
// 堆内存
memoryPoolDomainMap.put("Heap", wrapMemoryUsageDomain(heapMemoryUsage));
// 非堆内存
memoryPoolDomainMap.put("Non_Heap",
wrapMemoryUsageDomain(nonHeapMemoryUsage));
// 各内存池
MEMORY_POOLMX_BEANS.forEach(pool -> {
MemoryUsage memoryUsage = pool.getUsage();
memoryPoolDomainMap.put(
pool.getName().replace(" ", "_"),
wrapMemoryUsageDomain(memoryUsage));
});
return MemoryDomain.builder()
.memoryUsageDomainMap(memoryPoolDomainMap).build();
}
Phoenix 不只是采集“堆”和“非堆”这两个粗粒度的数据——它还遍历了所有 MemoryPoolMXBean,把每个内存池(如 Eden_Space、Old_Gen、Metaspace、Code_Cache 等)的使用量都记录下来。
每个内存区域记录四个指标:
| 指标 | 含义 |
|---|---|
init |
JVM 启动时向操作系统申请的初始内存量 |
used |
当前实际使用的内存量 |
committed |
JVM 已从操作系统获取到的内存量(已承诺分配) |
max |
最大可用内存量(可能为 -1,表示未定义) |
一个值得注意的细节:max 字段被声明为 String 类型而非 long。这是因为某些内存池(比如 Metaspace)的最大值可能是 -1(表示“没有上限”),Phoenix 选择在采集时就把这种情况转换为“未定义”字符串,避免下游代码到处写 if (max == -1) 的判断。
4.2 线程采集:不只是数数
public static ThreadDomain getThreadInfo() {
long[] threadIds = THREADMX_BEAN.getAllThreadIds();
List<String> threadInfos = Lists.newArrayList();
for (long threadId : threadIds) {
ThreadInfo threadInfo = THREADMX_BEAN.getThreadInfo(threadId);
if (threadInfo != null) {
threadInfos.add(StringUtils.trim(threadInfo.toString()));
}
}
threadInfos.sort(Comparator.naturalOrder());
return ThreadDomain.builder()
.threadCount(THREADMX_BEAN.getThreadCount())
.peakThreadCount(THREADMX_BEAN.getPeakThreadCount())
.daemonThreadCount(THREADMX_BEAN.getDaemonThreadCount())
.totalStartedThreadCount(THREADMX_BEAN.getTotalStartedThreadCount())
.threadInfos(threadInfos)
.build();
}
Phoenix 的线程采集做了两层工作:
第一层是“统计数据”:当前活跃线程数、历史峰值、守护线程数、累计创建总数。这四个数字就像一个线程系统的“仪表盘”——活跃数告诉你现在的负载,峰值告诉你历史上的最高水位,累计总数告诉你线程的“流转速度”(如果这个数字飞速增长,说明在频繁创建销毁线程,可能存在线程池配置问题)。
第二层是“线程快照”:遍历所有线程 ID,获取每个线程的 ThreadInfo,转成字符串后按字母排序存储。这些信息包含线程名、状态(RUNNABLE/WAITING/BLOCKED 等)、调用栈片段——相当于做了一次轻量版的 jstack。在 UI 页面上,运维人员可以直接查看某个实例当前所有线程的状态,无需 SSH 到服务器上手动执行命令。
4.3 GC 采集:谁在回收?回收了多久?
public static GarbageCollectorDomain getGarbageCollectorInfo() {
List<GarbageCollectorDomain.GarbageCollectorInfoDomain> infos
= Lists.newLinkedList();
GARBAGE_COLLECTORMX_BEANS.forEach(collector -> {
GarbageCollectorDomain.GarbageCollectorInfoDomain info =
GarbageCollectorDomain.GarbageCollectorInfoDomain.builder()
.name(collector.getName())
.collectionCount(collector.getCollectionCount() == -1L
? "未定义"
: String.valueOf(collector.getCollectionCount()))
.collectionTime(collector.getCollectionTime() == -1L
? "未定义"
: DateUtil.formatBetween(
collector.getCollectionTime(),
BetweenFormatter.Level.MILLISECOND))
.build();
infos.add(info);
});
return GarbageCollectorDomain.builder()
.garbageCollectorInfoDomains(infos).build();
}
不同的 JVM 配置会有不同的 GC 收集器组合。比如 JDK 8 默认可能是 PS Scavenge + PS MarkSweep,JDK 11 可能是 G1 Young Generation + G1 Old Generation。Phoenix 不做任何假设——它遍历 GarbageCollectorMXBean 列表,有几个收集器就采几个,每个收集器记录两个核心指标:
- collectionCount:GC 累计执行次数
- collectionTime:GC 累计耗时(格式化为可读字符串,如“2秒345毫秒”)
这两个数字的变化趋势非常有价值:如果 GC 次数在短时间内飙升、GC 耗时占比越来越大,就说明 JVM 的内存压力正在逼近临界点——可能需要调整堆大小、优化对象分配策略,或者排查是否存在内存泄漏。
4.4 类加载采集:三个数字的故事
public static ClassLoadingDomain getClassLoadingInfo() {
return ClassLoadingDomain.builder()
.totalLoadedClassCount(CLASS_LOADINGMX_BEAN
.getTotalLoadedClassCount())
.loadedClassCount(CLASS_LOADINGMX_BEAN.getLoadedClassCount())
.unloadedClassCount(CLASS_LOADINGMX_BEAN.getUnloadedClassCount())
.isVerbose(CLASS_LOADINGMX_BEAN.isVerbose())
.build();
}
类加载信息看似简单,就三个数字加一个开关,但它能发现一类非常隐蔽的问题——类加载泄漏。正常情况下,loadedClassCount(当前已加载类数)应该趋于稳定。如果这个数字持续攀升而 unloadedClassCount(已卸载类数)纹丝不动,就说明类加载器可能存在泄漏(常见于使用了大量动态代理、热部署场景、或 OSGI 容器)。在没有监控的日子里,这种问题往往要等到 Metaspace OOM 那一刻才会暴露。
4.5 运行时信息:JVM 的“身份证”
运行时信息通过 RuntimeMXBean 采集,内容涵盖 VM 名称、版本、供应商、JVM 启动参数、运行时长、启动时间等。这些信息大多是“静态”的(启动后不会变),但 uptime(运行时长)是动态的——它能让你一眼看出这个 JVM 进程已经连续运行了多久。
这五个采集方法最终在 getJvmInfo() 中被统一编排:
public static Jvm getJvmInfo() {
return Jvm.builder()
.classLoadingDomain(getClassLoadingInfo())
.memoryDomain(getMemoryInfo())
.garbageCollectorDomain(getGarbageCollectorInfo())
.runtimeDomain(getRuntimeInfo())
.threadDomain(getThreadInfo())
.build();
}
一次调用,五维数据尽收囊中。
五、JvmPackage:体检报告的“信封”
采集到的 Jvm 对象需要封装成 JvmPackage 才能发送:
public class JvmPackage extends BaseRequestPackage {
private Jvm jvm; // JVM全量信息
private long rate; // 传输频率
}
structureJvmPackage() 方法负责组装:
public JvmPackage structureJvmPackage(Jvm jvm) throws NetException {
JvmPackage jvmPackage = new JvmPackage();
this.structureBaseRequestPackage(jvmPackage, null);
jvmPackage.setJvm(jvm);
jvmPackage.setRate(ConfigLoader.getMonitoringProperties()
.getJvmInfo().getRate());
return jvmPackage;
}
和心跳包一样,JVM 包也携带了采集频率 rate——服务端可以据此了解这个实例的 JVM 数据上报频率。加上父类 BaseRequestPackage 的字段(实例 ID、IP、端点类型、请求时间等),一个完整的 JVM 信息包就像一份装在信封里的体检报告:信封上写着“谁的报告、什么时候做的”,里面装着五维体检数据。
六、服务端:六路并行的数据入库
JVM 信息包抵达服务端后,由 JvmServiceImpl.dealJvmPackage() 处理。这个方法是整个 JVM 数据处理链路中最值得细看的一段:
public Result dealJvmPackage(JvmPackage jvmPackage) {
// 先检查实例是否存在
int count = this.instanceService.count(
new LambdaQueryWrapper<MonitorInstance>()
.eq(MonitorInstance::getInstanceId, jvmPackage.getInstanceId()));
if (count == 0) {
return Result.builder().isSuccess(false)
.msg(ResultMsgConstants.FAILURE).build();
}
// 六路并行入库
CompletableFuture<Void> allFutures = CompletableFuture.allOf(
CompletableFuture.runAsync(() ->
this.jvmRuntimeService.operateMonitorJvmRuntime(jvmPackage),
this.instanceMonitorThreadPoolExecutor),
CompletableFuture.runAsync(() ->
this.jvmClassLoadingService.operateMonitorJvmClassLoading(jvmPackage),
this.instanceMonitorThreadPoolExecutor),
CompletableFuture.runAsync(() ->
this.jvmMemoryService.operateMonitorJvmMemory(jvmPackage),
this.instanceMonitorThreadPoolExecutor),
CompletableFuture.runAsync(() ->
this.jvmMemoryHistoryService.operateMonitorJvmMemoryHistory(jvmPackage),
this.instanceMonitorThreadPoolExecutor),
CompletableFuture.runAsync(() ->
this.jvmThreadService.operateMonitorJvmThread(jvmPackage),
this.instanceMonitorThreadPoolExecutor),
CompletableFuture.runAsync(() ->
this.jvmGarbageCollectorService
.operateMonitorJvmGarbageCollector(jvmPackage),
this.instanceMonitorThreadPoolExecutor)
);
try {
allFutures.get(30, TimeUnit.SECONDS);
} catch (TimeoutException e) {
allFutures.cancel(true);
// ...
}
return Result.builder().isSuccess(true)
.msg(ResultMsgConstants.SUCCESS).build();
}
这段代码有三个精彩的设计决策:
1. 实例存在性校验。入库前先查 MONITOR_INSTANCE 表,确认这个实例确实存在(由心跳包注册)。如果不存在,直接返回失败——不会为一个“不认识”的实例写入任何 JVM 数据。这就是前面说的“心跳打前站”策略的配合点。
2. CompletableFuture 六路并行。一个 JVM 包要写入六张表(运行时、类加载、内存实时、内存历史、线程、GC),这六个写入操作之间完全没有数据依赖——它们各自从 jvmPackage 中提取自己需要的维度数据,互不干扰。因此用 CompletableFuture.allOf() 并行执行,大幅缩短了处理延迟。
3. 故意不加事务。代码注释中明确写道:“此处不加事务,因为操作的表太多,数据太多,不加事务能提高并发性能,而且此处对数据的一致性要求并不是很高。”这是一个非常务实的权衡——监控数据的特性是“量大、容忍丢失、不需要强一致”。如果加了事务,六张表的写入必须全部成功才提交,任何一个失败都会回滚,这在高并发场景下会成为瓶颈。不加事务意味着某一维度写入失败不影响其他维度,下一轮采集会自动覆盖。
4. 30 秒超时保护。allFutures.get(30, TimeUnit.SECONDS) 设置了超时上限。如果某个写入操作因为数据库锁竞争、慢查询等原因卡住超过 30 秒,主线程不会无限等待——而是取消所有子任务并返回失败。这避免了线程池中的任务堆积。
七、入库细节:以内存为例的“新增或更新”模式
六个维度的入库逻辑都遵循同一个模式,我们以内存(JvmMemoryServiceImpl)为例来看:
@Retryable
public void operateMonitorJvmMemory(JvmPackage jvmPackage) {
String instanceId = jvmPackage.getInstanceId();
Date currentTime = new Date();
MemoryDomain memoryDomain = jvmPackage.getJvm().getMemoryDomain();
if (memoryDomain != null) {
Map<String, MemoryDomain.MemoryUsageDomain> map
= memoryDomain.getMemoryUsageDomainMap();
List<MonitorJvmMemory> saveList = Lists.newArrayList();
for (Map.Entry<String, MemoryDomain.MemoryUsageDomain> entry
: map.entrySet()) {
String memoryType = entry.getKey();
MemoryDomain.MemoryUsageDomain usage = entry.getValue();
// 查库:有没有这个实例+内存类型的记录?
int count = this.count(
new LambdaQueryWrapper<MonitorJvmMemory>()
.eq(MonitorJvmMemory::getInstanceId, instanceId)
.eq(MonitorJvmMemory::getMemoryType, memoryType));
MonitorJvmMemory entity = new MonitorJvmMemory();
entity.setInstanceId(instanceId);
entity.setMemoryType(memoryType);
entity.setInit(usage.getInit());
entity.setUsed(usage.getUsed());
entity.setCommitted(usage.getCommitted());
entity.setMax(usage.getMax());
if (count == 0) {
entity.setInsertTime(currentTime);
saveList.add(entity);
} else {
entity.setUpdateTime(currentTime);
this.update(entity, ...);
}
}
if (CollectionUtils.isNotEmpty(saveList)) {
this.saveBatch(saveList);
}
}
}
这是 Phoenix 中反复出现的“Upsert 模式”:先查询是否已存在,不存在则 INSERT,已存在则 UPDATE。对于内存这种有多个子类型(Heap、Non_Heap、Eden_Space、Old_Gen……)的数据,它按 instanceId + memoryType 作为联合唯一键来判断。
@Retryable 注解则为每个写入操作提供了自动重试能力——如果因为瞬时的数据库连接抖动导致写入失败,Spring Retry 会自动重试,而不是直接丢弃这一轮采集数据。
内存的“双表”设计
内存数据是所有 JVM 维度中唯一采用了“实时表 + 历史表”双表设计的。MONITOR_JVM_MEMORY 存储最新一次采集的数据(每个内存类型只保留一条),而 MONITOR_JVM_MEMORY_HISTORY 则以时间序列的方式追加每一次采集结果。
为什么只有内存有历史表?因为内存是最需要看趋势的指标。堆内存的 used 值如果呈锯齿形波动(GC 后下降、然后缓慢上升),说明一切正常;如果呈阶梯形持续攀升(GC 后也降不下来),就是内存泄漏的经典特征。没有历史数据,这种趋势分析就无从谈起。
八、完整数据流
把客户端到服务端的全链路串起来:
客户端 服务端
│ │
│ T=45s JvmThread 首次触发 │
│ ├── isReady()? ✅ │
│ ├── JvmUtils.getJvmInfo() │
│ │ ├── MemoryMXBean → 堆/非堆/内存池 │
│ │ ├── ThreadMXBean → 线程统计+快照 │
│ │ ├── GarbageCollectorMXBean → GC次数/耗时 │
│ │ ├── ClassLoadingMXBean → 类加载统计 │
│ │ └── RuntimeMXBean → VM信息/运行时长 │
│ ├── structureJvmPackage() 封装 │
│ └── DataExchanger.sendMessage() │
│ │ │
│ │ ──加密──压缩──WebSocket──▶ │
│ │
│ JvmServiceImpl.dealJvmPackage()
│ ├── 实例存在性校验
│ └── CompletableFuture.allOf()
│ ├── 运行时 → MONITOR_JVM_RUNTIME
│ ├── 类加载 → MONITOR_JVM_CLASS_LOADING
│ ├── 内存 → MONITOR_JVM_MEMORY
│ ├── 内存历史 → MONITOR_JVM_MEMORY_HISTORY
│ ├── 线程 → MONITOR_JVM_THREAD
│ └── GC → MONITOR_JVM_GARBAGE_COLLECTOR
九、小结
JVM 信息采集是 Phoenix 客户端中“最重”的一个定时任务——它一次性采集五大维度的数据,写入服务端六张表。但整个设计依然保持了 Phoenix 一贯的简洁与务实:
- 零侵入:完全基于 JDK 自带的 MXBean API,不需要
-javaagent,不需要字节码增强,JvmUtils一个工具类就能拿到所有数据。 - 可选开启:默认关闭,按需配置,不给不需要的应用增加额外负担。
- 时序安全:延迟 45 秒启动,确保心跳先行注册实例,JVM 包不会被服务端拒绝。
- 并行入库:六个维度的数据通过
CompletableFuture.allOf()并行写入,最大化吞吐量。 - 性能优先于一致性:监控数据天然容忍短暂丢失,不加事务换取并发性能,
@Retryable兜底瞬时故障。 - 内存双表:只有最需要趋势分析的内存维度才额外维护历史表,避免数据库膨胀。
下一篇,我们将从 JVM 层面上移到更高的抽象层——线程池信息采集。Phoenix 是如何通过 MonitoredExecutor 注册表机制,把你业务中使用的线程池“纳管”起来并定时上报的?敬请期待。
项目地址:
https://gitee.com/pifeng/phoenix
https://gitee.com/monitoring-platform/phoenix
https://github.com/709343767/phoenix

评论