《Netty 高性能网络编程》5.3 PooledByteBufAllocator 与 Arena

拆开 PooledByteBufAllocator 的 arena、thread cache、chunk、page 结构,用 metric() 读出 arena 分配数与线程缓存命中,验证 4.2 里 ByteBufAllocator.DEFAULT 的真实类型,并给出 linkd-v2 的分配器配置与指标打印代码。

本节目标:看懂池化分配器的四级结构,会用 metric() 判断缓冲区是在复用还是不断向系统要内存,能按场景在 pooled 与 unpooled 之间做选择。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-buffer 模块)。

承接的问题

5.2 的泄漏检测能告诉你「有一块内存没有还」,但回答不了「池子现在借出去多少、还剩多少、复用率如何」。linkd-v2 在 9093 端口压测时遇到过一个现象:连接数稳定在 800,QPS 也稳定,进程的堆外内存却每隔几分钟跳一级,最后被容器 OOM kill。日志里没有 LEAK,refCnt 的代码路径也查过一遍。

问题出在分配器层面:每个连接进来时创建 64 KB 的直接缓冲区,连接关闭时释放,看起来是配平的。但分配的粒度不是 64 KB,而是 chunk;每个 EventLoop 线程还有自己的缓存。缓冲区归还到线程缓存不等于还给操作系统,归还到 arena 也不等于还给操作系统。如果不看 PooledByteBufAllocator.metric(),这些层次在监控上是一个黑盒。

这一节先把 arena、chunk、page、subpage 的关系画清楚,再用本机数据说明 metric() 里每个数字的含义,最后落到 linkd-v2 的启动参数和指标打印。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。标注「本机输出」的内容来自这台机器,单次运行,未做统计。

四级结构:arena、chunk、page、subpage

PooledByteBufAllocator 把内存按四层组织:

PooledByteBufAllocator
├── PoolArena[0]        每个 arena 一把锁,减少线程间竞争
│   ├── PoolChunkList   (按使用率分段的 chunk 链表)
│   │   └── PoolChunk   默认 4 MiB(本机),向系统申请的大块
│   │       ├── PoolPage 默认 8 KiB(本机),chunk 按 buddy 算法切分
│   │       └── PoolSubpage 小于一个 page 的请求,在 page 内再切
│   └── ...
└── PoolArena[N-1]

申请路径从上到下:

  1. 先看当前线程的 thread cache。命中就直接返回,不加锁。
  2. 未命中,按请求大小选择 arena。普通缓冲区用 numDirectArenas 个 arena 分摊竞争。
  3. arena 内按大小分三条路:tiny(小于 512 字节)、small(小于一个 page)、normal(page 到 chunk 之间)。前两类走 subpage,第三类走 page 级 buddy 分配。
  4. 都不够就新申请一个 chunk。chunk 是向系统要内存的最小单位,也是 usedDirectMemory 增长的粒度。

这套结构的目的是把「每次请求都调 malloc」变成「一次要一块 chunk,内部切给很多请求」。代价是内存不会随单个缓冲区释放而还给系统:chunk 空闲之后留在 arena 的 chunk list 里,等下一次复用。这也是「连接数下降、内存不下降」的正常形态。

本机打印结构参数与 arena 数量:

import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.buffer.PooledByteBufAllocatorMetric;

public class AllocatorProbe {
    public static void main(String[] args) {
        PooledByteBufAllocatorMetric m = PooledByteBufAllocator.DEFAULT.metric();
        System.out.println("default=" + ByteBufAllocator.DEFAULT.getClass().getSimpleName()
                + " pageSize=" + PooledByteBufAllocator.defaultPageSize()
                + " maxOrder=" + PooledByteBufAllocator.defaultMaxOrder()
                + " chunkSize=" + m.chunkSize()
                + " directArenas=" + m.numDirectArenas()
                + " heapArenas=" + m.numHeapArenas());
    }
}

本机输出:

default=AdaptiveByteBufAllocator pageSize=8192 maxOrder=9 chunkSize=4194304 directArenas=20 heapArenas=20

10 核机器上有 20 个 direct arena,和 worker 线程数的默认值一致。arena 多了,锁竞争小,但每个 arena 都可能各自持有空闲 chunk;arena 少了,竞争大,内存更集中。默认值是多少要看 4.2 的实现,不要在代码里假设,metric().numDirectArenas() 会给出运行时真实值。

pageSize 与 maxOrder 共同决定 chunk 大小:chunkSize = pageSize << maxOrder。本机是 8192 << 9 = 4194304,即 4 MiB。这三个值都是启动时确定的,运行中不能改。

4.2 的默认分配器:先测再写

很多资料写「Netty 默认使用 PooledByteBufAllocator」。这句话在 4.2 上需要拆开看。本机直接打印 ByteBufAllocator.DEFAULT 的类型:

CP=$(find ~/.m2/repository/io/netty -path "*4.2.19.Final*" -name "*.jar" | tr '\n' ':')
java -cp "out:$CP" AllocatorProbe
java -Dio.netty.allocator.type=pooled -cp "out:$CP" AllocatorProbe
java -Dio.netty.allocator.type=unpooled -cp "out:$CP" AllocatorProbe

本机输出(只取每行开头的类型):

默认                  default=AdaptiveByteBufAllocator
-Dio.netty.allocator.type=pooled    default=PooledByteBufAllocator
-Dio.netty.allocator.type=unpooled  default=UnpooledByteBufAllocator

4.2 的默认值不是裸的 PooledByteBufAllocator,而是一个自适应实现,它在 io.netty.buffer 包里,类名以本机输出为准。对本机 1 KB 的直接缓冲区请求,它报告的 isDirectBufferPooled() 是 true,也就是说小缓冲区仍然走池化路径。自适应的分界与策略属于实现细节,以 4.2 的实际行为为准,不要按文章里的数字写死。

默认仍然偏向池化,理由和连接型负载有关:缓冲区分配频率高、大小集中在少数档位、生命周期短,池化把每次 malloc 换成一次缓存或 buddy 分配,省掉系统调用和页表操作。自适应实现只是在这条路径外面加了一层按规模选择的策略,并没有把默认行为改成「每次都向系统要内存」。

结论分两层:

  • 想要「一定走池化」,显式用 PooledByteBufAllocator.DEFAULT,或者设置 -Dio.netty.allocator.type=pooled。前者只影响你传进去的地方,后者影响 ByteBufAllocator.DEFAULT 的解析结果。
  • 想要「一定不走池化」,设置 -Dio.netty.allocator.type=unpooled。适合排查「到底是不是池子在占内存」这种问题,不适合作为生产默认。

linkd-v2 的选择是显式:ServerBootstrap 的 childOption 里写 PooledByteBufAllocator.DEFAULT,不依赖默认值随版本变化。这样升级 Netty 时分配行为是可预期的。

pageSize、maxOrder 与 arena 数量

三个最常被调的系统属性:

属性作用注意
io.netty.allocator.type选择 ByteBufAllocator.DEFAULT 的实现启动参数,值有 adaptive、pooled、unpooled
io.netty.allocator.numDirectArenas直接内存 arena 数量启动参数,默认值以 4.2 实现为准
io.netty.allocator.pageSize每个 page 的字节数必须是 2 的幂,启动参数
io.netty.allocator.maxOrderchunk 的 buddy 层数chunkSize = pageSize << maxOrder,启动参数

本机把 pageSize 改成 4096、maxOrder 改成 10、numDirectArenas 改成 4,再跑同一个探针:

java -Dio.netty.allocator.numDirectArenas=4 \
     -Dio.netty.allocator.pageSize=4096 \
     -Dio.netty.allocator.maxOrder=10 \
     -cp "out:$CP" AllocatorProbe

本机输出:

default=AdaptiveByteBufAllocator pageSize=4096 maxOrder=10 chunkSize=4194304 directArenas=4 heapArenas=20

pageSize 和 maxOrder 都变了,chunkSize 仍是 4 MiB,因为 4096 << 10 等于 8192 << 9。这说明调参时要一起看这两个值:只把 maxOrder 调大,chunk 会成倍变大;chunk 太大,每个 arena 的最低占用就高,arena 多的时候浪费明显;chunk 太小,大缓冲区会频繁申请新 chunk,分配延迟抖动。

numDirectArenas 只影响了 direct arena,numHeapArenas 仍是 20,因为堆内存 arena 有独立的属性 io.netty.allocator.numHeapArenas。linkd-v2 只用直接缓冲区,堆 arena 的数量不影响它,但仍会按默认值初始化。

这些属性都在类初始化时读取一次。运行中调用 System.setProperty 不会改变已经创建的分配器;要改只能重启,或者自己 new PooledByteBufAllocator(...) 并显式传进 bootstrap。

metric() 能读到什么

PooledByteBufAllocator.metric() 返回 PooledByteBufAllocatorMetric,可读的值分三组:

  • 结构:numHeapArenas()、numDirectArenas()、numThreadLocalCaches()、chunkSize()、tinyCacheSize()、smallCacheSize()、normalCacheSize()。
  • 总量:usedHeapMemory()、usedDirectMemory()。
  • 每个 arena 的明细:heapArenas() / directArenas() 返回 List<PoolArenaMetric>,可继续读 numAllocations()、numActiveAllocations()、numActiveBytes()、numTinyAllocations() 等。

在 EventLoop 线程上做三轮「分配 1024 个 1 KB 直接缓冲区再全部释放」,观察数字怎么变:

import io.netty.buffer.ByteBuf;
import io.netty.buffer.PoolArenaMetric;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.buffer.PooledByteBufAllocatorMetric;
import io.netty.channel.nio.NioEventLoopGroup;

public class AllocatorRounds {
    public static void main(String[] args) throws Exception {
        NioEventLoopGroup group = new NioEventLoopGroup(1);
        try {
            group.next().submit(() -> {
                PooledByteBufAllocator alloc = PooledByteBufAllocator.DEFAULT;
                PooledByteBufAllocatorMetric m = alloc.metric();
                System.out.println("thread=" + Thread.currentThread().getName()
                        + " caches=" + m.numThreadLocalCaches());
                for (int round = 1; round <= 3; round++) {
                    ByteBuf[] bufs = new ByteBuf[1024];
                    for (int i = 0; i < bufs.length; i++) {
                        bufs[i] = alloc.directBuffer(1024);
                    }
                    System.out.printf("round%d alloc arenaAlloc=%d active=%d used=%d%n",
                            round, totalAlloc(m), totalActive(m), m.usedDirectMemory());
                    for (ByteBuf b : bufs) {
                        b.release();
                    }
                    System.out.printf("round%d free  arenaAlloc=%d active=%d used=%d%n",
                            round, totalAlloc(m), totalActive(m), m.usedDirectMemory());
                }
                System.out.println("caches=" + m.numThreadLocalCaches());
            }).sync();
        } finally {
            group.shutdownGracefully().sync();
        }
    }

    static long totalAlloc(PooledByteBufAllocatorMetric m) {
        long v = 0;
        for (PoolArenaMetric a : m.directArenas()) {
            v += a.numAllocations();
        }
        return v;
    }

    static long totalActive(PooledByteBufAllocatorMetric m) {
        long v = 0;
        for (PoolArenaMetric a : m.directArenas()) {
            v += a.numActiveAllocations();
        }
        return v;
    }
}

本机输出:

thread=nioEventLoopGroup-2-1 caches=0
round1 alloc arenaAlloc=1024 active=1024 used=4194304
round1 free  arenaAlloc=1024 active=256 used=4194304
round2 alloc arenaAlloc=1792 active=1024 used=4194304
round2 free  arenaAlloc=1792 active=256 used=4194304
round3 alloc arenaAlloc=2560 active=1024 used=4194304
round3 free  arenaAlloc=2560 active=256 used=4194304
caches=1

逐条读:

  • 第一轮分配 1024 个 1 KB,usedDirectMemory 直接到 4 MiB,也就是一个 chunk 的大小。池化分配的计量单位是 chunk,不是单个缓冲区。
  • 第一轮释放后,active=256,其余 768 个归还 arena。留在 active 的 256 个进了当前线程的 small cache。
  • 第二轮只向 arena 要了 768 个(1792 - 1024),另外 256 个命中线程缓存。这就是 thread cache 的价值:不加锁、不做 buddy 分配。
  • 三轮之后 usedDirectMemory 仍是 4 MiB,chunk 没有还给系统;numThreadLocalCaches 从 0 变成 1,说明这个 EventLoop 线程建立了自己的缓存。

usedDirectMemory 是「已从系统申请、仍在池子手里的 chunk 总量」,不是活跃缓冲区字节数,更不是进程 RSS。连接全断之后它停在 4 MiB 是正常的,不代表泄漏。要判断泄漏,看 numAllocations 与 numActiveAllocations 的差值是否持续扩大,或者看 arena 数量是否随请求增长。

numActiveBytes 在本机三轮里都保持 4 MiB,它按 chunk 记账,不随单个缓冲区释放而下降;不要用它算「业务用了多少字节」。业务侧更可靠的口径是自己在 pipeline 里统计 readableBytes(),或者看 numActiveAllocations 乘以典型缓冲区大小。

在 linkd-v2 里落地

linkd-v2 的做法是把分配器显式写进 bootstrap,把指标打印挂到全局定时执行器。先看配置:

ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(boss, worker)
        .channel(NioServerSocketChannel.class)
        .childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
        .childOption(ChannelOption.TCP_NODELAY, true);

ChannelOption.ALLOCATOR 决定这条连接上 ctx.alloc() 返回哪个分配器。写在 childOption 里,所有 accept 出来的连接都生效;服务端监听 socket 自己的缓冲区用 option,量很小,通常不用改。显式传入的好处是:即使某个 Netty 版本改了 ByteBufAllocator.DEFAULT 的解析规则,linkd-v2 的连接仍然用池化分配器。

指标打印放在 LinkdV2Main,每 60 秒输出一行:

package com.plume.linkd.v2.app;

import io.netty.buffer.PoolArenaMetric;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.buffer.PooledByteBufAllocatorMetric;
import io.netty.util.concurrent.GlobalEventExecutor;
import java.util.concurrent.TimeUnit;

public final class LinkdV2Main {

    static void scheduleAllocatorMetrics() {
        GlobalEventExecutor.INSTANCE.scheduleAtFixedRate(
                LinkdV2Main::printAllocatorMetrics, 0, 60, TimeUnit.SECONDS);
    }

    static void printAllocatorMetrics() {
        PooledByteBufAllocatorMetric m = PooledByteBufAllocator.DEFAULT.metric();
        long arenaAlloc = 0;
        long active = 0;
        for (PoolArenaMetric arena : m.directArenas()) {
            arenaAlloc += arena.numAllocations();
            active += arena.numActiveAllocations();
        }
        System.out.printf("buf direct used=%d arenas=%d caches=%d chunk=%d "
                        + "arenaAlloc=%d active=%d%n",
                m.usedDirectMemory(), m.numDirectArenas(), m.numThreadLocalCaches(),
                m.chunkSize(), arenaAlloc, active);
    }
}

这段代码只依赖 PooledByteBufAllocator.DEFAULT 这一个实例,所以 bootstrap 里也必须用同一个实例,否则指标和实际分配对不上。如果以后按业务线创建多个分配器,指标也要按实例分别打印。

启动脚本里只放确实需要的参数:

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
"$JAVA_HOME/bin/java" \
  -Xms2g -Xmx2g \
  -XX:MaxDirectMemorySize=2g \
  -Dio.netty.allocator.type=pooled \
  -Dio.netty.allocator.numDirectArenas=8 \
  -cp "linkd-app/target/linkd-app-0.2.0-SNAPSHOT.jar:linkd-app/target/lib/*" \
  com.plume.linkd.v2.app.LinkdV2Main

numDirectArenas=8 是这台 10 核机器上的起点,不是普适值。arena 太少会争锁,太多会让空闲 chunk 分散在多个 arena 里;调整要用 14.2 的压测口径验证,先看 arenaAlloc 的增长速度和 GC 之外的直接内存曲线。

什么时候改用 unpooled

UnpooledByteBufAllocator 每次分配都向系统要内存,释放即还。它的优点是行为直白,缺点是把分配成本放回每次请求:

场景选择原因
长连接、固定大小、反复读写pooled分配成本被复用摊薄,chunk 内存可控
单次超大缓冲区(接近或超过 chunk)unpooled 或单独分配大块请求本来就走 huge 路径,池化收益低
短生命周期进程、命令行工具unpooled没有足够请求摊薄 chunk 的预留
排查「内存到底是不是池子占的」unpooled 对照关掉池化后内存曲线立刻变直,可确认来源
每个请求都要精确计量并立即归还unpooled池化的延迟归还会让计量口径变复杂
高并发小消息pooled线程缓存命中率高,锁竞争少

判断依据不是「pooled 更快」,而是「分配次数是否足够多、大小是否集中在少数档位」。请求大小在 1 KB 到 1 MB 之间随机分布时,subpage 和 page 的复用率都会下降,池化的收益变小,管理成本反而显现。这种负载要么先做大小分档,要么接受 unpooled 的分配开销。

常见错误与边界

错误一:把 usedDirectMemory() 当进程 RSS。 它只统计池化分配器持有的 chunk,不含 JNI、线程栈、代码缓存、MappedByteBuffer。进程内存上涨而它不动,说明问题不在池化缓冲区。

错误二:认为连接断开后 usedDirectMemory() 应该归零。 空闲 chunk 留在 arena 里等待复用,本机三轮分配释放后仍停在 4 MiB。要确认没有泄漏,看 numActiveAllocations 是否回到基线,而不是看 used 是否归零。

错误三:假设 ByteBufAllocator.DEFAULT 一定是 PooledByteBufAllocator。 本机 4.2.19 默认解析成自适应实现;写 instanceof PooledByteBufAllocator 做分支会走不到预期路径。需要池化就显式引用 PooledByteBufAllocator.DEFAULT。

错误四:把 pageSize 或 maxOrder 设得很大。 chunkSize = pageSize << maxOrder,两个参数一起放大,chunk 可能变成几十 MiB;每个 arena 至少占一个 chunk,arena 多的时候启动内存会明显上升。改之前先用探针打印 chunkSize。

错误五:运行中改系统属性。 分配器在类初始化时读取属性,System.setProperty 之后新建的连接不会换分配器。要换只能重启或显式构造新实例。

边界:metric() 是近似快照。 多线程同时分配时,各 arena 的计数不是同一瞬间的原子快照,求和会有少量偏差。用它看趋势和量级,不要用它做精确对账。

边界:池化不保证内存下降。 池化把「分配/释放」变成「借/还」,还回来的内存留在池里。容器内存限额要按「堆 + 直接内存上限 + 元空间 + 线程栈」一起算,MaxDirectMemorySize 只是其中一条线。

小结

  • 池化分配器按 arena、chunk、page、subpage 四级组织;chunk 是向系统申请内存的单位,本机 pageSize=8192、maxOrder=9,chunk 为 4 MiB。
  • 4.2.19 的 ByteBufAllocator.DEFAULT 本机解析为自适应实现,isDirectBufferPooled() 为 true;需要确定行为时显式使用 PooledByteBufAllocator.DEFAULT 或 -Dio.netty.allocator.type=pooled。
  • io.netty.allocator.pageSize、maxOrder、numDirectArenas 都是启动参数,默认值以 4.2 实现为准;chunkSize = pageSize << maxOrder。
  • metric() 可读 arena 数量、线程缓存数量、chunk 大小、已用直接内存,以及每个 arena 的分配计数。
  • 本机 EventLoop 线程上,1024 个 1 KB 缓冲区只占用一个 4 MiB chunk,释放后 256 个留在 small cache,第二轮少向 arena 要 768 个。
  • linkd-v2 在 childOption 显式指定池化分配器,并在 LinkdV2Main 每 60 秒打印一次指标,用 numActiveAllocations 判断是否回落。

分配器的账目清楚了,下一个问题是字节怎么变成消息。6.1 进入 ByteToMessageDecoder,看累积缓冲区如何保存半包、decode 被调用多少次,以及 5.1 的索引规则和 5.2 的释放规则在解码器里如何同时成立。

阅读导航:上一节:5.2 引用计数与泄漏 · 下一节:6.1 ByteToMessageDecoder 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发