Java 对象池技术实战——什么时候用对象池真的能省内存,什么时候是自欺欺人
Java 对象池技术实战——什么时候用对象池真的能省内存,什么时候是自欺欺人
适读人群:Java 中高级开发者,关注性能优化的同学 | 阅读时长:约 14 分钟 | 核心价值:彻底搞清楚对象池的适用场景和不适用场景,不再被"对象池能省内存"这句话糊弄
我入行第四年的时候,带着一腔热情给一个系统加上了对象池。我用 Apache Commons Pool2 实现了一套 JSON 解析器对象池,然后在 code review 上骄傲地说:"你们看,对象创建少了,GC 压力下降了!"
同事问了我一个问题,让我当场哑口无言:
"你测过加对象池之前和之后的 GC 频率了吗?"
我没测过。
后来一测,加了对象池之后,Young GC 频率确实降低了,但每次 GC 的停顿时间反而变长了,因为存活对象更多了,GC Roots 追踪更耗时。整体吞吐量反而略有下降。
那次之后,我开始认真研究:对象池到底什么时候有用。
对象池的本质是什么
很多人把对象池理解成"节约内存"的工具,这是错的。
对象池的本质是降低对象创建和销毁的成本。
当创建和销毁一个对象的开销远大于使用对象本身的开销时,对象池才有价值。
什么对象创建销毁成本高?
- 数据库连接(需要建立 TCP 连接、完成握手、认证)
- HTTP 连接(同上)
- 线程(JVM 层面的线程创建涉及 OS 线程)
- 大数组、大 ByteBuffer(内存分配成本高)
什么对象创建销毁成本低?
- 普通的 POJO 对象(就是 new 一下,heap 上分配几十字节)
- String
- 小集合
对于普通 POJO,现代 JVM 的 TLAB(Thread Local Allocation Buffer)分配极其便宜, 创建成本接近于递增一个指针。GC 也针对短命对象做了大量优化(Young GC)。给普通 POJO 加对象池,是在用复杂度换一个没有的收益。
真正值得用对象池的场景
场景一:数据库连接池
这是最经典也最正确的对象池用法。HikariCP、Druid 都是连接池的实现。
数据库连接的创建成本:
- TCP 三次握手:1-3ms(局域网)
- 数据库认证:2-5ms
- 发送初始化 SQL(
SET NAMES utf8mb4等):1-2ms
如果每个查询都创建一个新连接,一个连接建立就要 4-10ms,而一个简单查询本身可能只需要 1ms。这时候连接池的价值是巨大的。
场景二:直接内存 ByteBuffer
package com.example.pool;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.PooledByteBufAllocator;
/**
* Netty 的池化 ByteBuf 分配器示例
* 直接内存分配是非常昂贵的操作,Netty 对此做了池化处理
*/
public class DirectBufferPoolDemo {
// 全局唯一的池化分配器,不要每次都 new
private static final PooledByteBufAllocator ALLOCATOR = PooledByteBufAllocator.DEFAULT;
public static void processNetworkData(byte[] rawData) {
// 从池中申请一块直接内存
ByteBuf buffer = ALLOCATOR.directBuffer(rawData.length);
try {
buffer.writeBytes(rawData);
// ... 处理数据 ...
} finally {
// 必须 release,不然内存泄漏
// 我吃过这个亏,压测时内存一直涨,查了半天才发现 finally 里没有 release
buffer.release();
}
}
}直接内存(堆外内存)的分配要调用 malloc,成本远高于 TLAB 分配。池化直接内存是 Netty 性能的关键所在。
场景三:线程池(ExecutorService)
线程创建涉及 OS 层面的系统调用,成本极高。ThreadPoolExecutor 本质上就是一种线程对象池。
踩坑实录一:给 ObjectMapper 加对象池,结果适得其反
这就是我文章开头说的那次失误。
当时我的逻辑是:ObjectMapper 初始化有成本(加载类型信息、配置),所以做个池复用。
// 错误示范——别这么做
public class WrongObjectMapperPool {
private static final ObjectPool<ObjectMapper> pool = new GenericObjectPool<>(
new BasePooledObjectFactory<ObjectMapper>() {
@Override
public ObjectMapper create() {
ObjectMapper mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return mapper;
}
@Override
public PooledObject<ObjectMapper> wrap(ObjectMapper mapper) {
return new DefaultPooledObject<>(mapper);
}
}
);
public static String toJson(Object obj) throws Exception {
ObjectMapper mapper = pool.borrowObject(); // 借出
try {
return mapper.writeValueAsString(obj); // 使用
} finally {
pool.returnObject(mapper); // 归还
}
}
}问题在哪?
- ObjectMapper 本身是线程安全的,不需要池化,直接用一个单例就行。
- 池化之后,借用/归还的同步开销(锁竞争)比创建一个对象还贵。
- 对象在池中等待时占用内存,但 GC 不能回收。
正确做法:
package com.example.json;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
/**
* ObjectMapper 是线程安全的,单例就够了,根本不需要对象池
*/
public class JsonUtils {
// 一个单例,全局复用——这才是正确姿势
private static final ObjectMapper MAPPER = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
public static String toJson(Object obj) throws Exception {
return MAPPER.writeValueAsString(obj);
}
public static <T> T fromJson(String json, Class<T> clazz) throws Exception {
return MAPPER.readValue(json, clazz);
}
}踩坑实录二:自己实现的对象池,归还时忘记重置状态
有一次我们有个场景,确实需要复用一批计算对象(一个统计累加器,初始化成本约 3ms),我用了自己写的简单对象池:
// 这是一个有 BUG 的示例,请注意
public class BuggyCalculatorPool {
private final Queue<StatsCalculator> pool = new ConcurrentLinkedQueue<>();
public StatsCalculator borrow() {
StatsCalculator calc = pool.poll();
if (calc == null) {
calc = new StatsCalculator();
}
return calc;
}
public void returnToPool(StatsCalculator calc) {
pool.offer(calc); // BUG:没有 reset!
}
}这段代码的 bug 是:归还时没有重置计算器的状态。下一个使用者拿到的是一个已经有数据的计算器,计算结果自然是错的。更糟糕的是,这种 bug 是随机的,取决于上一个使用者是否恰好没有往里面写数据。
正确实现:
package com.example.pool;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 简单对象池,关键点:归还时必须重置对象状态
*/
public class StatsCalculatorPool {
private final Queue<StatsCalculator> pool = new ConcurrentLinkedQueue<>();
private final int maxSize;
private final AtomicInteger currentSize = new AtomicInteger(0);
public StatsCalculatorPool(int maxSize) {
this.maxSize = maxSize;
}
public StatsCalculator borrow() {
StatsCalculator calc = pool.poll();
if (calc == null) {
// 没有可用对象,新建一个
calc = new StatsCalculator();
}
return calc;
}
public void returnToPool(StatsCalculator calc) {
// 关键:归还前必须重置状态,否则下个使用者拿到脏数据
calc.reset();
// 池子满了就丢掉,让 GC 回收
if (currentSize.get() < maxSize) {
pool.offer(calc);
currentSize.incrementAndGet();
}
}
/**
* 模拟的统计计算器
*/
public static class StatsCalculator {
private long sum = 0;
private int count = 0;
public void add(long value) {
sum += value;
count++;
}
public double getAverage() {
return count == 0 ? 0 : (double) sum / count;
}
// 归还到池中之前必须调用这个方法
public void reset() {
sum = 0;
count = 0;
}
}
}踩坑实录三:对象池大小设置错误,变成了系统瓶颈
一个接口的并发量是每秒 500 个请求,我把对象池大小设成了 10,结果在高峰期对象全部被借出,新来的请求要等待归还,接口延迟从 20ms 暴增到 800ms。
这个教训很简单:对象池的大小要根据并发量来设置,不是越小越好。
一般的经验是:池大小 = 预期并发数 × 1.2(留一些余量)。同时要设置借用超时时间,超时了直接创建新对象而不是一直等:
package com.example.pool;
import org.apache.commons.pool2.impl.GenericObjectPool;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
public class PoolConfigExample {
public static <T> GenericObjectPool<T> createPool(
org.apache.commons.pool2.PooledObjectFactory<T> factory,
int expectedConcurrency) {
GenericObjectPoolConfig<T> config = new GenericObjectPoolConfig<>();
// 最大对象数 = 预期并发数 * 1.2,至少 10 个
config.setMaxTotal(Math.max(10, (int)(expectedConcurrency * 1.2)));
// 最小空闲数,保留一些预热的对象
config.setMinIdle(5);
// 最大等待时间:超过 200ms 没拿到对象,不等了,直接抛异常或者新建
config.setMaxWaitMillis(200);
// 借用时检测对象是否有效
config.setTestOnBorrow(true);
// 归还时检测对象是否有效
config.setTestOnReturn(true);
return new GenericObjectPool<>(factory, config);
}
}什么时候应该直接让 GC 干活,不用对象池
对于生命周期短的普通对象(比如请求处理中产生的 DTO、临时 List 等),不要用对象池,让 GC 做 Young GC 更高效。
现代 JVM 的 Young GC 对短命对象非常友好:
- TLAB 分配速度极快(纳秒级)
- Young GC 只扫描存活对象,短命对象死了就直接回收
如果你把大量短命对象池化,反而会导致:
- 对象变成长命对象,晋升到 Old Gen
- Old GC(Full GC)频率增加,停顿时间变长
我的决策规则很简单:
- 对象的创建成本 > 1ms?值得考虑对象池。
- 对象是线程安全的单例就能解决的?不要池化,直接单例。
- 对象有可变状态?池化时必须 reset,考虑清楚是否真的值得。
- 并发量 < 100/s?大概率不需要对象池,先做 profiling 再说。
推荐用 Apache Commons Pool2,不要自己造轮子
自己实现的对象池很容易有 bug(正如我展示的那个例子)。生产环境推荐用 Apache Commons Pool2,它处理了很多边界情况:对象有效性检测、空闲对象超时回收、借用超时、指标监控等。
如果是 Netty 网络场景,用 Netty 自带的 Recycler(轻量级线程本地对象池)或者池化 ByteBufAllocator。
如果是 Spring 应用里的数据库连接,直接用 HikariCP,参数调好就行,不要重新发明轮子。
对象池是一个有具体适用条件的工具,不是"加上就好"的万能药。下次有人说"我们加个对象池优化一下",你应该先问:创建这个对象要多久?有没有量过?
