☰
ByteBuf分配提速,从理解线程本地缓存开始
2026/10/20 16:47:59 网站建设 项目流程

我翻了前几篇的留言,发现不少人卡在一个点上:ByteBuf分配太慢。其实单纯用池化并不够,真正的性能差异往往来自"线程私有缓存"这一层。这篇就把我在MyNetty里实现池化内存线程本地缓存的过程完整拆开,包括为什么这么做、核心数据结构怎么设计、分配和回收入口怎么写、以及实测下来到底快了多少。

1. 分配慢的根源:我为什么要给MyNetty加这层缓存

1.1 ByteBuf从哪来:网络事件的分配入口

先回到一个最基础的问题:一个网络请求进来,ByteBuf是在哪里产生的?

以MyNetty的IO线程模型为例,每次selector检测到可读事件,我们要分配一块缓冲区来承载从socket读取的数据。这块缓冲区可能是16字节的小消息,也可能是几十KB的完整数据包。事件处理完,这块缓冲区又被释放,回到内存池等着下一次分配。

也就是说,整个系统的内存分配频率非常高——每个连接、每个请求、每个读写事件,都可能伴随着一次分配和一次释放。如果每次分配和释放都做重量级操作,比如直接向JVM申请堆外内存、通过全局锁去修改共享内存池的元数据,那么在连接数多、事件密集的时候,分配本身就会成为明显瓶颈。

这也是Netty从4.0开始引入PooledByteBufAllocator的原因。MyNetty毕竟是个教学性质的项目,但到了第九篇,不能再回避这个问题:必须把池化这套东西做起来,否则后面的高并发优化都是空谈。

1.2 真正的高开销藏在哪

我最初在MyNetty里的做法很简单:每次需要缓冲区就new一块,用完就扔。但这套方案在压测里表现很差,而且差的原因还不是GC,是下面三件事:

第一,堆外内存的申请和释放本身有系统调用开销。Java里的DirectByteBuffer在申请时会调用malloc或者mmap,释放时要通过Cleaner或者显式调用free。虽然JVM帮我们做了很多,但每一次系统调用都是实打实的成本。当请求量达到每秒几十万次时,这个成本会被无限放大。

第二,所有线程共享同一个内存池时,必然出现竞争。如果用一个全局锁保护内存池的分配元数据,那么每分配一次都要过锁。在高并发下,锁竞争会成为最核心的瓶颈。我试过一个最原始的带锁内存池,压测时CPU占用很高,但吞吐上不去,大部分时间都在等锁。

第三,JVM在分配很多同规格对象时,会有隐藏的分配成本,比如TLAB相关的内存初始化、对象头写入等。池化虽然避免了重复对象创建,但如果每次都要走全局同步结构,池化本身带来的收益会被抵消大半。

所以问题的本质不是"内存不够",而是"分配动作太昂贵"。要解决它,光靠一个全局池还不够,必须让每个线程尽可能在自己的私有空间里完成内存的获取和归还。

1.3 池化解决一半问题,另一半靠线程本地

第一版MyNetty的内存池设计参考了经典的分层思想:申请到一大块内存后,按照大小规格切成小块,用伙伴分配法管理。这一层负责解决"系统调用频繁"的问题——因为我们已经提前从系统申请了足够的内存,后续的分配都在这块大内存内部完成。

但全局池仍然存在竞争问题。每当一个线程需要内存时,都要访问共享的分配结构。无论我们用锁还是CAS,多个线程同时操作同一个分配结构,必然有等待。

这时候线程本地缓存的价值就体现出来了:每个线程把自己释放的内存块暂存在ThreadLocal结构里,下次需要同规格内存时,优先从自己本地取。只有当本地缓存为空时,才去找全局池申请;只有当本地缓存满了,才把多余的内存真正还回全局池。

这么一做,大部分分配和释放操作就完全绕开了共享数据结构的竞争。全局池还在,但它变成了一个"兜底"的角色,而不是每次分配的必经之路。

2. MyNetty内存池的骨架设计:四个模块各司其职

2.1 大小规格归一化:让缓存能按档位命中

在讲线程本地缓存前,得先说清楚MyNetty怎么给内存分档。如果不按规格分类,缓存就没法命中——你要的16字节和你要的17字节,可能就落到了不同的管理路径上。

MyNetty的规格归一化规则很简单:

  • 小于512字节的请求,按16字节对齐,分成16、32、48、64……一直到512。
  • 大于512字节且小于等于8KB的请求,按512字节对齐。
  • 大于8KB的请求,按PageSize的整数倍分配,也就是8KB、16KB、24KB这样的档位。

这个归一化逻辑为什么要做?因为在实际网络场景里,大部分消息的长度是接近的。比如一个网关应用经常转发200字节到400字节的请求,那么按512对齐后,大量请求都落到同一个档位上。同一档位的内存块可以被反复缓存和复用,命中率自然就上去了。

分级还有一个好处:小内存块和大内存块互不干扰。16字节的小块不会被一个需要8KB缓冲区的请求"吃掉",8KB的内存块也不会被一堆小请求浪费。

2.2 Arena与Chunk:全局结构只负责真正申请和归还

MyNetty的全局池部分,核心由两个结构组成:

Chunk是一大块连续内存,默认大小按PageSize的整数倍管理。每个Chunk内部按页(Page)拆成多个小格子,分配时从Chunk里切出需要的页。一个Chunk被切完后,就去申请下一个Chunk。

Arena则是Chunk的管理容器。MyNetty里每个Arena维护若干Chunk,负责分配和回收内存块。Arena内部有一个大锁或者一段原子操作来保护Chunk的元数据更新。每个线程创建时会绑定到一个Arena,线程生命周期内主要跟自己的Arena打交道。

这层的设计我不打算展开太多,因为前几篇文章已经讲过了。重点是:全局池的所有操作都意味着锁或者原子操作,所以能不进全局池就尽量不要进。

2.3 PoolThreadCache:在全局池前面加一个私有储物柜

到这里,核心就来了。我在Arena和线程之间加了一个中间层:PoolThreadCache。

这个类实现了ThreadLocal接口(准确说是内部持有ThreadLocal字段),每个IO线程初始化时都会创建一个属于自己的PoolThreadCache实例。它的作用非常直观:

  • 每个规格档位维护一个缓存队列。
  • 线程释放ByteBuf时,如果对应队列没满,就把内存块暂存进去。
  • 线程分配ByteBuf时,先看对应队列里有没有缓存,有就直接用,没有才去Arena申请。

数据结构如下所示:

public final class PoolThreadCache implements MemoryCache { private final PoolArena arena; // 按规格索引的缓存队列数组 private final MemoryRegionCache[] smallCaches; private final MemoryRegionCache[] normalCaches; private final int smallCacheSize; private final int normalCacheSize; public PoolThreadCache(PoolArena arena) { this.arena = arena; this.smallCacheSize = 64; this.normalCacheSize = 64; this.smallCaches = new MemoryRegionCache[numSmallSubPages]; this.normalCaches = new MemoryRegionCache[7]; } }

每个MemoryRegionCache就是一个固定容量的队列。队列本身不存储真正的内存块,而是存储描述内存位置的条目——一个指向所属Chunk的引用加上一个偏移量handle。当真正需要ByteBuf时,再根据条目信息去Arena中把这段内存"还原"出来。

这个设计的好处是:缓存队列里不保存ByteBuf对象本身,只保存内存位置的元数据,省去了大量对象头开销;同时内存块始终归属于Arena,只是暂时被线程"占住"而已。

3. PoolThreadCache核心代码实现与分配算法

3.1 缓存条目:一个chunkId加一个offset就够了

在MyNetty里,缓存条目是我重新设计过的一个小东西。一开始我打算直接存PoolChunk引用和偏移量两个字段,后来发现可以进一步压缩。

在JVM里,一个普通对象头就要占据一部分内存,如果我们让每个缓存条目都带一个PoolChunk引用,那缓存几千个条目时,引用本身的开销也会累积起来。

所以MyNetty的Entry走了位运算路线:

final class MemoryRegionCacheEntry { private final long handle; private final int chunkId; MemoryRegionCacheEntry(int chunkId, long handle) { this.chunkId = chunkId; this.handle = handle; } }

chunkId用来在Arena的chunk集合里定位是哪一个Chunk,handle则是这个Chunk内部的内存偏移量。分配时取出chunkId,通过Arena的chunk数组拿到PoolChunk对象,再用handle还原出具体位置,非常轻量。

我还考虑过把chunkId和高位offset塞进同一个long里,进一步减少字段数。但为了可读性,最终保留了两个字段。如果是要做极致优化的版本,可以考虑继续合并。

3.2 缓存队列的组织与容量上限

每个规格档位对应一个MemoryRegionCache,内部是一个循环数组队列,容量上限默认64。为什么设64?这是我压测后拍脑袋定出来的一个相对稳的值。

如果容量太大,比如256,线程本地会囤积大量内存,全局池的可复用内存被各个线程瓜分,其他线程分配时反而容易触发新的Chunk申请;如果容量太小,比如8,缓存命中率又会很低,大部分内存直接归还给全局池,缓存的意义就没了。

缓存队列的组织方式如下:

final class MemoryRegionCache { private final Entry[] entries; private int headIndex; private int tailIndex; private final int maxSize; MemoryRegionCache(int maxSize) { this.maxSize = maxSize; this.entries = new Entry[maxSize]; } synchronized boolean offer(Entry entry) { if (size() >= maxSize) { return false; } entries[tailIndex] = entry; tailIndex = (tailIndex + 1) % entries.length; return true; } synchronized Entry poll() { if (headIndex == tailIndex) { return null; } Entry entry = entries[headIndex]; entries[headIndex] = null; headIndex = (headIndex + 1) % entries.length; return entry; } int size() { int size = tailIndex - headIndex; if (size < 0) { size = entries.length + size; } return size; } }

注意这里用了synchronized。为什么一个线程私有的缓存队列还要加锁?因为一个场景:ByteBuf可能被另一个线程释放。比如A线程创建的ByteBuf被传递到B线程后,B线程直接release,这时候释放路径要尝试往B线程自己的PoolThreadCache里放,而不是A线程的。所以每个线程的PoolThreadCache可能被别的线程间接访问,队列本身需要做简单的并发保护。

不过这个锁的粒度极低,只是针对一个长度上限64的队列进行几次判断和赋值,和全局PoolArena的大锁相比,开销几乎可以忽略。

3.3 allocate主流程:优先命中缓存,未命中再找Arena

分配路径是整个模块里最核心的代码。MyNetty的分配入口长这样:

ByteBuf allocate(ByteBufAllocator allocator, int reqCapacity, int maxCapacity) { int size = normalizeCapacity(reqCapacity); if (isTinyOrSmall(size)) { MemoryRegionCache cache = smallCaches[cacheIndex(size)]; ByteBuf buf = cache.allocate(allocator, size, maxCapacity); if (buf != null) { return buf; } } // 缓存未命中,降级到Arena PooledByteBuf buf = arena.allocate(size, maxCapacity); return toByteBuf(buf); }

MemoryRegionCache的allocate方法逻辑很直接:

ByteBuf allocate(ByteBufAllocator allocator, int size, int maxCapacity) { Entry entry = poll(); if (entry == null) { return null; } PoolChunk chunk = chunkIdToChunk(entry.chunkId); return chunk.initBuf(allocator, entry.handle, size, maxCapacity); }

这里面有一个关键点:从缓存拿到的内存块需要重新初始化。因为这块内存在释放时虽然被标记为可复用,但ByteBuf对象内部的readerIndex、writerIndex、capacity这些字段还残留着上一次使用的状态。还原时必须重新设置,否则可能出现读指针错乱、容量不对的问题。

池化分配里经常会忽略"初始化"这一步,但它恰恰是最容易出bug的地方。MyNetty里initBuf会统一做以下事情:

  • 设置ByteBuf的memory指向Chunk对应偏移量。
  • 把readerIndex、writerIndex重置为0。
  • 把capacity设置成当前规格对应的容量。
  • 把refCnt设置为1,表示这块内存的使用计数从新开始。

这套初始化逻辑在每次分配时都会执行,无论是从缓存取出还是从Arena申请,所以从业务代码看,拿到的ByteBuf永远是全新状态。

3.4 free流程:能缓存先缓存,缓存满了再还池

释放路径是池化设计里很容易忽视、但实际影响很大的代码。我在最初实现时犯过一个错误:直接把释放的内存交回给Arena,导致后续分配时大量走全局池,缓存形同虚设。

正确流程应该是这样的:

void free(PoolChunk chunk, long handle, int normCapacity) { if (normCapacity <= maxCachedSize) { MemoryRegionCache cache = getCache(normCapacity); if (cache != null) { MemoryRegionCacheEntry entry = newEntry(chunk.id, handle); if (cache.offer(entry)) { // 成功进入线程本地缓存 return; } } } // 缓存失败或规格不被缓存,真正还回Arena arena.returnChunk(chunk, handle); }

也就是说,只有当本地缓存队列满了,或者这个规格的内存选择不进入缓存(比如单个超过16KB的大块),才会触发真正的"还给全局池"操作。

这样设计之后,大部分内存释放其实只是往线程本地队列里塞一个条目,没有任何锁竞争,没有涉及Arena内部的状态变更,也没有系统调用,性能提升非常明显。

4. 线程本地缓存与Arena的完整交互链路

4.1 一次读事件中ByteBuf的分配和归还

为了更好地说明,我拿一个典型的网络读事件走一遍完整链路:

假设IO线程从socket读到一段数据,需要一块512字节的ByteBuf。

第一步,IO线程调用PooledByteBufAllocator的directBuffer(512)申请内存。 第二步,进入PoolThreadCache的allocate方法,normalize之后发现规格落在small范围,索引到对应缓存队列。 第三步,从缓存队列poll出一个Entry,拿到chunkId和handle,通过arena还原出内存位置,初始化ByteBuf。 第四步,业务逻辑处理完数据,调用release(),引用计数归零。 第五步,free方法尝试把这块内存塞回当前线程的缓存队列,如果队列没满,就只更新队列,流程结束。

整个过程里,Arena只是被访问了一下chunk数组,甚至没有触发分配元数据的更新。没有锁竞争,没有系统调用。这就是线程本地缓存带来的直观改变。

4.2 handle位运算:从缓存条目还原真实地址

在MyNetty里,handle是long类型,低40位用来表示在Chunk中的偏移量,高24位保留一些标志位。还原过程非常简单:

int offset = (int) (handle & 0xFFFFFFFFFFL);

为什么要单独用一个offset而不是直接存绝对地址?因为Chunk可能被整体重新分配或者迁移,保存相对偏移量比保存绝对指针更安全可靠。还原时只需要通过chunkId拿到Chunk,再用offset计算真实地址即可。

这里有个细节:MyNetty在设计offset时用的是位掩码,而不是直接强转int。因为如果handle里还有其他标志位,直接强转会把标志位一起转成负数,后面算内存位置时会越界。

这个坑我在测试时踩过一次,当时现象很诡异:分配出的ByteBuf时不时报越界异常,排查半天发现是handle的低位偏移算错了。后来统一用掩码,再没出过问题。

4.3 跨线程归还场景:为什么我不做复杂转移

前面提到,ByteBuf可能在别的线程被释放。这时候严格来讲,它应该进入创建它的线程的缓存,还是进入释放它的线程的缓存?

Netty里的做法是:释放路径会尝试放入当前释放线程的PoolThreadCache。也就是说,谁释放,谁缓存。MyNetty也沿用了这个策略。

为什么这么设计?因为大部分场景下,ByteBuf都是被同一个线程创建和释放的。比如IO线程读取数据,处理完直接release,线程没变。即使发生跨线程传递,跨线程释放也是少数情况,这时候进入释放线程的缓存也无可厚非——反正内存块属于哪个线程本身并没有绑定关系,只要规格匹配就能复用。

我还考虑过更复杂的方案:维护一个线程ID到缓存的映射,释放时找到创建者的缓存塞回去。但这会增加全局ThreadLocalMap的访问频率,收益却很小。最终决定接受"谁释放谁缓存"的朴素策略,实测效果已经很满意。

4.4 缓存失效与定时trim

线程本地缓存虽然快,但有一个隐患:它会长期占用内存。如果一个线程高频分配过大量内存,之后不再使用,那些缓存在线程池里的内存块就一直卡着,回不到全局池,其他线程想用也用不到。

MyNetty针对这个问题做了一个简易的trim机制。不必像Netty那样搞复杂的阈值判断,我的实现很简单:

  • 每次分配时随机抽样,统计一下缓存队列里有多少条目超过指定时间没有被动过。
  • 如果超过阈值,就清空部分缓存,归还给Arena。

实际代码里我甚至没有引入时间戳,而是用一个更懒的办法:在缓存中还回的次数累计到一定阈值(默认1024次)时,主动trim一次,把当前缓存中较旧的条目释放掉。

这个方案的思路是:一个线程如果持续进行网络读写,它自然会在短时间内又一次分配内存,缓存很快被重新利用;如果一个线程陷入空闲,那么这些缓存就该被回收。用"释放次数"作为触发信号,虽然不如时间戳精确,但实现简单、性能开销低。

5. 压测记录:有缓存到底能快多少

5.1 测试方法

为了验证线程本地缓存的价值,我在一台开发机上跑了三组对比测试:

  • 无池化:每次分配都new一块DirectByteBuffer,用完让GC回收。
  • 池化无缓存:用Arena直接分配,线程之间共享同一个Arena。
  • 池化加线程本地缓存:也就是MyNetty最终版。

测试场景很简单:8个线程并发循环10万次,每次分配一块固定大小的内存,模拟写入数据后立即释放。测试分别覆盖了16字节、512字节、8KB三种规格。

5.2 结果对比

结果如下表所示。耗时是10万次循环的总耗时,越低越好:

分配方式规格总耗时相对于无池化的加速比
无池化512B约180ms1x
池化,无线程缓存512B约115ms约1.6x
池化加线程缓存512B约35ms约5.1x
分配方式规格总耗时
无池化8KB约210ms
池化,无线程缓存8KB约140ms
池化加线程缓存8KB约45ms

可以看到,线程本地缓存在这个测试场景下提升非常显著,相对于无池化有4到5倍的差距。而对于16字节的小规格内存,提升更夸张,因为小内存分配的系统开销占比本身就高,缓存命中后几乎就是纯队列操作。

5.3 结果解读与适用范围

不过这里我要泼一点冷水:性能提升取决于场景。如果业务线程数量很少,比如只有1到2个线程,且分配频率不高,那么线程本地缓存相比单纯池化提升有限,因为几乎没有竞争。如果分配的内存规格特别杂乱,每次都在不同档位之间切换,缓存命中率也会下降。

还有一个需要注意的点:线程本地缓存对高并发、高分配频率的场景收益最大。如果你的应用本身就是低并发、低频分配,那么上这个模块可能带来的内存占用增加会大于性能收益。做技术选型时,还是先想想自己的实际负载。

6. 实现中踩过的坑和最终的一些心得

6.1 线程缓存造成的"假性内存不足"

我在集成到MyNetty之后跑过一次长时间稳定性测试,结果发现一个奇怪的现象:系统整体内存占用不高,但偶尔会新分配Chunk,导致实际使用内存比预期多。

排查后发现,原因是线程本地缓存里囤积了太多内存块,而有些线程已经空闲了,这些缓存既没有被动用,也没有被trim。其他线程想要复用这部分内存,却看不到它们。表现就是总内存不缺,但当前活跃线程偶尔还会触发新Chunk的申请。

解决方式就是我前面提到的trim机制。上线trim之后,内存占用曲线平滑了很多,新Chunk申请次数明显下降。这件事提醒我一个重要原则:线程本地缓存一定要有过期淘汰机制,否则它就会从"优化"变成"内存黑洞"。

6.2 ByteBuf复用时残留数据的坑

另一个坑来自ByteBuf复用时的数据残留。有次测试发现某条业务链路偶发数据错乱,用日志定位了半天,最后发现是缓存命中后,内存块里还残留着上一次写入的旧数据。

虽然从逻辑上讲,大多数场景读取数据前都会先写入数据,覆盖旧内容。但总有一些边界场景:比如只读了ByteBuf的部分内容,剩余部分直接交给另一个组件,组件误以为那段旧数据是有效载荷。

池化分配的ByteBuf默认不负责清空内存,因为清零本身也有性能开销。所以正确的做法是:编写分配器时明确"缓存命中返还的内存不保证内容为空"这一语义,业务侧不要依赖内存初始值。底层可以在initBuf时把writerIndex和readerIndex重置,但不清空内存块本身。

6.3 位运算细节:带符号位移导致地址变负

上面提到过的位运算问题,这里再展开一下。如果handle的高位有标志位,还原offset时用了有符号位移,比如:

int offset = (int) (handle >> 16);

当handle超过int范围时,这个位移会把符号位带进来,得到的offset变成负数,后面的内存操作全部出错。

正确做法是用掩码先截断高位:

long masked = handle & 0x0000FFFFFFFFFFFFL; int offset = (int) (masked & 0xFFFFFFFFFFL);

这个坑因为排查链路长,当时浪费了不少时间。做底层内存管理时,涉及位运算的代码一定要刻意使用无符号位移和掩码,并且写得足够直白,不然几个月后自己回头读代码也会一知半解。

6.4 后续可以继续做的几个优化方向

MyNetty的线程本地缓存目前是一个相对初级的版本,真要往生产级靠拢,还有几个方向:

  • 更细粒度的规格划分。目前按16字节对齐,未来可以针对小报文按8字节甚至4字节对齐,进一步降低浪费。
  • 内存块的GC级别分离。不同规格的内存采用不同的回收策略,让高频小内存更"容易"被复用,而大块内存倾向于及时归还。
  • Chunk内部碎片的整理。当某个Chunk的碎片过多时,适时触发合并或者整块回收,配合线程缓存的淘汰机制统一归还。

这些方向里,我自己最想做的是第一个:更细的规格划分。因为在真实网络报文里,几十字节的小包占比极高,能不能把这部分缓存做厚,直接影响总的分配效率。

写到这里,MyNetty的线程本地缓存模块也算告一段落了。这个模块做完之后,对"内存池化"这件事的理解比之前深了不少——它不只是一堆分配复用的代码,真正的难点在于判断"什么时候该缓存、缓存多少、什么时候该放回"。把这三个问题想明白,你的池化稳定性和性能才能同时站得住。如果你也在自己写类似的项目,我建议你从最简单的ThreadLocal加队列开始,先跑通分配和释放,再逐步加上规格分级、容量控制和trim机制,这条路比一开始照搬全网方案要顺得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询