1. 为什么偏要自己写一个系统监控工具类
我最早做系统监控的时候,第一反应也是去接现成的开源套件,比如 Prometheus 那套生态。但后来在实际项目里发现一个很典型的问题:很多时候我们的需求并不是“搭建一套完整的监控运维平台”,而是要在自己的 Java 应用里嵌入一个轻量的系统状态查询能力。比如给管理系统加一个“服务器概况”页面、给后台加一个“节点健康检查”接口、或者在桌面工具里做一个实时网速悬浮窗——这些场景如果动不动就上 Prometheus + Grafana,反而显得笨重,部署成本和学习成本都不低。
于是我就开始关注 JVM 层面直接获取操作系统信息的方案。Java 本身没有提供一套完整、跨平台的系统监控 API,Runtime 类只能查 JVM 内存,System.getProperty 只能拿一些基础属性,真要拿到 CPU 使用率、内存总量、磁盘读写速度、网卡实时上行下行速率,就得依赖第三方库或者 JNI 本地调用。在调研了一圈之后,我最终锁定了 oshi。简单说,它是对操作系统的硬件和系统信息做封装的纯 Java 库,底层通过 JNA 调用本机 API,Windows、Linux、macOS 都能覆盖,API 设计得也比较规整,不需要我自己去写一堆容易踩坑的本地代码。
这个工具类我用了挺长时间,也改了好几版。现在回头看,最核心的价值其实就是两点:一是把“CPU、内存、磁盘、网络”这些分散的监控项收敛成一套统一的 Java API;二是解决了网速上行下行的实时速率计算问题——这块如果不仔细处理,很容易算出负值或者归零,后面我会专门讲。适合谁参考呢?我觉得是那些需要在 Java 项目里快速做出一块“监控面板”或“健康状态接口”的开发者,尤其是暂时不想引入重型运维组件、希望几十分钟内就有一版可用代码的团队。
2. 整体设计思路:先想清楚监控什么、多久采一次
在动手写代码之前,建议先别急着建类,而是把需求场景想清楚。我在模拟项目X里设计这套工具类时的目标很明确:要在一个后台管理系统里展示服务器实时状态,顺便把历史趋势数据落到数据库。所以第一版就覆盖了五类指标:基础设备信息、CPU、内存、磁盘、网络速率。后续如果需要,可以再加进程列表、传感器温度等信息,但最初的核心就是这五个模块。
采样频率是个很关键的决策。一开始我把所有指标都放到一个 1 秒的定时任务里执行,结果发现两个问题:一是 CPU 使用率本身需要两次采样做差值,1 秒间隔太短时抖动很大;二是频繁调用磁盘 IO 的采样会导致额外的系统开销,得不偿失。后来我用了“分层采样”策略:网络速率和 CPU 使用率这类对实时性要求高的指标,用 1 到 2 秒周期;磁盘读写速率用 5 到 10 秒周期,偶尔刷新即可;设备信息和硬件配置这类基本不变的字段,只加载一次并缓存。这个思路也推荐给读者,不要所有指标共用同一个周期。
从代码结构上,我把它设计成一个单例类。为什么用单例?因为 oshi 的 SystemInfo 在初始化的时候需要读取大量系统信息,如果每次调用都 new 一个实例,代价很高,而且网络速率、磁盘速率这类统计型指标天然需要“连续采样同一个对象实例”,中途换对象会导致数据对不上。单例模式加上内部缓存,可以让整个工具类在项目生命周期内只创建一次系统信息对象,后续所有监控请求都复用这套实例。
我还把对外暴露的格式统一成了一个小 DTO,避免调用方直接面对一堆 oshi 自家的对象类型。这样后续无论输出 JSON、入库、还是显示到前端模板,都只需要处理一个统一的 MonitorData 对象。模块之间的关系是一个典型的“采集-缓存-对外”三层结构:最底层是 oshi 的原生能力,中间层是我自己的转换与计算逻辑,最顶层是线程池驱动的定时刷新和被动查询接口。
这里特别想提一下网络上行下行速率的设计难点。很多人第一次写网速监控时容易掉进一个误区:以为能像查文件大小一样直接拿到“当前速率”。实际上操作系统只保存网卡自启动以来累计接收和发送的字节数,实时速率必须靠“两次采样之间的差值除以时间差”来推算。也就是说,我们必须持有上次采样的字节数和时间戳,在下次采样时做计算。为此我定义了一个”采样点”数据结构,里面保存网卡标识、上次读取时间、上一周期的累计收发字节数,计算时用当前累计值减去上一周期值,再除以间隔秒数,就得到 x KB/s 这样的实时速率。
还有一点容易被忽视:网卡的过滤。一台服务器上往往有很多网络接口,除了我们关心的物理网卡,还会有 docker0、virbr0 这类虚拟网桥,以及 lo 回环接口。如果不做过滤,把这些接口的上行下行全部加总,网速数值会被严重虚高。我调研后发现,osh 的 NetworkIF 对象有一个 getIfAlias 或者 getName 可以拿接口名,使用时需要手工排除一些已知的虚拟接口名。不同操作系统的虚拟网卡命名规则不太一样,Linux 常见 docker0、br-xxx、vethxxx,Windows 常见 vEthernet 开头,最稳妥的做法是维护一个接口名黑名单,再加上“优先选择对外 IP 所在的网卡”这个策略。
3. 核心指标拆解:每块监控到底应该怎么拿数据
3.1 硬件与系统基础信息
基础信息这块相对简单,主要是给监控面板头部展示用的。通过硬件抽象层 HardwareAbstractionLayer 可以拿到厂商 manufacturer、处理器型号 processor 的 getName、物理核心数 getPhysicalProcessorCount、逻辑处理器数 getLogicalProcessorCount 等。操作系统信息通过 OperatingSystem 拿,包括系统家族 getFamily、版本号 getVersionInfo、以及系统 uptime 即开机时长。
这里我踩过一个小坑:oshi 的 CPU 名称在部分 Linux 系统上会返回特别长的字符串,里面包含了制造商和主频信息,展示时会撑破 UI。所以我建议对外输出前做一次截断处理,或者只保留到第一个“@”符号之前。另外序列号这类敏感信息,如果项目没有硬性要求,最好别打进日志,避免信息暴露。
3.2 内存监控:物理内存和交换分区要分开看
内存监控分为两部分:物理内存和 JVM 堆内存。物理内存通过 GlobalMemory.getTotal() 和 getAvailable() 拿,注意 oshi 返回的单位是字节,需要除以 1024^3 转成 GB。这里有个值得注意的细节:getAvailable() 拿到的是“当前可用物理内存”,并不能直接反映 JVM 自己能用的堆大小,如果你在做告警判断,应该把它和 JVM 堆内存监控分开看。交换分区的总大小和已用量在 GlobalMemory.getSwapTotal() 和 getSwapUsed() 里。
JVM 堆内存这块是 Java 自己就能解决的。Runtime.getRuntime() 里的 maxMemory、totalMemory、freeMemory 分别对应最大堆、当前已申请的堆、已申请堆中剩余部分。用 totalMemory 减去 freeMemory 得到的是当前已用堆内存,而“最大可用堆减去已用堆”才是真正的剩余可用堆。很多初学者容易直接用 freeMemory 当整体剩余堆,但 totalMemory 并不等于 maxMemory,这个换算逻辑一定要理清。
从经验来看,如果要监控一台 Java 应用服务器的健康状况,物理内存和堆内存最好都展示出来,因为两者含义完全不同:物理内存反映的是整台机器有没有被耗尽,堆内存反映的是当前 JVM 进程会不会马上 OOM。
3.3 CPU 监控:两次采样的差值才算得出使用率
CPU 使用率是监控工具最核心的指标之一。oshi 获取 CPU 使用率有两种方式:一种是 getSystemCpuLoad() 直接返回一个 0 到 1 之间的值,底层已经处理了采样差值;另一种是 getSystemCpuLoadTicks() 配合 tick 数组自己计算。但从我的实测经验来看,直接调用 getSystemCpuLoad() 在部分 Windows 版本上偶尔会返回 0,尤其是第一次调用时,因为内部计数器还没有完成初始化。如果你的监控要求比较高,建议用经典的两阶段采样法。
两阶段采样的思路是:第一次先调用 CentralProcessor.getProcessorCpuLoadTicks() 拿到一组 tick 数组,这里面包含空闲、用户、系统、IO 等待等各类 CPU 时间片状态;线程池在下一个周期再次调用 getProcessorCpuLoadTicks(),对两组 tick 做差,用空闲时间的变化量除以总时间的变化量,得到的就是这一段时间内的 CPU 使用率。
这里要特别提醒:getProcessorCpuLoadTicks() 返回的是一个 long 数组,而且不同操作系统下数组下标对应的含义是固定的,比如下标 0 是空闲时间,下标 1 是用户态时间,下标 2 是内核态时间,还有其他状态位。实际开发时可以直接使用 oshi 封装好的计算方式,但如果你需要把“用户态/内核态”分开展示,就需要自己解析 tick 数组了。我一般在工具类里暴露两层接口:一个直接返回综合使用率,一个返回包含详细状态的对象,方便做深入分析。
CPU 负载还有一个容易被混淆的概念:系统负载 load average。它反映的是“可运行进程和不可中断进程的平均数量”,并不是使用率的百分比。Unix/Linux 上看到 load average 显示 1.5 并不代表 CPU 用了 150%,而是有一段时间内平均 1.5 个进程在等待。如果机器是单核,1.5 的负载已经算高了;如果是 4 核,1.5 就比较正常。这点在写监控指标说明和告警阈值时必须区分清楚,别拿负载当使用率去设告警。
3.4 磁盘监控:空间和读写速率是两套逻辑
磁盘监控分两个层面:一个是分区空间使用情况,另一个是磁盘的 IO 读写速率。分区空间逻辑简单,HardwareAbstractionLayer.getDiskStores() 可以拿到磁盘列表,每个 HWDiskStore 里可以继续取 partitions 分区信息。通过 Partition 对象能拿到总空间 getSize() 和当前可用 getFreeSpace(),从而算出已用空间和占用率。
磁盘读写速率则复杂一点。HWDiskStore 对象有 getReadBytes() 和 getWriteBytes() 两个方法,分别返回自启动以来累计读取/写入的字节数。跟网速一样,要算实时速率也得做“两次采样差值除以时间间隔”。区别在于:磁盘统计的更新频率相对更低,如果你把采样间隔设得太短,比如 1 秒,很可能会看到速率值来回跳动或者为 0,因为底层磁盘计数器并不一定每秒都刷新。我的实测建议是把磁盘 IO 采样周期放在 5 到 10 秒,必要时甚至可以设到 15 秒,换取更平滑的曲线。
另外,磁盘的“读写字节数”和“读写次数”是两个不同指标,前者反映吞吐量,后者反映频繁的小文件操作情况。如果你的监控重点偏向数据库或日志服务,IO 次数往往比吞吐量更有诊断价值。
3.5 网络上行下行速率:全篇最容易踩坑的地方
这部分是这个工具类的核心卖点。先明确一个概念:操作系统不会直接给你一个“当前网速”的变量,我们能拿到的只是网卡累计收发字节数。要想知道实时的上行、下行速率,必须自己采样。
具体操作步骤是:先从 NetworkIF 列表里筛出物理网卡,然后调用 updateAttributes() 刷新统计数据(这个步骤非常关键,我一开始漏掉它,导致数据一直不变),再读取 getBytesRecv() 和 getBytesSent(),分别代表累计接收字节和累计发送字节。第一条数据记录到缓存中,并记录当前时间戳;等到下一个采样周期,再次刷新同一网卡的属性,用新的累计值减去旧的累计值,再除以两次采样之间的秒数,就得到这一段时间内的平均速率,单位是字节/秒。换算展示时,需要除以 1024 得到 KB/s,或者除以 1024^2 得到 MB/s。
这里有几个细节值得展开。第一,采样间隔不宜太短,我最低设到 1 秒,如果低于这个值,受系统调度影响,速率计算波动会非常明显。第二,NetworkIF 实例一定要复用。因为某些平台的计数器和该对象的内部状态绑定,如果每次采样都从 getNetworkIFs() 重新拿一个对象,对象内部可能没有保持上次的采样状态。第三,计算速率的字节差需要处理”溢出“场景。虽然 64 位无符号整数溢出几乎不可能,但严谨一点还是要判断当前值如果小于上次值,说明计数器可能被重置,此时应跳过本次计算,而不是直接得出一个负的巨大速率。
还有一点在容器环境里特别常见:如果 Java 应用跑在 Docker 容器中,你在容器内部看到的网卡其实是虚拟网卡,并不是宿主机的物理网卡。此时如果你把宿主机物理网卡的速率当作业务网络流速,往往是不准确的。容器监控更合理的做法是监控容器网络命名空间里对应虚拟网卡的速率,但这需要对容器网络有进一步的了解。我在设计工具类时做了一个开关:允许通过配置指定“网卡名称”,如果设置了就用指定的那张;如果没设置,才自动从系统网卡列表里挑选默认路由对应的那一张。
4. 完整工具类实战:从依赖到对外接口一步到位
4.1 Maven 依赖引入
先加依赖。我用的 oshi 版本是 6.x,目前它的 API 相对稳定。需要注意 oshi 6.4 之后对 Java 版本有要求,建议你就近选择一个和你项目 JDK 匹配的版本。我的基线是 JDK 8,所以用的版本是 6.4.x,再高版本就要求 JDK 11 了。
<dependency> <groupId>com.github.oshi</groupId> <artifactId>oshi-core</artifactId> <version>6.4.12</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.4</version> </dependency>jackson 不是必须的,但你如果最终要向外输出 JSON 格式数据,可以顺手加上。如果项目里已经引入了其他 JSON 库,换成自己的即可。
4.2 数据载体类:定义统一的监控结果对象
我先把对外数据载体定义好。这样做的好处是,上层无论是写接口还是存库,都不会被 oshi 的 API 变化牵着走。下面是我用的简化版本:
public class MonitorData { // 基础信息 private String osName; // 操作系统名称 private String osVersion; // 操作系统版本 private String hostname; // 主机名 private String cpuModel; // CPU 型号 private int physicalCores; // 物理核心数 private int logicalCores; // 逻辑处理器数 // CPU private double cpuUsage; // CPU 使用率 0-100 private double cpuUser; // 用户态占比 private double cpuIdle; // 空闲占比 // 内存 private long totalMemory; // 物理内存总数 private long usedMemory; // 已用物理内存 private long freeMemory; // 可用物理内存 private double memoryUsage; // 物理内存使用率 // JVM private long jvmTotalMemory; private long jvmUsedMemory; private long jvmMaxMemory; // 磁盘 private List<DiskInfo> disks; // 磁盘/分区列表 // 网络 private List<NetSpeedInfo> netSpeed; // 按网卡区分速率 // 时间 private long timestamp; // 采样时间戳 // 省略 getter/setter,建议用 Lombok 简化 public static class DiskInfo { private String name; private long total; private long used; private long free; private double usage; private double readRate; // KB/s private double writeRate; // KB/s } public static class NetSpeedInfo { private String name; private long downBytes; // 累计接收字节 private long upBytes; // 累计发送字节 private double downSpeed; // 下行速率 KB/s private double upSpeed; // 上行速率 KB/s } }你可能会问:为什么要搞这么多嵌套类和属性?其实就是一个原则:“对外稳定,对内自由”。无论 oshi 怎么调整内部实现,上游系统的对接方只看到 MonitorData 一份契约。后续如果加传感器温度、进程数等字段,也不会破坏已有调用方的代码。
4.3 工具类核心代码:单例+缓存+采样
接下来是工具类主体。我会把核心代码分段展示,并说明每段的作用。整体结构包括:初始化、基础信息、CPU、内存、磁盘、网速、周期性采样线程。
public class SystemMonitorUtil { private static volatile SystemMonitorUtil instance; private final SystemInfo systemInfo; private final HardwareAbstractionLayer hardware; private final OperatingSystem operatingSystem; // 上次 CPU ticks,用于计算使用率 private long[] prevTicks; private long prevCpuTime; // 磁盘速率采样缓存 private final Map<String, DiskSample> diskSampleCache = new ConcurrentHashMap<>(); // 网卡速率采样缓存 private final Map<String, NetSample> netSampleCache = new ConcurrentHashMap<>(); // 定时任务 private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); // 上次全局采样时间 private volatile long lastNetCollectTime = System.currentTimeMillis(); private SystemMonitorUtil() { systemInfo = new SystemInfo(); hardware = systemInfo.getHardware(); operatingSystem = systemInfo.getOperatingSystem(); prevTicks = hardware.getProcessor().getProcessorCpuLoadTicks(); startScheduler(); } public static SystemMonitorUtil getInstance() { if (instance == null) { synchronized (SystemMonitorUtil.class) { if (instance == null) { instance = new SystemMonitorUtil(); } } } return instance; } }单例的初始化里做了两件要紧的事:第一,保存处理器的 tick 数组作为后续计算 CPU 使用率的基准;第二,启动定时任务。为什么要启动定时任务?因为 CPU 使用率、磁盘速率、网速这些指标必须要定期采样,如果完全依赖调用方“每次现算”,第一次拿到的数据基本是 0 或者跳变的。
定时任务我开两个线程:一个长周期,默认 5 秒跑一次综合采样;另一个短周期,默认 1 秒跑一次网络和 CPU 快速采样。实际部署时你可以根据机器规模调整。如果你觉得线程资源紧张,也可以合到一个线程池里,但注意核心线程数至少为 2,因为两个任务的执行时间最好不要互相阻塞。
4.4 CPU 监控代码实现
CPU 部分核心是使用 tick 数组差值来算使用率。以下是采集逻辑:
public CpuStats getCpuUsage() { CentralProcessor processor = hardware.getProcessor(); long[] ticks = processor.getProcessorCpuLoadTicks(); long idle = ticks[CentralProcessor.TickType.IDLE.getIndex()] - prevTicks[CentralProcessor.TickType.IDLE.getIndex()]; long total = Arrays.stream(ticks).sum() - Arrays.stream(prevTicks).sum(); double idlePercent = total > 0 ? 100d * idle / total : 0; double cpuUsage = 100d - idlePercent; // 保存当前 ticks 供下次使用 prevTicks = ticks; double user = ticks[CentralProcessor.TickType.USER.getIndex()] - prevTicks[CentralProcessor.TickType.USER.getIndex()]; // ... 其他指标按需计算 return new CpuStats(cpuUsage, user, idlePercent); }这里有一个不能忽视的顺序问题:必须先计算差值,再更新 prevTicks,如果反过来,下一次计算就没有参照点了。另外,如果你用 getSystemCpuLoad() 的简化 API,第一次调用通常返回 0,我的建议是在初始化阶段就主动调用一次做预热,之后的调用就会正常了。
还有 CPU 使用率“虚高”的问题。在 Windows 上,如果系统有多个物理 CPU 或 CPU 是多核的,osh 默认返回的是总体使用率,而不是某个核心的使用率,这通常是符合预期的。但当你需要展示每核使用率时,可以调用 getProcessorCpuLoadBetweenTicks() 返回一个 double 数组,每个元素对应一个逻辑核心。不过这个数组的索引基于逻辑核心排列,和任务管理器展示的核心顺序未必一致,显示的时候不要硬套核编号。
4.5 内存监控代码实现
内存采集逻辑不太复杂,但要注意单位。我在对外接口中统一返回字节数,让上层自己格式化。这样灵活度最高。
public MemoryStats getMemoryInfo() { GlobalMemory globalMemory = hardware.getMemory(); long total = globalMemory.getTotal(); long available = globalMemory.getAvailable(); long used = total - available; double usage = total > 0 ? used * 100d / total : 0; long swapTotal = globalMemory.getSwapTotal(); long swapUsed = globalMemory.getSwapUsed(); Runtime runtime = Runtime.getRuntime(); long jvmMax = runtime.maxMemory(); long jvmTotal = runtime.totalMemory(); long jvmFree = runtime.freeMemory(); long jvmUsed = jvmTotal - jvmFree; return new MemoryStats(total, used, available, usage, swapTotal, swapUsed, jvmMax, jvmTotal, jvmUsed, jvmMax - jvmUsed); }你如果需要在 Web 界面或者 API 里展示,通常转换成 MB 或者 GB 比较直观。我的建议是把格式化的逻辑放在展示层,工具类保持原始字节数,否则一旦你在工具类里转成 GB,后续要精确到字节做告警判断就尴尬了。
4.6 磁盘监控代码实现
磁盘空间和 IO 速率我集成到同一个方法里。为了不让磁盘 IO 采样变得太重,我用了一个”缓存+按需刷新“的机制:只有超过设定的间隔时间才真正调用 oshi 的刷新,否则直接返回缓存值。
public List<DiskInfoVO> getDiskInfo() { List<DiskInfoVO> result = new ArrayList<>(); List<HWDiskStore> diskStores = hardware.getDiskStores(); for (HWDiskStore disk : diskStores) { DiskInfoVO vo = new DiskInfoVO(); vo.setName(disk.getName()); vo.setModel(disk.getModel()); // 分区空间信息 long totalSpace = 0; long usedSpace = 0; for (HWPartition partition : disk.getPartitions()) { totalSpace += partition.getSize(); long free = partition.getFreeSpace(); usedSpace += partition.getSize() - free; } vo.setTotal(totalSpace); vo.setUsed(usedSpace); vo.setFree(totalSpace - usedSpace); vo.setUsage(totalSpace > 0 ? usedSpace * 100d / totalSpace : 0); // 磁盘 IO 速率:基于两次采样差值 long readBytes = disk.getReadBytes(); long writeBytes = disk.getWriteBytes(); long now = System.currentTimeMillis(); DiskSample prev = diskSampleCache.get(disk.getName()); if (prev != null) { long timeDiff = (now - prev.timestamp) / 1000; if (timeDiff > 0) { double readRate = (readBytes - prev.readBytes) / (timeDiff * 1024d); double writeRate = (writeBytes - prev.writeBytes) / (timeDiff * 1024d); vo.setReadRate(readRate); vo.setWriteRate(writeRate); } } else { prev = new DiskSample(); } prev.readBytes = readBytes; prev.writeBytes = writeBytes; prev.timestamp = now; diskSampleCache.put(disk.getName(), prev); result.add(vo); } return result; }这个实现里有一点需要注意:磁盘列表和磁盘对象的身份稳定性。在 Linux 下,磁盘名称一般是 sda、nvme0n1 这种,相对稳定。但在某些虚拟化环境中,磁盘管理器可能每次返回的列表顺序不同,为了稳妥,我用磁盘名称作为 map 的 key。如果你在 Windows 下看到磁盘名是奇怪的编号,不要慌,这只是 oshi 对该系统的展示方式不同。
4.7 网速监控代码:重点详解上行下行速率
网速这块是本工具类最“值钱”的部分,我完整贴出来。它做了三件事:选网卡、定期采样、按差值计算速率。
public List<NetSpeedVO> getNetSpeed() { List<NetSpeedVO> result = new ArrayList<>(); List<NetworkIF> networkIFs = hardware.getNetworkIFs(); long now = System.currentTimeMillis(); long interval = Math.max((now - lastNetCollectTime) / 1000, 1); for (NetworkIF net : networkIFs) { // 过滤虚拟网卡 String name = net.getName(); if (!isPhysicalNetCard(name)) { continue; } // 关键:刷新网卡统计数据! net.updateAttributes(); long recvBytes = net.getBytesRecv(); long sentBytes = net.getBytesSent(); NetSample prev = netSampleCache.get(name); double downSpeed = 0; double upSpeed = 0; if (prev != null && interval > 0) { // 累计值回退,视为数据重置,本次跳过 if (recvBytes >= prev.recvBytes && sentBytes >= prev.sentBytes) { downSpeed = (recvBytes - prev.recvBytes) / (interval * 1024d); upSpeed = (sentBytes - prev.sentBytes) / (interval * 1024d); } } else { prev = new NetSample(); } prev.recvBytes = recvBytes; prev.sentBytes = sentBytes; netSampleCache.put(name, prev); NetSpeedVO vo = new NetSpeedVO(); vo.setName(name); vo.setDownSpeed(downSpeed); vo.setUpSpeed(upSpeed); vo.setTotalRecv(recvBytes); vo.setTotalSent(sentBytes); result.add(vo); } lastNetCollectTime = now; return result; } private boolean isPhysicalNetCard(String name) { if (name == null || name.isEmpty()) { return false; } String lower = name.toLowerCase(); if (lower.contains("docker") || lower.contains("veth") || lower.contains("br-") || lower.contains("lo") || lower.contains("vmnet") || lower.startsWith("veth") || lower.startsWith("vEthernet")) { return false; } return true; }几个实战要点我再强调一下。第一条,updateAttributes() 必须调用。如果你不调,NetworkIF 对象里的收发字节数还是上一次的值,速率会一直是 0。这个坑我在第一次写完后面板后死活不出数据,排查了很久才发现是没刷新属性。第二条,采样间隔设置。上面的代码用当前时间减去上次采集时间作为 interval,也就是“本次计算覆盖了从上一次到现在的平均速率”。如果你希望速率更平滑,可以把间隔拉长到 2 到 3 秒;如果你希望看到瞬间峰值,1 秒间隔也够用。第三条,网卡过滤。如果你的机器上有很多虚拟网卡,不排除的话,下行上行总速率会叠加出一堆无意义的数据。上面 isPhysicalNetCard 里的黑名单基本覆盖了常见情况,你还可以额外加一条:如果这台机器存在明显的外网网卡,可以把它的名字直接写进配置里,优先读取这一张。
最后一个是“虚拟环境”特例。比如你在 VirtualBox、VMware 里跑测试,网卡名一般是 vboxnet0 或者 vmnet8,它们本质是虚拟网卡,但对业务来说实际上就是你这台虚拟机的网络出口,究竟是“虚拟网卡”还是“物理网卡”,取决于这个工具类用在什么层级。如果是宿主机上的 Java 应用,过滤掉虚拟网卡没问题;如果应用就运行在虚拟机内部,就不要过度过滤,否则可能一张卡都留不下。稳妥的方式是“白名单优先,黑名单兜底”:如果配置了明确的网卡名,就用配置的;否则走通用过滤逻辑。
4.8 定时刷新与对外接口
采样线程我用了一段很朴素的代码。之所以没有用 Spring 的 @Scheduled,是为了保持工具类不依赖框架,方便在任何 Java 项目里直接拷贝使用。
private void startScheduler() { // 快速采样线程:1秒 scheduler.scheduleAtFixedRate(() -> { try { getCpuUsage(); getNetSpeed(); } catch (Exception e) { // 监控出错不能影响主业务,吞掉并记录 e.printStackTrace(); } }, 1, 1, TimeUnit.SECONDS); // 慢速采样线程:10秒 scheduler.scheduleAtFixedRate(() -> { try { getDiskInfo(); getMemoryInfo(); } catch (Exception e) { e.printStackTrace(); } }, 3, 10, TimeUnit.SECONDS); }注意这里用了 scheduleAtFixedRate,如果某一次执行时间超过了周期,下一轮不会叠加执行,这正好符合监控采样的需求。另外一定把异常捕获住——监控组件本身挂了不能拖垮主业务流程。这是我一开始没注意到的,结果因为一个采样异常把整个后台请求线程阻塞了很久,教训很深。
对外接口很简单,汇总成 MonitorData 返回即可。如果项目里用了 Spring Boot,可以直接在 Controller 里注入这个单例,暴露一个 GET /monitor 接口;如果是普通 Java 服务,可以直接把 MonitorData 序列化成 JSON 存到库里。
5. 常见问题与排查技巧实录
5.1 CPU 使用率第一次返回 0
这个问题几乎每个人都会遇到。原因也很简单:oshi 的 CPU 使用率是基于两次采样差值的,第一次采样时没有上一轮的 tick 数据,自然算不出百分比。解决办法有两个:一是在工具类初始化时先调用一次 getProcessorCpuLoadTicks() 做“预热”,让 prevTicks 有值;二是对外暴露接口时,如果发现第一次拿到的数据是 0,可以在上层做“忽略首条数据”的处理。我的工具类里用的是第一种方式,简单直接。
5.2 网速一直是 0,但不报错
排查顺序建议如下:第一步,确认是否调用了 updateAttributes(),没有调用的话,累计收发字节数不会刷新;第二步,确认网卡过滤器是否把当前网卡误伤了。我在一台 CentOS 机器上就遇到 veth 开头的容器虚拟网卡被当成目标卡,实际业务网卡 eth0 反而被过滤掉的情况;第三步,检查网卡名大小写。Linux 的网卡名是小写,但 Windows 上可能是 Ethernet、WLAN 这类名称,如果你的过滤逻辑严格按小写判断,要记得先 toLowerCase;第四步,确认系统是否运行在内网环境且没有网络流量,如果服务器确实没有对外通信,速率自然是 0。
5.3 磁盘读写速率时而正常时而跳为 0
这是我用 1 秒采样周期时遇到的现象。定位后确认磁盘统计计数器的刷新频率低于采样频率,导致部分周期的差值几乎为 0。我随后把磁盘采样周期改成了 10 秒,速率曲线明显平滑了。如果你对磁盘瞬时峰值非常敏感,建议周期至少 3 秒,并在展示层做滑动平均,而不是直接显示原始采样值。
5.4 Windows 下物理内存显示“已用”接近 100%
这里有个 Windows 固有的“坑”:Windows 的系统缓存会占用一部分内存,而 oshi 的 getAvailable() 在某些场景下会扣掉这部分被缓存的内存,导致“可用内存”看起来非常少,使用率虚高。解决思路是在展示层区分“已用”和“缓存”,或者使用 oshi 的 available 时留意它是否包含了 standby 内存。一般我在 Windows 监听面板上不会拿 oshi 的这个数值直接做告警,而是结合任务管理器做人工校准。
5.5 返回的数据里有重复网卡名
如果虚拟网卡和物理网卡恰好重名(某些虚拟化平台的桥接网卡),map 的 key 会冲突,后面的数据覆盖前面的数据。更稳妥的主键是网卡的索引 getIndex() 或 MAC 地址 getMacaddr(),而不是名字。我项目里之所以坚持用名字,是因为 Mac 地址在虚拟环境里可能也会重复,而索引在重启后可能变化,具体选哪个看你运行环境的稳定性。如果出现覆盖问题,建议改成 index+name 组合作为 key。
5.6 定时任务里打印堆栈导致日志灌爆
我犯过的一个错误:为了排查问题,在 catch 块里直接 printStackTrace(),结果监控线程每 1 秒失败一次,控制台瞬间被刷了几百行。生产环境务必使用正式的日志框架,并且将监控采样的日志级别设为 warn 或 error。这个细节虽然简单,但真实项目中很多人会忽略,等到排查问题时才发现日志早就把磁盘占满了。
我把这些常见问题整理成一个速查表,方便你对照:
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| CPU 使用率一直是 0 | 没有初始化 prevTicks | 初始化时先做一次 tick 预热 |
| 网速全为 0 | 没调 updateAttributes() | 采样前必须刷新网络属性 |
| 网速虚高 | 虚拟网卡未被过滤 | 增加 docker/veth/br 等过滤规则 |
| 磁盘速率跳变 | 采样间隔太短 | 周期改为 5-10 秒 |
| Windows 内存虚高 | 系统缓存被计入已用 | 展示层区分缓存,或人工校准 |
| 数据偶发负速率 | 计数器重置未处理 | 判断当前值小于上次值时跳过 |
6. 我的一些实践心得与扩展想法
这套工具类我前前后后改了七八版。最大的体会是:监控模块不复杂,但细节特别多,尤其是“采样-计算-缓存”这套链路,每个环节都可能因为时序问题产生不可思议的结果。最初我图省事用 oshi 的简化 API,一小时内就能出活,但到做告警和趋势分析阶段,就发现简化 API 的粒度不够用——比如你想知道 CPU 有多少消耗在内核态、有多少在等待 IO,简化 API 给不了你这些。所以后来我干脆在工具类里把原始 tick 数据也暴露出来,给上层保留充分的扩展空间。
如果你拿这套工具类继续往下扩展,可以考虑几个方向。第一个是历史数据落库:把 MonitorData 持久化到数据库,配上简单的时间序列查询接口,就能做出一个轻量级的“过去 24 小时 CPU/网速曲线”,完全不需要额外引入时序数据库。第二个是告警通知:当 CPU 使用率连续 N 次超过阈值,或者磁盘使用率高于 90% 时,发一条消息到钉钉或者企业微信。这个逻辑可以在定时任务的最后统一判断,不需要单独做一套组件。第三个是接口优雅关闭:在 Spring Boot 项目里,可以监听容器销毁事件,调用工具类里的 shutdown() 方法把线程池停掉,避免生产环境频繁重启导致线程泄漏。
最后分享一个我在实际使用中的小技巧:如果你部署的机器上有多个网卡,而且你只关心某一张特定网卡的上行下行,千万别只靠黑名单过滤,最好是留一个配置项,把网卡名直接写死。比如你自己知道业务流量走的是 eth0,就把 eth0 配到配置中心里,工具类在 getNetworkIFs() 返回后直接匹配 name 等于 eth0 的对象,其余全部跳过。这样既省掉了过滤逻辑的维护成本,又不会因为网卡名变化而突然丢数据。我在某公司的一个生产项目里就是这么做的,配合配置中心热更新,网卡切换时只需要改配置,不需要动代码,非常省心。
这个工具类最终在模拟项目X里稳定跑了几个月,监控数据没有出过大差错。如果你现在就准备动手写,我的建议是:先复制上面的核心代码跑通,加进自己的项目里,然后从“网速过滤规则”和“采样周期”这两个地方开始调参,大概率就能满足大部分业务场景。写监控不追求代码多花哨,关键是每一个数值背后都有清晰的计算链路,出问题时能顺着链路快速定位,这就够了。