ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java服务内存升级实战:从8G到16G的JVM调优与GC优化复盘

Java服务内存升级实战:从8G到16G的JVM调优与GC优化复盘 1. 从“偶尔卡顿”到“频繁超时”一次服务内存升级的完整复盘先说结论这次升级不是什么高深莫测的技术改造就是把一台跑了两年多的Java服务从8G内存挪到了16G内存顺带把JVM参数、容器配置、监控策略都重新梳理了一遍。但整个过程踩了不少坑尤其是那些“你以为不是内存问题”的隐性坑差点让我白忙活一场。把这篇文章写出来是给同样被服务内存折腾的同行一个参考——内存升级不只是“加根条子”那么简单它牵扯到服务架构、业务峰值、GC策略、运维流程的一连串连锁反应。事情起因是有用户反馈每天下午业务高峰时段我们的订单查询接口偶尔会出现超过3秒的超时严重的时候甚至直接报错。一开始我们怀疑是数据库慢查询查了一圈没有明显问题后来看监控发现服务的Full GC频率异常老年代回收后内存占用依然居高不下——这就是典型的内存吃紧信号。服务部署在一台8G内存的云服务器上留给JVM堆的最大空间只有5G而业务代码在高峰期瞬间要处理大量请求内存根本兜不住。这篇文章主要分享我从“发现内存问题”到“完成扩容并稳定上线”的全过程包括怎么做内存画像分析、怎么确定8G升16G的具体参数、怎么平滑操作避免影响线上业务以及升级之后那些容易被忽略的验证和调优动作。适合正在做服务压测优化、处理过OOM或者准备给服务扩容的开发者参考哪怕你不是专业运维看完也能知道内存升级这件事的完整套路。2. 升级前最关键的一步先搞清楚内存到底耗在哪里很多人一看到服务卡顿、接口超时第一反应就是“内存不够加内存”。但我的经验是不先给内存做个“体检”就盲目扩容大概率是把问题往后拖而不是真正解决。内存升级这件事最核心的第一步不是买机器而是做内存画像搞清楚现有内存是怎么被消耗掉的。就好比家里总觉得空间不够如果不先收拾整理、看看什么东西占地方就贸然换大房子过不了多久大房子也会被塞满。2.1 先看监控曲线高峰期内存到底涨到多少我打开监控平台把服务最近一周的内存使用率曲线拉出来重点关注三个时间点凌晨低峰、上午平常时段、下午业务高峰。不看不知道服务在凌晨的内存占用大概稳定在3.2G左右上午缓慢爬到4G上下但一到下午两点以后内存占用就像坐了火箭半小时内能冲到5.5G甚至一度接近6G。同时我注意到一个关键现象内存涨上去之后并没有随着请求量下降而回落。正常情况下如果只是并发请求带来的临时对象增加请求结束后内存应该能降下来但这个服务的内存曲线是“阶梯式爬升”——涨一点、平稳、再涨一点。这种形态说明要么有对象没有被及时回收要么有缓存或连接资源在持续累积占用。光看这一条曲线我已经基本判断出内存压力不是瞬时高峰造成的偶然现象而是日常水位已经偏高稍微有点风吹草动就会触顶。再说下怎么判断“内存不够用”和“代码有泄漏”的区别。如果Full GC之后内存能降到一个相对低的水平然后随着业务增长再涨起来这是典型的“堆太小”问题如果Full GC之后内存还在高位徘徊甚至越回收越高那就得怀疑是不是有集合类对象一直被引用存在内存泄漏隐患。这次的情况比较偏向前者——每次Full GC后内存能回到4G左右不过维持不了多久又涨上去了。2.2 结合GC日志算一笔账5G堆真的不够吗有了整体曲线我再把服务的GC日志调出来。这个服务用的是JDK 8默认G1垃圾收集器但启动参数里并没有主动配置过G1相关的目标停顿时间等参数属于裸奔状态。我统计了最近一周的GC情况Minor GC新生代回收平均间隔大概1.5秒一次频率偏高但还算正常Mixed GC和Full GC每天有四五次集中在下午高峰时段每次Full GC耗时大约300到500毫秒。这里可以算一笔账假设一次Full GC造成400毫秒的停顿如果刚好有请求在这期间到达接口响应时间就会直接增加400毫秒以上再叠加业务处理本身的耗时超过3秒甚至报错就不奇怪了。而且频率上一天四到五次Full GC说明堆内存长期处于“快要满又没满”的状态每次都是被逼到临界点才触发回收。我当时先做了一个简单估算把JVM堆从5G扩到10G服务器从8G升到16G后堆可以留大一点理论上Full GC的频率会下降多少基于内存分配速率基本不变的假设堆容量翻倍后触发回收的间隔通常也能拉长接近一倍Full GC从一天四五次降到一天一两次是比较合理的预期。但这只是账面估算实际表现还得等升级后用监控验证。2.3 别忽略堆外内存容器里的隐藏内存杀手检查完JVM堆内的情况我还多留了个心眼排查了一下堆外内存的占用。服务是跑在Docker容器里的之前就遇到过类似案例堆内存明明没满容器却被操作系统OOM Killer给杀了——典型的堆外内存超限问题。我看了下这个服务的容器内存限制是8G而JVM堆最大是5G理论上堆外还有3G可用应该够用才对。不过为了保险起见我还是进容器里跑了一下cat /sys/fs/cgroup/memory/memory.usage_in_bytes再对比JVM通过JMX暴露的堆内使用量算了一下堆外实际消耗。结果显示这个服务堆外内存大概占用了1.2G到1.5G其中包括Metaspace、线程栈、DirectByteBufferNIO用的堆外内存以及JIT编译产物。这个数据很重要因为它决定了服务器从8G升到16G后JVM堆参数能调到多大——不能把16G全给堆必须给系统、堆外、容器管理工具留足余量。3. 为什么是升级到16G而不是继续优化代码按很多人的直觉服务内存吃紧第一件事应该是查代码、找泄漏、做优化。这也是我最初的想法但深入分析后发现这个服务的问题不能简单靠“改代码”解决扩容内存是当前性价比最高、风险最可控的方案。这里面有几个考量我觉得挺值得分享的。3.1 业务增速超过了代码优化的速度这个服务是两年前上线的当时日请求量大概几十万5G堆绰绰有余。但这两年业务增长很快接口调用量翻了好几倍加上不断叠加新功能单次请求处理的对象数量也在增加。从监控数据看服务的基础内存水位什么业务都不做时的占用已经比上线初期高了近一倍。理论上通过代码优化确实可以降低内存消耗比如查一下有没有大对象没复用、有没有集合没设初始容量、有没有缓存过大等问题。但问题是这个服务代码量不小涉及多个模块全面优化一轮少说要两三周而且优化过程中很容易引入新问题。相比之下升级内存是几个小时就能完成的事先把燃眉之急解决了再在后续迭代中逐步优化代码更符合实际的运维节奏。3.2 加内存不是“土办法”而是给JVM留出缓冲空间GC的表现和堆大小之间有很直接的关系尤其是对于G1这种分代垃圾收集器来说堆越大意味着GC能更从容地组织回收策略降低回收频率。这就像家里的垃圾桶——如果垃圾桶只有一个小纸杯那么大你可能一天要倒十几次但如果换成一个大垃圾桶虽然最终还是要倒但一天倒一两次就够了中间被打断的次数自然就少了。Java服务也是一样的道理内存充足的情况下JVM会倾向于让对象在新生代里多存活一会儿等熬过几次Minor GC再晋升到老年代老年代的压力就小很多。而内存不足时对象一旦没在新生代被回收掉很快晋升到老年代老年代一满就触发Full GC全堆扫描、停顿时间长性能自然就崩了。3.3 我为什么不直接把堆从5G调到15G这里要特别提醒一下内存升级不等于把JVM最大堆内存直接拉到接近物理内存上限。服务器16G内存系统本身要占1G到2G容器运行时和监控agent也要占几百MB如果JVM堆直接给15G叠加堆外内存总量很容易超过物理内存轻则系统开始用swap导致性能雪崩重则触发OOM Killer把进程杀了。我当时定的分配方案是这样的服务器总内存16GJVM初始堆和最大堆都设为8G留出另外8G给堆外内存、操作系统、监控agent等。堆外按之前的测算最多占用1.5G再加上系统开销8G的余量绰绰有余。设Xms和Xmx相等也是一个小技巧——避免JVM在运行过程中动态伸缩堆大小带来的性能损耗让堆内存从一开始就到位。4. 从申请资源到业务低峰期操作一次平滑扩容的完整流程方案定了之后就进入实操环节。内存升级虽然不像发布新功能那样对代码有要求但操作不当同样可能造成线上事故。我这次做了一套比较标准化的操作流程从申请资源、做备份、改配置、滚动重启到观察验证每一步都有讲究。4.1 操作前准备快照、备份、回滚方案一个都不能少先在云控制台上给服务器做了一份磁盘快照。这个步骤千万别省万一升级过程中出现系统启动异常或者配置改坏了快照是让你快速回到安全状态的保命符。快照的耗时取决于磁盘数据量我这台服务器数据不算多大概十分钟就完成了。另外我把当前容器使用的镜像tag、启动命令、环境变量、挂载盘信息全部记录下来存到一个文档里。这样做的目的有两个一是如果升级后需要回滚可以照着记录把容器恢复到升级前的状态二是方便对比升级前后的配置差异排查问题时不用靠脑子记。还要确认一下服务有没有依赖其他中间件做了白名单限制。比如有些服务的数据库连接串是配置了来源IP白名单的如果扩容需要迁移到新机器必须提前把新机器的IP加进白名单。这次我的方案是在原机器上加内存不涉及IP变化省了这一层麻烦但如果你们打算直接换更高配置的机器一定要提前确认这点。4.2 停机窗口的选择凌晨两点上线不是随便定的升级过程中需要重启容器重启期间服务是中断的。虽然服务有负载均衡可以摘流量但考虑到操作的复杂度我还是选择在业务低峰期执行。这个服务的主要用户在国内凌晨两点到五点基本没什么流量是最好的操作窗口。实际操作前我做了两步预检第一步在监控平台上看了一下确认当前请求量确实处于全天最低点第二步和业务方在群里同步了操作计划和时间点让他们知悉这段时间接口可能出现短暂不可用。做运维久了会越来越意识到技术操作前的“同步”往往比操作本身还重要——业务方不知情的情况下出了故障那就是事故提前打了招呼再出问题最多算有预案的风险操作。预检没问题后我先把容器优雅停止。这里用的docker stop命令它会先给容器内的主进程发送SIGTERM信号让应用有机会做收尾工作比如把内存中的数据刷到磁盘、关闭数据库连接池等。如果超过等待时间进程还没退出Docker才会强制发送SIGKILL。我们的服务配置了优雅停机机制处理一个正在进行的请求后才会退出所以整个过程还算顺利大概十几秒就完全停掉了。4.3 硬件资源升级在云控制台上调整配置容器停止后我登录云服务器控制台找到这台实例点击“调整配置”或“变更规格”。不同的云厂商按钮名称有细微差别但逻辑都差不多选择目标规格把内存从8G调到16G确认费用变化然后提交。这里有一个非常容易踩的坑有些云平台调整配置后需要重启实例才能生效有些是热升级不用重启。如果是需要重启的一定要把重启时间控制在业务低峰期并且和你的容器启动流程做好配合——实例一重启Docker服务是不是会自动启动容器设置了restart: always策略没有如果没设置你就要手动把容器拉起来忘记了就是一次线上事故。我这个平台是热升级内存直接加上去了实例没有重启。但我还是不太放心登录服务器跑了一下free -h确认总内存确实变成了16G再看了一下docker info确认容器运行时一切正常然后才继续下一步操作。4.4 容器启动参数调整不是简单的“内存条插上就行”硬件内存变了但如果容器和JVM的配置不跟着改那等于白升。我这次对三个层级的参数做了调整。第一层是容器启动参数。之前容器是用-m 8g限制最大内存的这次要改成-m 14g留2G左右的余量给宿主机本身的进程和管理工具。这里有个细节值得说一下容器内存限制不要卡着物理内存上限设比如16G的机器给容器设16G看着挺合理但宿主机还有内核进程、Docker守护进程、监控agent在跑它们也需要内存设太满容易出现宿主机本身内存不足的尴尬局面。第二层是JVM参数。原来的启动参数是-Xms5g -Xmx5g我改成-Xms8g -Xmx8g。同时我给G1垃圾收集器加了两个参数-XX:MaxGCPauseMillis200告诉G1尽量把GC停顿控制在200毫秒以内-XX:InitialHeapOccupancyPercent40调低G1触发Mixed GC的老年代占用阈值。这几个参数不是说加了就一定好但在这个服务的场景下它们能让G1在堆空间变大后更积极地回收避免出现“堆大了GC反而变得很懒等到堆快满了才一次性大回收”的问题。第三层是JVM的元数据空间。之前我提过这个服务的Metaspace占用不小虽然我没给Metaspace设上限但为了防止极端情况下元数据区无限制增长我显式设定了-XX:MaxMetaspaceSize512m这样即便有类加载器泄漏现实中真见过这种情况最多把Metaspace打满触发Full GC不会拖垮整个容器。改完这些配置后我把启动命令放在一个shell脚本里再用docker run基于更新后的启动脚本重新创建容器。注意我这里说的是“重新创建”而不是“重启”因为Docker容器的参数是在创建时确定的光docker restart不会让新的-m参数生效必须删掉旧容器再以新参数创建新容器。5. 升级完成后的观察验证别急着说“搞定”容器重新启动起来后很多人会觉得事情已经结束了。但我的经验是升级完成后48小时才是真正的考验期这段时间要密切留意服务表现确认升级效果是否达到预期以及有没有引入新的问题。5.1 观察JVM运行情况我先连上容器用jstat -gc pid看了一下JVM当前的堆使用情况。第一次看的时候新生代Eden区在使用率很低的状态下开始运行这完全符合预期因为服务刚启动业务流量还没起来。接着我用jstat -gcutil pid 1000 10每秒输出一次GC统计信息确认一下垃圾收集器工作正常。这一步主要是排查启动阶段有没有异常比如类加载太多导致Metaspace溢出、或者配置参数没生效之类的低级问题。确认没问题后我再用jstack pid看一下线程状态确认业务线程都正常起来了没有被死锁或者卡住的情况。5.2 让流量自然恢复不要一上来就全量压测容器恢复运行后负载均衡会自动把流量重新分发过来。我特意没有做任何人为干预让流量自然慢慢恢复。这样做是想观察服务在真实业务流量下逐步升温的过程——如果内存参数有问题它会在流量爬坡的过程中暴露出来而不是在压测的虚假高峰中突然爆发。接下来的几个小时内我一直盯着监控平台看几个核心指标的变化趋势内存使用率、GC频率和耗时、接口的P99延迟和错误率。大概过了两个小时业务流量基本恢复到日常水平。我用监控平台对比了升级前的数据接口P99延迟从之前的1.8秒降到了700毫秒左右错误率从0.3%降到了接近零Full GC次数到下午四点高峰期的时候只出现了一次而且耗时只有150毫秒。看到这些数据我才算松了一口气——这笔钱花得值至少当前这个阶段内存确实不再是瓶颈了。5.3 持续观察一周确认“水位”稳住了不过单看一天的数据不够有说服力内存问题的验证周期至少要拉长到一周。因为有些内存问题只在特定的业务节奏下才会显现比如月底的报表任务、每周一次的数据清理任务等。我这一周基本保持每天看两次监控的习惯上午一次看夜间表现下午一次看高峰期表现。一周下来数据趋势让我挺欣慰的Full GC从每天四到五次降到了每两三天一次而且都是发生在凌晨的批量任务时段——这符合预期因为批量任务一次性加载大量数据内存压力大是正常的GC后内存能顺利降下来就说明回收机制工作正常。内存水位方面日常运行稳定在4.5G到5.5G之间高峰期的上限也就在6G出头距离8G堆的上限还有接近2G的缓冲空间。这意味着即使业务再增长一段时间这个服务的内存余量也是够用的不至于马上又陷入之前的困境。6. 升级过程中踩过的坑和排查实录这次升级总体还算顺利但过程中也遇到了几个有意思的问题。我把它们记录下来你们以后操作时如果碰到类似情况可以直接拿来参考。6.1 容器重启后IP变了导致xxx组件访问报错第一次重启容器后我立刻发现服务的日志里不断报错提示无法连接到某个内部依赖组件当时的第一个反应是依赖组件挂了正准备联系对应团队后来仔细一想容器重启后会重新分配IP地址而服务连接依赖组件走的是配置了固定IP的地址重启后IP变了连接自然就断了。这个问题的本质是服务的注册与发现机制没做好。服务启动时应该通过服务名去发现依赖组件的地址而不是写死IP。但要彻底改这个机制不是一时半会儿的事所以我当时的临时解法是把容器网络模式改成host模式让容器直接复用宿主机的网络栈。这样容器的IP就是宿主机的IP重启后IP不会再变。不过这个方案有它的局限性——host模式会让容器共享宿主机的端口空间端口冲突的可能性高不适合做端口隔离要求高的多容器部署场景。如果是正式环境建议提前做好服务发现的改造不要用我这种“土办法”。6.2 升级后第一次Full GC反而比升级前慢了升级后观察内存的时候发现一个有点反常的现象新内存参数下的第一次Full GC耗时竟然比升级前还长了几十毫秒。我当时第一反应是参数是不是改错了后来看了GC日志才明白——堆变大了GC做全堆扫描的时候需要遍历的对象变多耗时自然略有增加这个代价是正常的。但关键是Full GC的频率大幅下降了从一天几次降到两三天一次总体算下来GC造成的服务停顿总时长从升级前的每次几百毫秒乘以多次变成了偶尔一次服务可用性大幅提升。所以遇到这种情况不要慌关键是看综合效果而不是单次GC的耗时。6.3 云平台监控和容器内看到的内存数据不一致还有一个容易让人凌乱的问题是云平台监控页面显示的内存使用率和在容器里执行free -h看到的内存占用率对不上有时候能差好几个百分点。这是因为它们的统计口径不一样——云平台页面显示的通常是整个实例的物理内存使用情况包括page cache文件系统缓存而在容器里执行free看到的是容器视角下的内存。Linux系统默认会把空闲内存用作page cache来加速文件读写这部分内存在需要时会自动释放给应用程序所以看到云平台显示内存用了不少但容器内应用实际可用内存还挺充足的情况不用太担心。真正需要担心的是当容器内的进程实际申请内存时因为物理内存不够而被系统杀掉的情况——这就要靠我们前面提到的预留余量的方式来规避了。6.4 常见问题速查表我把这次升级和过去几年处理内存问题的经验整理成一个速查表方便你们在实际操作中对照排查现象可能原因排查方式解决建议服务频繁Full GC且GC后内存不降老年代对象持续增长可能有泄漏用jmap -histo:live导出存活对象查看Top对象类型定位并修复泄漏点或增大堆内存做缓冲容器被OOM Killer杀掉但堆内存没满堆外内存超限看/var/log/messages或dmesg中的OOM记录对比堆内堆外占用限制DirectBuffer大小、Metaspace大小或增大容器总内存Java进程启动后宿主机内存飙升Xmx设置过大叠加堆外内存超物理内存查看JVM启动参数和free -h调整Xmx预留20%以上内存给系统和堆外堆扩容后GC单次耗时增加堆变大GC扫描对象增多看GC日志的耗时明细单次耗时略有增加是正常的关注总体停顿时间而非单次容器重启后组件访问异常容器IP变化导致写死IP的配置失效对比重启前后的IP和配置中的IP改为服务发现机制或临时使用host网络模式7. 内存升级这件事的额外心得升级完成并稳定运行一段时间后我复盘了整个过程有几点心得值得单独拎出来说。第一内存问题的处理不应该是被动的——等到服务频繁超时了才去排查往往已经影响了用户体验。如果你发现服务的内存水位长期超过堆上限的70%就该提前做规划了。可以按当前的增长速度估算一下大概多久后会触顶提前申请资源、安排升级窗口而不是等报警响了再手忙脚乱。第二内存升级最好和代码优化同步进行。升级内存解决了眼前的瓶颈但代码里真正的不合理内存占用就像一个漏水的水桶——桶换大了水还是会在漏只是漏满的时间变长了。我这次升级完内存后还是列了一个优化清单包括排查有没有大对象可以复用、改进缓存策略等计划在下个迭代慢慢做。第三关于工具除了前面提到的jstat、jmap我还推荐你们日常备着arthas这个开源诊断工具。它能在不重启服务的情况下在线查看类加载情况、方法调用耗时、内存占用分布等信息遇到线上问题排查时特别方便。另外监控平台一定要配上JVM相关的监控指标包括堆内存使用量、GC次数、GC耗时、FULL GC发生时间等配上之后你就不用总手动登录服务器看数据了报警机制也会帮你盯着省心很多。这次内存升级给我最大的感受是硬件资源的升级常常被视为“没什么技术含量”的操作但实际上它同样需要系统的分析、严谨的操作和完善的验证只要你认真对待每个环节就能把一个看似简单的扩容升级做得很扎实。
返回列表