遭了,屎山竟是我自己


事情是这样的,在不久之前我不是分享了一篇文章说我写了端口检测关闭的 GUI 工具,如果你还没看过可以去瞧一眼:我写了一个程序,让端口占用无路可逃

一切本都平平无奇,直到今天在使用时在任务管理器瞄了一眼,好家伙内存占用怎么有百来兆。

不知道你第一眼看到这个内存是作何反应,反正我是挺惊讶的,按我理解 10MB 就已经不得了了,这直接王炸翻了个量级。

作为一个遥遥领先的 Javer,对于内存爆炸早已见怪不怪,没有什么是加条内存不能解决的,如果不能那就再加一条。但这时候没有甲方,机子的一分一毛全靠自己,况且这稀碎的内存表现怎么让我好意思分享。

今天,我还就一定要把它底裤都都扒光。

首先,当然还是需要确定这么大的内存到底耗费在了哪个地方?一开始,由于程序是通过 exe4j 工具将 Jar 打包为 exe 执行程序,我最先怀疑的就是是不是哪里配置我缺失了导致内存狂飙。

验证方式也相当简单,分别通过 Jar 以及 Exe 两种方式分别运行,但最后发现的两种方式的内存占用都在 100MB 上下波动,那说明问题的根源还是在程序本身。

这就让人蛋疼了,程序中并没有涉及到复杂的对象操作,主线流程可谓简洁明了:启动 Swing 窗口后填充表格数据,若硬要扯那就只有表格的数据对象。

1. 工具排查

没办法,只能祭出 JDK 自带神器 VisualVM 了,其提供了针对 Java 运行程序的可视化 JVM 监控能力,可谓是让人每用一次都能大喊一声卧槽的存在。

Windows 中完成 JDK 8 安装后其程序默认路径为 C:\Program Files\Java\jdk1.8.0_202\bin 下的 jvisualvm.exe,双击即可运行。而在 JDK 11 后续版本中其已经剥离出来了,需要自行安装,别担心,下载链接已经给你准备好了:官网直达

打开后在左侧树选中运行的 Java 应用并选中监视,即可看到下图所示内容:

仔细看第二个图,可以看到项目一运行默认的内存初始大小为 500MB,已使用的为 50MB 左右。

那这个 500MB 又是根据依据来的?我们都知道可以通过 -Xms-Xmx 限制堆的大小,但在未手动指定的前提下,JVM 同样会为其设置一个默认值,这个默认值取值如下:

  • 最小值:通常是物理内存大小的 1/64,但不超过 1GB,可通过 -Xms 参数修改。
  • 最大值:通常是物理内存大小的 1/4,但不超过 32GB,可通过 -Xmx 参数修改。

我的电脑内存是 32G,那 1/641/4 就刚好是 500MB8G,也就跟上图的中的数据对上了。

但问题又来了,JVM 只是为程序设定的初始的内存为 500MB,并不代表程序一开始就会全部用到,图中的信息显示内存占用了 50MB,但这个数据显然也并不合理。

2. 对象大小

那是由于程序中存在大对象导致的吗?直觉告诉我也不太可能,但还是通过证据说话。

程序中涉及到数据对象的主要在于存储执行端口进程查询后返回的集合对象,那就先测一下这个对象到底能有多大?

这里同样推荐一个工具库 JOL,同样是有 JDK 原班人马开发,可以便捷的查看一个对象的内存占用。

使用方式也十分简单,在 Mavenpom.xml 文件中引入插入依赖:

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.17</version>
    <scope>test</scope>
</dependency>

引入依赖后即可直接调用 GraphLayout 类打印对象的占用信息,如下述定义了一个 1024 的字节数组并打印其占用信息:

public class MyTest {
    public static void main(String[] args) {
        byte[] data = new byte[1024];
        String usage = GraphLayout.parseInstance(data).toFootprint();
        System.out.println(usage);
    }
}

运行程序后可以看到下图内容,其中 1040data 对象的内存占用大小,单位为字节。你可能会疑惑为什么不是 1024 而是 1040,那是因为 1024 是真正的数据内容大小,但 data 还包含一些基础的对象属性等信息。

在程序里所涉及的两个数据对象分别为如下,同样通过 JOL 打印输出内存占用。

public class ProcessTest {
    public static void main(String[] args) {
        // [进程名与内存占用]
        List<ServiceDetail> serviceDetail = ProcessUtils.getServiceDetail();
        Object count1 = GraphLayout.parseInstance(serviceDetail).toFootprint();
        System.out.println("Count-1: " + count1);

        // [进程端口等信息]
        List<ProcessDetail> taskDetail = ProcessUtils.getTaskDetail();
        Object count2 = GraphLayout.parseInstance(taskDetail).toFootprint();
        System.out.println("Count-2: " + count2);
    }
}

运行程序可以看到打印的结果分别为 59760131280 字节,也就是 0.05MB0.12MB 左右,加起来甚至不到 1MB,也跟我之前的直觉相符,内存占用显然也不是在这。

3. 内存分析

既然看不出个所以然来,那就直接看下程序的内存堆信息这个百来兆到底存了个啥玩意。

在之前博客中已经分享过通过 jmap 可以查看程序的内存快照信息,可以快速回顾下:JVM参数调优记录。在命令行执行 jmap -heap <pid> 可以得到下图结果:

让我们拆开进行分析,在第一块信息中展示了程序的内存堆基本信息,其中包含以往介绍过的最大内存 (MaxHeapSize)、元空间 (MetaspaceSize) 以及指针压缩空间 (CompressedClassSpaceSize) 等等。

你可能会疑惑 NewSizeOldSize 这两又是啥东西?在之前介绍 JVM 的文章中有提到过,Java 8 默认使用 Parallel 垃圾收集器,而其又是基于标记复制模式,更具体的即其是使用 Appeal 模式执行内存回收。简单的讲就是将堆内存拆分为了两个部分,青年代及老年代,二者的比例为 1:2,感兴趣的可以回去看下之前的文章:Java JVM虚拟机详解

而之前提到了 JVM 为程序默认分配的初始堆内存大小为 500MB,根据 Appeal 方式的分配比例则青年代及老年代的内存分配大致为:170MB340MB,正好对应了图中的 NewSizeOldSize

上图中的第二及第三部分,则对应这 Appeal 方式对于堆内存的具体分配,配置逻辑参考下图:

观察图中的数据可以看到对象基本都处于青年代的 Eden 分区,大小为 39MB,而对于青年代的另两个分区 From SpaceTo Space 占用率均为 0,同时老年代也仅有 3MB 左右的对象内存占用。

这也看出一个问题,有大量的对象堆积于青年代没有被垃圾回收及时清理,根据之前的 JOL 验证结果,显然程序中涉及了其它对象。

那既然在刚才 JOL 排查中可以看到数据对象的内存占用并不大,那青年代中的几十兆内存又是被什么对象占用呢?通过 jmap -histo <pid> 命令,则可以看到进程对象数据量与内存占用。

返回结果的各列描述信息如下:

列名 描述
num 序号,根据内存占用倒排。
#instances 实例对象数量。
#bytes 所有实例对象所占的内存。
class name 实例对象对应的类名。

执行后在下图可以看到,其中内存占用最大的前三项为 [I[B[C,分别代表 int[]byte[]char[],其中 int 数据占用最多达到近 30MB

4. 参数调优

经过一顿猛如虎的操作,一看结果还是没能定位到根本原因。

我们就换个思路来看,以内存占用为切入点,既然青年代存留大量的对象实例没能被垃圾回收,那我就逼你执行触发回收动作。

最简单粗暴,启动程序时直接将 JVM 虚拟机内存限制到 50MB 先看下效果,在启动时添加下述参数:

# 设置 JVM 堆内存最小值为 50m
-Xms50m

# 设置 JVM 堆内存最大值为 50m
-Xmx50m

启动程序后再次通过 jmap 查看内存布局情况,可以看到此时青年代的 From Space 大小为 2MB 且处于满状态,老年代的占用比例也有所提升,这说明 Young GC 的次数相较于上次也是有所提升。

显然上述的配置是有所效果的,我们目的是以一个合理的频次触发 Young GC 以回收内存,同时降低 Full GC 的次数,那如何验证效果呢?

我们可以同 jstat -gcutil 命令打印进程 GC 的相关信息,以下述为例每间隔 5s 执行一次打印输出。

在上图可以看到最开始进程执行过的 YGC 次数为 8,此时在程序页面选中查询模拟操作可以看到 YGC 的次数在逐次增加,但 FGC 还是保持在 0,而程序只要不频繁的触及 FGC,都是在我们的可接收范围之内。

那如何判断堆内存的设置是否合理呢?很简单,就是一个个试,将上面的参数设置为 30MB 后我们再以同样的方式进行观察。

GC 活动日志打印调整为 1s 并在程序随机选择查询模拟操作,得到下述结果:

此时我们再看一眼堆内存的分布,重复几次可以看到随着 YGC 的执行,老年代的占用比例维持在 30% 左右,不至于太低也不至于太高从而触发 FGC

当然,你可以重复上述步骤直至试了一个你觉得最佳的临界点,这里我就不啰嗦展开了。

至此,整个问题的排查也告一段落了,终于又可以安心的网上冲浪了。


文章作者: 烽火戏诸诸诸侯
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 烽火戏诸诸诸侯 !
  目录