这篇是搬运整理的文章(原作者见文末)。最近在补池化技术这块的基础,这篇是我读到讲得最透的一篇:从 malloc 一行代码背后的成本,一路讲到内存池、对象池的实现、压测方法和上线 checklist,整理一份存档。

很多人一听池化技术,马上想到线程池、连接池。再深入一点,知道内存池、对象池。再往后呢?

内存池和对象池到底是不是一回事? 什么情况下池化有用? 内存池和对象池到底差在哪? 为什么 malloc/new 看着就一行代码,跑起来却可能这么贵? 为什么有些系统池化后更快,有些系统池化后更慢,甚至更容易 OOM? 自己写一个又该注意什么?上线前怎么压测,出了问题怎么查?

这篇文章就聊这个。

高性能服务的核心奥义,从来不是压榨CPU算力,而是尽可能减少无效的资源消耗。而池化技术,就是解决这类问题最核心、最通用的工程手段。

一、池化技术的背景与价值

1.1 从 malloc 和 new 开始看资源分配的成本

我们平时写代码,调用 new 创建对象、malloc 申请内存,看起来只是一行简单代码的事。但你调用的那一刻,你知道这行代码背后,操作系统和运行时做了多少事吗?

上层代码看似无成本,底层全是隐形开销。

C/C++ 的 malloc 并不会直接向操作系统申请内存。用户态会先在进程内存池中截取空闲内存,一旦本地内存不足,才会触发系统调用 brkmmap

系统调用是什么概念?会陷入内核态,打断CPU流水线,触发上下文切换,这个开销远比普通代码执行高几十倍。

1.2 频繁创建和销毁问题

频繁分配释放带来的问题远不止把系统拖慢。

第一是锁竞争。 在多线程环境下,malloc/free通常需要加锁。线程越多,竞争越激烈。ptmalloc在这种场景下的表现堪称灾难。

**第二是 CPU 成本。**每次分配都要做元数据维护,每次释放也要做回收处理。小对象多了以后,这些成本会被放大。

第三是内存碎片。 你分配释放的节奏不一样,内存就被切得七零八落。明明还有几百兆空闲,但就是分配不出一块连续的大内存。内碎片是你用了5字节但分配器给了你8字节,那3字节就浪费了;外碎片是空闲内存到处都是,但谁也不挨着谁。

第四是GC压力。托管语言里,对象创建便宜不代表没有代价。分配很快,回收也得有人买单。

第五是外部资源成本。有些对象不只是内存里的一个结构。比如数据库连接、TCP 长连接、TLS 会话、压缩器、序列化上下文。创建它们可能要走网络握手、认证、初始化缓冲区,甚至触发系统调用。你每次用完就销毁,这是在烧钱。

1.3 池化技术的基本思路

既然频繁创建销毁这么慢,那能不能换个思路?

不用每次需要资源都去重新申请,不用每次用完都立刻销毁。

这就是池化技术的核心逻辑

预分配。系统启动时或首次使用时,提前申请一批资源。 复用。资源用完后不立即销毁,而是清理状态后放回池子。 集中管理。资源的创建、分配、归还、销毁都经过统一组件,方便限流、监控和保护系统。

这套思路听起来不复杂,但真正写起来就不一样了。池子满了怎么办?没人还怎么办?还回来的对象状态脏了怎么办?多线程同时借对象怎么办?长期空闲的资源要不要销毁?超过容量时阻塞还是失败?

池化技术说白了,就是用一次性的固定内存开销,换取长期稳定的高性能收益

1.4 内存池与对象池在性能优化体系中的定位

最底层是内存池,聚焦操作系统内存分配优化,解决内存碎片、系统调用开销问题,属于基础设施级优化。

中层是对象池,聚焦业务对象实例复用,解决对象初始化、资源绑定的重复开销,属于业务框架级优化。

上层是线程池、连接池,聚焦线程、网络连接这类重型资源复用,属于服务调度级优化。

内存池和对象池,是所有池化技术的基石。底层优化到位,上层所有复用逻辑才能稳定高效运行。

二、池化技术的核心思想与适用边界

2.1 池化的本质

池化的本质就八个字:空间换时间,集中控资源。。

你提前占住一部分资源,换取后续获取资源时更低的延迟。你让资源长期存在,换取少创建、少销毁、少系统调用、少锁竞争。

这买卖划算不?大部分时候划算。它就是把一次次昂贵动作,变成少量批量动作。

举个简单例子。你每次处理请求都创建 8KB buffer,请求量每秒 5 万。哪怕单次分配只花很少时间,总量也很吓人。池化之后,你可以维护一批 8KB buffer,请求结束后清空并归还。下一次请求直接拿。

2.2 池化技术解决的核心问题

池化要解决的无非三件事:

性能——少分配、少释放、少GC、少锁竞争。

资源——控制总量,不让某个组件把资源吃光。

稳定性——预分配保证关键时刻有资源可用,不会因为分配失败而崩溃。

2.3 池化技术的常见类型

工程中常见的四种池化技术:内存池、对象池、线程池、连接池。

内存池管理内存块。

对象池管理对象实例。

线程池管理线程执行资源。

连接池管理数据库连接、Redis 连接、HTTP 长连接。

它们有几个共同动作:初始化、借出、使用、归还、回收、扩容、收缩、监控。

但它们的风险点不一样。

内存池怕碎片、越界、重复释放、并发错误。

对象池怕状态污染、泄漏、错误复用。

线程池怕队列堆积、线程耗尽、拒绝策略不合理。

连接池怕连接泄漏、空闲连接失效、数据库被打爆。

所以别把所有池子都当一个队列。

本文主要聊前两个。

2.4 池化带来的成本

很多人以为池化是万能优化,无脑堆砌。事实上池化本身是有成本的,天下根本就没有免费的午餐:

内存占用——池子里得囤货吧?囤货就得占地方。jemalloc的内存开销大概10%-20%,tcmalloc大概15%-25%。

实现复杂度——管理空闲列表、处理并发、处理对象状态……代码量直接翻倍。

状态管理——对象池里的对象是有状态的。借出去用完了还回来,你怎么保证它被“洗干净”了?下一个人拿到手会不会读到上一个用户的数据?这事儿坑死过无数人。

2.5 池化技术的适用边界

既然池化有成本,那到底哪些场景不能用?

我自己的粗糙的判断:优化的前提,一定是收益大于成本,对吧?

对象创建很便宜,不要池化。 对象生命周期极长、几乎不销毁的场景,不要池化。 没有通过 profiling 证明分配是瓶颈,不要池化。 池化后状态清理比重新创建还贵,不要池化。 资源量很小,不要池化。 语言运行时已经优化得很好,不要自作聪明。

三、内存池

3.1 内存池的定义与定位

内存池是干啥的?说白了就是替你向操作系统批发内存,然后零售给你

操作系统是总代,每次至少卖你一页(通常是4KB或8KB)。但你写程序的时候可能只需要几十个字节。内存池的作用就是:先从总代那儿批发一大块,然后切成小块零售,省得你每次都去找总代。

3.2 内存池的演进路径

内存池的演进大概经历了三个阶段:

第一阶段:定长内存池。只支持一种大小的内存块。实现简单,但灵活性差。

第二阶段:变长内存池。支持多种大小。但管理复杂度上来了。

第三阶段:分层内存池。像TCMalloc那样搞三级缓存——线程级的、中心级的、页级的。每一层解决不同的问题。

3.3 内存池的核心结构

不管哪种内存池,底层核心结构就这三个:

空闲链表(Free List) ——把空闲的内存块串成链表。要分配的时候从链表头取一个,释放的时候放回去。

内存对齐 ——CPU访问内存有对齐要求。不对齐的话,轻则性能下降,重则直接报错。

批量分配 ——每次从上层拿一批,而不是拿一个。减少交互次数。

3.4 TCMalloc 的多级缓存思想

TCMalloc是Google搞出来的内存分配器,Chrome、Redis都在用,性能吊打原生glibc malloc。核心思想就一句话:每个线程自己管一小块,尽量不打架

可以把它理解成三层:

ThreadCache / Per-CPU Cache
CentralCache
PageHeap
Operating System

小对象优先从线程或 CPU 本地缓存拿。这个路径最快,通常不需要全局锁。

本地缓存不够了,就向 CentralCache 批量申请。CentralCache 负责不同 size class 的共享空闲对象管理。

CentralCache 也不够了,就向 PageHeap 要 span。span 可以理解为若干连续 page 组成的一段内存。

PageHeap 再向操作系统申请或释放大块内存。

这个设计厉害在哪里呢?

大多数分配请求都是小对象。小对象如果能在本地缓存解决,就避开了锁。只有缓存 miss 时才走中心路径。中心路径再通过批量迁移降低频率。

这就是高性能分配器的味道。

它不是让所有操作都变神奇,而是让热路径尽量短、尽量少共享、尽量少锁。

3.5 JEMalloc 的 Arena、TCache、Slab 和 Extent

除了TCMalloc,JEMalloc是另一款工业级内存分配器,FreeBSD默认分配器,MySQL、Nginx大量采用,稳定性拉满。

它的设计更注重多核扩展和碎片控制,核心是多Arena架构。

Arena 是内存管理区域。多线程程序可以使用多个 arena,减少所有线程争一个全局堆的情况。

TCache 是线程缓存。线程本地缓存小对象,减少锁竞争。JEMalloc 手册里也能看到关于 tcache 创建、销毁和 flush 的 mallctl 接口说明。

Slab 通常用于小对象。一个 slab 会被切成多个同 size class 的 region,适合快速分配固定档位的小块。

Extent 更偏向大块内存管理,是 JEMalloc 管理虚拟内存范围的重要单位。

相比于TCMalloc,JEMalloc内存碎片控制更好,长期运行服务首选。这也是为什么数据库、网关这类7*24小时运行的服务,几乎清一色用JEMalloc。

3.6 内存池的扩容与回收策略

内存池不是固定大小的。用的人多了得扩容,用的人少了得回收。

扩容通常以页为单位。CentralCache不够了就去PageHeap要几页。PageHeap不够了就去操作系统要——Linux下用mmapbrk,Windows下用VirtualAlloc

回收讲究的是“能合并就合并”。PageHeap维护着空闲span的伙伴系统,相邻的空闲页可以合并成更大的块。这样能减少外碎片。

3.7 内存池的线程安全设计

内存池的并发安全,是性能差距的核心分水岭。

TCMalloc的方案:小对象走ThreadCache,无锁。CentralCache和PageHeap用自旋锁——比互斥锁快,但占用CPU。自旋锁在锁持有时间短的时候效率很高,但如果持锁时间长,CPU就空转了。

JEMalloc的方案:多Arena机制,每个线程绑定到一个Arena,减少跨Arena的竞争。每个Arena内部再用细粒度的锁。

3.8 内存池的适用场景

内存池最适合的场景:

游戏引擎——每帧要创建销毁大量对象,对延迟极其敏感。

数据库内核——长期运行,内存分配频率极高,碎片控制至关重要。

网络缓冲区——Netty的PooledByteBuf就是典型例子,通过池化减少GC压力。

如果你的应用每秒分配不到百万级,其实不用太纠结内存池——操作系统的分配器够用了。

四、对象池

4.1 对象池的定义与定位

很多人分不清内存池和对象池,其实一句话就能区分:内存池管的是“字节”,对象池管的是“对象”。

对象池比内存池高一层。

它管理的不是原始内存,而是对象实例。

比如:

数据库连接。 Redis 连接。 HTTP client 连接。 RPC channel。 大型 buffer。 压缩器。 解析器。 游戏子弹对象。 脚本执行上下文。

这些对象的共同特点是,创建成本较高,或者数量需要控制,或者初始化过程复杂。

对象池不只负责存对象,还负责对象生命周期。

这比内存池的“拿一块、还一块”复杂多了,因为对象有状态。

4.2 对象创建成本

为什么对象需要复用?因为很多重型对象的创建成本,真的太高了。

普通空对象创建,可能只是分配一块内存。但业务对象不一样,构造过程要做大量工作。数据库连接需要TCP握手、账号认证、权限校验、会话初始化。RPC连接需要建立链路、协商协议、初始化编解码器。业务实体需要加载配置、初始化字段、绑定资源句柄。

单次创建可能耗时几毫秒甚至几十毫秒。高并发场景下,每次请求都新建对象,服务直接被拖垮。

而且这类对象销毁也有成本,需要关闭连接、释放句柄、清理资源,频繁创建销毁完全是无效损耗。

4.3 对象池的核心模型:借出、使用与归还

对象池的工作模型就三个动作:借出、使用、归还。

借出(borrow) ——从池里拿一个对象。池里有空闲的就直接给,没有就创建一个(如果没达到上限),或者阻塞等待(如果达到上限了)。

使用 ——调用方拿着对象干活。

归还(return) ——用完了还回来。池子负责重置对象状态,然后放回空闲队列。

对象池的使用模型通常是这样:

PooledObject obj = pool.borrowObject();
try {
    obj.doSomething();
} finally {
    pool.returnObject(obj);
}

看起来很简单。可对象池难点都藏在“可用”两个字里。

可用是什么意思?

连接没断。 对象状态干净。 没有被别的线程同时使用。 没有超过最大生命周期。 没有处于异常状态。

归还时也一样。池子不能无脑收。

一个连接已经坏了,不能放回去污染池子。

一个对象被业务改成不可复用状态,也不能硬塞回去。

一个对象归还两次,更不能让它在池里出现两个引用。

对象池的核心不是队列,是生命周期协议。

4.4 对象生命周期管理

原生对象的生命周期只有创建、使用、销毁三段。对象池给对象新增了完整的复用生命周期,这是核心进阶点。

创建(makeObject) ——工厂方法创建新对象。

激活(activateObject) ——对象被借出前调用,让它“活过来”。

钝化(passivateObject) ——对象归还时调用,让它“休眠”。

销毁(destroyObject) ——对象被淘汰时清理资源。

验证(validateObject) ——检查对象是否还可用。

这套机制设计得很精巧。比如一个数据库连接,借出前要activate(可能执行一个SELECT 1探活),归还时passivate(回滚未提交的事务),销毁时destroy(关闭底层socket)。

4.5 对象池的关键配置:容量、驱逐、验证与阻塞策略

线上落地对象池,参数配置直接决定服务性能和稳定性,核心配置我总结了四个:

第一是容量配置,包含最大活跃数、最小空闲数、初始容量,用来控制资源上限,防止无限膨胀OOM。

第二是驱逐策略,空闲超时驱逐、LRU最少使用驱逐,避免大量对象长期闲置占用内存。

第三是对象验证策略,借出归还时校验对象是否有效,剔除失效连接、损坏对象。

第四是阻塞策略,无空闲对象时的处理逻辑,阻塞等待、快速失败、后台扩容,适配不同业务场景。

4.6 对象复用中的状态管理

对象池最大的问题,不是性能,是脏对象。

你用完一个对象还回去,如果不把它的状态清理干净,下一个人拿到手就可能读到你的数据。

比如你从池里拿了一个ByteBuffer,往里写了一些数据,用完还回去了,但没调用clear()。下一个人拿到这个Buffer,以为它是空的,直接往里写——结果发现里面还有上次的残留数据。

怎么防?

重置(Reset) ——归还时调用重置方法,把对象恢复到初始状态。 隔离 ——每次借出时返回的是一个“干净”的副本,而不是原始对象。但这样成本太高了,一般不这么干。 验证 ——借出时检查对象状态是否正常。 测试覆盖 ——写单元测试专门测状态污染的场景。

4.7 对象池的适用场景

对象池的刚需场景集中在重型资源复用领域:

连接池 ——数据库连接、Redis连接、HTTP连接池。建立连接的成本太高了。

RPC长连接 ——跟连接池类似,维护长连接复用。

游戏实体复用 ——游戏中频繁创建销毁实体对象(子弹、粒子、怪物),用对象池可以大幅降低GC压力。

频繁创建的临时对象 ——Gin的Context、Go的pp对象。

五、内存池 vs 对象池

5.1 核心差异对比

内存池管理的是内存块。

对象池管理的是对象实例。

这个区别看似简单,实际非常关键。

内存池关心大小、对齐、碎片、页、span、extent、free list。

对象池关心状态、生命周期、验证、容量、超时、泄漏。

内存池返回的是一段可写内存。

对象池返回的是一个带语义的资源。

内存池的释放通常意味着这块内存可以重新分配给任何用途。

对象池的归还意味着这个对象经过清理后可以再次以同类身份使用。

比如一块 4KB 内存,下一次可以存网络包,也可以存索引节点。内存池不管。

一个数据库连接归还后,它下次还必须是数据库连接。你不能把它当 Redis 连接用。废话,但抽象层就是这样分开的。

内存池偏系统层,靠近 allocator。对象池偏应用层,靠近业务资源。

内存池通常无业务状态。最多有分配器元数据。

对象池一定要面对业务状态。连接状态、事务状态、认证状态、对象字段状态、引用状态。

所以对象池比内存池更容易出现“功能正确性”问题。内存池写错了,可能 crash、内存破坏、泄漏。对象池写错了,可能数据串了、连接错了、权限错了。一个偏底层灾难,一个偏业务灾难。都不好看。

5.2 内在联系:对象复用与底层内存分配机制的关系

对象池底层最终还是要用内存。

比如 Netty 的 PooledByteBuf,它既有内存池味道,也有对象池味道。

Netty 官方文档说,ByteBuf 是最典型的引用计数对象之一,引用计数用于提升分配和释放性能。另一篇 Netty 文档还说,ByteBuf 的生命周期绑定到引用计数;当计数归零时,底层内存区域会被显式解除引用、释放或归还到池里。

这就是很好的协同案例。

ByteBuf 是对象,有读写索引、容量、引用计数等状态。

它背后可能持有堆内存或直接内存。

释放时,不只是让 Java 对象等 GC,而是把底层内存归还给池。

所以它既需要对象层面的生命周期管理,也需要底层内存复用。

5.3 选型依据:什么时候选择内存池,什么时候选择对象池

如果你的场景是高频零散内存分配、大量临时缓冲区、需要解决内存碎片和GC问题,优先用内存池。比如网络读写缓冲、临时数据存储、帧数据处理。

如果你的场景是重型业务对象、创建初始化成本高、包含外部资源绑定,优先用对象池。比如各类连接、服务实例、配置载体。

极致高性能场景,两者组合使用,底层内存池控内存,上层对象池控业务状态。

5.4 协同案例:Netty 中的 PooledByteBuf、Channel 复用与连接管理

Netty 的高性能,核心就是内存池+对象池的完美协同设计,这也是工业级落地的标杆案例。

Netty 的 PooledByteBuf 底层基于内存池实现,提前预分配各类大小的内存缓冲区,读写数据直接复用,彻底消灭网络IO的内存碎片和GC开销。

同时 Netty对 Channel、EventLoop、连接资源做了对象池复用。EventLoop线程池常驻复用,Channel连接按需复用,从底层内存到上层连接,全链路池化优化。

这也是为什么 Netty 能碾压原生 NIO,支撑百万级并发的核心原因,每一层的资源都做到了极致复用。

六、从零实现简易池组件

这一章咱们手搓两个简易组件:

  1. 一个定长内存池——只支持固定大小的内存块。
  2. 一个通用对象池——支持任意类型的对象。

当然别指望能跟TCMalloc比,但核心逻辑是通的。

下面用 C++ 写一个简化版定长内存池。

6.2 定长内存池实现:基于 Free List 的分配与释放

定长内存池的核心就是一个Free List——把所有空闲块串成链表。

class FixedSizeMemoryPool {
private:
    struct Block {
        Block* next;
    };
    Block* freeList;
    size_t blockSize;
    size_t poolSize;
    char* pool;

public:
    FixedSizeMemoryPool(size_t blockSize, size_t numBlocks)
        : blockSize(blockSize), poolSize(blockSize * numBlocks) {
        pool = (char*)malloc(poolSize);
        freeList = (Block*)pool;
        // 把所有块串成链表
        char* curr = pool;
        for (size_t i = 0; i < numBlocks - 1; i++) {
            Block* b = (Block*)curr;
            b->next = (Block*)(curr + blockSize);
            curr += blockSize;
        }
        ((Block*)curr)->next = nullptr;
    }

    void* allocate(){
        if (!freeList) return nullptr;
        void* result = freeList;
        freeList = freeList->next;
        return result;
    }

    void deallocate(void* ptr){
        Block* b = (Block*)ptr;
        b->next = freeList;
        freeList = b;
    }

    ~FixedSizeMemoryPool() {
        free(pool);
    }
};

初始化时把一大块内存切成等大小的块,串成链表。分配时从链表头取一个,释放时放回链表头。

但有个致命问题——不支持多线程。多个线程同时allocate/deallocate,freeList就乱套了。

怎么解决?加锁呗。但加锁会慢。后面会讲更好的方案。

6.3 通用对象池实现

通用对象池的核心接口就三个:借出、归还、创建。

public interface ObjectPool<T> {
    T borrowObject() throws Exception;
    void returnObject(T obj);
    void invalidateObject(T obj);
}

public interface PooledObjectFactory<T> {
    T create() throws Exception;
    void activate(T obj) throws Exception;
    void passivate(T obj) throws Exception;
    boolean validate(T obj);
    void destroy(T obj);
}

工厂模式把对象的创建逻辑跟池的管理逻辑解耦了。池不关心对象怎么来的,只管借和还。

核心实现:

public class GenericObjectPool<T> implements ObjectPool<T> {
    private final PooledObjectFactory<T> factory;
    private final BlockingQueue<T> idleObjects = new LinkedBlockingQueue<>();
    private final Set<T> activeObjects = ConcurrentHashMap.newKeySet();
    private final int maxTotal;
    private final AtomicInteger totalCount = new AtomicInteger(0);

    public T borrowObject() throws Exception {
        T obj = idleObjects.poll();
        if (obj != null) {
            // 有空闲的,直接用
            factory.activate(obj);
            if (factory.validate(obj)) {
                activeObjects.add(obj);
                return obj;
            } else {
                // 验证失败,销毁
                factory.destroy(obj);
                totalCount.decrementAndGet();
            }
        }

        // 没有空闲的,尝试创建
        if (totalCount.get() < maxTotal) {
            obj = factory.create();
            totalCount.incrementAndGet();
            activeObjects.add(obj);
            return obj;
        }

        // 达到上限,阻塞等待
        obj = idleObjects.poll(maxWait, TimeUnit.MILLISECONDS);
        if (obj == null) {
            throw new NoSuchElementException("Pool exhausted");
        }
        factory.activate(obj);
        activeObjects.add(obj);
        return obj;
    }

    public void returnObject(T obj) {
        if (!activeObjects.remove(obj)) return;
        factory.passivate(obj);
        idleObjects.offer(obj);
    }
}

6.4 并发安全设计

池组件一般都要面对并发。

最朴素是加锁。优点是清楚,缺点是吞吐受限。

优化思路有几种:

按桶分段。不同 size class 或不同资源组使用不同锁。 读写分离。状态统计和核心借还路径分开。 线程本地缓存。每个线程保留少量对象,减少共享池访问。 无锁队列。用 CAS 管理空闲对象,但 ABA、内存可见性、回收问题都要小心。 异步销毁。归还路径不做重操作,把销毁交给后台线程。

不过我建议大多数业务系统先把锁版本写对。池子借还本来不该成为业务热点,如果已经热点到锁扛不住,要么资源粒度不对,要么你真进入高性能组件领域了。那时候就别靠感觉写,认真 benchmark。

6.5 异常处理机制

对象池的异常处理机制:

超时等待 —— 池满的时候,调用方可以等一会儿。等到了皆大欢喜,等不到抛异常。

快速失败 —— 不等,直接抛异常。适合对延迟敏感的场景。

资源回收 —— 如果借出去的对象长时间不归还(可能调用方挂了),得有机制把它收回来。Commons Pool2里有removeAbandoned机制,就是干这个的。

七、性能测试与效果分析

7.1 性能优化前提:为什么需要先进行 Profiling

说个真心话:不要为了池化而池化。性能优化的第一原则,永远是先定位瓶颈,再动手优化。

在动手之前,先用profiler看看你的应用到底慢在哪。也许瓶颈在数据库查询,也许在网络IO,也许在锁竞争——跟内存分配半毛钱关系没有。

你花一周搞了个内存池,结果发现CPU时间全花在序列化上了,这不是白忙活吗?

先profiling,再优化。这是铁律。

7.2 核心指标定义

衡量池化效果,看这几个指标:

吞吐量 —— 每秒能处理多少次分配/借出操作。单位时间处理的请求次数,越高越好。

延迟 —— 单次操作耗时。单次操作平均耗时、P99耗时,越低越好。

GC暂停 —— 对象池最直接的收益之一就是减少GC。STW时长,越短越稳定

内存占用 —— 池化通常会多占一些内存。常驻内存、峰值内存,越低越节省资源。

7.3 内存池测试:普通分配与池化分配的对比分析

假设我们测 C++ 定长内存池。

普通分配:

for (int i = 0; i < N; ++i) {
    void* p = std::malloc(128);
    std::free(p);
}

池化分配:

FixedMemoryPool pool(128, 1024);

for (int i = 0; i < N; ++i) {
    void* p = pool.allocate();
    pool.deallocate(p);
}

单线程下,池化大概率更快。因为 free list 操作太便宜了。

多线程下,不一定。如果池里只有一把锁,高并发下可能被 malloc 打败。现代 malloc 本来就不是吃素的,TCMalloc、JEMalloc 这类分配器已经做了大量并发优化。你自己写个全局锁 free list,然后说要替代它,多少有点勇。

所以 benchmark 要对比:

系统 malloc。 TCMalloc 或 JEMalloc。 自研内存池。 不同线程数。 不同块大小。 不同生命周期。 不同分配释放比例。

只赢一个 toy case 没意义。线上不是 toy。

7.4 对象池测试:对象复用对吞吐量与延迟的影响

对象池的优化效果比内存池更夸张。

以数据库连接对象为例,原生每次新建连接耗时10-20ms,池化复用后单次获取耗时不足1us。高并发下吞吐量直接提升百倍,接口抖动彻底消失。

普通业务对象,初始化逻辑越复杂,池化收益越高。空对象池化收益有限,带资源绑定的重型对象,池化是刚需。

对象池收益取决于对象创建成本。

如果对象只是:

new UserDTO()

池化多半没收益。

如果对象创建像这样:

class HeavyClient {
    HeavyClient() {
        connect();
        handshake();
        loadMetadata();
        allocateLargeBuffer();
    }
}

那对象池收益才明显。

可以写个模拟 benchmark:

public class HeavyObject {
    private byte[] buffer = new byte[64 * 1024];

    public HeavyObject() {
        try {
            Thread.sleep(1); // 模拟连接或初始化成本
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    public void reset() {
        buffer[0] = 0;
    }
}

然后测:

每次 new。

对象池 borrow/return。

对象池容量不足时等待。

对象池验证开启和关闭。

你会发现,池化不是永远更快。创建成本越高,复用收益越明显。创建成本越低,池管理开销越容易盖过收益。

7.5 池容量:不同 Watermark 下的性能拐点

池子不是越大越好。

池容量小了,借不到对象,请求等待。

池容量大了,空闲资源占用内存,还可能压垮下游。

容量调优要看 watermark。

可以粗略分几类指标:

当前活跃数。 历史峰值活跃数。 平均空闲数。 最大等待时间。 等待请求数。 创建速率。 销毁速率。 命中率。

如果活跃数长期贴着 maxTotal,说明池容量可能太小,或者下游太慢,或者泄漏了。

如果 idle 长期很高,说明池容量太大,资源浪费。

如果创建和销毁频繁抖动,说明 maxIdle、minIdle 或驱逐策略不合理。

如果等待时间突然升高,但活跃数没满,可能是锁竞争或验证太慢。

调池子不是只改一个 max。它是看一组指标。

7.7 池化优化

池化不但没提升性能反而下降了?常见原因有几个:

对象创建成本本来就低 —— 池化的管理开销比创建还大。

池子太小 —— 频繁创建销毁,池子成了摆设。

重置成本太高 —— 对象状态太复杂,每次归还都要花大量时间重置。

锁竞争严重 —— 池的实现用了粗粒度的锁,高并发下大家都在等锁。

八、工程落地里的风险防控

落地必须做三层防控。第一是借出埋点记录,记录每个对象的借出时间、线程、业务标识。第二是后台定时扫描,超时未归还的对象强制回收、告警。第三是引用监控,防止对象被业务逻辑持有无法释放。

8.1 容量规划

容量规划可以从公式开始,但别迷信公式。

对象池容量大致可以按并发估算:

需要容量 ≈ 峰值 QPS × 单次资源持有时间

比如峰值 2000 QPS,每个请求平均持有连接 20ms。

2000 × 0.02 = 40

理论上 40 个连接能覆盖平均情况。但工程里要加冗余,考虑 P95 持有时间、突发流量、慢请求和下游抖动。

可以先设:

maxTotal = P95 并发持有量 × 1.2 到 1.5
maxIdle = 平稳期常用量
minIdle = 低峰预热量
maxWait = 业务可接受等待时间

内存池容量也类似。看峰值分配量、对象生命周期、空闲保留成本和机器内存上限。

千万别为了“永不阻塞”把池调得巨大。池子不是聚宝盆。你放进去的资源,都是系统要买单的。

8.2 资源泄漏检测

池化最怕借出不还。

连接池不还,最后所有请求都等连接。

ByteBuf 不 release,直接内存慢慢涨。

对象不归还,池活跃数一直升。

泄漏检测可以做几件事。

借出时记录时间、线程、调用栈。 超过阈值打印告警。 定期扫描长时间未归还对象。 暴露 active count 和 borrow duration。 debug 模式开启更详细追踪。

Netty 的引用计数机制就是为了管理这类资源生命周期。官方文档明确说 ByteBuf 生命周期绑定引用计数,引用计数归零时底层内存会释放或归还池中。

这类机制麻烦吗?麻烦,但没有它更麻烦。

8.3 状态污染防控

前面说过了,状态污染是大坑。

防控手段:

  • 归还时强制重置 —— 在passivateObject里把对象恢复到初始状态。
  • 借出时校验 —— 在activateObject里检查对象状态。
  • 单元测试 —— 专门写测试用例验证状态隔离。

8.4 可观测性建设

没有监控的组件,就是黑盒组件,线上出问题完全无法排查。

池化组件必须监控四个核心指标:

  • 命中率 —— 借出时有多少比例是从空闲池里直接拿的。命中率低说明池子太小或者预热不够。
  • 等待时长 —— 池满的时候调用方等了多久。
  • 活跃数 —— 当前有多少对象被借出去了。接近maxActive的时候要预警。
  • 失败率 —— 有多少次借出失败了(超时或抛异常)。

8.5 常见故障一:池化后性能下降的排查思路

症状:上了池化,性能反而下降了。

排查思路:

先看锁。

火焰图里如果大量时间在 ReentrantLock.lockpthread_mutex_lock、CAS retry,那就是并发结构有问题。

再看验证。

如果每次 borrow 都 ping 数据库,性能不下降才怪。验证应该按场景设计,比如空闲时验证、失败后验证,而不是每次无脑验证。

再看池容量。

活跃数贴满、等待线程升高,说明容量不够或资源持有时间太长。

再看对象重置。

有些 reset 比 new 还贵。比如清一个巨大 map,可能还不如重新创建一个小对象。

再看 GC。

池子长期持有大对象,会改变对象生命周期,让本来该被回收的东西晋升到老年代。

最后看业务模型。

有些对象根本不适合复用。你硬复用,就是跟运行时对着干。

8.6 常见故障二:内存持续上涨或 OOM 的排查思路

症状:内存一直涨,最后OOM。

排查思路:

第一步分清是堆内还是堆外。

C++ 内存上涨,看分配器 stats、heap profile、RSS、pageheap、arena 保留情况。

常见原因:

对象借出未归还。

空闲池过大。

ThreadLocal 缓存过多。

大对象进入池后长期不释放。

分配器缓存未归还 OS。

内存碎片。

引用链清理不彻底。

还有一个坑。池的内存上涨不一定是泄漏,也可能是高峰后缓存保留。你要看 active、idle、reserved、in-use 的区别。别一看到 RSS 不降就喊泄漏。也别一看到“缓存”就放心。缓存也会把机器吃死。

8.7 常见故障三:对象获取超时或线程阻塞的排查思路

症状:调用borrowObject的时候卡住了。

排查思路:

对象池获取超时,先看 active 是否打满。

如果打满,看资源持有时间。是不是业务慢了?是不是下游慢了?是不是事务没提交?是不是连接被长时间占用?

如果没打满,看锁竞争和验证耗时。

再看等待策略。maxWait 是否太短?阻塞队列是否公平?有没有惊群?

还有一种恶心情况是对象泄漏导致池逐渐耗尽。一开始只是偶发超时,后来越来越多,最后全挂。

这个时候看借出追踪最有效。没有追踪,就只能 grep 日志、翻代码、祈祷。很原始,也很常见。

8.8 常见故障四:复用对象状态异常的排查思路

症状:偶发出现上一请求数据。字段值莫名其妙。权限串用户。错误码和实际不符。集合里多了历史元素。回调被触发两次。

排查思路:

给对象加唯一 id,记录每次借出归还。

记录 reset 前后的关键字段。

debug 环境开启 use-after-return 检测。

缩小池容量到 1,强制复用同一个对象,更容易复现。

增加随机化测试,模拟异常路径。

检查所有可变字段是否清理。

检查内部集合是否 clear。

检查外部资源是否解绑。

池容量调成 1 这个招很土,但好用。因为状态污染最怕“不稳定复现”。你强制大家共用一个对象,鬼就比较容易现形。

九、总结

内存池解决的是“分配慢”的问题——通过预分配和缓存,减少系统调用和锁竞争。

对象池解决的是“创建贵”的问题——通过复用对象实例,减少创建销毁开销和GC压力。

两者的目标不一样,但思路一样:别每次都从头来,把用过的存起来下次再用


原文作者:Debug 蟹老板
原文地址:吃透池化技术:内存池与对象池,高性能服务的底层基石