找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1166

积分

1

好友

156

主题
发表于 前天 01:30 | 查看: 7| 回复: 0

在实际的Java应用性能优化工作中,例如进行全链路压测、调整GC参数或优化JVM内存分配时,一个关键问题是:单次HTTP请求究竟会占用多少堆内存?了解这个数据后,我们可以更精确地计算:在目标并发量下,系统需要申请多少堆内存。结合新生代堆大小,可以推算出一分钟内可能发生的GC次数,并判断该频率是否过高,从而进行针对性优化。

我们的目标是让单次RPC或HTTP请求申请的堆内存尽可能少,这样有助于减少因GC导致的系统停顿,提升系统整体性能,并支撑更高的单机并发量。本文将通过一次实验来探究这个问题。

实验思路与关键步骤

为了量化单次HTTP请求的内存开销,我们设计了以下实验:

  1. 创建SpringBoot应用:版本为2.5.4。
  2. 声明HTTP接口:新增一个Post接口,供压测工具调用。
  3. 配置压测计划:使用开源的JMeter工具,创建包含10个线程的线程组,每个线程循环执行2000次HTTP接口调用,总计20000次请求。
  4. 监控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压测计划

  1. 新增线程组:设置线程数为10,循环次数为2000。
    JMeter线程组配置
    JMeter循环次数配置

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

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

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

实验过程

  1. 启动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
  2. 预热与清理:在正式压测前,先通过curl调用几次/gc接口,手动触发GC,以清理JVM启动过程中加载的类、对象等,减少对实验结果的干扰。

    curl http://localhost:8080/gc
  3. 执行压测:启动JMeter测试计划,执行20000次HTTP调用。
    启动JMeter压测

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

实验结果分析

根据实验数据计算,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参数、优化代码和架构以降低单请求资源消耗至关重要。




上一篇:C++直方图统计实战:数组应用与GESP三级真题解析
下一篇:RP2040复现12通道sigrok逻辑分析仪完整指南:编译、配置与实战
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2025-12-17 18:48 , Processed in 0.127750 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表