news 2026/9/25 3:27:36

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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,对应128+9。这个过程是内核干的,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默认的新生代比例(NewRatio=2,即新生代占堆的1/3)对常见Web应用是合理的,除非你在压测里明显看到Minor GC频率异常高,否则不用手动去调-XX:NewRatio、-Xmn这些参数。调多了反而容易出现Survivor区溢出,导致对象提前晋升到老年代。

2.2 元空间:被CGLIB和反射撑起来的类区

元空间这块特别容易被忽略,我见过好几个服务堆内存设置得很健康,结果在元空间上栽了跟头。

SpringBoot应用天生是"类加载大户"。自动配置、AOP的CGLIB动态代理、第三方库里的反射调用,都会在运行期生成大量类。每个类的元数据要占元空间,如果项目里还用了Groovy脚本、动态编译或者大量的动态代理,元空间涨起来非常快。

更要命的是,JVM的-XX:MaxMetaspaceSize默认是无限大的,也就是说元空间只受操作系统物理内存限制。在物理机上跑可能问题不大,但在容器里,元空间和堆共用同一个内存上限,元空间涨到一定程度,就把堆的可用空间挤没了,最终触发OOM Killed。

我的建议是显式设置上限,一般给512m就够绝大多数SpringBoot服务用了,除非你的项目里动态类特别多。配置示例:

-XX:MaxMetaspaceSize=512m

想观察元空间的使用情况,可以用jstat:

jstat -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:MaxDirectMemorySize=256m

对于绝大多数SpringBoot服务,256m的堆外直接内存足够日常NIO流量使用。如果你确认自己的服务大量做文件流、音视频流处理,可以放宽到512m,但要同步把容器limit留足余量。

2.4 垃圾回收器选型与一组可复用的参数

垃圾回收器本身不是内存参数,但它直接影响内存的利用效率。JDK8默认的Parallel GC在吞吐量上很好,但面对长时间运行的SpringBoot服务,我建议在容器里显式启用G1:

-XX:+UseG1GC -XX:MaxGCPauseMillis=100

G1的优势是能设定暂停时间目标,并且把堆分成多个Region,在内存动态增长和碎片化控制上比Parallel GC更稳。JDK11及以上版本默认就是G1,如果你是JDK8,建议显式加上。

一个小建议:MaxGCPauseMillis不要设得太低,比如设成20ms。G1为了满足这个目标会频繁做垃圾回收,反而浪费CPU、降低吞吐量,让内存水位不稳定。100ms是一个相对均衡的起点。

我经常给团队发的一套JVM基线参数长这样,可以直接复制:

-Xms2g -Xmx2g -XX:MaxMetaspaceSize=512m -XX:MaxDirectMemorySize=256m -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/heapdump.hprof -XX:+ExitOnOutOfMemoryError

最后两行额外说一下。HeapDumpOnOutOfMemoryError和HeapDumpPath是让JVM在堆OOM时自动导出堆快照,这对后面排查泄漏至关重要。ExitOnOutOfMemoryError则是让JVM在堆OOM时直接退出,交给容器重新拉起,避免一个半死不活的进程继续对外提供超时请求。

参数对比可以看这个表:

内存区域默认情况风险建议值
堆(Heap)容器内存的25%左右堆太小导致频繁GC容器limit的50%-70%,Xms=Xmx
元空间(Metaspace)无上限挤占堆和容器内存512m
直接内存(DirectMemory)等于堆大小容器OOMKilled隐蔽元凶256m-512m
代码缓存(CodeCache)240m左右一般无碍,观察即可保持默认

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的默认配置其实已经比较合理(默认maximumPoolSize=10),但有一种情况要注意:如果你用默认配置,然后把连接池的useshort等参数乱调,或者把maximumPoolSize调成200,那一池子连接就能吃掉几百MB内存。

我在生产环境常用的一组配置:

spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 20 idle-timeout: 60000 max-lifetime: 1800000

minimum-idle和maximum-pool-size设成一样,目的是让连接池启动时就备足连接,避免突发流量时动态创连接。如果你的数据库连接数本身就紧张,可以适当降,但不要为了"省内存"把minimum-idle降到1,那样流量一起来连接池会疯狂创建销毁连接,GC压力反而更大。

3.3 Tomcat线程与异步线程池:线程数翻倍,内存跟着翻倍

SpringBoot内置Tomcat的默认maxThreads是200,每个请求线程都会有自己的线程栈和相关的IO缓冲,线程数一旦真的冲到200,对内存的压力是很可观的。更现实的问题是:很多服务的QPS根本用不到200线程,却按默认值白白准备这么多。

我一般会根据压测结果调整Tomcat参数。比如一个管理后台服务,压测最大并发只有60左右,我就会把Tomcat线程压到100:

server: tomcat: threads: max: 100 max-connections: 2000 accept-count: 200

max-connections是TCP连接数上限,accept-count是等待队列长度,这两个影响的是连接层的内存缓冲。调整的原则很简单:线程数匹配真实并发量,而不是匹配想象。你可以在压测时通过Actuator的metrics看到Tomcat实际活跃线程数,再决定要不要降。

还有一个特别隐蔽的坑:SpringBoot里@Async默认使用的线程池。如果你不加任何配置,直接写@Async,SpringBoot的异步执行器默认情况下每次调用都会新建线程,不会复用。高并发下一旦任务耗时长,线程数会无上限增长,内存被线程栈一点点吃掉。

解决方式是显式声明一个线程池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: maximumSize=5000,expireAfterWrite=10m

这样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只有2G,JVM默认堆就是8G,跑起来直接超限被杀。

JDK 8u131开始引入容器感知支持,8u191之后默认开启。现在大家用的JDK8新版本和JDK11、17基本都默认感知容器。但感知归感知,有一件事还是要手动确认:如果显式设置了-Xmx,JVM就不会再用容器内存百分比去自动算堆上限了。

换句话说,你设了-Xmx2g,无论容器limit是几G,JVM堆上限就是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、MaxMetaspaceSize=512m、MaxDirectMemorySize=256m、线程开销约400m,不算余量就已经3.1G左右,再乘1.3,Pod 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:MaxMetaspaceSize=512m \ -XX:MaxDirectMemorySize=256m \ -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:NativeMemoryTracking=summary,不然看不到。生产环境如果不想为这个多花开销,也可以在出问题时临时重启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,format=b,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报告更接近答案。

流程是这样的:

  1. 打开MAT后先看Leak Suspects报告,MAT会自动给出几个嫌疑对象,通常准确率很高。
  2. 进入Dominator Tree,按Retained Heap排序,最大的几个对象就是内存大头。
  3. 对最大的对象右键-See incoming references,看是谁一直持有它。

我上次排查一个服务的完整过程是这样的:Dominator Tree里第一名是一个java.util.concurrent.ConcurrentHashMap,Retained 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分区、应用组件三者之间的平衡。把这个平衡变成日常发布的习惯,比任何单次优化都管用。希望这篇全链路的实践整理能帮你少走一些弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/25 3:24:29

华强北手表参数造假揭秘:用ADB验出真实内存与存储

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华