关于我们

质量为本、客户为根、勇于拼搏、务实创新

< 返回新闻公共列表

2026 服务器租用跑 Java 应用内存怎么分:堆、元空间、线程栈与容器限制的实测取舍 + 避坑全攻略

来源:一万网络  发布时间:2026-11-12

2026 服务器租用跑 Java 应用内存怎么分:堆、元空间、线程栈与容器限制的实测取舍 + 避坑全攻略

给 Java 服务挑服务器时,被问得最多的一句话是「我这应用 4G 堆,买 8G 内存的机器够不够」。这句话本身就藏着问题的一半:提问的人默认内存只对一只 JVM 进程负责,也默认堆就是这台机器上的全部开销。现实里这两条假设都不成立。这台机器上大概率还跑着操作系统页缓存、日志采集组件、监控探针,可能还有同机部署的数据库或者 Nginx;而在这个 Java 进程里,堆只是若干内存区域中的一块。

本篇要给出的判断是:物理内存先扣除系统与其他进程的占用,再用剩下的可用内存倒推堆大小,堆一般不超过可用内存的 50% 到 70%,并且永远给堆外留一倍余量;核数则决定 GC 线程数与 JIT 编译线程数,不是核越多越好,也不是核越少越省。一万网络深耕 19 年(成立于 2007 年),在服务器租用这件事上见过太多「配置看着很富余、上线两周就被杀进程」的例子,问题几乎从不出现在堆本身,而出在堆外那部分预算被算漏了。

下面的内容只基于 JVM 的通用公开机制展开:除写明来源的规格外,所有数字都是按经验估算的量级,需要在你自己的业务峰值压测里复核。没有跑分,没有客户案例,也没有「照抄即可」的参数模板——恰恰相反,本篇想说明的是为什么照抄别人的参数模板大概率会出事。

只有一半的内存在堆里:一张进程内存地图

操作系统眼里只有一个进程。它给进程编了一段虚拟地址空间,其中真正占用的物理页叫常驻集,也就是 top、ps 里 RSS 那一列。RSS 才是租用场景里要花钱买的东西,因为账单上的内存就是它。而 -Xmx 写的只是这段空间里的其中一小块:Java 堆。

一个典型 Java 进程的常驻内存由这些部分拼起来:堆本体;元空间,存放类的元数据;代码缓存,存放 JIT 编译产物;每条线程各自的线程栈;通过 ByteBuffer.allocateDirect 或某些网络框架申请的直接内存;垃圾回收算法自己的卡表、记忆集、位图等数据结构;以及 JVM 自身、JDK 本地库与 JNI 调用者分配的内存。

「我明明只给了 4G 堆,RSS 却有 6G 多」的那两个 G 就来自这里。把这个差量搞清楚,选型问题就从「要不要加内存」变成「内存加给谁」——每一块的扩张原因不同,解法也不同,有的要加内存,有的只改一个参数,有的得改架构。混为一谈就会出现典型的错误循环:看到内存满了就加,加完继续涨,再继续加,直到某天翻台账发现堆从来没涨过。

堆的三个数字:初始、最大与提交量

堆本身也不止一个数字:-Xms 是初始大小,-Xmx 是允许涨到的上限,当前实际提交给操作系统的量介于两者之间。JVM 会按回收情况决定是否向系统要更多页,也可能把不用的页释放回去,是否释放取决于收集器与相关参数,不同版本行为有差异。

常见经验是把 -Xms 与 -Xmx 设成同一个值,理由不是性能神话,而是把不确定性提前到启动瞬间:两者一致时,JVM 在启动那一刻就一次性申请走全部堆空间,机器不够会立刻暴露;两者不一致时,进程可能运行数小时后才申请更多内存,正好撞上别的进程也在申请的时刻,问题随机且难以复现。代价是这部分内存基本不再退回给系统,所以「要不要设成一样」取决于这台机器给几件事用。

元空间装的不是对象,是类的元数据

元空间是 JDK 8 之后取代永久代的东西,位置从堆搬到了本地内存。它装的是类的元数据:类结构描述、方法字节码信息、常量池、注解、Klass 结构等。最常见的误解是以为它装的是对象,于是拿业务对象数量去估算它,方向从一开始就反了——它对应的是类的数量与复杂度。

让它持续上涨的主因是动态生成类:反射调用达到一定次数后 HotSpot 会生成膨胀的访问器类,ORM 与序列化框架会生成动态代理,模板引擎与脚本容器会把内容编译成类。更麻烦的是热部署,每次重新加载一批类就新造一批类加载器,旧加载器若还被某个静态引用挂着,它名下的元数据全部不能回收,即使在低峰期也退不下去。

容易被误读的一点是,元空间并非真的不受限,而是受本机剩余可用内存约束——上限不是你设的,是当时机器上还剩多少。在同机多实例的环境里,任何一个邻居都可能压低你的天花板。显式设置上限值通常是更稳的做法,它还能把元数据泄漏提前暴露为一次明确的报错,而不是等到整机内存耗尽。

代码缓存耗尽之后的现象很隐蔽

这一块常被忽略,绝对数值也不大,几十 MB 到一两百 MB 是常见量级。但它出问题的表现特别容易误诊:不是崩,而是性能随时间缓慢下滑。热点方法的即时编译产物放在这里,放满之后 HotSpot 会停止进一步编译,已经编译好的代码照跑,没编译过的只能在解释器里慢慢执行。

于是曲线是这样的:上线时响应很快,跑两三天热点编译完到达峰值,之后开始缓慢变差,重启立刻恢复。「重启就好」很容易被归因为内存泄漏或者玄学问题,其实看一眼编译是否被禁用就能分辨,日志里通常有明确的提示说明编译器已停用。大量使用动态代理、反射、脚本注入的应用更容易撞上,其需要编译的方法数量远超源码体积给人的印象。

线程栈:按虚拟内存算很吓人,按物理内存算没那么吓人

这一条是本篇最想纠正的偏差。线程栈在 Linux 上的常见默认量级是 1MB 每条线程,具体值取决于 JDK 版本与平台,需要按实际环境确认。粗算一下,500 条线程就是约 500MB 的虚拟地址空间占用。这个数字很显眼,很多人看到它就开始紧张,然后做出错误判断。

关键在于虚拟地址空间的预留与物理页的提交不是一回事。线程栈确实一次性保留了这么长的地址范围,但里面的物理页按实际使用深度逐步提交。多数业务线程的调用深度都很浅,真正触到的栈页也就几十 KB 量级。同一个 500 线程的进程,虚拟占用约 500MB,实际常驻可能只有几十 MB。

那么改小 -Xss 有没有意义?有,但要清楚它换来的是什么。改小主要省下地址空间层面的余量,在 32 位环境或地址空间受限时意义更大;代价是深递归与长调用链的空间变短,真的需要那么深的栈时会换成另一种完全不同的错误。它不解决每条线程在内核中的调度成本,也不解决几百条线程抢占几核带来的上下文切换开销。换句话说,线程栈在内存账单上是小事,在 CPU 账单上是大事。

直接内存与堆外内存谁在悄悄申请

直接内存指绕过 Java 堆、直接在本地内存里分配的缓冲,最常见的入口是 ByteBuffer.allocateDirect。它存在的原因很正当:网络与磁盘 IO 时,数据要在内核缓冲区与应用缓冲区之间搬运,如果应用缓冲区在堆里,垃圾回收移动对象这件事会让这段搬运变得非常别扭;放在堆外可以少一次拷贝,代价是这块内存不再由 GC 直接管理。

关于默认值有一种流传很广的说法:不指定时上限约等于堆的最大值。这个说法在某些 JDK 版本与实现上确实成立,但不应当作规划依据,因为版本与虚拟机实现之间存在差异。正确的态度是把默认值视为不受有效约束,然后在容量规划里主动给它留位置。

分配到堆外并不等于彻底脱离垃圾回收。直接缓冲自身对象很小,它在堆里有一个对应的引用对象,这个对象被回收时会触发对应本地内存的清理。问题出在时机上:引用对象要等 GC 判定死亡,而 GC 是否发生取决于堆的压力。于是出现一种典型现象——堆还很空,回收迟迟不触发,堆外的实际占用已经悄悄累积到很大。

GC 自己也要吃掉一块看不见的配额

这一项没有命令行开关可以直接封顶,因为它是收集器完成工作必须维护的数据结构:记录跨代引用的卡表与记忆集、标记阶段使用的位图、分区收集器为管理 Region 维护的表结构等。它与平时说的「GC 开销主要指 CPU 与停顿」是两个维度,一个是时间成本,一个是空间成本。

大致规律是与堆规模正相关,堆越大绝对值越大;而不同收集器之间差别很大,有的用额外空间换来极短停顿,有的用更少空间换来更长的回收时间。所以换一个收集器往往比调几个参数更能改变内存格局,换完之后必须同步重新计算可用内存。

还有一个常被算漏的角色是 JVM 自身的本地开销与第三方本地库:压缩、加密与 JNI 调用会在本地堆里分配 glibc 的管理区域,这些同样不在 -Xmx 里。单项数值都不大,但在一个已经算到临界位的配置里,它们加起来往往就是超出预期的那一部分。

报价单上的内存和 JVM 看到的内存不是同一回事

进入容器之前先讲一个更基础的区别。租用一台机器时,报价单上的「内存 16G」指这台机器可用的物理内存;而 JVM 在没有被明确告知的情况下,会用自己的启发式规则去推导该给堆多大。两者之间的桥梁是参数,这道桥没修好,两边就会各算各的。

HotSpot 的默认堆是套公式算出来的,输入是它读到的可用物理内存与 CPU 核数。直接跑在物理机上的年代这还算可靠,虚拟化普及后开始出现偏差:JVM 读到的可能是宿主机的内存总量,而不是分配给这台虚拟机的额度,于是它按一个自己并不拥有的数值去安排堆,结果就是超用。

容器把这个偏差放大到了无法忽视的程度。容器的资源约束由内核的控制组机制表达,没有适配这套机制的旧版 JVM 会完全无视它,继续按宿主机的维度做规划。于是出现租用场景里最让人困惑的一幕:容器规格写的是 2G,堆参数看着也不大,进程却被系统直接杀掉,日志里没有任何 Java 异常。

容器感知的几道版本分水岭

JDK 在 8u191 这个时间点引入了容器支持的开关,之后版本默认开启,JVM 开始读取控制组里记录的内存额度,用它替代宿主机内存去推导默认堆。再往后,JDK 10 及后续版本对这套机制的完整性做了改进,包括对不同版本控制组的识别与 CPU 方面的感知,行为与早期版本存在差异。

这里要强调的是版本差异需要实测。这套机制涉及 JDK 小版本实现、宿主机控制组版本、容器运行时的挂载方式等多个变量,纸上推导不如在目标镜像里跑一次可靠。验证成本极低:启动容器后打印虚拟机的堆与环境信息,看它汇报的最大堆是否符合你给的额度;如果汇报的是宿主机内存的四分之一,说明根本没读到限制。

还有一种中间态值得单独提:JVM 读到了限制,但推导结果与预期差距很大。启用容器支持之后常见的默认策略是取可用额度的四分之一左右作为堆上限,给容器 2G 时堆可能只有约 512M。现代框架型应用在这么小的堆里会频繁回收,表现为延迟抖动、CPU 使用率偏高,而堆占用率看起来并不高,这种「看着很闲却又很慢」很容易被误判成别的原因。

显式指定比依赖默认推导稳在哪里

由上述差异得出的结论可以概括成一句话:别依赖推导,写死。给容器里的 Java 进程显式指定堆规模要稳妥得多。写法有两类思路,一是绝对值,直接写明堆的最大值;二是相对值,用百分比参数标明堆能占容器额度的多少。

绝对值所见即所得,坏处是同一份镜像迁移到不同规格的容器时要改参数;相对值的坏处是它的基数依赖容器感知是否正常工作——JVM 没读到限制时会拿宿主机内存当基数,百分比就失去意义。两者不是替代关系,而是在确认容器感知生效的前提下使用相对值。堆的初始值同样建议对照前文的取舍自行确定。

内存区域 由什么参数控制 不设上限时会发生什么 典型估算方法 常见 OOM 报错 对应的扩容动作
Java 堆 -Xms 与 -Xmx;容器环境常配合 -XX:MaxRAMPercentage 使用 由 JVM 按读到的可用内存自行推导,可能远小于预期导致回收频繁,也可能远大于容器额度而被系统杀掉 取扣除系统与其他进程后可用内存的 50%–70%,按经验初始值建议与最大值一致 OutOfMemoryError: Java heap space 先分辨泄漏与不足:前者改应用代码,后者加大物理内存后同步上调 -Xmx
元空间 -XX:MetaspaceSize 与 -XX:MaxMetaspaceSize 上限由当时本机剩余内存决定,动态类生成过多时会一路涨到把进程撑爆 常见 256M–512M 起步,框架与反射密集的业务需观察一段时间取稳定值 OutOfMemoryError: Metaspace 收敛动态代理与反射的使用范围,排查类加载器泄漏,必要时上调上限并同步加内存
代码缓存 -XX:ReservedCodeCacheSize 放满后 HotSpot 停止进一步编译,之后新增热点只能解释执行,性能在运行一段时间后缓慢下滑 常规服务几十 MB 量级,大型框架与大量动态代理的场景按经验取到一两百 MB 多为日志告警而非崩溃:CodeCache is full,Compiler has been disabled 扩大预留并核对是否由框架动态类导致,通常伴随整体内存上调
线程栈 -Xss 或 -XX:ThreadStackSize,总占用等于单栈大小乘以线程数 线程池上限失控时吃光虚拟地址空间,或触及系统级线程数量限制而无法再建线程 单个线程栈常见默认在 1MB 量级(按 JDK 版本与平台确认),乘以最大线程数即为虚拟内存占用 OutOfMemoryError: unable to create new native thread 收紧线程池上限、适度下调 -Xss,或改为异步模型;加内存只能缓解不能根治
直接内存 -XX:MaxDirectMemorySize;应用侧的 allocateDirect 与各类网络缓冲池参数 默认值不保证有限——「约等于堆最大值」的说法因版本而异,不可作为规划依据,实际等于缺少约束 按连接数、缓冲池大小与单批处理量相乘估算,NIO 场景通常是主要变量 OutOfMemoryError: Direct buffer memory,或 Out of swap space 显式设定上限,检查释放路径与池化参数,内存确实不够时应扩容物理内存
GC 与 JVM 自身开销 由所选收集器决定,无统一封顶参数;-XX:ParallelGCThreads 影响线程维度 这部分无法硬性限制,堆越大绝对值越明显,跳过预估就会挤占其他区域的余量 按经验按堆的一定比例预留,不同收集器差异较大,需按实测的常驻内存与堆之差反推 Out of swap space,或进程被系统 OOM Killer 直接终止、无任何 Java 异常 换停顿特性更匹配的收集器,或同时加 CPU 与内存,只调 -Xmx 通常解决不了

四种 OOM 报错分别长什么样

分辨报错类型是本篇最有操作性的部分,因为四种报错指向四件完全不同的事。以下按常见的 HotSpot 报错文本描述,判断时抓关键词即可,具体措辞会随 JDK 版本略有差异。

第一种是堆空间不足,文本里会写出 Java heap space,含义是堆已经涨到上限,且这次分配前已经做过一次完整的回收仍没腾出足够空间。「已经回收过」这个前提很重要,它说明这是回收之后的失败,不是回收之前的暂时紧张。

第二种写明 Metaspace,关键词指向类加载,与业务对象多少基本无关。第三种是 unable to create new native thread,字面是不能新建本地线程,指向线程总数触到了某种限制,可能是地址空间也可能是系统限制。第四种是 Direct buffer memory 或 Out of swap space,指向堆外申请失败,或者整个进程的可用内存已被压到极限。

每种 OOM 对应的补救动作完全不同

第一种发生后的第一件事不是加内存,而是判断性质。如果业务量没变,而每次回收后的最低点在长时间尺度上一路抬高,那更接近引用被长期持有的泄漏,加内存只是把崩溃时间往后推;如果最低点一直是平稳的,说明只是峰值需求大,此时加内存与上调 -Xmx 同步进行。

第二种要往两个方向查。一是有没有滥生成类:框架配置不当会为每个实体生成访问器,脚本引擎可能每次执行都新建类加载器。二是有没有类加载器泄漏,热部署、插件机制、脚本执行容器是三大高发场景。处置方式是收敛动态类的生成来源,再给元数据区一个明确上限,把「凭运气」变成「到点报错」。

第三种常被错误地当成内存不够来处理。线程创建失败通常意味着线程池配置与机器规格不匹配,或者有什么逻辑在不受控地建线程。先把线程池上限写明确,再看单栈大小是否要按实际调用深度调整,同时核对系统层面的用户线程数限制与容器内的进程数额度。加内存对这个错误的帮助最小。

第四种要看是在哪一层申请的。网络框架自带的缓冲池就找它的池化参数并显式设上限;业务代码手动申请的就检查是否要等到 GC 才回收、能否显式清理。这里有个提醒:堆外申请失败常常同时意味着整机物理内存已经吃紧,此时不能只看堆监控,必须看进程常驻量与整机可用量的曲线。

核数是被 GC 线程数直接乘进去的

转到 CPU,因为堆大小和核数在 GC 这件事上是耦合的。并行垃圾回收不是单线程在工作,HotSpot 会依据可用处理器数量推导并行线程数与并发线程数。常见的推导规则是:处理器数量较少时并行线程数直接等于核数,超过某个阈值后按递增比例继续增长,并发线程数通常按并行线程数的若干分之一取整。

直接后果是核数越少的机器并行度越低,回收同样大小的堆需要的时间越长。一个 4G 的堆在 2 核上做一次完整回收的耗时,与在 8 核上相比差异明显。这解释了为什么不少「内存给得很足却依然卡」的 Java 服务,问题其实出在核数太少而不是内存不够。

反过来的方向也成立。核数很多而堆并不大时,被推导到较高值的回收线程会在回收阶段同时抢占 CPU,与业务线程争抢时间片,此时适当减少并行线程数反而可能更稳定。所以这两个数并不是越大越好,它们的合理值取决于这次回收要处理的堆有多大、这台机器有几核。

核太少和核太多都会拖慢 GC

合起来看,这是一个带中间最优区的判断:核太少时并行度不够,停顿被拉长;核太多而堆太小时,回收线程过度抢占。中间的最优解依赖实测,没有一个通用的数值可以照抄。

由此可以得到一个选型层面的经验:2 核通常被认为是 Java 服务的现实下限,低于此数连编译线程的安排都显得局促;4 核是一个比较舒服的起点,能容纳应用层线程、一定数量的 GC 线程与编译线程,还能给日志、监控采集留出余量。这是经验判断而非物理限制,具体要按业务的线程模型复核。单核性能影响的是单请求响应速度与编译速度,核数影响的是并发吞吐与回收并行度;把一个 4G 堆放在只有 1 核的机器上,无论内存多便宜都是坏主意。

JIT 编译线程也在抢同一块 CPU

除了 GC 线程,还有一类长期存在但不计入业务负载的 CPU 消费者:即时编译器线程。开启分层编译后通常有若干条 C1 线程负责快速编译,另有数量更少的 C2 线程负责深度优化,它们的数量同样由核数推导出来。

这解释了一个常见现象:服务刚启动时 CPU 占用很高、响应忽快忽慢,跑一段时间后反而稳定下来,那段时间正是编译线程在忙着把热点方法编译完,业务线程与编译线程在抢同一份 CPU。很多流量型应用的流量是突发进入的,刚扩容出来的实例恰好处于预热期,于是「一扩容就变慢」成了困扰大家很久的伪问题。

在核数极少的配置下,还可能出现 C2 编译线程拿不到可用配额、分层编译被削弱的情况,应用长期停留在只有 C1 低优化代码的状态。这种情形无法靠加内存缓解,只能增加可用核——这也是前面那句「核太少连编译线程都安排不开」的技术依据。

GC 日志是一个长期、稳定的磁盘写入源

GC 日志常被当成排障时才开的东西,开着的时候才想起它有磁盘写入量。如果打算在生产环境长期记录,就必须为它预留配额。写入量的大小取决于回收频率、日志详细程度与堆规模:回收越频繁、级别越详细,写量越大。

按经验估算,在高负载、日志级别较详细且回收频繁的服务上,长时间运行下累计达到每天 GB 级是可能的,这是量级估算而不是精确数字,实际值必须按你的日志配置实测。写量之所以会失控,往往是因为采用了逐事件级别的详细记录,再叠加上较高的回收频率。

应对方式有两个方向,可以同时使用。一是控制详细程度:长期记录保留关键信息即可,精细的逐事件日志只在排障窗口临时开启。二是必须配置轮换:按文件大小切割配合保留份数限制,保证日志占用的总磁盘有上限。这里有个版本差异要留意,较旧的 JDK 使用一组专门的日志轮换参数,JDK 9 之后统一到新的日志配置体系,写法不同但目的相同。

还有一个常被忽略的点:如果 GC 日志与业务日志、甚至与数据库的数据盘写在同一块设备上,日志的持续写入会与业务的 IO 需求互相干扰。把日志单独放一块盘,或者至少保证它与其他写入路径分开规划容量,性价比很高。

一次 Full GC 之外的坑:堆转储要提前留的空间

这一段单独拎出来讲,是因为它极容易被忽略,且一旦发生就不可逆。堆转储是把这一刻堆里的对象关系完整地写到文件里,供事后分析。它的大小接近堆的已用部分,而不是接近 -Xmx 的最大值。一个刚发生过 OOM 的进程,堆几乎是满的,那么转储文件的大小就接近 -Xmx 这个值本身。

这意味着什么?如果堆上限是 16G,一次自动转储写出来的文件可能就是十几 GB 的量级。存放路径所在的分区没有这么多余量,转储会失败或者只写一半,更糟的情况是把分区写满,导致同一块设备上的其他服务一起受影响。在故障现场丢失转储文件的代价很高,因为那一次的状态不可再现,重启之后现象可能就变了。

正确的做法是提前规划:明确给堆转储指定一个专门路径,并确保该路径所在分区的空间大于堆的上限值,再留出一定冗余;如果平时也需要手动导出诊断文件,还要为此追加余量。由于转储是偶发行为,把它放在数据盘通常比系统盘更合理;同时要注意这些文件可能包含业务数据,需按数据盘的等级来管清理与权限。

另外一个容易被忽略的现实:写这么大的文件本身会产生一次较长的停顿,并且是集中式的磁盘写入。因此不要把它安排在 IOPS 能力很弱的存储上,也不要让它与数据库实例共享同一分区。这里的取舍是,宁可牺牲一点转储速度换取隔离性,也不要让诊断行为拖垮正常的写入路径。

Swap 对 Java 服务的不友好是结构性的

关于交换空间的态度,对数据库与 Java 服务的通用建议是关闭或严格限制。理由与一般程序不同:回收垃圾需要遍历堆内的对象引用关系,这是一次覆盖面很广的内存访问。如果堆的一部分页面被换出到磁盘,遍历时就必须把它们逐一换回来,而每换回一页都是一次磁盘随机读,结果是停顿时间从毫秒级飙升到不可使用。

这也是为什么「有 swap 兜底更安全」这种直觉在 Java 场景里经常是反的。没有 swap 时,内存耗尽的结果是进程立刻被杀然后由守护机制拉起;有 swap 时,进程不死了,转而以近乎卡死的姿态持续提供服务,监控上延迟全线拉长却没有明确报错,从运维角度看后者的定位成本更高。

要不要一点都不留?通用建议之外也有需要保留少量交换区的场景:某些机器上有少量长期休眠但必需的低频进程,把它们的冷内存换出去反而能释放物理内存给热路径使用;也可以在内核层面只调整换出倾向而不彻底关闭。这些属于经过判断的例外,不适合作为默认策略,默认策略就是别指望 swap 来给 Java 服务补内存缺口。

16G 机器怎么倒推:一个完整算式

现在把前面的内容合并成一次实际的计算。假设租用一台物理内存 16G 的机器,上面只跑一个 Java 服务,另外有操作系统、日志采集与监控探针。

第一步扣除系统与其他进程:操作系统、页缓存与常规运维组件按经验预留约 2G,各家环境的实际值差别很大,需要用空载状态下的实测可用量来校准。之后可分配给 JVM 的量约为 14G。

第二步倒推堆:按「堆不超过可用内存 50% 到 70%」的规则,堆的上限落在 8G 到 10G 之间,剩下的 4G 到 6G 就是堆外的余量。

第三步把这 4G 到 6G 分配下去:元空间按实际需求给 256M 到 512M,框架密集或热部署场景可以更高;代码缓存给到一两百 MB 通常够用;单线程栈 1MB 量级乘以业务线程数得到线程栈的虚拟占用;直接内存按连接池与批量大小估算;GC 自身的数据结构按堆的一定比例预留。加总之后通常会发现,这点余量并非宽裕,而是刚好合适。

第四步验算:这些假设成立与否的唯一检验方式是压测。让服务跑在接近峰值的负载下持续一段时间,观察进程的实际常驻内存是否落在预期区间,观察最坏情况下单次回收的停顿是否满足延迟目标。以上所有数字都是按经验给出的估算,不是可以直接照搬的确定值。

8G 跑 Spring Boot 单体的账常常算不平

小规格机器的账要算得更仔细,因为余量小,任何一处估错都会立刻暴露。以一台 8G 机器跑 Spring Boot 单体、内嵌 Tomcat 的组合为例。

内存账:先给操作系统与其他组件留出起步 1G 左右,剩下约 7G;堆通常设 4G;元数据区 256M 起步;代码缓存按经验预留百余 MB 量级。再看线程栈:内嵌容器的默认最大工作线程数常常在 200 这个量级,按单个线程栈默认 1MB 计算,仅线程栈这一项的虚拟地址空间占用就约 200MB。虽然实际提交的物理页远少于这个数字,但只要同机还有别的消费者,这 8G 就已经偏紧了。

CPU 账:8G 这类规格通常搭配 2 核或 4 核。按前面的推导规则,2 核时可用的并行回收线程与编译线程都明显偏少,4G 的堆在 2 核上做一次完整回收的停顿能否接受,必须实测确认。这也是为什么「内存相同的情况下选 2 核还是 4 核」的答案,在 Java 场景里几乎总是偏向后者。

还有一个现实提醒:如果这台机器上还同时部署了数据库,就不能按「减去 1G 其他」来算。数据库的缓存池会主动申请一大块内存并长期持有,它的内存行为与 Java 完全不同。这种情况下最干净的做法是数据库单独一台,让 Java 服务独占剩下的资源。

微服务多实例:宁可多台小规格

把一个大应用拆成若干小实例之后,会出现一个很自然的选型问题:是把这些实例堆在一台大规格机器上,还是分散成多台小规格?从 Java 的内存特性看,答案通常偏向后者。

核心理由是大堆的成本是非线性的。其一,堆越大,一次彻底回收需要处理的对象越多,追求极短停顿的收集器虽然在努力压缩时间,但绝对处理量摆在那里,最坏情况下的停顿会随堆规模上升。其二,堆越大,万一要取一次转储,落盘的数据量与由此产生的停顿同步变大,前面讲的磁盘预留成本也被放大。其三,单机上多路 CPU 与内存亲和性的影响,会随着进程占用变大而更明显。

分散部署还带来三个附带好处。一是故障半径变小,一台机器出问题时损失的是它承载的那一部分流量。二是扩容粒度变细,可以按实际负载逐步增加实例数量,不必一步跳到更高规格。三是某个实例需要调整参数或重启观察时,影响面可控。代价是运维复杂度上升与可能的额外开销,这个取舍要按团队能力来定,但纯从资源效率看,多台小堆通常优于单台大堆。

一个实用的经验边界:把单实例的堆控制在不至于让最坏停顿不可接受的范围内,这通常远小于机器能装下的最大值。宁可堆小一点、实例多一点,也不要追求单机上的极致密度。

批处理与消息消费:内存要按批量乘并发估算

这类负载的内存模型与在线服务不同。它的输入是一批数据,内存占用取决于「一次处理多少」乘以「同时处理几个」,再乘以单个对象的平均体积。这个乘积如果在配置里允许任意扩张,那么无论机器多大都不够。

因此这类应用的第一步不是急着设堆,而是给并发度与批量大小设明确的上限。把每批拉取的条数、并发处理的任务数写成可调整的固定值,而不是让它随输入队列长度自适应增长。有了上限,内存占用的峰值才是一个可以计算出来的数字。

在这类场景里,直接内存往往是内存增长的主因而不是堆。原因是很多消息队列与网络客户端为了吞吐优先使用堆外的池化缓冲,批量越大这些缓冲区占用的本地内存越多,而这些数值同样不受 -Xmx 约束。所以针对这类负载,网络客户端的缓冲池参数与直接内存上限的优先级,应该排在 -Xmx 之前。

还有一个隐藏变量:批处理任务的单次执行往往不像在线请求那样均匀,峰值可能集中在窗口期的头几分钟。评估内存时要按峰值算而不是按平均算,并留出空间给瞬时产生的中间对象。这也是少数几个「初始值可以不等于最大值」的合理场景之一,进程在空闲期可以释放一部分堆给同时段的别的任务使用。

网关类应用:瓶颈通常不在堆而在连接

高并发网关、代理类应用采用的多是事件驱动的非阻塞模型,这类模型的线程数并不多,往往与 CPU 核数处于同一量级。所以按「几百条线程乘以 1MB」去估算它的线程栈,方向从一开始就错了。

它真正占用内存的是什么?连接状态。每一条长连接都要维护读写缓冲、协议解析状态与可能的 TLS 会话信息,占用取决于并发连接数乘以单连接的缓冲开销。如果每连接分配了较大的缓冲,十万级别的并发连接对应的内存占用会相当可观,而且这部分可能一部分在堆内、一部分在堆外。

除了内存,这类应用要核对的是系统与文件句柄层面的限制:单个进程可打开的文件描述符上限、用户级限制、内核层面的全局限制,以及端口范围、半连接队列长度等网络参数。把这些核对清楚通常比纠结 -Xmx 带来的收益大得多,网关类应用的典型症状就是内存没满、CPU 没跑满,偏偏建立不了新连接。

这类应用的堆其实可以设得比其他类型更小,因为它不持有大量业务对象。省下来的内存应该留给操作系统去做网络缓冲与页缓存,而不是强行分给堆。这与本篇主推的「给堆外留余量」是一致的,只是在这里,堆外的余量主要被连接消耗掉了。

磁盘除了 dump 还要考虑 IOPS

内存选型讨论到磁盘,主要是因为两件事:前面说的转储文件,以及日志的持续写入。这两件事都要求磁盘能扛住突发的大块顺序写,而这与运行状态下的稳态 IOPS 是两个不同的能力。

常见的一个坑是机器的内存规格选得很好、磁盘容量也很大,但磁盘的性能指标偏低。日常运行毫无问题,一旦触发一次自动转储,十几 GB 的文件写下去把写入带宽占满,同设备上的其他读写全部排队。结果是本来一次可恢复的单点故障,被放大为多个服务同时超时。

所以选型阶段就要明确三件事:转储文件落在哪个路径、该路径所在设备的写入能力如何、它与哪些服务共享 IO。无法做到物理隔离就至少保证容量预留;如果 IO 也不能保证,就要考虑关闭生产环境的自动转储、改为在维护窗口手动执行,这是明确的取舍——用诊断能力的损失换取故障时爆炸半径的收敛。

先算可用内存,再倒推堆大小

把本篇的选型动作压缩成可执行的顺序。第一步扣除:在没有任何业务进程运行的空载状态下去看这台机器的实际可用内存,把这个数字而不是标称内存作为起点。第二步倒推:用可用内存乘以 50% 到 70% 得到堆的上限,注意这是上限不是目标值。

第三步验算堆外需求:把元数据区、代码缓存、线程栈的乘积、直接内存与网络缓冲、GC 自身的数据结构逐项列出并给出数字,确认它们的总和不超过剩下的部分。第四步写明确:把上面所有的数字写成显式参数,不要依赖 JVM 的默认推导,尤其在容器环境里。

这个顺序的价值在于它会强迫你正视堆外。按「堆等于几 G」去挑机器时,堆外的数字从来不出现在讨论里;而按可用内存倒推时,堆外的每一项都必须被写成具体的数,否则等式不成立。这个必须写出具体数字的动作,本身就是大部分避坑的来源。

四种 OOM 分别对应四种不同的加机器方式

再强调一次四种报错对应的处置方向,因为这是把「加内存」这个动作做对的关键。

Java heap space 对应的主流动作是加内存并同步上调 -Xmx,前提是已经排除泄漏。Metaspace 对应的动作不是加内存,而是控制动态类的数量与排查类加载器泄漏,加内存只能延后问题爆发的时间点。unable to create new native thread 对应的动作是调整线程池与架构,加内存的收益最小。Direct buffer memory 与 Out of swap space 对应的动作是给堆外设显式上限并检查释放逻辑,必要时再扩容物理内存。

如果拿不准该做哪个动作,最有效的一句话是:先看报错原文里的关键词,再决定加什么。看都不看直接加内存,是四类错误里至少三类会复发的原因。

GC 日志与堆转储要提前占的磁盘

磁盘这块要提前而不是事后占好。GC 日志侧要做的是限定详细程度、配置按大小切割的轮换策略、明确它落在哪块设备上;给它多少配额要按你的日志级别实测决定,在高负载下每天累计到 GB 级是可能的量级。

堆转储侧要做的是给它指定一个专门路径,并保证该路径所在分区的剩余空间大于堆的上限值再加一定冗余。不要让它默认落在系统盘,不要让它与数据库的数据文件共享分区。这份预留平时完全是闲置的,它的意义在于保证真正需要诊断的那一次能成功——那一次的状态是不可重来的。

这台机器上还跑了别的服务时要怎么让

最后是共享机器的场景。如果一台机器上同时有 Java 服务、数据库、Web 服务器或者多个 Java 实例,那么「堆应该多大」这个问题就从参数题变成了配额题。

稳妥的处置顺序是:先按优先级给每个进程定一个明确的内存额度,而不是让它们自由竞争。数据库有自己明确的缓存参数,把它先扣掉;然后是 Web 服务器与其他中间件;剩下的才是 JVM 的可用量,再对这部分取 50% 到 70% 得到堆。注意这个比例是对留给 JVM 的那部分取的,不是对整机内存取的。

其次要考虑这些服务的峰值是否同期。如果 Java 服务的业务高峰在白天,而批处理或备份任务在凌晨,那么理论上可以错峰,此时把 Java 的初始值设为最大值就不太合适,会长时间锁住内存不放;反之如果几条业务的峰值完全重合,就要按叠加后的峰值来算,宁肯把每一项的额度压低一点,也不要指望剩下的那点余量。

如果条件允许,把数据库单独放出去通常是最划算的一步。数据库的内存管理模型与 Java 完全不同,它的缓存会长期稳定地占着一大块,而且它的 IO 特征也会跟 Java 的日志与转储写入打架。同机部署省下来的那点成本,往往会在第一次故障时连本带利还回去。

本篇能直接拿走的判断依据

第一条:给 Java 服务选机器时不要按「堆有多大」买内存,堆只是进程的一部分,实际常驻内存才是账单上的数字。第二条:先扣掉系统与其他进程,把剩余部分作为可用内存,堆按它的 50% 到 70% 倒推,剩下的留给元数据区、代码缓存、线程栈、直接内存与 GC 开销。

第三条:所有堆外区域的参数都要显式指定,不要依赖默认推导;尤其在容器里,先验证 JVM 是否真的读到了容器的限制,再决定用百分比还是绝对值。第四条:核数决定 GC 线程数与编译线程数,2 核是 Java 服务的现实下限,4 核是较舒服的起点,核太少会拉长停顿,核太多而堆太小时回收线程会与业务线程抢 CPU。

第五条:四种 OOM 报错对应四件不同的事,先看报错关键词再决定加什么,而不是一律加内存。第六条:GC 日志与堆转储要提前规划磁盘,转储路径的剩余空间要大于堆的上限值,日志要配轮换。第七条:数据库与 Java 服务通常建议关闭或严格限制交换空间,因为回收需要遍历堆,一旦被换出就会造成极长的停顿。

第八条也是最后一条:以上所有数字都是量级估算,真正的数字来自你的压测。把「在接近峰值的负载下跑一段时间,观察实际常驻内存与最坏停顿」这件事写进上线流程,比记住本篇任何一个具体数值都重要。


上一篇:2026 服务器租用部署 JumpServer 堡垒机怎么配:会话录像、并发会话与审计库三处的资源账 + 避坑全攻略

下一篇:2026 Gitea 自建仓库服务器租用选型手册:裸仓库目录与备份盘的容量避坑全攻略