
之前做一个2D射击对战小游戏的服务端模拟时我踩过一个非常典型的性能瓶颈每一帧都有大量子弹和碰撞检测对象需要创建生命周期往往只有几十毫秒。最开始图省事代码里到处是new和delete压测到1200并发左右CPU占用率直接冲上99%用perf一看malloc和free占了接近九成的时间。后来我把这些短暂对象全部改走对象池模式后同样规模下CPU占用直接掉到四成整个系统瞬间就“松”了。所谓对象池模式Object Pool Pattern核心就是一句话把需要频繁创建和销毁的对象提前批量创建好放在池子里用的时候借走用完归还而不是反复向操作系统申请和释放内存。这种做法可以显著减少动态分配开销、降低内存碎片、提升缓存命中率。C因为没有内置垃圾回收开发者在内存管理上的选择空间很大对象池也因此成为服务端中间件、游戏引擎、网络库、图形学基础组件里非常常见的一类组件。这篇文章不打算只贴一份能在面试里背出来的八股答案而是把我实际落地对象池时做的取舍、踩过的坑、性能对比方法都写出来。适合两类人一类是写C已经有一段时间、想让自己的服务或游戏减少对象创建开销的开发者另一类是准备面试想把对象池相关的原理、代码、坑位一次聊透的人。下面从性能账开始讲。1. 为什么需要对象池先把这笔性能账算清楚1.1 一次new/delete到底贵在哪很多人一开始想不明白new/delete不就是一次malloc/free吗能慢到哪里去我当年也是这么想的直到真的用perf工具看到火焰图集中在分配函数上才意识到动态内存分配的开销远不止“分配几字节”这么简单。一次malloc尤其在高并发多线程环境下至少要经历这几个环节用户态堆管理器查找空闲块。主流分配器glibc的ptmalloc、jemalloc、tcmalloc都会维护分箱结构、空闲链表甚至红黑树查找和合并都不是严格的O(1)操作。加锁。多线程程序共享同一个堆申请和释放都要拿锁。同一时间只有一个线程能修改堆元数据锁竞争一高线程全都堵在这一步。在某些情况下触发系统调用比如brk扩展堆段或mmap映射大块内存。一旦进入内核态开销轻松到几百纳秒甚至微秒级。释放的时候还要考虑内存碎片合并、部分内存归还给操作系统。拿普通的长整型对象举例在常见的glibc环境下如果有锁竞争一次new和delete可能要花费几百纳秒甚至更高而操作一个已经在池子里、地址已经确定的槽位一次acquire/release只需要一次指针操作加CAS几纳秒到几十纳秒就完成了。两者差距一个数量级是常态。生活里类比一下每次客人来了都现搬板材、现锯现钉做一张桌子而不是提前在仓库里备好一批桌子客人来了直接抬走用完还回来。调用方真正需要的是“一个可用的对象实例”而不是它背后那段昂贵的内存分配过程。理解到这一层你就抓住了对象池的本质把“创建”这个动作从昂贵的通用路径变成廉价的专用路径。1.2 什么场景值得上对象池什么场景别硬上说到这先泼一盆冷水对象池不是银弹。它的收益和场景强绑定硬上只会引入额外复杂度甚至让代码更难维护。我用下面几个特征判断一个对象是否适合池化创建和销毁频率极高单位时间内可能要创建几万甚至上百万个实例。平均生命周期极短通常是毫秒到秒级别用完能立刻归还。数量存在相对稳定的上下限不会出现“某段时间大量持有、但长时间闲置”的极端情况。创建过程不仅仅是内存分配还伴随昂贵资源获取比如网络连接、数据库连接、文件句柄、GPU资源。这类对象池化的收益比单纯省内存更大。反过来如果对象生命周期跨度很大有的存活几毫秒、有的存活几个小时池子里的对象数量会涨到难以控制如果对象内部持有超大块缓冲区空闲时依然占着大量内存进程内存水位线会明显升高如果对象构造本身极简单但到处都有多线程竞争锁开销甚至可能盖过分配开销。这些场景都不建议硬上对象池。还有一个常见误用把对象池单纯当作“减少内存分配”的工具在不需要的地方到处套。正确姿势应该是先用定位工具perf、火焰图、内存分配统计确认分配开销确实是瓶颈再动手优化。我那个2D射击服务端的例子就是因为先看到malloc占据热点才决定引入对象池否则盲改无异于用大炮打蚊子。1.3 对象池 vs 其他内存管理方案的取舍很多初学者会把对象池和另外几种方案混为一谈我把它们边界理清方案核心思想优点缺点裸new/delete按需分配手动释放简单、灵活分配慢、碎片、容易泄漏栈上对象生命周期与作用域绑定零分配开销只能处理确定生命周期不能动态创建Arena/区域分配一次性分配大块批次释放批量创建极快、释放集中单个对象延迟归还难、生命周期不灵活智能指针所有权自动管理防泄漏、配合RAII不减少分配shared_ptr有原子计数开销对象池预创建、借还复用分配极快、降低碎片、控制峰值需要管理归还、有初始化陷阱、占内存从这张表能看出来对象池最适合“高频、短生命周期、数量有限”的场景。Arena适合“大批量对象在同一阶段创建、同一阶段销毁”的情况比如帧内临时计算对象智能指针是C里最基础的资源管理手段和对象池并不冲突两者往往组合使用对象池负责提供快速分配智能指针负责带动RAII自动归还。一句话总结这一章对象池解决的是“高频短生命周期对象的动态分配开销”。它不解决所有内存问题也不是用来替代智能指针的。动手之前先确认程序确实有这个瓶颈这比写池子本身更重要。2. 核心设计思路先想清楚这四件事再写代码2.1 数据结构选择指针列表还是索引池写对象池最核心的数据结构是空闲列表也就是记录哪些槽位现在能用。最简单的实现是std::vectorT*acquire取尾部指针release把指针push回来每次操作都是O(1)。不过实际工程里我很多时候更偏好索引池所有对象存在一个std::vectorT或std::vectorstd::unique_ptrT里空闲列表存的不是T*而是int或uint32_t类型的下标。这么做有个关键好处release的时候可以立刻校验下标是否合法、是否处于“已借出”状态而且因为下标是整数天然避免了指针地址本身被篡改或悬空导致的不可信问题。纯指针方案也有优势拿到对象后不用多一次“下标到对象”的映射在极其追求热路径性能的场合能省掉一次数组索引。但实际测试下来这个差距通常都在噪声级别以内。我的建议是项目处于调试期或对安全要求高优先选索引池如果对极致的性能敏感且能保证release的指针必然来自同一个池指针列表才是更低开销的选择。无论选哪种都有一个前提是通用的池内对象的地址必须稳定。如果对象本体存放在一个会扩容的std::vectorT里扩容会导致所有对象发生拷贝/移动原先交给外部的指针或索引全部失效。这也是我推荐用std::vectorstd::unique_ptrT持有对象的原因——扩容时移动的是unique_ptr外壳对象在堆上的地址纹丝不动。这个点特别容易被新手踩到一扩容线上就出现诡异的随机崩溃。2.2 池的初始大小和扩容策略池子建多大、满了怎么办是设计对象池绕不开的问题。最简单的策略是固定池分配N个对象最多同时借出N个第N1个acquire直接返回失败或等待。这种设计特别适合“峰值并发量可确定”的业务比如连接池、有界任务队列还能天然起到流量保护作用超出的请求要么排队要么拒绝。如果业务波动比较大比如游戏里的单位数量、消息对象数量固定池就不好用了。我通常采用“初始容量 按需翻倍扩容”的策略。初始容量按“服务启动时最保守并发量”来设比如一个服务器的连接对象池先给64个不够再翻倍到128、256。翻倍增长能保证均摊成本低避免反复小步扩容带来的分配次数过多。扩容时要注意一个细节新增对象只追加到空闲列表尾部不碰已经借出去的对象。所以在单线程下扩容逻辑非常简单创建一批新对象把它们的指针或索引追加到空闲列表尾部就行。多线程版本里扩容必须持有池锁否则会出现两个线程同时扩容、freeList被重复修改的竞争问题。我一般不建议在业务运行过程中频繁“缩容”。对象池存在的意义就是控制高频分配池子一边扩容一边销毁大批对象等于让内存频繁走free路径又重新制造碎片和系统调用。真要控制内存水位可以在池子空闲超过一定比例且持续一段时间后做一次清理但这件事细节偏多我放到第四章实战部分单独说。2.3 多线程安全与ABA问题服务端程序绕不开多线程对象池自然也要考虑并发安全。最简单的版本是给acquire和release各加一把互斥锁。这个方案在小并发下没问题但如果你用16个线程同时高频借还对象锁会成为新热点性能可能比裸new/delete还差。所以实际工程里多线程对象池通常有三种递进方案第一种是池内部加锁简单通用适合并发量不算极端、业务逻辑不追求极致性能的场景。第二种是线程本地池。每个线程维护一个专属的本地临时池acquire先从本地池取不够再到全局池取一批release先归还到本地池本地池攒到一定数量再整批还给全局池。这个思路和tcmalloc、jemalloc的分层分配器思想一致通过减少跨线程访问来降低锁竞争批量转移也摊薄了锁开销。第三种是无锁对象池。基于std::atomic操作把空闲列表做成无锁LIFO栈release就是push头部acquire就是pop头部。听起来优雅但这里藏着一个经典陷阱——ABA问题。ABA问题的故事是这样的线程A读取栈顶指针X然后被切出去了线程B趁A不在把X出栈随后又因为别的release把X压回来这时栈里对象和地址看起来和之前一模一样线程A恢复执行用CAS把top从X改成自己知道的YCAS发现top仍然是X判定“条件满足、操作成功”。但实际上X已经被B用了一个来回此刻X可能已经处在被某处借出的状态A的这一改直接就把本应还在使用中的对象从栈里拿了下来。轻则逻辑错乱重则内存踩踏。对付ABA问题有几种手段给每个槽位额外维护一个版本号用tagged pointer把版本号编码进指针高位或者干脆放弃无锁LIFO改用复杂得多的hazard pointer方案。对绝大多数业务场景来说我的建议是不要为了“无锁”而无锁。先做线程本地池全局互斥池的版本实测对比后再决定要不要上无锁。无锁光是在写、调、review上的投入就比锁版本高一个数量级收益不一定匹配。2.4 构造和析构的时机怎么控制对象池并不只是“省掉内存分配”这么简单它还隐含了一个关键问题什么时候真的调用对象的构造函数和析构函数最常见的设计是池初始化或扩容时一次性把对象构造好。之后acquire只负责从空闲列表取出对象不重新构造release只负责放回列表不调用析构。这样做的优点很直接acquire/release路径极快只有链表/数组操作。缺点也明显对象会带上一次使用留下的状态。比如消息对象上一轮set了长度为1024这一轮借出来如果不主动reset残留数据就会污染下一轮。另一种设计是release时调用析构、acquire时用placement new重新构造。这样每个对象每次被借出都是全新状态省掉了业务方手动重置的心智负担代价是每次acquire多一次构造、release多一次析构。如果对象构造便宜、字段多但简单这种方式用起来最省心。折中方案是在对象内部提供一个轻量reset()方法对需要重置的成员做清零池不参与状态管理。热路径上调用方在acquire之后顺手调reset再使用。这个方案把“重置”控制权交还给业务方代价是约定业务方必须记得调用。我个人的经验是凡是对象字段里有容器、字符串、指针这类需要深度清理的成员优先选“析构重建”方案凡是纯数值型、可整体清零的轻对象用“reset约定”就行。提前把这层策略定下来能省掉后面大量排查“对象状态串台”的bug时间。3. 从零实现一个可上线的C对象池3.1 单线程基础版先把核心流程跑通先上一个最基础的单线程实现这个版本只负责把“借/还”的核心流程跑通#include vector #include memory #include cassert template typename T class ObjectPool { public: explicit ObjectPool(size_t initSize 0) { for (size_t i 0; i initSize; i) { addOne(); } } T* acquire() { if (m_freeList.empty()) { grow(); } T* obj m_freeList.back(); m_freeList.pop_back(); return obj; } void release(T* obj) { assert(obj ! nullptr); m_freeList.push_back(obj); } size_t capacity() const { return m_objects.size(); } size_t available() const { return m_freeList.size(); } private: void addOne() { auto ptr std::make_uniqueT(); m_freeList.push_back(ptr.get()); m_objects.push_back(std::move(ptr)); } void grow() { size_t oldSize m_objects.size(); size_t growSize (oldSize 0) ? 16 : oldSize; for (size_t i 0; i growSize; i) { addOne(); } } std::vectorstd::unique_ptrT m_objects; std::vectorT* m_freeList; };这里有一个容易被忽略的设计选择为什么m_objects存的是unique_ptr而不是直接存T因为m_objects本身是个会扩容的vector如果直接存T扩容时的移动会让所有已借出对象的内存地址发生变化外部拿到的裸指针会瞬间全部失效。改成存unique_ptr以后vector扩容时移动的只是智能指针外壳底层堆对象地址始终不变这是整个实现稳定的基石。m_freeList用的是vectorT*acquire/release都只操作尾部天然就是LIFO顺序效率上完全够用。release里的assert也很重要它虽然不能接收任意指针但至少能在调试期帮你尽早暴露“释放了空指针”这种低级错误。再强调一遍这个基础版没有做对象重置。acquire出来后对象可能带着上一次使用的残留数据。测试阶段可以先拿struct Point { int x, y; };这种平凡类型验证逻辑真正上生产之前务必按后面说到的方案补上状态管理。3.2 RAII自动归还彻底防泄漏的最佳姿势裸用acquire/release有一个很实际的风险业务代码中途return了或者抛了个异常release那行被跳过对象就一直挂在“已借出”状态池子慢慢变空最终表现为“池不够用了”却查不出谁没还。解决这个问题最符合C习惯的办法是RAII包装让对象在生命周期结束时自动回到池中。我推荐直接用unique_ptr 自定义删除器#include memory template typename T class ObjectPool; // 前置声明 template typename T struct ObjectPoolDeleter { ObjectPoolT* pool nullptr; void operator()(T* obj) const { if (pool obj) { pool-release(obj); } } }; template typename T using PoolPtr std::unique_ptrT, ObjectPoolDeleterT; template typename T class ObjectPool { public: // ... 上述核心实现略 ... PoolPtrT acquireManaged() { T* raw acquire(); return PoolPtrT(raw, ObjectPoolDeleterT{this}); } };这里给出一个最简单使用方式注意acquireManaged返回的PoolPtr不能拷贝只能移动这正好符合对象池的独占语义——同一个池中对象在同一时刻只允许一个所有者移动即所有权转移ObjectPoolMessage pool(64); { auto msg pool.acquireManaged(); msg-payload 42; // 函数返回或抛异常时msg析构对象自动release回池 }相比手写一个PoolGuard类用unique_ptr的好处是它本身就是标准库组件移动语义、判空、重载operator-天然具备别人接手时不需要学习一个陌生的guard类型。另一个细节是如果业务方需要把对象借给其他函数使用直接返回PoolPtrT也是可以的因为移动构造会正确转移所有权。千万不要在函数里return裸指针那样对象池的保护作用就前功尽弃了。3.3 线程安全版从加锁到线程本地缓存如果项目需要多线程共用同一个池最简单做法是在上节代码基础上包一层锁。我不建议把锁散在主流程代码里而是封装在池内部#include mutex template typename T class ThreadSafeObjectPool { public: T* acquire() { std::lock_guardstd::mutex lock(m_mutex); if (m_freeList.empty()) { grow(); } T* obj m_freeList.back(); m_freeList.pop_back(); return obj; } void release(T* obj) { std::lock_guardstd::mutex lock(m_mutex); m_freeList.push_back(obj); } private: std::mutex m_mutex; std::vectorstd::unique_ptrT m_objects; std::vectorT* m_freeList; };这个版本代码很薄但有一个明显性能隐患如果16个线程同时高频acquire/release这把锁就是全系统公共瓶颈。我在8核机器上做过粗略测试16线程并发频繁借还时加锁对象池的耗时甚至可能接近甚至超过裸new/delete。原因很简单裸malloc在多线程下也有很多优化而你的池锁把所有线程串行化了。所以并发规模上来以后我推荐做“线程本地缓存池全局池”的分层结构每个线程先用thread_local的本地池处理大部分借还只有本地池空了才去全局池批量取本地池满到某个水位才把整批归还全局池。这样大部分借还都发生在本地根本不会碰到全局锁。具体实现细节比这篇展开的要多但方向是固定的参考tcmalloc的分层思路不会走错。3.4 调试与统计让池子自己开口说话生产环境里对象池最让人头疼的是“池空了但不知道谁借走了没还”。我的建议是在池里维护一张状态表至少记录每个对象当前是空闲还是使用中并暴露统计接口。状态校验用哈希表完成逻辑清晰但性能差所以只推荐在调试构建中开启#include unordered_set template typename T class DebugObjectPool : public ObjectPoolT { public: T* acquire() { T* obj ObjectPoolT::acquire(); assert(m_inUse.insert(obj).second Double acquire!); return obj; } void release(T* obj) { assert(m_inUse.erase(obj) 1 Release a free object!); ObjectPoolT::release(obj); } private: std::unordered_setT* m_inUse; };有了状态表以后还可以顺手记录一些统计量峰值使用数、总借出次数、扩容次数。这些指标压测时非常有用——看到峰值使用数接近容量上限说明该扩容了看到扩容次数很多说明初始容量设小了。如果项目追求更强的调试能力可以在release时额外校验传入的对象是否真的属于本池做法包括保存对象槽位反向表或用哈希集合记录所有合法对象地址。这类校验有一定开销一般只在调试配置_DEBUG或NDEBUG未定义下开启生产环境直接跳过。3.5 怎么验证性能提升一套能落地的基准方法写完对象池一定会有人问“到底快了多少”。别凭感觉说跑一个能服众的基准。下面这套方法可以用来测单线程版和裸new/delete的差距#include chrono #include cstdio volatile long g_sink 0; static constexpr int kIterations 10000000; void benchNewDelete() { auto start std::chrono::steady_clock::now(); for (int i 0; i kIterations; i) { auto* obj new long(i); g_sink ^ *obj; delete obj; } auto ms std::chrono::duration_caststd::chrono::milliseconds( std::chrono::steady_clock::now() - start).count(); std::printf(new/delete: %lld ms\n, ms); } void benchPool() { ObjectPoollong pool(1024); auto start std::chrono::steady_clock::now(); for (int i 0; i kIterations; i) { auto* obj pool.acquire(); *obj i; g_sink ^ *obj; pool.release(obj); } auto ms std::chrono::duration_caststd::chrono::milliseconds( std::chrono::steady_clock::now() - start).count(); std::printf(object pool: %lld ms\n, ms); }这里有几点经验第一一定要用volatile变量或者不可内联的外部函数消费掉对象内容否则编译器发现分配出来的对象没被实际使用直接连整条循环都优化没了测出来的时间毫无意义。第二不要只跑一轮。冷启动、系统时钟调度、其他进程干扰都会造成抖动至少跑5轮取中位数或最小值才有参考价值。第三两边用同样的对象类型、同样的迭代次数、同一套编译选项。Debug和Release都要测。有时候Debug下对象池的胜出幅度反而更明显因为STL容器在Debug下的检查逻辑更严格公平起见两边都保持相同的构建配置。在我自己机器上单线程、平凡long对象、千万次循环的对比里对象池通常比new/delete快一个数量级以上。但这个数字和平台、编译器、锁竞争程度强相关当它作量级参考就好不要当绝对结论。真正有说服力的是用你自己的业务对象在压测环境里实测出来的结果。4. 实战踩坑记录与排查技巧4.1 对象残留状态引发的诡异bug对象池最大的隐性坑就是对象上的旧数据不会自动消失。举一个真实例子一个消息对象池结构体里有个std::string body。第一次使用set了hellorelease回池第二次acquire出来调用方很自然地执行msg-body , world;结果字符串变成了hello, world而不是预期的, world。这种bug非常隐蔽因为你单看acquire调用的那几行代码根本想不到字符串里已经有残留。解决思路有三层我按优先级排列对象提供reset()方法acquire返回后由使用方主动调用逻辑清晰、路径可控池在release时不做任何处理只约定“凡是池化对象默认状态不可信”acquire后必须手动重置干脆用release时析构acquire时placement new重建让对象每次都是全新构造从根上消灭残留。第三种方案最安全代价就是每次acquire多一次构造函数。如果你的对象构造不贵我强烈推荐直接用第三种。如果担心构造开销就选reset方案但一定要把“acquire后必须reset”写进团队代码规范甚至可以通过静态检查来约束。4.2 悬空指针与双重释放另一个高频坑是双重释放同一个池里的对象被两个模块各自release了一次。第一次release没问题第二次release会把一个已经空闲的指针再次塞进空闲列表导致列表里出现两个相同地址。之后acquire两次会拿到同一个对象两个调用方互相覆盖数据表现就是“莫名其妙的数据串台”。防御手段刚才提过核心是两点用状态表标记每个槽位的状态release时校验必须是InUse否则立刻assert或抛异常用索引池而非裸指针池release时校验传入的下标是否在合法区间内。还有一个更隐蔽的问题如果对象被某个模块直接delete而不是release回池那个裸指针就变成悬空指针后续其他模块再release一次池就拿到了一个已经释放的地址。这种问题单靠池自身很难完全防御唯一稳妥的办法是约定凡是从池里取出来的对象一律禁止裸delete所有释放必须通过池的release接口或RAII包装器完成。代码审查时我有一条硬规矩看到new/delete和对象池混用必须打回去重写。另一个容易被忽略的要求release永远只能接受本池acquire返回的地址。如果业务方从一个池子里acquire却release到另一个池子里状态表校验能直接查出来如果是裸指针版本的池程序可能当场就崩。这也是我推荐索引池的一个重要理由。4.3 锁竞争与缓存伪共享多线程池的实际性能瓶颈往往不止锁。有一个经典问题叫缓存伪共享false sharing两个线程各自频繁修改两个不同的变量但它们碰巧被编译器分配到同一条64字节缓存行上。每个线程修改自己的变量都会导致这条缓存行在其他核心上的副本失效两个线程看似独立实际上互相拖慢性能差距可能到5倍以上。对象池里出现伪共享的典型场景是多个线程各自持有本地变量记录本线程的借还次数、命中次数等统计值。这些变量在内存上紧挨着于是每个线程更新自己的计数器都让所有人的缓存行失效。解决办法很简单把每个线程的统计变量按缓存行大小对齐填充比如用alignas(64)修饰一个结构体让每个线程独占一条缓存行struct alignas(64) ThreadStat { long long acquireCount 0; long long releaseCount 0; };另一个相关经验是如果多线程共享同一个池池的核心成员空闲列表头指针、锁变量最好也能做对齐处理。很多高性能代码会把mutex放在结构体开头并加alignas(64)避免它和别的字段共享缓存行。这个优化在低并发时看不出差别线程数到8以上就非常明显。说到底多线程下的优化顺序应该是先确认锁竞争是不是瓶颈再考虑无锁先用线程本地池降低跨线程访问再考虑内存布局优化。千万别跳过前两步直接上无锁那会让你在调试ABA问题和内存序问题上消耗大量时间。4.4 池该不该缩容资源回收的节奏最后说一个很多人都会纠结的问题对象池满了会扩容那什么时候缩容我的态度是默认不缩容。原因在于对象池的核心价值是保持分配路径稳定。如果池在运行中动态收缩等于让一部分内存频繁走free路径重新制造碎片和系统调用。而且缩容判定本身很难定义是发现空闲对象很多就缩还是持续空闲超过某段时间才缩不同业务场景波动规律差别很大很容易出现“刚缩完容下一秒并发高峰又来了池子又开始频繁扩容”的抖动。如果确实存在内存水位压力可以这样处理池本身不做动态缩容而是在进程启动时根据历史监控数据合理设置初始容量和峰值上限让容量覆盖绝大多数场景如果发现某个池长期空闲对象占比超过80%可以在业务低峰期主动重建一个更小的池再整体切换。注意切换前必须确保旧池中所有对象都已归还否则会丢失还在使用中的对象。最后还有一个容易忽略的点对象池析构时如果还有对象没有归还它不会帮你优雅地逐个释放——因为无法区分“未归还”和“处于使用中”。所以析构前一定要确保业务方已经把所有对象释放回池或者干脆容忍析构时底层vector直接销毁全部对象。如果你的对象持有外部资源比如文件句柄、数据库连接更要警惕这里可能导致的资源泄漏。写到最后分享一点我多年用池子的体会对象池是一个非常经典但很容易用歪的模式它的价值永远是为业务场景服务的而不是为了在代码里增加一个听起来很高级的组件。我见过太多人一上来就写无锁版本、把复用搞得很复杂结果业务根本到不了那个并发量白白增加维护成本。我的做法是先写一个带统计功能的最简单版本用真实压测数据决定要不要继续优化。先让池子跑起来、能观测、能统计再谈无锁、线程本地缓存那些进阶手段。如果你正打算在项目里引入对象池希望这篇文章能帮你避开我踩过的大多数坑。