☰
基于OShi的Java系统监控:网速上下行增量计算与避坑指南
2026/10/21 4:15:01 网站建设 项目流程

监控面板上那几个数字,看起来简单,做起来坑不少。尤其是“网速上行下行”,如果只是简单调用接口把数值读出来,会发现数字要么不动,要么直接爆表,完全没法用。这篇就写一下我基于 OShi 封装的一个系统监控工具类,重点说清楚网速上下行到底是怎么算出来的,以及封装过程中踩过的那些文档里不会写的坑。

我自己是在给一个内部后台管理系统做首页大屏时开始搞这个的。当时需求很普通:展示CPU使用率、内存使用率、磁盘占用,再加一个实时的网络上行下行速率。Java 生态里能拿系统信息的方案不算多,我最后选了 OShi,主要原因是它一个依赖就能覆盖 CPU、内存、磁盘、网络、进程、负载等近乎全部指标,而且跨平台表现稳定。如果你也正打算做类似的监控工具,或者已经在用 OShi 但发现网速怎么都算不对,这篇应该能直接帮你省下半天时间。

1. 为什么系统监控这块我最后选了 OShi

1.1 另外两条路的对比

做 Java 系统监控,绕不开方案选型。我身边有不少同事第一反应是两条路:一是用Runtime.exec()去解析系统命令,二是用 JMX 拿 JVM 和操作系统层面的数据。

命令行解析这条路,典型的做法是执行top、vmstat、cat /proc/net/dev之类的命令再逐行解析文本。听起来很直接,但实际用起来痛点非常明显:输出格式随操作系统发行版变化,Windows 和 Linux 的命令完全不同;在容器环境里经常拿不到宿主机真实指标,拿到的反而是容器自己的视图,有些命令权限不够直接输出一堆报错。更要命的是频繁派生子进程采集数据的开销不小,监控系统本身反而把系统负载拉高了,有点本末倒置。

JMX 这条路能拿到 JVM 内存、线程、GC 等信息,但拿操作系统级别的硬件信息非常有限。CPU 使用率勉强能通过OperatingSystemMXBean.getSystemLoadAverage()拿个 1 分钟、5 分钟、15 分钟的负载均值,但这不是实时 CPU 占用率;内存总量和可用内存也能拿一部分,但颗粒度不够细。到了网络上行下行这块,JMX 就彻底没法子了,它压根不暴露网卡流量计数。做个简单的 JVM 监控还行,要做真正意义上的系统监控工具类,差得远。

1.2 OShi 的核心机制:JNA 封装 Native API

OShi 全称是 OSHI,一个纯 Java 实现的开源系统信息库。它的核心思路是通过 JNA 调用操作系统底层的 Native API,比如 Windows 上的 Kernel32、Linux 上的/proc文件系统,把这些底层数据翻译成统一的 Java Bean。

打个比方,JMX 像酒店的客房服务,你只能点到菜单上有的东西;命令行解析像你打电话到后厨问今天买了什么菜,能不能问到看运气,而且沟通成本很高;OShi 则像是你直接拿到了后厨的进货单和库存系统,每一项数据清清楚楚摆在那里。

依赖引入也很简单,Maven 里加一行坐标就行,它会自动带上 JNA 相关的依赖。我用的时候直接选了较新的版本,具体版本号大家用的时候去 Maven 仓库搜一下最新的即可。

<dependency> <groupId>com.github.oshi</groupId> <artifactId>oshi-core-java11</artifactId> <version>按需替换</version> </dependency>

用上之后确实省心很多。CPU 时间片、内存页、网卡字节计数这些底层数据,OShi 都做了跨平台封装。我在 Linux 服务器上测试和在本地 Windows 上跑,拿到的指标结构是同一套,不用针对不同系统写两套解析逻辑。

提示:OShi 对 Java 版本有要求,新版本要求 JDK 8 以上,如果你用的是 Java 11+,建议直接选带java11后缀的构件,类路径更干净。

2. 工具类的整体设计与核心思路

2.1 监控指标范围怎么定

在动手写代码之前,建议先明确这个工具类到底要对外暴露什么。我最后定的是五个维度:CPU 使用率、内存使用率、磁盘使用率、网络上行速率、网络下行速率。前三个都是“瞬时快照型”指标,取当前值展示即可;后两个是“区间速率型”指标,必须通过增量计算才能得到。

这里有个很容易被忽略的点:内存和磁盘使用率是比值型数据,天然就是即时准确的;CPU 使用率则要小心,如果只是调用一次getSystemCpuLoad(),拿到的是从系统启动到当前这段时间的均值,不是当前实时负载。要达到“刷新一次变一次”的效果,得用两次采样之间的时间片差值来计算。

网络上行下行这里,核心难点在于网卡流量数据本身是累计值。比如NetworkIF.getBytesRecv()拿到的是网卡从开机到现在收到的总字节数,这个数字只会一直增加。要得到“每秒速率”,必须做两次采样,用字节数差值除以时间差。原理和测平均速度一样,你不能只看里程表上的一个读数就说当前车速是 120,得看一分钟前和现在之间跑了多少公里。

2.2 网速增量计算的算法逻辑

网速的核心算法用一句话概括:速率 = (当前累计字节数 - 上一次累计字节数) / 时间间隔。

但在实际工程里,这句话的落地有四个细节必须处理到位。

第一,第一次采样的数据不能直接参与计算。因为第一次采样时没有“上一次累计字节数”作为基线,如果硬算,得到的是一个从开机到现在的平均速率,完全不是实时值。我封装的时候,内部维护了一个采样状态:第一次调用只记录基线,不返回速率,从第二次调用开始才返回有效值。

第二,采样间隔必须固定并且足够短。间隔越长,速率越趋向于“平均速率”,实时性越差;间隔太短,比如 100 毫秒,字节数的差值很容易被舍入误差拖累,数据跳来跳去没法看。我实测下来,1 秒间隔是一个比较均衡的选择。

第三,多网卡必须汇总。一台服务器上通常有物理网卡、虚拟网卡、回环地址等多种网络接口,如果只取某一个接口的数据,很可能拿到的是空的或者只包含部分流量。工具类里需要遍历所有非回环网卡,分别取收发字节数,再各自汇总。

第四,字节数与比特数要区分清楚。网卡底层统计的是字节(Byte),而日常说的“百兆带宽”“千兆网络”用的是比特(bit)。1 字节等于 8 比特,展示给用户的时候如果想用“MB/s”这种单位,直接用字节数换算没问题;如果想对标带宽,就要注意除以 8。我自己的工具类统一用“MB/s”展示,换算逻辑写在单位格式化方法里。

2.3 工具类的整体架构设计

这个工具类我设计成了静态方法为主、内部维护采样状态的模式。为什么用静态方法?因为监控数据在同一个 JVM 进程里是全局唯一的,用静态方法免去了到处传递单例对象的麻烦,在后台管理的定时任务里直接SystemMonitor.getCpuUsage()就能拿到当前值,代码简洁很多。

但静态方法有一个副作用:内部状态是进程级的,如果有多个地方同时调用网速方法,它们会共享同一个“上一次采样基线”,导致互相干扰。我在工具类里用了一个锁来保证线程安全,定时刷新任务和请求展示线程各司其职,实测下来不会出现计算错乱的情况。

工具类内部的依赖关系我分为两层:硬件信息获取层和指标计算层。硬件信息获取层每次调用都会重新获取SystemInfo的快照,底层 OShi 本身有缓存,这里不用担心性能;指标计算层负责把快照数据转换成对外展示的百分比、速率值。

3. 核心实现:手写一个可用版工具类

3.1 CPU 和内存监控实现

CPU 使用率这里我说两个实现方式,一个是简单直接但不推荐,另一个是实际推荐的做法。

简单的方式是调用processor.getSystemCpuLoad(),它返回 0 到 1 之间的值,乘以 100 就是百分比。问题在于这个值是系统启动以来的平均负载,刷新页面看到的数字基本一动不动,根本没有“监控”的感觉。如果你只是要个大概数,用这个也无妨,但如果要做曲线图或者实时大屏,必须用下面这种方式。

推荐的方式是拿两次 CPU 时间片数组做差值:

private static long[] prevTicks = new long[CentralProcessor.TickType.values().length]; private static long prevTickTime = System.currentTimeMillis(); public static double getCpuUsage() { CentralProcessor processor = new SystemInfo().getHardware().getProcessor(); long[] ticks = processor.getSystemCpuLoadTicks(); long totalDiff = 0; long idleDiff = 0; CentralProcessor.TickType[] types = CentralProcessor.TickType.values(); for (int i = 0; i < types.length; i++) { long diff = ticks[i] - prevTicks[i]; totalDiff += diff; if (types[i] == CentralProcessor.TickType.IDLE) { idleDiff = diff; } } prevTicks = ticks; if (totalDiff <= 0) { return 0.0; } double usage = (1.0 - (double) idleDiff / totalDiff) * 100.0; return Math.min(100.0, Math.max(0.0, usage)); }

第一次调用前prevTicks是全 0,直接返回一个 0 作为初始值也说得过去。每个刷新周期里,这个方法会把当前快照和上一次快照做差值,算出来的就是这一段时间内的平均 CPU 占用率,比那个“开机均值”准太多了。

内存就简单得多,总量、可用量都是直接能拿的:

public static double getMemoryUsage() { GlobalMemory memory = new SystemInfo().getHardware().getMemory(); long total = memory.getTotal(); long available = memory.getAvailable(); return (total - available) * 100.0 / total; }

内存这里要提一个专业提醒:OShi 的GlobalMemory在 Linux 下拿到的是物理内存视图,不会自动包含 Swap 分区。如果你的机器内存不大但在跑 Java 服务,Swap 的使用情况可能比物理内存更值得关注,需要在工具类里额外从OperatingSystem里拿 swap 信息补充进去。

注意:如果你部署在容器环境里,直接通过 OShi 拿到的可能是宿主机数据而不是容器配额。OShi 从 6.x 开始提供了容器感知的指标接口,部署在 Docker 里要留意这个差异,否则明明容器已经卡死,监控面板上显示的资源还很充裕。

3.2 磁盘使用率监控实现

磁盘这块我一开始也踩过坑。简单调FileSystem.getFileStores()能拿到所有挂载点,但返回的是一个FileStore列表,里面既有根分区,也有临时分区、光驱、软驱之类的废数据。直接遍历求和会把一堆无关的东西算进来,展示出来的磁盘占用率偏大。

我的做法是先过滤掉无效挂载点,只统计空间大于 0 且类型不是光驱、网络映射盘等设备的部分。每个FileStore都有getTotalSpace()和getUsableSpace(),算使用率很直接。

public static double getDiskUsage() { OperatingSystem os = new SystemInfo().getOperatingSystem(); FileSystem fileSystem = os.getFileSystem(); long total = 0; long used = 0; for (OSFileStore store : fileSystem.getFileStores()) { if (store.getTotalSpace() > 0) { total += store.getTotalSpace(); used += store.getTotalSpace() - store.getUsableSpace(); } } return total == 0 ? 0.0 : used * 100.0 / total; }

这段代码在大部分场景下够用了。不过它统计的是逻辑分区维度的磁盘空间使用率,不是物理磁盘的读写速率。如果你的监控大屏需要“磁盘 IO 的读写速度”,那要走Oshi的HWDiskStore,那又是另一套采样增量计算的逻辑,这里不展开。

另外一个容易踩的坑是网络挂载点。内部服务器有时候会挂载 NFS 或者 CIFS 远程目录,FileStore会把它们当成普通文件系统列出来,统计getTotalSpace()时延迟可能很高,进而拖慢整个工具类的响应。我的实际做法是加了一个过滤条件,只统计本地磁盘,凡是在循环里识别到远程类型的直接跳过。不过不同系统对远程文件系统的类型枚举定义不同,我最后图省事直接用了一个“黑名单”字符串列表,把"nfs"、"cifs"、"smb"之类常见远程协议名过滤掉,跑下来没出过问题。

3.3 网速上下行增量计算实现

这块是整个工具类最核心的部分,值得多说几句。

HardwareAbstractionLayer.getNetworkIFs()返回的是所有网卡接口列表,每个NetworkIF对象有getBytesRecv()和getBytesSent(),分别表示该网卡累计收到的字节数和发送的字节数。

关键是,这俩数字从开机起就一直累计,直接瞬时时展示是没意义的。之前网上有个做法是直接调用getSpeed(),那个字段代表的是网卡协商速率——也就是“这张网卡最高能跑多快”,不是实际速率,拿它来当实时网速纯属张冠李戴。

我写的网速工具类内部维护了一个缓存对象,保存上一次采样时每块网卡的收发字节数以及对应的时间戳:

private static volatile long lastSampleTime = 0L; private static volatile long lastRecvBytes = 0L; private static volatile long lastSentBytes = 0L; private static volatile boolean initialized = false; public static synchronized double[] getNetworkSpeed() { SystemInfo si = new SystemInfo(); HardwareAbstractionLayer hal = si.getHardware(); long totalRecv = 0L; long totalSent = 0L; for (NetworkIF netIf : hal.getNetworkIFs()) { netIf.updateAttributes(); if (netIf.getBytesRecv() > 0 || netIf.getBytesSent() > 0) { totalRecv += netIf.getBytesRecv(); totalSent += netIf.getBytesSent(); } } long now = System.currentTimeMillis(); double[] result = new double[] { 0.0, 0.0 }; if (!initialized) { initialized = true; lastSampleTime = now; lastRecvBytes = totalRecv; lastSentBytes = totalSent; return result; } double intervalSec = (now - lastSampleTime) / 1000.0; if (intervalSec <= 0) { return result; } double downSpeed = (totalRecv - lastRecvBytes) / intervalSec; double upSpeed = (totalSent - lastSentBytes) / intervalSec; lastSampleTime = now; lastRecvBytes = totalRecv; lastSentBytes = totalSent; result[0] = downSpeed; result[1] = upSpeed; return result; }

这里面有个细节:循环里必须调用updateAttributes()。getNetworkIFs()返回的NetworkIF对象是一个快照,如果直接读getBytesRecv(),拿到的可能是上一次缓存的值,必须刷新一次才能真正去读底层网卡数据。这个坑我印象特别深,当时网速数值一直不变,排查半天才发现是这个。

汇总的时候我做了一个小优化:只累计那些收发字节数有实际值的网卡,等于自动过滤掉了回环接口和完全空闲的虚拟网卡。不过这样做也有个副作用,如果系统里装了虚拟机软件,虚拟网卡偶尔会有少量流量,也会被汇总进来,导致大屏上的网速略微虚高。如果要彻底精确,应该让调用方传入想监控的网卡名。

第一次调用方法时返回[0, 0]是特性不是 bug。想想看,没有任何历史基线的情况下算出来的速率是没有参考意义的。后续每次调用都会基于上一次采样时间计算,等到真实刷新周期来临时,拿到的就是精准的秒级速率。

提示:这个网速方法我加了synchronized,保护的是算法状态不被并发调用破坏。如果你的刷新线程和请求线程可能同时触发采样,不加锁的话采样状态会错乱,算出来的速率一会儿大一会儿小。

3.4 工具类怎么集成到定时刷新里

工具类本身只是能力提供者,真正让它动起来需要一个定时调度。我在项目中用的是 JDK 自带的ScheduledExecutorService,间隔 1 秒跑一次采样任务,把结果缓存在内存里。请求侧拿到的是上一次采样的缓存值,而不是每次请求都实时去算。

为什么不用@Scheduled注解直接搞?因为如果项目里已经有多个定时任务,Spring 默认的定时线程池大小是 1,所有任务会排队执行。监控指标本身要保证采样的时间间隔稳定,如果前面排队了别的任务,哪怕只是几十毫秒的偏移,网速计算用的时间间隔就会被拉长,拿到的速率自然偏小。用独立的调度线程池,采样节奏才稳定。

定时任务的骨架大致是这样:

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); scheduler.scheduleAtFixedRate(SystemMonitor::refreshSnapshot, 0, 1, TimeUnit.SECONDS);

refreshSnapshot里把 CPU、内存、磁盘、网速全部刷一遍存到静态字段。web 接口层只需要读静态字段,这个设计让整个工具类变成一个耦合度很低的“即插即用”组件。

还有一点是关于SystemInfo的实例化。OShi 的SystemInfo初始化需要探测操作系统和硬件信息,有一定耗时,不建议在每个方法里重复 new 出多个实例。我实际做的是把SystemInfo、HardwareAbstractionLayer声明成静态常量,整个 JVM 生命周期只初始化一次。不是每一个方法内部都需要一个全新的SystemInfo,用重复的一样能拿到数据,性能上没什么差别。

4. 实战中踩过的坑与排查记录

4.1 回环网卡和虚拟网卡造成的数据污染

第一次跑通工具类的时候,我发现网速数据很奇怪:服务器上明明没有下载任务,下行速率却时不时有几 KB/s 的跳动。排查下来发现,回环地址和 Docker 虚拟网卡贡献了一部分流量。

回环接口通常叫lo,它代表本机内部通信的流量,正常的业务监控是不应该统计进去的。Docker 的veth网卡流量也不反映真实物理链路的使用情况。我最后的处理办法是,汇总时只统计物理网卡,判断依据是网卡名称是否以eth、en、wlan开头或者是 Windows 下的 “Ethernet” 和 “WLAN”。这个判断在 Windows 和 Linux 上表现都还不错。如果你要监控的就是某一条特定的物理链路,可以把网卡名做成配置项,这样比自动过滤更可控。

4.2 字节和比特的换算混乱

项目里不同人对“网速”的单位理解不一样。运维同事习惯看带宽单位,张口就是“这条链路是 100M 的”,里面说的是 100 Mbps;产品经理做展示时习惯看下载速度,说的是 MB/s。这俩的差别是 8 倍,如果不做转换直接在页面上标成 Mbps,很容易闹出下楼看大屏数据以为带宽翻倍的乌龙。

我自己最后在工具类里统一用字节作为内部计算单位,对外提供getNetworkSpeed()返回的数值也是字节每秒,在展示层再根据 UI 需要格式化。这样做的好处是逻辑链条清晰:底层给的是字节,换算成 MB/s 就是除以 1024 的平方,换算成 Mbps 就是先乘以 8 再除以 1024 的平方。

4.3 首次采样数据无效

这个坑几乎每个做网速工具类的人都会碰到。如果不做首次采样状态保护,第一次调用会拿“系统开机到现在”的累计数据除以“开机到现在的时间”,得出一个极低的平均速率,并且在页面第一次加载时跳出来,看起来像个 bug。

我做的保护是在网速工具类里添加了 initialized 标记,第一次调用只记录基线不返回实际速率。这个方法第一次执行时返回[0, 0],但从第二个采样周期开始就是准确的。为了防止因为定时器还没跑过就有人先触发查询,我在接口返回的时候做了兜底,如果initialized == false,直接把网速显示为--,比显示假的 0 更合理。

4.4 常见问题速查

症状可能原因解决办法
CPU 使用率恒定不变用了getSystemCpuLoad()取的是开机均值改用两次getSystemCpuLoadTicks()差值计算
网速始终为 0采样间隔为 0 或者首次采样未初始化确认当前不是第一次调用,且两次调用的时间差大于 0
网速数值一直在跳采样间隔不固定,或者网卡汇总包含虚拟网卡用固定频率调度线程,过滤掉非物理网卡
网速偏高或偏低 8 倍字节和比特单位混淆统一内部计算用字节,展示层再做单位换算
监控方法响应特别慢每次都 new 了多个SystemInfo将SystemInfo声明为静态常量,只初始化一次
Docker 里看到的内存超大默认读取的是宿主机物理内存使用 OShi 的容器感知接口,以 cgroup 配额为准

4.5 关于updateAttributes()和其他两个小细节

刚才代码里提过,每个NetworkIF对象在使用前要调用updateAttributes()。原因是 OShi 的getNetworkIFs()返回的对象默认只填充了静态属性,像 MAC 地址、接口名称这些;流量相关的动态属性需要显式刷新才会去读最新值。

另外还有两个细节值得提。第一,getNetworkIFs()返回的列表顺序在 Linux 下不一定稳定,特别是服务器上有多个网卡做 bond 的时候,同一个接口名可能对应不同的NetworkIF对象顺序。不要依赖列表索引来识别网卡,最好用网卡名或者 MAC 地址做键。

第二,OShi 在 Windows 上的网卡名称可能包含中文字符(比如“以太网”),过滤或者展示的时候要注意编码问题,不要假设网卡名一定是纯 ASCII。我遇到过本地测试正常、部署到中文版 Windows 服务器之后网卡识别不出来的情况,原因就是过滤逻辑里没包含 “以太网” 这三个字。

写在最后的实操体会

这套工具类我用了有一段时间了,过程中最大的感受是:监控类需求的难点不在调库,而在搞清楚“数据到底代表什么意思”以及“怎么处理采样与缓存的边界”。

网速的增量算法,说白了就是“现在减过去除以间隔”,但真正用起来的小问题都在细节里:第一次不能算、间隔要固定、网卡要过滤、单位要统一、更新要刷新。这些东西单独拎出来任何一项都不难,串在一起就可能让一个新手折腾一整天。

如果你后续想在这个工具类上继续扩展,方向也不少:可以试着把采样数据序列化到内存队列里,做成滑动窗口,用来绘制 5 分钟以内的实时曲线;也可以把 CPU 和内存的数据加上时间戳批量上报,作为更长时间段的趋势分析;还可以结合 OShi 的进程监控能力,把占用 CPU 最高的前几个进程也展示出来。基础打好了,往上加东西就是水到渠成的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询