跳到主内容

从每秒 800MB 到 120MB:对象池化降低 GC 压力实战 ​

问题背景:高吞吐服务为什么被 GC 拖住 ​

在高并发网关、RPC 服务、消息代理或日志采集系统中,业务逻辑本身可能并不复杂,但每次请求都会创建连接、字节缓冲区、编解码对象等短生命周期对象。瞬时分配速度一旦超过 JVM 年轻代回收能力,就会出现 Young GC 频繁、晋升过快、Mixed GC 或 Full GC 抖动等问题。

典型现象是:接口 P99 延迟周期性抬高,CPU 中 GC 线程占比超过 15%,GC 日志显示大量 byte[]、ByteBuffer、Socket 相关对象存活;压测吞吐提升后,延迟并没有线性改善,反而出现毛刺。此时单纯增加堆大小往往只能推迟问题,真正有效的手段是识别热点对象,并通过对象池化减少重复分配。

对象池化的核心价值不是“不创建对象”,而是把高频、昂贵、生命周期清晰的对象复用起来。连接池复用的是 TCP 连接、认证上下文和内核资源;ByteBuf 池化复用的是直接内存或堆内存块,减少 ByteBuffer 分配和本地内存释放成本。

请求线程对象池连接或 ByteBuf复用资源借出与归还降低分配减少 GC监控指标:借出耗时、等待数、泄漏数池大小、活跃数、命中率

方案对比:连接池、ThreadLocal 与池化 ByteBuf ​

常见复用方案有三类。

第一类是通用对象池,例如 Apache Commons Pool,适合连接、复杂客户端等创建成本高的对象。优点是最大空闲数、驱逐策略、阻塞等待等能力完整;缺点是每次借还都有并发控制开销,不适合极小对象。

第二类是 ThreadLocal,适合编解码工具、SimpleDateFormat 这类非线程安全且线程内反复使用的对象。它几乎没有竞争,但线程池扩缩容时容易残留过期对象,且必须在线程执行结束后清理,否则会产生上下文污染和内存泄漏。

第三类是 Netty 的池化 ByteBuf。Netty 使用 jemalloc 风格的 Arena、Chunk、Page 和 Subpage 管理内存,配合线程本地缓存减少锁竞争。它特别适合网络 I/O 中的字节缓冲区复用:堆内存减少 byte[] 分配,直接内存减少一次 JVM 堆到内核缓冲区的数据拷贝。

方案适合对象主要收益主要风险
连接池数据库、Redis、HTTP 客户端减少握手、认证和连接创建成本池太小排队,太大压垮下游
ThreadLocal格式化器、轻量工具对象无竞争、访问快线程复用导致脏数据或泄漏
PooledByteBuf网络读写缓冲区降低字节数组和直接内存分配引用计数错误、内存泄漏

实操步骤一:用连接池控制昂贵资源 ​

下面示例使用 HikariCP 演示连接池的关键配置。重点不是把池调大,而是根据下游容量和请求耗时计算合理并发数。

java
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

public class ConnectionPoolDemo {
    public static void main(String[] args) throws Exception {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai");
        config.setUsername("root");
        config.setPassword("password");

        // 核心池大小应参考:峰值 QPS × 平均 SQL 耗时,而不是盲目设置为几百
        config.setMaximumPoolSize(20);
        config.setMinimumIdle(5);

        // 获取连接必须有超时,避免流量高峰时请求线程无限挂死
        config.setConnectionTimeout(1000);
        config.setIdleTimeout(60_000);
        config.setMaxLifetime(30 * 60_000);

        // 借出连接时做轻量校验,避免拿到被数据库或中间件关闭的死连接
        config.setConnectionTestQuery("SELECT 1");

        // 开启泄漏检测阈值,单位毫秒;业务连接占用超过该时间会打印堆栈
        config.setLeakDetectionThreshold(5_000);

        try (HikariDataSource dataSource = new HikariDataSource(config);
             Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement("SELECT id, name FROM users WHERE id = ?")) {
            ps.setLong(1, 1L);
            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    System.out.println(rs.getLong("id") + ":" + rs.getString("name"));
                }
            }
        }
    }
}

连接池最容易被误判为“本地内存问题”,实际上它更像并发闸门。池大小超过下游承载能力后,请求虽然能拿到本地连接,却会在数据库侧排队,最终造成慢查询、锁等待和级联超时。

实操步骤二:开启并正确使用池化 ByteBuf ​

Netty 4 默认推荐池化直接内存,但生产环境建议显式配置,避免不同版本默认值差异。服务端启动时可通过系统属性设置分配器,再在 Channel 中统一使用。

java
import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public class PooledByteBufServer {
    private static final int PORT = 8080;

    public static void main(String[] args) throws InterruptedException {
        // 显式开启池化直接内存;堆内场景可改为 unpooled 或 pooled heap
        System.setProperty("io.netty.allocator.type", "pooled");
        System.setProperty("io.netty.noPreferDirect", "false");

        // 测试环境建议开启 SIMPLE 或 PARANOID,生产可使用 DISABLED 或 SIMPLE
        System.setProperty("io.netty.leakDetection.level", "SIMPLE");

        EventLoopGroup boss = new NioEventLoopGroup(1);
        EventLoopGroup worker = new NioEventLoopGroup();

        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(boss, worker)
             .channel(NioServerSocketChannel.class)
             .childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
             .childHandler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 protected void initChannel(SocketChannel ch) {
                     ch.pipeline().addLast(new EchoHandler());
                 }
             });
            b.bind(PORT).sync().channel().closeFuture().sync();
        } finally {
            boss.shutdownGracefully();
            worker.shutdownGracefully();
        }
    }

    static class EchoHandler extends ChannelInboundHandlerAdapter {
        @Override
        public void channelRead(ChannelHandlerContext ctx, Object msg) {
            ByteBuf in = (ByteBuf) msg;
            try {
                // 只读场景直接 writeBytes;不要先复制成新的 byte[]
                ctx.write(in.retainedDuplicate());
            } finally {
                // 入站 ByteBuf 由当前 handler 负责释放
                in.release();
            }
            ctx.flush();
        }

        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
            cause.printStackTrace();
            ctx.close();
        }
    }
}

如果需要主动分配缓冲区,应通过 ctx.alloc() 获取与 Channel 绑定的分配器,而不是直接 Unpooled.buffer():

java
ByteBuf out = ctx.alloc().buffer(128);
try {
    out.writeBytes("OK".getBytes(java.nio.charset.StandardCharsets.UTF_8));
    ctx.writeAndFlush(out);
} catch (RuntimeException e) {
    // 写出失败时 Netty 通常会释放消息,但异常分支自行释放更稳妥
    out.release();
    throw e;
}

池化 ByteBuf 使用引用计数管理生命周期。retain() 增加引用,release() 减少引用,计数归零后内存归还池化分配器。跨线程、异步回调或封装到其他对象时,要明确所有权转移,不能只凭方法调用位置猜测是否释放。

如何验证 GC 压力确实下降 ​

优化前后应使用同一压测模型对比,至少观察分配速率、GC 次数、暂停时间、直接内存和池命中率。JDK 11 以上可使用如下命令:

bash
# 观察堆使用、GC 原因和暂停时间
jcmd <pid> GC.log_start filename=gc.log

# 打印原生内存,包含部分 Direct Buffer 使用情况
jcmd <pid> VM.native_memory summary

# 压测后触发一次 GC,再观察堆外内存是否持续增长
jcmd <pid> GC.run

Netty 还可以通过 PooledByteBufAllocatorMetric 暴露 Arena、Chunk、活跃内存等指标。判断池化是否有效,不应只看堆内存下降,还要看 Young GC 频率、接口 P99、直接内存峰值以及泄漏日志是否稳定。

避坑总结:池化不是加上配置就结束 ​

坑一:连接只借出不归还,请求超时后连接被长期占用 ​

常见错误现象是压测开始几分钟后 Connection is not available, request timed out after 1000ms,数据库连接数并不高,但连接池活跃数一直打满。根因通常是手动获取 Connection、ResultSet 或流式结果后,没有在异常分支关闭。修正方式是所有连接、语句和结果集都使用 try-with-resources;把 leakDetectionThreshold 临时调小到 3 到 5 秒,定位长时间占用连接的业务堆栈。

坑二:ByteBuf 重复释放或遗漏释放 ​

错误日志常见为 REFCNT: 0,说明对象已经归还后又被使用;另一种现象是 Netty 输出 LEAK: ByteBuf.release() was not called before it's garbage-collected,说明引用计数没有归零。前者多发生在自定义 Encoder、失败重试和 finally 中重复 release();后者多发生在把入站 ByteBuf 存入队列、转成业务对象后丢失释放责任。修正方式是规定“谁最后持有,谁释放”,跨异步边界使用 retainedDuplicate() 并在回调完成后释放,测试环境开启 PARANOID 抽样定位。

坑三:把对象池当成万能优化,小对象也强行池化 ​

池化会引入借出、归还、容量限制和线程竞争。对于创建成本极低、状态清理复杂、持有时间很短的普通 DTO,池化可能比直接创建更慢,还会因为对象被意外复用造成脏数据。判断依据是分配 profiling 中该类对象确实贡献大量分配,并且初始化成本或资源成本明显高于池化同步成本。

坑四:只监控堆内存,忽略直接内存上限 ​

使用池化直接内存后,Young GC 可能明显减少,但如果 ByteBuf 泄漏,进程会在堆外持续增长,最终触发 OutOfDirectMemoryError 或被容器 OOM Killer 杀掉。修正方式是通过 -XX:MaxDirectMemorySize 设置明确上限,接入 Netty allocator 指标和容器 RSS 监控,并在预发环境保留泄漏检测抽样。

对象池化的本质是用更可控的复用机制替代不可控的频繁分配。连接池要围绕下游容量设计,ByteBuf 池化要围绕引用计数建立纪律,再用压测和监控验证收益,才能真正把 GC 压力转化为稳定的吞吐和更低的延迟。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。