在实际的Java应用性能优化工作中,例如进行全链路压测、调整GC参数或优化JVM内存分配时,一个关键问题是:单次HTTP请求究竟会占用多少堆内存?了解这个数据后,我们可以更精确地计算:在目标并发量下,系统需要申请多少堆内存。结合新生代堆大小,可以推算出一分钟内可能发生的GC次数,并判断该频率是否过高,从而进行针对性优化。
我们的目标是让单次RPC或HTTP请求申请的堆内存尽可能少,这样有助于减少因GC导致的系统停顿,提升系统整体性能,并支撑更高的单机并发量。本文将通过一次实验来探究这个问题。
实验思路与关键步骤
为了量化单次HTTP请求的内存开销,我们设计了以下实验:
- 创建SpringBoot应用:版本为2.5.4。
- 声明HTTP接口:新增一个Post接口,供压测工具调用。
- 配置压测计划:使用开源的JMeter工具,创建包含10个线程的线程组,每个线程循环执行2000次HTTP接口调用,总计20000次请求。
- 监控GC日志:启动SpringBoot应用时,开启详细的GC日志记录功能,重点观察压测期间新生代堆内存的增长量(绝大多数新对象都分配在新生代)。
实验的核心在于:通过JMeter调用20000次接口后,手动触发一次Full GC。通过分析GC前后的详细日志,即可计算出压测期间新生代堆内存的总增长量,进而求得单次请求的平均内存开销。
声明HTTP接口
我们创建了一个简单的TestController,包含两个接口:
create:一个Post接口,接收一个订单对象作为请求体,用于模拟业务请求。
gc:一个Get接口,调用System.gc(),用于在需要时手动触发垃圾回收。
@Slf4j
@RestController
public class TestController {
private AtomicLong count = new AtomicLong(0);
@ResponseBody
@RequestMapping(value = "create", method = RequestMethod.POST)
public String create(@RequestBody Order order) {
// 生产环境中可选择性开启日志,注意其对内存的影响
// log.warn("收到提单请求 cnt{}:{}", count.getAndIncrement(), order);
return "ok";
}
@ResponseBody
@RequestMapping(value = "gc", method = RequestMethod.GET)
public String gc() {
System.gc();
return "ok";
}
}
配置JMeter压测计划
-
新增线程组:设置线程数为10,循环次数为2000。


-
新建HTTP请求默认值:设置服务器IP和端口。

-
新建HTTP信息头管理器:由于请求体为JSON格式,需添加Content-Type: application/json头。

-
新建HTTP请求:指定具体的URL路径和JSON请求体。

实验过程
-
启动SpringBoot应用:使用以下JVM参数启动应用,指定堆内存4G(新生代2G),并输出详细GC日志到指定文件。
java -server -Xmx4g -Xms4g -XX:SurvivorRatio=8 -Xmn2g -XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=1g -XX:MaxDirectMemorySize=1g -XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCCause -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -XX:+UnlockDiagnosticVMOptions -XX:ParGCCardsPerStrideChunk=32768 -XX:+PrintCommandLineFlags -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:ParallelCMSThreads=6 -XX:+CMSClassUnloadingEnabled -XX:+UseCMSCompactAtFullCollection -XX:+CMSParallelInitialMarkEnabled -XX:+CMSParallelRemarkEnabled -XX:+CMSScavengeBeforeRemark -XX:+PrintHeapAtGC -XX:CMSFullGCsBeforeCompaction=1 -XX:CMSInitiatingOccupancyFraction=70 -XX:+UseCMSInitiatingOccupancyOnly -XX:+PrintReferenceGC -XX:+ParallelRefProcEnabled -XX:ReservedCodeCacheSize=256M -Xloggc:/Users/testUser/log/gc.log -jar target/activiti-0.0.1-SNAPSHOT.jar
-
预热与清理:在正式压测前,先通过curl调用几次/gc接口,手动触发GC,以清理JVM启动过程中加载的类、对象等,减少对实验结果的干扰。
curl http://localhost:8080/gc
-
执行压测:启动JMeter测试计划,执行20000次HTTP调用。

-
触发GC并解读日志:压测结束后,再次手动触发GC。关键点在于分析GC日志:GC之后,新生代Eden区的使用量会降为0(或极低值),而GC之前Eden区的使用量,就是这20000次HTTP调用所累积申请的内存总和。

实验结果分析
根据实验数据计算,SpringBoot在处理一次简单的HTTP Post请求时,即使请求体很小(约50个字符),平均每次调用仍会申请大约34KB的堆内存。
这个数字显著大于请求体本身的大小,说明大部分内存开销来源于SpringBoot框架内部处理请求链路上创建的各种对象(如封装对象、线程上下文对象等)。
请求体大小的影响:当我们将请求体中的detail字段内容增加到1200个字符时,单次请求的平均内存开销上升到约36KB。两次实验相差2KB,与增加的字符量基本匹配(考虑一定误差)。这表明在本次实验的路径下,SpringBoot内部没有对请求体进行多次拷贝。
日志打印的巨大影响:当我们取消create接口中的日志注释(打印整个order对象)后,单次请求的内存开销激增至约56KB,增加了20KB之多。而如果仅打印基础字段(移除detail),内存开销则回落至35.7KB。
- 结论:日志输出,特别是打印大对象或长字符串,会显著增加单次请求的内存开销,从而可能引发更频繁的GC,影响系统性能。在生产环境中,必须严格控制单条日志的体积和输出级别。

对线上环境的思考
本次实验得出“单次请求消耗约34KB内存”是一个在简化场景下的理想值。在真实的复杂业务系统中,单次RPC/HTTP请求的内存消耗通常在0.5MB到1MB甚至更高。
原因在于,复杂的业务逻辑、多层服务调用、数据库查询、缓存操作、以及业务日志等,都会在调用链上创建大量临时对象,累积占用可观的内存。
这个数据也解释了为何在线上经常观察到:即使新生代Eden区配置了5GB,Young GC仍然很频繁(如每分钟1-2次)。简单估算一下:如果单次请求消耗0.5MB内存,当QPS达到500时,每分钟需要分配的内存高达 0.5MB * 500 * 60 ≈ 15GB。这意味着至少需要3次Young GC才能回收这些新生成的对象。因此,通过此类实测理解内存开销,对于合理设置JVM参数、优化代码和架构以降低单请求资源消耗至关重要。
|