32G内存跑12个微服务?JVM参数调优实战指南

📅 发布时间:2026/9/20 10:44:18
32G内存跑12个微服务?JVM参数调优实战指南
如果你也试过在一台普通服务器上把微服务全家桶整套梭哈起来那你大概能体会我上周的心情。团队要搭一套完整的 Spring Cloud Alibaba 微服务开发环境基于开源若依二开网关、认证、系统、监控、文件、定时任务、代码生成再加上我们自己拆的商品、订单、库存、支付一共 12 个 Java 服务。机器是一台 32G 内存的通用服务器不算差了。服务一个个拉起来注册中心里全部 ONLINE结果点开页面卡了两秒再点一次又卡。free -h一敲used 28.8G总量 32G内存占用 90%swap 已经在动了。当时旁边就有人开始喊要加内存了吧但约束很明确不加内存不砍服务你把这个事解决掉。我最后解决这个问题的办法没改一行业务代码纯粹是给 12 个 JVM 重新做了一遍内存配额把最大堆、元空间、线程栈、堆外内存重新梳理了一遍落地成一张参数表和一套启动脚本。重启之后内存占用稳定在 70% 左右页面响应也恢复到正常水平。整个过程踩了不少坑今天把完整的排查链路、参数清单和背后的原理一次说清楚。如果你也在单机跑多端微服务或者打算在普通配置的服务器上搭开发环境这篇应该能帮你少走很多弯路。1. 现象32G 服务器被 12 个微服务吃到了 90%先别急着加内存1.1 第一眼看到的数据free、top、ps 三段命令定位现场问题出现时我做的第一件事不是猜而是把系统的内存分布先拉出来看。第一步是free -h看到的结果非常直观$ free -h total used free shared buff/cache available Mem: 31Gi 28Gi 1.1Gi 1.2Gi 2.1Gi 1.4Gi Swap: 7.9Gi 3.2Gi 4.7Giused 28Giavailable 只剩 1.4Giswap 已经写入了 3.2G。这说明系统内存是真的扛不住了连交换分区都被迫开始接手。这也是页面卡顿的直接原因之一内存不够用操作系统在内存和 swap 之前频繁搬运数据进程的每次内存访问都可能被拉长几十倍。第二步是top按内存排序看看是谁在吃内存。结果毫不意外排在前面的几乎全是java进程。第三步用ps把所有 Java 进程的实际内存列出来$ ps -eo pid,rss,args --sort-rss | grep [j]ava 2345 2298432 java -jar ruoyi-auth.jar 2351 2187652 java -jar ruoyi-system.jar 2360 2104320 java -jar ruoyi-gateway.jar ...这里有个关键点是RSS列。它表示进程实际驻扎在物理内存中的大小单位是 KB。12 个 Java 进程的 RSS 普遍在 1.8G 到 2.4G 之间加起来已经超过 24G。再加上 MySQL、Redis 各自占掉几个 G系统内存就这么被吃光了。很多人在这个阶段会直接把原因归结为机器太小如果你也这么想只能说明还没看到真正的根子。1.2 页面卡顿不是某一个服务的锅是系统在持续换页为什么所有服务看起来都活着但页面就是卡因为问题不是某一个服务挂了而是整台机器的内存进入了一个临界状态。Java 进程的特点是内存不会立刻释放它按需从操作系统申请即使部分内存已经不再使用也可能留在进程空间里。当 12 个进程都这样做系统内存耗尽后内核就开始频繁换页也就是把内存页写到 swap、再从 swap 读回来。这个状态最坑的地方在于top里看到的 CPU 使用率可能并不高因为大量时间花在了等待内存换页上而不是计算。如果你这时候去观察 GC 日志还会发现 Full GC 的频率明显上升因为 JVM 在内存不足时会先把宝贵的 CPU 耗费在垃圾回收上试图腾出空间。这也是为什么这个阶段直接加内存确实能暂时解决问题但并没有回答一个核心问题这些 JVM 的内存上限到底是按什么规则定的你有没有主动给它们设过值我在跟团队确认后得到一个事实所有服务都是直接用java -jar xxx.jar启动的没有任何一个脚本里写过-Xmx、-Xms之类的参数。换句话说这些 JVM 全部使用的是 JDK 的默认内存规则。2. 真正的根因每个 Java 进程都按独占整机设置了默认内存上限2.1 JDK 默认参数每个 JVM 都以为自己独占整台服务器JDK 有个非常大方的默认行为如果你在启动 Java 进程时没有显式指定-XmxJVM 会把最大堆设置为物理内存的四分之一。这台机器是 32G 内存所以理论上每个 Java 进程允许扩张到的最大堆是 8G。12 个 Java 进程合在一起光是最大堆的理论占用上限就接近 96G。你可能会说最大堆只是最大不一定会用满。对但问题是 JDK 的默认初始堆是物理内存的六十四分之一也就是大约 512M。也就是说每个进程在刚启动时就会按 512M 的量级向操作系统申请内存之后随着业务流量上来堆会动态扩张到-Xmx允许的上限。当十几个进程同时处在这种随时可能往大了扩的状态谁都不会让着谁内存就在某个临界点被集体引爆。这里可以用一个生活化的类比一栋写字楼每个新入驻的公司都按整栋楼的供电容量给自己申请了最大用电额度。单个公司申请时觉得自己要用很多电是合理的但整栋楼的电网容量是固定的十几家公司同时按最大额度用电总闸必然跳。而内存这件事比用电更隐蔽因为 Java 进程并不会老老实实贴着配额走它在底层还会动态申请、扩容、抖动。2.2 为什么开发环境几乎没人配置 JVM 参数顺着这个思路排查之后第二个问题冒出来了为什么这么多开发环境、演示环境甚至小规模生产环境都存在同样的问题我觉得原因是多方面的。第一IDE 里直接启动 Spring Boot 时默认参数是一套让程序能跑的最小配置不会有人去刻意改它。大多数人Run一下就开始了下一轮开发。第二微服务拆分的常见最佳实践教程里关注点都在注册中心、配置中心、网关路由、熔断降级上很少有人讲你这十几个 JVM 放在同一台机器上内存该怎么分配这一课。第三开发阶段服务少、并发低、堆扩张慢等到把整套环境完整跑起来或者开始压测时问题才集中暴露。另外一个隐蔽点在于除了-XmxJDK 还有几个默认参数同样值得注意。Metaspace元空间默认是不设上限的只要系统物理内存够它就能一直长线程栈-Xss在多数 Linux x64 下默认是 1MJIT 编译器还会保留一部分 CodeCache。一个 Java 进程真正占用的内存绝不只是堆那一块。这也是为什么很多人设置了-Xmx512m之后发现top里 RSS 仍然有 1G 多觉得设置没生效。不是没生效是堆只是一部分。3. 核心骚操作给 12 个微服务重做 JVM 内存配额并统一落地3.1 先算账一张内存预算表比一个命令更重要我做的第一件事并不是去改参数而是先算了一笔账。整台机器是 32G不可能把 32G 都分给 Java 进程至少要给操作系统、MySQL、Redis 和一些日常运维命令留出余量。我的预算是这样的操作系统内核、基础服务、登录会话等预留 3GMySQL预留 3GRedis预留 1GNginx、日志采集、监控 agent 等杂项预留 1G剩下约 24G 分配给 12 个 JVM24G 分配给 12 个 JVM平均每个接近 2G听起来很宽裕。但注意这里说的 2G 指的是 JVM 全部内存开销包括堆、元空间、线程栈、直接内存而不是单纯的-Xmx。所以我在给每个服务设堆大小时并没有无脑给 1.5G 或 2G而是根据服务角色做了分档服务角色说明-Xms / -Xmx-XX:MaxMetaspaceSizenacos注册配置中心512m / 512m256mruoyi-gateway网关流量的第一入口1g / 1g256mruoyi-auth认证中心512m / 512m128mruoyi-system系统模块核心业务1g / 1g256mruoyi-monitor监控模块256m / 256m128mruoyi-file文件服务512m / 512m128mruoyi-job定时任务256m / 256m128mruoyi-generator代码生成256m / 256m128mproduct商品服务1g / 1g256morder订单服务1g / 1g256minventory库存服务512m / 512m128mpay支付服务512m / 512m128m表格里所有服务都用了-Xms等于-Xmx的方式。这个做法的好处是让 JVM 在启动时就确定好堆的大小不再根据负载动态扩张和收缩避免多个 JVM 在内存紧张的时候互相争抢、反复扩容。代价就是进程启动时就会把配额占住所以配额给多少心里要有数。3.2 统一注入 按档位设置不手改 12 个启动脚本的方案确定了配额之后下一个问题是怎么落地。12 个服务总不能一个个去改 systemd 脚本或者启动脚本哪怕只改JAVA_OPTS也容易漏。我用的方案是公共参数走环境变量、个性参数走启动命令。公共参数放环境变量。JDK 8 支持JAVA_TOOL_OPTIONSJDK 9 还支持JDK_JAVA_OPTIONS两者的作用类似进程启动时会自动读取并加入 JVM 参数。我把所有服务通用的参数放在这里export JAVA_TOOL_OPTIONS -XX:UseG1GC -XX:MaxMetaspaceSize128m -XX:ReservedCodeCacheSize96m -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/heapdump -Xss256k 解释一下几个关键项。-XX:UseG1GC是让 JVM 使用 G1 垃圾收集器相比 JDK 8 默认的 Parallel GCG1 更强调停顿时间可控对于单机部署的多个微服务来说整体响应更平稳。-XX:MaxMetaspaceSize128m是给元空间设一个明确上限避免类加载器缓存无限增长。-Xss256k是限制线程栈大小后面我会专门展开说它。HeapDumpOnOutOfMemoryError这个选项属于保险措施万一某个服务真的 OOM至少能留下一份堆转储文件后面排查有依据。个性参数放在启动命令里。每个服务启动时显式带上自己的-Xms和-Xmx这个优先级最高不会被环境变量覆盖。我把启动逻辑整理成了一个简单的start.sh按服务名匹配档位#!/bin/bash # start.sh 简化版 SERVICE$1 # 公共参数区 COMMON_OPTS-XX:UseG1GC -XX:MaxMetaspaceSize128m -Xss256k # 按服务名匹配内存档位 case $SERVICE in nacos|system|product|order) JVM_MEM-Xms1g -Xmx1g ;; gateway) JVM_MEM-Xms1g -Xmx1g ;; auth|file|inventory|pay) JVM_MEM-Xms512m -Xmx512m ;; monitor|job|generator) JVM_MEM-Xms256m -Xmx256m ;; *) JVM_MEM-Xms512m -Xmx512m ;; esac # 启动应用 nohup java $JVM_MEM $COMMON_OPTS -jar /data/app/$SERVICE.jar \ /data/logs/$SERVICE.log 21 这里有一个实际使用中容易踩的坑JAVA_TOOL_OPTIONS里的参数会被所有 Java 进程读取但你如果同时设置了两个互相冲突的参数最终以命令行参数为准。所以在脚本里我特意把-Xms、-Xmx放在命令行公共参数只放那些所有服务都通用的项。如果你把-Xmx放到了环境变量里那所有服务的堆大小就都被统一成一个值了和按角色分档的设计矛盾。3.3 重启验证从 28.8G 降到 22G页面也顺了参数配好之后重启所有服务。这一步要做的事是等着不是重启完就完事至少要看五分钟到十分钟让 JVM 把类加载完成、线程池初始化完毕、注册中心数据同步完成再去看内存。实测结果$ free -h total used free shared buff/cache available Mem: 31Gi 22Gi 7.8Gi 1.1Gi 2.0Gi 8.5Giused 从 28Gi 降到了 22Gi 左右内存占用从 90% 降到了 71% 上下。再看每个服务的 RSS大部分都在 1G 到 1.4G 的区间内比之前动辄 2G 以上明显瘦身。页面响应恢复正常不再出现点击后等好几秒的情况。这里有一个必须说清楚的点优化后的 22Gi 里依旧有很大一部分不是堆而是 JVM 自身运行需要的基础开销。如果你把-Xmx压到 256m服务确实也能启动但稍微有请求进来堆就满了紧接着就是频繁 Full GC 甚至 OOM。所以配额不是越低越好而是够用就好。判断够不够用的标准也很简单看这个服务在日常请求下堆占用是否稳定在 60% 到 80% 的区间。4. 拆解原理除了 -Xmx还有三个内存隐形人在拖后腿4.1 MetaspaceSpring Cloud 全家桶的隐形消耗很多人以为限制完-Xmx就万事大吉结果发现进程 RSS 还是很高第一个藏起来的消耗大户就是 Metaspace。Metaspace 存放的是类的元数据、方法信息、常量池还有 Spring Cloud 里大量使用的动态代理生成的类。一个基于 Spring Boot 和 Spring Cloud 的微服务启动完成后 Metaspace 很容易就吃掉 100M 到 200M。如果不限制MaxMetaspaceSize类加载得越来越多Metaspace 能长到什么程度完全看运气。我在自己的服务里给大多数模块设了 128m给 nacos、gateway、system 这几类服务设了 256m。你可能会担心 128m 不够导致OutOfMemoryError: Metaspace这个担心合理但开发环境里服务的类基本在启动阶段就加载完了除非有频繁热部署、反复生成代理类的操作否则 128m 足够。如果真出现元空间溢出日志里会明确提示到时候再按需调大也不迟。你可以用jstat -gcutil pid看元空间占用情况输出里最后几列是M和MC分别表示元空间使用率和容量。如果长时间稳定在一个值附近说明配额合理。4.2 线程栈12 个服务几千个线程每省 1M 都很可观第二个隐蔽的内存消耗是线程栈。Java 每创建一个线程都会在 JVM 内存之外分配一块线程栈空间默认大小在多数 Linux x64 环境是 1M。12 个微服务每个服务光 Tomcat 默认就可能开 200 个工作线程再加上业务自己创建的线程池、定时任务线程、异步线程一个服务搞出 300 个线程非常正常。12 个服务就是 3600 个线程每个默认 1M 栈空间理论上的最大占用量接近 3.6G。虽然在真实场景里线程栈不会每个都满打满算用 1M但把线程数量乘以栈大小得到的数字足以说明问题。我的做法是把-Xss从 1M 降到 256k。这里有个度的把握如果服务里的递归很深或者调用链很长比如某些复杂的报表计算、参数树解析256k 可能出现StackOverflowError。稳妥起见可以先用 512k跑一段时间观察日志。我实测在若依这套环境里256k 跑日常开发没有任何问题但在一个导出报表的服务上出现过栈溢出最后还是给它单独调回了 512k。想看你服务的线程数用jstack pid | grep -c java.lang.Thread.State就能数出来。数完你会明白线程绝对数量在这里也是内存的一大开销源。4.3 Direct Memory 与 CodeCache另外两个容易忽略的角落第三个隐形人是堆外直接内存和 CodeCache。先说 Direct MemoryJava NIO、Netty、一些缓存客户端会使用堆外内存默认情况下MaxDirectMemorySize和-Xmx大体相当。当你在微服务里用 Netty、Redis 客户端、MQ 客户端时这部分内存要额外汇入总量。CodeCache 则是 JIT 编译器存放编译后的热点代码的区域默认保留空间不小但实际占用通常在几十 M 到一两百 M 之间。我在公共参数里统一加了-XX:ReservedCodeCacheSize96m目的就是给它一个明确的天花板避免它跟着系统内存随缘增长。另外Nacos 和 Gateway 这类基础组件对直接内存的使用比其他普通业务服务更明显这也是为什么我把它们单独列出来给更大的配额。现实中不少团队把所有服务都用一模一样的 JVM 参数启动这是很大的误区因为网关吃的是连接和临时 buffer业务服务吃的是业务对象监控任务吃的是定时任务线程各自的内存画像完全不同。5. 压测一周后的复盘哪三个参数我又改了5.1 压测后 gateway 第一个顶不住单机内存降下来之后不能只靠静态观察还得看有流量进来的表现。我们后续用 JMeter 脚本做了一轮简单压测主要验证在并发请求下内存会不会反弹。结果发现 gateway 是第一个顶不住的堆占用在某些瞬间飙到接近 1GFull GC 次数明显上升。原因不复杂网关是所有请求的统一入口Spring Cloud Gateway 在处理请求时会在内存里构建不少临时对象加上和下游服务之间的连接分配对堆的需求比普通业务模块大。我后来把 gateway 的堆从 1G 调到了 1.5G同时把server.tomcat.max-threads从默认的 200 调低到 128因为对网关来说与其靠更多线程处理请求不如把有限的线程数稳住减少线程切换和线程栈开销。调整之后堆占用稳定在 800M 左右预留了足够的弹性。5.2 一个业务服务频繁 FGC问题出在分页查询而不是参数另一个有意思的现象是订单服务在压测时出现了比较频繁的 Full GC。起初我也以为是配额给小了把堆从 1G 加到 1.5G 试了试情况没有根本改善。后来用jmap -dump:live,formatb,fileorder.hprof pid导了一份堆转储用 MAT 打开一看发现堆里大部分空间都被byte[]和char[]占着再往上是几个大 SQL 查询相关的对象。真相是某个分页查询没有加索引每次查询会把大量数据一次性加载到内存再交给权限框架做数据过滤。这不是 JVM 参数能解决的问题而是业务代码的问题。把 SQL 优化之后内存占用立刻降下来了。所以想提醒大家限内存参数只是第一步真正要让系统稳还得配合代码层面的优化。千万别把一切问题都归结为内存没给够否则就是在错误的方向上加码。5.3 容器化部署时这套参数怎么继续用最后说一个扩展场景。现在很多人也会把微服务放到 Docker 或 Kubernetes 里如果用了容器之前讲的物理内存四分之一默认规则有另一种坑JDK 8u191 之后虽然默认支持容器感知但如果宿主机内存很大而容器限制很小JVM 可能拿不到准确的内存上限导致堆按宿主机内存的 1/4 来算。解决办法有两个一是像我上面一样启动命令里显式写死-Xms和-Xmx二是使用-XX:MaxRAMPercentage60、-XX:InitialRAMPercentage60这类按容器内存比例计算的参数。我个人在容器场景更推荐显式写死-Xmx因为配额一目了然也方便和 Kubernetes 的resources.limits.memory对应起来。比如给 Pod 限制 1G那-Xmx就不要超过 600m剩下的留给元空间、线程栈、直接内存这些开销这样即使某个服务内存吃紧也不至于把 Node 打爆。如果后续遇到某个服务堆内存不停上涨且 Full GC 越来越频繁我一般会用jstat -gcutil看趋势确认 GC 情况后jmap导堆转储重点看有没有明显的对象泄漏。限内存参数解决的是大家一起抢的问题代码泄漏就是另外一回事了两者不能混为一谈。这次调整给我最深的体会是单机跑多微服务最重要的不是把参数调得越极端越好而是要提前做一张内存预算表。每个服务该占多少、堆和非堆怎么分、留多少余量给突发流量想清楚再动手。否则今天把内存压到 70%明天一台机器上再加两个服务又得重新调一轮。