硬核干货丨JAVA+Linux的内存优化实践

admin 2026-05-20 06:38:34 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文通过实际案例分析了JAVA应用在Linux环境下的内存优化实践。针对系统运行数月后出现的性能劣化问题,作者发现JVM内存配置不合理(堆内存过大至2560MB而实际使用仅665MB)及ParallelGC导致的频繁FullGC(每10-20秒停顿1秒)是主因。优化方案包括:将堆内存调整为1280MB、线程栈从1MB减至256KB、限制直接内存为256MB、配置Netty内存参数(pageSize=4KB,maxOrder=9),并切换至G1垃圾回收器以将GC停顿控制在200毫秒内。最终系统内存使用率从90%降至合理范围,页面响应显著提升。 综合评分: 85 文章分类: 应用安全,安全工具,安全建设,安全运营,解决方案


cover_image

硬核干货丨JAVA+Linux的内存优化实践

原创

胡俊鑫 胡俊鑫

威努特安全网络

2026年5月13日 08:02 北京

在小说阅读器读本章

去阅读

做后端开发的同学,或多或少都会遇到这样的场景:系统上线初期一切正常,运行几个月后,用户开始反馈页面加载缓慢、查询超时,甚至偶尔出现数据查不出来的情况。最近,我们的某个产品在现场就遇到了这个问题。经过远程排查,最终定位分析结果是JVM内存配置不合理以及垃圾回收器选型不当导致的。今天把整个排查和优化过程分享出来,希望对大家有所帮助。

01

问题现象

现场反馈的症状很典型:

1、系统运行数月后,页面操作明显卡顿;

2、部分查询请求超时,返回不了数据;

3、重启服务后短暂恢复,但过一段时间又会复现。

这种”渐进式劣化”的表现,经验告诉我们,大概率跟内存有关。

02

内存现状分析

远程登录服务器后,第一步先看整体内存使用情况。

结果:

总内存:15943 MB

已用内存:14767 MB

可用内存:仅 761 MB

内存使用率超过 90%

服务器内存已经非常紧张了。接下来用 top 命令看看是哪些进程在”吃”内存。

排在第一位的是我们的collector微服务(PID 4402),这个服务负责接收和处理日志数据,是整个系统中数据吞吐量最大的组件之一。接下来已这个为例,分析一下内存使用情况。

03

深入JVM内存分析

内存结构

首先我们需要熟悉一下java进程的内存组成,下面这张表列举了一个java进程所包含的主要内存结构:

| | | | | | | — | — | — | — | — | | 内存区域 | 所属 | -Xmx 限制 | GC 可回收 | 关键控制参数 | | Eden 区 | 堆内存 | 是 | 可以 | -Xmn, -XX:SurvivorRatio | | Survivor 区 | 堆内存 | 是 | 可以 | -XX:MaxTenuringThreshold | | 老年代 | 堆内存 | 是 | 可以 | -Xmx | | 元空间 | 非堆内存 | 否 | 部分 | -XX:MaxMetaspaceSize | | 代码缓存 | 非堆内存 | 否 | 否 | -XX:ReservedCodeCacheSize | | GC 开销内存 | 非堆内存 | 否 | 否 | 随GC 算法自动分配 | | 直接内存 | 进程其他 | 否 | 否 | -XX:MaxDirectMemorySize | | 线程栈 | 进程其他 | 否 | 否 | -Xss | | JNI 本地分配 | 进程其他 | 否 | 否 | 无JVM 参数可控 |

开启本地内存跟踪

根据上表,我们看一下实际的JVM内部内存分布,我们在collector服务的启动脚本中添加了 Native Memory Tracking 参数:

-XX:NativeMemoryTracking=summary

重启服务后,使用 jcmd 命令导出内存详情:

jcmd VM.native_memory summary >> /root/memory.txt

整理成表格展示:

| | | | | | — | — | — | — | | 内存区域 | Reserved (MB) | Committed (MB) | 说明 | | Java Heap | 2560.00 | 2560.00 | 堆内存已完全分配 | | Class | 1191.22 | 187.61 | 加载了27,430 个类 | | Thread | 329.38 | 329.38 | 共330 个线程 | | Code | 256.35 | 73.03 | JIT 编译代码缓存 | | GC | 103.51 | 103.51 | 垃圾回收器专用内存 | | Internal | 478.96 | 478.95 | JVM 内部数据结构 | | Symbol | 34.53 | 34.53 | 字符串表、常量池等 | | NMT | 7.03 | 7.03 | NMT 功能自身消耗 | | Compiler | 1.49 | 1.49 | 编译器专用内存 | | Arena Chunk | 1.85 | 1.85 | 内存分配块 | | 总计 | 4964.32 | 3777.39 | |

接下来我们重点看堆内存的使用情况。先手动触发一次 Full GC,清理掉可回收的对象:

jcmd GC.run

GC 后的堆内存使用情况:

jmap -head

整理成表格:

| | | | | | — | — | — | — | | 内存区域 | 已用 | 总容量 | 使用率 | | Eden 区 | 15.20 MB | 763 MB | 2.0% | | From Survivor 区 | 0 MB | – | 0% | | To Survivor 区 | 0 MB | – | 0% | | 新生代合计 | ≈ 15 MB | 763 MB | 2.0% | | 老年代 | 649.66 MB | 1600 MB | 40.6% | | 整体堆内存 | ≈ 665 MB | 2560 MB | ≈ 26% |

关键发现

从这两张表中,我们可以提炼出几个关键信息:

Java Heap(2560 MB):堆内存是最大的内存消耗者。Reserved 等于 Committed,意味着JVM启动时就把堆内存全部申请了,没有任何弹性空间。这个配置对于一个日志收集服务来说,明显偏大。另外,我们给JVM分配了 2560 MB 的堆内存,但实际常规使用量只有 665 MB 左右。大量内存被占着却不适用,白白浪费了宝贵的系统资源。

Class(187.61 MB):加载了将近 2.7 万个类,占用了近 188 MB 的元空间。这个数字需要持续关注,如果类加载数量异常增长,可能存在类加载泄漏的风险。

Thread(329.38 MB):330 个线程占用了约 330 MB 内存,平均每个线程栈约 1 MB。这是JDK8的默认配置,属于正常范围。

Internal(478.96 MB):这部分包含JVM内部的数据结构、字符串表等。接近 479 MB 的占用偏高,可能意味着内部缓存或数据结构存在膨胀。

04

内存优化方案

堆内存调整

在JVM内存调优中,有一个经典的经验法则:在应用的常规峰值使用量基础上,预留一倍的缓冲空间。这个缓冲空间用于应对:

1、突发流量带来的临时内存增长

2、GC过程中的内存抖动

3、业务增长带来的潜在内存需求

根据上面的分析,collector 服务的常规峰值使用量约为 665 MB,按照一倍缓冲的原则:推荐堆内存 = 665 MB × 2 ≈ 1280 MB

将 JVM 堆内存从 2560 MB 调整为 1280 MB,既能保证服务稳定运行,又能释放出1280MB 的内存给其他进程使用。其他微服务也按照同样的方法进行分析和调整。

配置参数:

-Xms1280m -Xmx1280m

线程栈优化

线程栈内存是独立于堆内存(-Xmx)之外的,它直接占用操作系统的本地内存。这意味着线程越多,栈内存的总消耗就越大。

JDK 8 中默认值为 1 MB,回顾我们前面的分析,collector 服务有 330 个线程,占用了约 330 MB 内存——正好是每个线程 1 MB 的默认配置。

在微服务或高并发场景下,减小栈大小能显著降低单线程的内存开销,从而支撑更高的并发量。以我们的 collector 服务为例:

调整前:330 线程 × 1 MB = 330 MB

调整后:330 线程 × 256 KB = 82.5 MB

节省内存:约 247.5 MB

这将近 250 MB 的内存节省,对于一台内存使用率已经超过 90% 的服务器来说,是非常可观的。

什么情况下 256 KB 可能不够?

1、深度递归算法(如树的深度优先遍历、复杂的分治算法)

2、框架嵌套层级过深(如多层 AOP 代理、复杂的过滤器链)

3、大量局部变量的方法(尤其是包含大数组的局部变量)

配置参数:

-Xss256k

直接内存优化

直接内存(堆外内存)主要用于 NIO 的 ByteBuffer,在 Netty 等高性能网络框架中会被大量使用。它绑定在操作系统的本地内存上,不经过 JVM 堆,因此读写效率更高,常用于网络 I/O 和文件 I/O 场景。

在较新的 JDK 版本中,如果不显式设置 -XX:MaxDirectMemorySize 参数,JVM 默认会将其上限设置为与最大堆内存(-Xmx)相同。由于直接内存不受 GC 直接管理,JVM 不会主动回收这部分内存,最终可能迅速耗尽物理机内存,导致进程被操作系统直接 Kill 掉(OOM Killer)。更可怕的是,这种情况下进程是被操作系统强制杀死的,不会留下 Java 层面的异常堆栈,排查起来非常困难。

显式设置为 256m 或 512m 是一个很好的防御性编程习惯:

-XX:MaxDirectMemorySize=256m

Netty 内存优化

如果你的项目使用了 Netty(Spring WebFlux、gRPC、Dubbo 等底层都依赖 Netty),那么除了 JVM 层面的参数,Netty 自身的内存分配策略也值得关注。

1、Page 是 Netty 内存池中最小的分配单元,所有的内存申请都会以 Page 为基本粒度进行。

Netty 默认的 Page 大小是 8 KB,将 pageSize 下调至 4096(4 KB)可以减少系统层面的内存管理开销,减少产生碎片。

配置方式:

-Dio.netty.allocator.pageSize=4096

2、Chunk 是 Netty 内存池中的基本大块单元。

在较老的 Netty 版本中,maxOrder 默认是 11。如果 pageSize 是 8 KB,那么一个 Chunk 高达:8KB × 2^11 = 16 MB

在较新的 Netty 版本(如 4.1.x 后期版本)中,maxOrder 的默认值已经调整为 9。显式指定 maxOrder=9 并配合 pageSize=4096,意味着单个 Chunk 的大小被控制在:

4KB × 2^9 = 4096 × 512 = 2MB

配置方式:

-Dio.netty.allocator.maxOrder=9

05

垃圾回收器优化方案

问题发现

内存调整只是第一步。我们继续用 jstat 观察垃圾回收情况:

jstat -gc 1000

每秒打印一次 GC 统计信息,发现了严重的问题:

1、Full GC 周期极短:仅仅 10-20 秒就触发一次 Full GC

2、停顿严重:每次 Full GC 都会导致应用暂停约 1 秒

这就解释了为什么用户会感觉到页面卡顿——每隔十几秒,服务就要”停下来”做一次大扫除,在这期间所有请求都得排队等着。

问题根因

项目使用的是 JDK 8,默认的垃圾回收器是 Parallel GC。Parallel GC 的设计目标是追求吞吐量最大化,它会在 GC 时暂停所有应用线程(Stop-The-World),集中力量快速完成垃圾回收。

这种策略适合批处理任务,但对于微服务架构来说就不太合适了。微服务强调的是快速响应,用户不会接受”每隔十几秒卡一下”的体验。

切换到G1垃圾回收器

综合考虑项目的微服务特性,我们决定将垃圾回收器更换为 G1(Garbage First)。

为什么选择 G1?

1、G1 支持设定最大停顿时间目标。我们可以告诉 G1:”每次 GC 停顿不要超过 200 毫秒”,G1 会尽力在这个时间预算内完成回收工作。

2、G1 采用了全新的内存布局。它将堆内存划分为很多大小相等的小区块(Region),每次 GC 时优先回收垃圾最多的区块——这也是”Garbage First”名字的由来。通过这种方式,G1 可以在有限的时间内回收尽可能多的垃圾,避免了传统收集器那种”要么不做,要做就全做”的尴尬。

配置方式

在启动脚本中添加以下参数:

-XX:+UseG1GC -XX:MaxGCPauseMillis=200

参数说明:

-XX:+UseG1GC:启用 G1 垃圾回收器

-XX:MaxGCPauseMillis=200:设定最大 GC 停顿时间目标为 200 毫秒

06

优化效果与总结

经过上述优化后:

1、内存使用率从 90% 以上降到了合理范围,系统有了充足的内存余量

2、GC 停顿时间从每次约 1 秒降低到 200 毫秒以内,用户几乎感知不到

3、页面响应速度明显提升,查询超时的问题不再出现

完整的启动参数汇总

结合前面所有的优化,完整的启动参数建议如下:

-Xms1280m -Xmx1280m -Xss256k -XX:MaxDirectMemorySize=256m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -Dio.netty.allocator.pageSize=4096 -Dio.netty.allocator.maxOrder=9

各参数的作用一目了然:

| | | | — | — | | 参数 | 作用 | | -Xms1280m -Xmx1280m | 堆内存固定为1280 MB,避免动态扩缩容开销 | | -Xss256k | 线程栈缩小至256 KB,支撑更高并发 | | -XX:MaxDirectMemorySize=256m | 限制堆外内存上限,防止泄漏拖垮系统 | | -XX:+UseG1GC | 使用G1 垃圾回收器,降低停顿时间 | | -XX:MaxGCPauseMillis=200 | GC 最大停顿目标 200 毫秒 | | -Dio.netty.allocator.pageSize=4096 | Netty 内存页缩小至 4 KB,减少小包碎片 | | -Dio.netty.allocator.maxOrder=9 | Chunk大小控制为 2 MB,加速内存回收 |

几点经验教训

1、不要盲目给大内存。 很多人觉得内存越大越好,实际上过大的堆内存会导致 GC 时间变长,反而影响性能。合理的做法是根据实际使用量来配置。

2、垃圾回收器要匹配业务场景。 追求吞吐量用 Parallel GC,追求低延迟用 G1 或 ZGC。微服务场景下,响应时间通常比吞吐量更重要。

3、定期关注 JVM 运行状态。 不要等到用户投诉了才去排查。建议在生产环境中配置 JVM 监控,关注堆内存使用率、GC 频率和停顿时间等关键指标。

希望这次分享对大家有所帮助。内存优化不是一蹴而就的事情,需要结合具体的业务场景和运行数据来做决策。如果你的项目也遇到了类似的问题,不妨按照这个思路排查一下。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:威努特安全网络 胡俊鑫 胡俊鑫《硬核干货丨JAVA+Linux的内存优化实践》

答读者来信 网络安全文章

答读者来信

文章总结: 本文作者结合自身终端安全领域工作经验,分享了对国内EDR行业的观察与思考。文章指出微步的EDR产品在能力与效果上获得业界公认口碑,其成功关键在于将安
评论:0   参与:  0