ARTICLE DETAIL

资讯详情

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

SpringBoot容器内存调优:从OOM Killed到全链路排查

SpringBoot容器内存调优:从OOM Killed到全链路排查 先说一个我踩了很久才想明白的坑。之前把一个SpringBoot订单服务部署到HoRain云托管的Kubernetes集群上内存limit给了2G当时觉得这个量绰绰有余。结果跑了大概三周容器开始反复重启。我第一时间去翻应用日志一个OutOfMemoryError都没有登录节点看容器历史记录退出码是137。那会儿是真的懵业务日志什么都没报为什么Pod就没了后来才彻底搞清楚这就是容器层内存问题的典型形态。内存优化如果只调-Xmx、只改连接池大小其实根本不叫全链路。我理解的全链路至少要看三层JVM进程内部的各个内存区域、SpringBoot框架本身带来的开销、容器和云平台对进程的资源限制。这三层任何一层出问题表现出来的都是内存不够但根因可能完全不在堆上。这篇文章我就把在这套体系下调优、排查、落地的完整过程写出来给同样在做SpringBoot容器化部署的同学一个可以复现的参考。1. 一次没有报错的内存死亡OOM Killed的三层真相1.1 退出码137背后谁杀了你的Java进程那次的经过是这样的。告警是凌晨来的Pod进入了CrashLoopBackOff状态。我登上去一看这个Pod不是第一次重启了前一天就已经重启过几次因为夜间流量低没触发告警阈值到了晚高峰直接崩掉。Java进程日志里确实没有OutOfMemoryError这一点非常关键。很多刚做容器化运维的同学都会陷入一个误区只有日志里出现OutOfMemoryError才算内存问题。实际上在容器环境下JVM根本来不及抛异常。原因在cgroup。Kubernetes的resources.limits.memory会写入容器的cgroup内存限制当容器内所有进程的内存用量注意是RSS而不是只有Java堆超过这个限制时Linux内核的OOM Killer会直接给进程发送SIGKILL信号。进程被杀退出码是137对应1289。这个过程是内核干的Java虚拟机完全没有机会输出异常堆栈。用个生活化的比喻宿舍楼管在每层装了一个总电闸任何寝室瞬间功率超标整栋楼直接断电而不是某个寝室的熔丝先烧断、还能让你看到是哪个电器出的事。你只能根据总闸跳了这个结果去反推。所以排查的第一步不是看日志而是先把JVM进程内存和容器内存限制这两本账分开算。怎么算我放到第四章细讲这里先记住这个结论容器环境里进程被内核杀掉远比JVM自己抛OOM要常见得多。1.2 为什么这类问题总要等到三周后才暴露那次故障还有一个很典型的特征项目上线初期一点问题没有跑了一两周之后才逐渐显现。这也是内存问题最迷惑人的地方。大部分内存故障都不是一次性分配超了而是缓慢积累。比如某个定时任务每次漏掉一小块对象引用某张缓存表只往里写、却没有过期策略某个低频接口每次返回的数据里带着一堆不需要的字段。这些东西单个看都很小几KB但每天以固定频率增长经过两到三周老年代就被悄悄填满了。等老年代开始频繁触发GC又因为引用没有真正断开而回收不掉内存就像温水煮青蛙一样缓慢爬升直到触顶。这次触顶撞上的不是堆的Xmx而是容器内存limit于是进程被kill。这个现象反过来也解释了为什么要做常态化基线管理内存优化不是上线时调一次参数就完事你得持续盯着它的变化趋势。我在第六章会给出具体的监控指标和阈值。1.3 全链路的三层内存地图既然叫全链路先把链路画清楚。我通常把SpringBoot服务的内存分成三层去看第一层JVM进程内部。包括堆、元空间、直接内存、线程栈、代码缓存。这一层通过JVM参数控制也是大多数调优文章的主角。第二层SpringBoot应用自身。自动配置类、连接池、线程池、缓存组件、日志组件它们不会直接体现在JVM参数里但会在运行时实实在在吃掉内存。这一层最容易出配置了JVM参数但内存还是暴涨的问题。第三层容器和云平台。Pod的requests和limits、节点可用内存、云监控告警。这一层决定了应用能看到多少内存也决定JVM被杀还是存活。三层之间相互影响。你在第二层把连接池开得太大第一层的堆外内存就会涨你在第三层把limit设得比JVM预期低第一层再合理也会被杀。所以下面我会按这三层逐一展开每部分都给出可以直接抄的参数和配置。2. JVM内存分区实战堆、元空间和直接内存怎么分配才合理2.1 堆内存-Xms和-Xmx为什么建议设同一个值堆是SpringBoot对象的主要栖身之所绝大多数调优都从这里开始。最常见的错误写法是只设-Xmx不设-Xms。这种情况下JVM启动时堆只有初始大小随着业务量增长再逐步扩容。扩容意味着需要向操作系统申请内存、重新分配对象这个过程会带来额外的延迟在高并发下还可能触发一次比较长的停顿。我的做法很简单-Xms和-Xmx直接设成同一个值。比如容器plan留了4G给JVM那就写成java -Xms2g -Xmx2g -jar app.jar这样JVM启动时一次性把2G堆内存拿足运行过程中不需要再做堆伸缩GC行为更可预测。代价是启动时占用的内存稍微多一点但对容器化环境来说这点成本完全可以接受。那堆到底设多大我习惯按容器内存limit的50%到70%来预估。为什么不是100%后面元空间和直接内存的章节会说明。假如Pod limit是4G堆设2G到2.5G是比较稳的区间。另外顺带提一下新生代。SpringBoot应用的特点是短命对象极多绝大多数对象在新生代就被回收。JVM默认的新生代比例NewRatio2即新生代占堆的1/3对常见Web应用是合理的除非你在压测里明显看到Minor GC频率异常高否则不用手动去调-XX:NewRatio、-Xmn这些参数。调多了反而容易出现Survivor区溢出导致对象提前晋升到老年代。2.2 元空间被CGLIB和反射撑起来的类区元空间这块特别容易被忽略我见过好几个服务堆内存设置得很健康结果在元空间上栽了跟头。SpringBoot应用天生是类加载大户。自动配置、AOP的CGLIB动态代理、第三方库里的反射调用都会在运行期生成大量类。每个类的元数据要占元空间如果项目里还用了Groovy脚本、动态编译或者大量的动态代理元空间涨起来非常快。更要命的是JVM的-XX:MaxMetaspaceSize默认是无限大的也就是说元空间只受操作系统物理内存限制。在物理机上跑可能问题不大但在容器里元空间和堆共用同一个内存上限元空间涨到一定程度就把堆的可用空间挤没了最终触发OOM Killed。我的建议是显式设置上限一般给512m就够绝大多数SpringBoot服务用了除非你的项目里动态类特别多。配置示例-XX:MaxMetaspaceSize512m想观察元空间的使用情况可以用jstatjstat -gcmetacapacity pid重点关注MC列和MCC列。MC是已用元空间MCC是当前元空间的上限也就是JVM会自动扩容到的目标值。如果MC一直稳定在300m以下512m的上限就是安全的。2.3 直接内存Netty和NIO的隐形消耗直接内存是另一个盲区中的盲区。SpringBoot生态里Netty出现得比你想的频繁内置的WebFlux容器用Netty很多Redis客户端、HTTP客户端底层也用Netty。Netty会通过java.nio的DirectByteBuffer在堆外分配内存这部分开销不归堆管、不归元空间管走的是-XX:MaxDirectMemorySize。这里有个很容易踩的坑-XX:MaxDirectMemorySize默认值和堆大小一样。如果你-Xmx设了2G那就意味着直接内存理论上也能到2G。如果在容器4G limit下堆2G加直接内存2G再加元空间一旦实际使用逼近上限直接内存还没触顶容器已经在cgroup层把进程杀了。而且直接内存排查比堆困难得多因为它不参与堆的GC统计jstat看不到要用jcmd或者Arthas才能看到部分信息。所以我的策略是主动限制-XX:MaxDirectMemorySize256m对于绝大多数SpringBoot服务256m的堆外直接内存足够日常NIO流量使用。如果你确认自己的服务大量做文件流、音视频流处理可以放宽到512m但要同步把容器limit留足余量。2.4 垃圾回收器选型与一组可复用的参数垃圾回收器本身不是内存参数但它直接影响内存的利用效率。JDK8默认的Parallel GC在吞吐量上很好但面对长时间运行的SpringBoot服务我建议在容器里显式启用G1-XX:UseG1GC -XX:MaxGCPauseMillis100G1的优势是能设定暂停时间目标并且把堆分成多个Region在内存动态增长和碎片化控制上比Parallel GC更稳。JDK11及以上版本默认就是G1如果你是JDK8建议显式加上。一个小建议MaxGCPauseMillis不要设得太低比如设成20ms。G1为了满足这个目标会频繁做垃圾回收反而浪费CPU、降低吞吐量让内存水位不稳定。100ms是一个相对均衡的起点。我经常给团队发的一套JVM基线参数长这样可以直接复制-Xms2g -Xmx2g -XX:MaxMetaspaceSize512m -XX:MaxDirectMemorySize256m -XX:UseG1GC -XX:MaxGCPauseMillis100 -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/heapdump.hprof -XX:ExitOnOutOfMemoryError最后两行额外说一下。HeapDumpOnOutOfMemoryError和HeapDumpPath是让JVM在堆OOM时自动导出堆快照这对后面排查泄漏至关重要。ExitOnOutOfMemoryError则是让JVM在堆OOM时直接退出交给容器重新拉起避免一个半死不活的进程继续对外提供超时请求。参数对比可以看这个表内存区域默认情况风险建议值堆Heap容器内存的25%左右堆太小导致频繁GC容器limit的50%-70%XmsXmx元空间Metaspace无上限挤占堆和容器内存512m直接内存DirectMemory等于堆大小容器OOMKilled隐蔽元凶256m-512m代码缓存CodeCache240m左右一般无碍观察即可保持默认3. SpringBoot应用层的账本自动配置、连接池、线程池与缓存3.1 自动配置类的浪费与排除SpringBoot的自动配置是双刃剑。引入一个starter可能就会触发几十个AutoConfiguration类的加载。虽然这些类在前台对象里占的内存不大但动态生成的代理类、缓存元数据、BeanDefinition对象加在一起元空间和堆外都会跟着涨启动时间也会拉长。如果你是个严格的优化派可以花点时间做一次瘦身。打开Spring Boot的启动日志里面会列出所有ConditionEvaluationReport也就是自动配置的匹配结果。凡是标记为negative的配置类都是因为条件不满足而没有加载的这些不用管。但标记为positive且你明确知道自己用不到的就可以排除掉。比如我有个项目引入了spring-boot-starter-activemq但实际只用了MQ的API没用到JMS监听后来发现一堆JMS相关自动配置都被激活了纯粹浪费内存。最后在配置里做了排除spring: autoconfigure: exclude: - org.springframework.boot.autoconfigure.jms.JmsAutoConfiguration - org.springframework.boot.autoconfigure.jms.activemq.ActiveMQAutoConfiguration排除之后启动速度快了一截元空间涨幅也小了一些。不过说实话这一项的优化是锦上添花级别真正的大头在下面几个部分。3.2 HikariCP连接池连接数不是越多越稳HikariCP是SpringBoot默认的数据库连接池性能很好但它对内存的影响比大部分人想的要大。每个数据库连接底层都要维护socket缓冲区、协议解析对象、预处理语句缓存一个连接吃饱了撑得慌可以占到几MB内存。很多人有个朴素的错误认知连接数开大一点数据库访问就更快。实际上当连接池连接数超过数据库的最大并发处理能力后多余的连接只是排队白白占着内存。我给过一个很朴素的公式连接数 应用需要同时执行的数据库请求数 少量余量。对于绝大多数带Web接口的SpringBoot应用10到20个连接完全够用。HikariCP的默认配置其实已经比较合理默认maximumPoolSize10但有一种情况要注意如果你用默认配置然后把连接池的useshort等参数乱调或者把maximumPoolSize调成200那一池子连接就能吃掉几百MB内存。我在生产环境常用的一组配置spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 20 idle-timeout: 60000 max-lifetime: 1800000minimum-idle和maximum-pool-size设成一样目的是让连接池启动时就备足连接避免突发流量时动态创连接。如果你的数据库连接数本身就紧张可以适当降但不要为了省内存把minimum-idle降到1那样流量一起来连接池会疯狂创建销毁连接GC压力反而更大。3.3 Tomcat线程与异步线程池线程数翻倍内存跟着翻倍SpringBoot内置Tomcat的默认maxThreads是200每个请求线程都会有自己的线程栈和相关的IO缓冲线程数一旦真的冲到200对内存的压力是很可观的。更现实的问题是很多服务的QPS根本用不到200线程却按默认值白白准备这么多。我一般会根据压测结果调整Tomcat参数。比如一个管理后台服务压测最大并发只有60左右我就会把Tomcat线程压到100server: tomcat: threads: max: 100 max-connections: 2000 accept-count: 200max-connections是TCP连接数上限accept-count是等待队列长度这两个影响的是连接层的内存缓冲。调整的原则很简单线程数匹配真实并发量而不是匹配想象。你可以在压测时通过Actuator的metrics看到Tomcat实际活跃线程数再决定要不要降。还有一个特别隐蔽的坑SpringBoot里Async默认使用的线程池。如果你不加任何配置直接写AsyncSpringBoot的异步执行器默认情况下每次调用都会新建线程不会复用。高并发下一旦任务耗时长线程数会无上限增长内存被线程栈一点点吃掉。解决方式是显式声明一个线程池Bean并用Async(xxxExecutor)指定Bean(taskExecutor) public Executor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(20); executor.setQueueCapacity(100); executor.setThreadNamePrefix(async-); return executor; }注意这里的核心参数核心线程数、最大线程数、队列容量三者要一起看。如果队列设成Integer.MAX_VALUE那最大线程数等于没设任务全堆在队列里内存照样涨。3.4 缓存设计Caffeine的尺寸与过期策略缓存是内存优化的重灾区因为它表面上看是提升性能实际上一不小心就变成内存黑洞。Spring Cache的默认行为如果只加EnableCaching和Cacheable注解SpringBoot默认使用一个无界ConcurrentHashMap做缓存没有容量上限、没有过期时间用多久就涨多久。解决办法很简单引入Caffeine并明确设置maximumSize和过期时间。spring: cache: type: caffeine cache-names: userCache,tokenCache caffeine: spec: maximumSize5000,expireAfterWrite10m这样userCache最多5000条每条写入10分钟后过期。我在另一个服务里排查过一个诡异的内存爬坡问题最后发现就是某个Cacheable的缓存条目一直在涨涨了两周老年代满了加上这个配置之后内存曲线瞬间平稳。另外检查一个容易漏的点手动往静态Map里塞数据。团队里有时候图省事直接用private static final Map做内存缓存只put不remove也没有任何淘汰逻辑。这类代码是静态集合泄漏的固定套路遇到一个清理一个。3.5 日志、文件上传与大数据导出日志看起来不起眼但异步日志队列和缓冲区在极端流量下能吃掉几十乃至上百MB内存。logback的AsyncAppender默认队列大小是256如果生产环境业务日志量很大队列里堆积的日志事件对象都是活对象GC都回收不了。实在要加深队列容量请同步观察内存大多数情况下256就够了队列溢出比内存溢出好处理得多。文件上传方面SpringBoot的MultipartFile有个内存阈值默认1MB。就是说小于1MB的文件直接放内存超过1MB才写临时文件。如果你为了提高性能把这个阈值调到比如10MB那就要做好10MB文件的多个并发请求同时占内存的心理准备。默认1MB已经很合理不用动。大数据导出则要注意Apache POI的API选择。导出Excel时用XSSFWorkbook会把整个工作簿都加载到内存导一个5万行的表就能吃掉几百MB堆。改成SXSSFWorkbook它默认只保留100行在内存里其余的直接刷到磁盘内存占用直接降到原来的零头SXSSFWorkbook workbook new SXSSFWorkbook();4. HoRain云容器环境的内存协同Pod限额、JVM感知和参数留白4.1 容器limit和JVM的双向盲区如果说前面两层还能靠JVM参数解决到了容器层就得多想一步了。HoRain云托管的Kubernetes集群里Pod的内存limit就是JVM的上限。这个limit规定了容器内所有进程总共能用的物理内存。这里有个历史坑早期的JDK版本完全不感知cgroup内存限制。JVM在容器里看到的是宿主机的全部内存然后按照宿主机内存的1/4来设默认堆大小。如果你宿主机32G容器limit只有2GJVM默认堆就是8G跑起来直接超限被杀。JDK 8u131开始引入容器感知支持8u191之后默认开启。现在大家用的JDK8新版本和JDK11、17基本都默认感知容器。但感知归感知有一件事还是要手动确认如果显式设置了-XmxJVM就不会再用容器内存百分比去自动算堆上限了。换句话说你设了-Xmx2g无论容器limit是几GJVM堆上限就是2G。这时候如果容器limit只有2G那元空间、直接内存、线程栈把剩余的几百MB一占进程大概率还是会被kill。这就是双向盲区JVM以为堆只占2G很安全容器却已经没有余额支撑其他部分了。4.2 给JVM之外的内存留多少余量我建议用一组经验公式来算。假设你想让堆最大为H那么Pod的limit建议limit ≈ (H MaxMetaspaceSize MaxDirectMemorySize 线程数 × 1MB 代码缓存) × 1.3线程数×1MB是给线程栈留的空间Tomcat默认连接数、我们自己建的线程池、ForkJoin池里的公共线程都要算进去。粗略估算一个200线程的Tomcat加业务线程池线程栈就要准备300MB以上。代码缓存加JIT编译器留100MB比较稳。拿前面那套参数来举例-Xmx2g、MaxMetaspaceSize512m、MaxDirectMemorySize256m、线程开销约400m不算余量就已经3.1G左右再乘1.3Pod limit取4G比较稳妥。如果limit给2G堆还占2G那基本必炸。HoRain云控制台里设置Pod的资源配额时我一直建议把requests和limits分开看。requests是调度用的保证Pod能被分配到有足够内存的节点limits是运行时上限真正限制内存的是它。一个常见的失误是只设requests不设limits这样Pod在节点上会无限使用内存把同节点的其他Pod拖垮。反过来只设limits不设requests调度器可能把Pod分配到一个内存不足的节点虽然有限额保护但会频繁被杀。4.3 Deployment资源配置与JVM参数协同的实操写法给你一个我在HoRain云上常用的Deployment片段resources: requests: memory: 2Gi cpu: 500m limits: memory: 4Gi cpu: 2对应的JVM参数java -Xms2g -Xmx2g \ -XX:MaxMetaspaceSize512m \ -XX:MaxDirectMemorySize256m \ -XX:UseG1GC \ -jar app.jar这样堆2G、元空间512m、直接内存256m都算上线程池和代码缓存整体实际占用应该在3G上下容器limit留了4G余量1G用于应对瞬时抖动。这个组合我跑过很多服务稳定性很高。有一个错误我见过好多回limit设置成1G但JVM参数里-Xmx却写了1500m。这种情况下JVM启动时可能要申请1.5G内存cgroup直接限制不让给进程启动失败或者运行中刚尝试扩容就被杀。容器limit和JVM参数必须是同一个预算体系不能各算各的。4.4 云环境观测别让告警只盯CPU很多团队的容器告警只盯CPU使用率内存告警等于没有。在HoRain云这类平台上排查时我建议至少配两个维度的内存看板。第一个维度是容器层的内存使用率。Pod内存使用量如果长期超过limit的70%就要去看JVM各区域的实际占用。第二个维度是JVM层通过Actuator的metrics把堆内存、非堆内存、GC次数暴露给Prometheus再在Grafana里画出趋势线。这两个维度对不上是很常见的容器内存高但JVM堆很低那问题基本出在堆外或者直接内存JVM堆高但容器内存不高反而说明堆设置偏大、limit留有冗余也可以接受。排查的时候还有一个实用技巧在Pod内直接看当前JVM的Native内存占用可以执行jcmd pid VM.native_memory summary不过这个需要JVM启动时加-XX:NativeMemoryTrackingsummary不然看不到。生产环境如果不想为这个多花开销也可以在出问题时临时重启Pod加上这个参数再观察一轮。5. 一次OOM根因排查完整复盘从jstat预警到MAT定位5.1 事前预警jstat是成本最低的侦察兵内存优化最怕的不是出问题而是等到出问题了才意识到。我个人的习惯是把所有SpringBoot服务接入一套定时采集jstat数据的脚本每天拉一次JVM的GC趋势。哪怕只是存到本地日志里出问题时也有据可查。常用的命令是jstat -gcutil pid 1000 10输出里最该盯的是FGC和FGCT。如果FGC列的数字在运行几天后持续增长而且FGCT也在涨说明老年代经常满了已经在做频繁的Full GC。这时候哪怕容器还没被杀也已经到了该处理的时候。另一个判断指标是Old区占用。正常情况下老年代使用率应该在一个区间内上下波动GC后明显下降。如果每次GC之后降不下去或者持续单边上升这就是内存泄漏最直接的信号不用等OOM再动手。5.2 堆转储自动dump和手动jmap双保险前面我推荐了-XX:HeapDumpOnOutOfMemoryError和HeapDumpPath参数这是自动触发stack。如果JVM还没到堆OOM只是容器被杀自动dump很可能没触发就需要手动dump。手动dump尽量找内存水位比较高的时刻做这样dump出来的堆更接近真实问题现场jmap -dump:live,formatb,file/data/logs/heap-20250101.hprof pid注意live参数它表示只导出存活对象dump会先触发一次Full GC可能让服务停顿一下。生产环境低峰期操作比较合适。如果服务已经濒死为了抓到现场也可以在压测环境里复现问题再dump。还有一种情况Pod被杀之后dump文件跟着Pod一起没了。所以生产环境我强烈建议把HeapDumpPath指向宿主机的持久化目录或者用PVC挂载别留在容器可写层。这条我用一个教训换来的很重要。5.3 MAT实战认准Dominator Tree拿到hprof文件之后用Eclipse MAT打开。很多人一上来就看Histogram但其实Dominator Tree和Leak Suspects报告更接近答案。流程是这样的打开MAT后先看Leak Suspects报告MAT会自动给出几个嫌疑对象通常准确率很高。进入Dominator Tree按Retained Heap排序最大的几个对象就是内存大头。对最大的对象右键-See incoming references看是谁一直持有它。我上次排查一个服务的完整过程是这样的Dominator Tree里第一名是一个java.util.concurrent.ConcurrentHashMapRetained Heap占了堆的60%。顺着引用链往上找发现是一个静态的成员变量Map里面按日期存了每天的订单快照只增不删。本质上是代码逻辑把计算用的中间结果存成了常驻内存对象日期一多老年代就满了。定位到这一行代码改成每次任务结束清空Map内存曲线立刻平了。5.4 四种常见泄漏代码模式的自查清单结合几次排查经验我把SpringBoot项目里最常见的泄漏代码模式整理成一个自查清单每次内存告警我都会对照一遍。模式一静态集合只增不减。最常见。喜欢用static Map/LIst存各种东西却又没有淘汰机制。注意排查那些看起来很小的集合量少时确实无所谓量一大就是定时炸弹。模式二ThreadLocal没清理。线程池里的线程是复用的如果你在请求里往ThreadLocal塞了对象却没有在finally里remove这个对象就会挂在长时间存活的线程上永远不会被GC。排查时重点看过滤器、拦截器里有没有ThreadLocal的操作。模式三IO和连接没关闭。早期的代码里InputStream、OutputStream、ResultSet容易漏关。现在SpringBoot默认配置会自动关大部分资源但是自定义的Socket、JSch连接、FTP连接这类还是要手动关。可以用try-with-resources重写一遍。模式四实例化过多的大对象。比如一个请求里new了一个几十MB的StringBuilder、一个大型List并发一起来内存瞬间打满。这类问题不是泄漏是容量超卖需要压测验证并发数上限。这个清单也适合做Code Review的检查项。把内存问题的代码模式前置到评审阶段比事后用MAT找根源划算得多。6. 把内存优化变成常态基线、发布清单和压测演练6.1 设定内存基线什么样的GC频率是健康的我偏好用两个数字定义一条服务的内存健康线Full GC频率和堆内存使用率。如果服务每天Full GC次数超过5次就已经是不健康状态。正常情况下一个运行中的SpringBoot服务Full GC应该以周为单位才合理。触发Full GC频率这么高说明要么堆太小要么存在持续增长的老年代对象不管哪样都该查。堆内存使用率方面我关注的是GC之后堆占用是否回到基线。如果每次GC之后堆都能回到差不多的水位说明没有持续泄漏只是业务高峰期的正常起伏。如果GC之后水位一次比一次高那基本可以判定泄漏了。把这个逻辑落成监控指标比单纯看绝对值更有意义。6.2 发布前的内存检查清单每次SpringBoot服务发布前我会按下面这个清单过一遍把内存风险扼杀在发布前JVM参数是否与容器limit匹配堆元空间直接内存线程开销是否在limit的80%以内数据库连接池与Tomcat线程数是否与实际压测并发匹配是否引入了新的starter或第三方库是否会产生新的自动配置和动态类缓存组件是否设置了maximumSize和过期时间新增的Cacheable是否覆盖了配置有没有新增定时任务定时任务里是否持有大数据结构是否往静态集合里塞数据新增代码是否涉及IO流、HTTP客户端、数据库查询有没有关闭和释放这个清单不是形式主义每一条背后都是真实挂过的服务。6.3 压测与故障演练让内存问题提前暴雷内存问题最好通过压测提前暴露而不是等用户来报。我给团队的惯例是每次大版本上线前做一次持续压测时间至少4到8小时。为什么不是跑几分钟就完事因为短时间的压测只能发现内存分配不足这类急性问题发现不了缓慢积累这类慢性问题。持续几个小时的压测配合jstat每半小时取一次数据内存泄漏的趋势很容易看得出来。如果团队有条件和精力可以再做一次内存故障演练临时把容器limit下调30%或者用混沌工具注入内存压力看看服务的GC策略和日志告警能不能正常响应。这个动作不是为了看服务挂不挂而是验证监控和应急路径是否有效真出问题时不至于手忙脚乱。最后再多说一句我这几年的体会内存优化没有一劳永逸的银弹也没有一套参数走天下的配置。每一次调优决策都要算出容器limit、JVM分区、应用组件三者之间的平衡。把这个平衡变成日常发布的习惯比任何单次优化都管用。希望这篇全链路的实践整理能帮你少走一些弯路。
返回列表