JavaScript 对象池:减少高频对象创建与 GC 压力

在 JavaScript 中,函数内部创建对象是一种非常常见的写法:

typescript 复制代码
        
function createMessage(type, payload) {
  return {
    type,
    payload,
    timestamp: Date.now()
  };
}

for (let i = 0; i < 100000; i++) {
  const message = createMessage("event", i);
  // 使用 message
}
        
      

这段代码本身没有问题。但如果函数被高频调用,并且每次都会创建临时对象,就可能产生大量短生命周期对象。它们会频繁进入垃圾回收流程,在某些场景下造成:

  • 内存分配次数增加;
  • Minor GC 频繁执行;
  • 游戏或动画帧出现卡顿;
  • 高频数据处理吞吐量下降;
  • 移动端设备出现更明显的性能波动。

对象池(Object Pool)是一种通过"重复使用对象"来减少重复分配的优化技术。它预先创建或按需创建一批对象,使用时从池中获取,使用完成后归还池中,而不是每次都创建新对象。

不过需要注意:现代 JavaScript 引擎的垃圾回收已经非常高效。对象池并不是所有场景都适合,必须经过性能分析后再使用。


一、对象池的基本思想

普通方式:

ini 复制代码
        
function createVector(x, y) {
  return { x, y };
}

for (let i = 0; i < 100000; i++) {
  const vector = createVector(i, i);
  // 使用 vector
}
        
      

对象池方式:

ini 复制代码
        
const vectorPool = [];

function acquireVector() {
  return vectorPool.pop() || { x: 0, y: 0 };
}

function releaseVector(vector) {
  vector.x = 0;
  vector.y = 0;
  vectorPool.push(vector);
}

for (let i = 0; i < 100000; i++) {
  const vector = acquireVector();

  vector.x = i;
  vector.y = i;

  // 使用 vector

  releaseVector(vector);
}
        
      

核心流程是:

rust 复制代码
        
获取对象 -> 设置数据 -> 使用对象 -> 清理状态 -> 归还对象
        
      

与其反复执行:

rust 复制代码
        
创建对象 -> 使用对象 -> 等待 GC 回收
        
      

对象池将大量短生命周期对象变成了少量可重复使用的对象。


二、一个通用对象池实现

可以先实现一个通用对象池。

kotlin 复制代码
        
class ObjectPool {
  constructor(factory, reset, initialSize = 0, maxSize = Infinity) {
    this.factory = factory;
    this.reset = reset;
    this.maxSize = maxSize;
    this.items = [];

    for (let i = 0; i < initialSize; i++) {
      this.items.push(factory());
    }
  }

  acquire() {
    return this.items.pop() || this.factory();
  }

  release(object) {
    this.reset(object);

    if (this.items.length < this.maxSize) {
      this.items.push(object);
    }
  }

  get size() {
    return this.items.length;
  }
}
        
      

使用示例:

ini 复制代码
        
const userPool = new ObjectPool(
  () => ({
    id: 0,
    name: "",
    score: 0,
    active: false
  }),

  user => {
    user.id = 0;
    user.name = "";
    user.score = 0;
    user.active = false;
  },

  10,
  100
);
        
      

获取和释放对象:

ini 复制代码
        
const user = userPool.acquire();

user.id = 1001;
user.name = "Tom";
user.score = 90;
user.active = true;

// 使用 user

userPool.release(user);
        
      

这里的 reset 非常重要。如果不重置对象状态,下次获取时可能残留上一次使用的数据。


三、为什么必须重置对象状态

错误示例:

ini 复制代码
        
const pool = [];

function acquireUser() {
  return pool.pop() || {
    name: "",
    age: 0
  };
}

function releaseUser(user) {
  pool.push(user);
}

const user1 = acquireUser();
user1.name = "Alice";
user1.age = 18;
releaseUser(user1);

const user2 = acquireUser();

console.log(user2);
// { name: "Alice", age: 18 }
        
      

user2 继承了 user1 的数据,这可能导致非常隐蔽的业务 bug。

正确做法:

ini 复制代码
        
function releaseUser(user) {
  user.name = "";
  user.age = 0;
  pool.push(user);
}
        
      

或者在获取时重置:

ini 复制代码
        
function acquireUser() {
  const user = pool.pop() || {
    name: "",
    age: 0
  };

  user.name = "";
  user.age = 0;

  return user;
}
        
      

一般建议在 release 阶段完成清理,因为归还对象时状态更明确。


四、应用场景一:游戏中的粒子系统

粒子系统通常会在每一帧创建、更新和销毁大量粒子,例如:

  • 爆炸效果;
  • 烟雾;
  • 火焰;
  • 雨雪;
  • 子弹轨迹;
  • 魔法特效。

如果每个粒子都通过 new 创建,可能产生大量短生命周期对象。

4.1 不使用对象池

javascript 复制代码
        
function createParticle(x, y) {
  return {
    x,
    y,
    vx: Math.random() * 2 - 1,
    vy: Math.random() * 2 - 1,
    life: 1,
    size: 4
  };
}

const particles = [];

function emitParticle(x, y) {
  particles.push(createParticle(x, y));
}
        
      

大量创建和删除粒子时,会产生较多垃圾对象。

4.2 使用对象池

ini 复制代码
        
class ParticlePool {
  constructor(size = 1000) {
    this.pool = [];

    for (let i = 0; i < size; i++) {
      this.pool.push(this.create());
    }
  }

  create() {
    return {
      x: 0,
      y: 0,
      vx: 0,
      vy: 0,
      life: 0,
      size: 0,
      active: false
    };
  }

  acquire(x, y) {
    const particle = this.pool.pop();

    if (!particle) {
      return null;
    }

    particle.x = x;
    particle.y = y;
    particle.vx = Math.random() * 2 - 1;
    particle.vy = Math.random() * 2 - 1;
    particle.life = 1;
    particle.size = 4;
    particle.active = true;

    return particle;
  }

  release(particle) {
    particle.x = 0;
    particle.y = 0;
    particle.vx = 0;
    particle.vy = 0;
    particle.life = 0;
    particle.size = 0;
    particle.active = false;

    this.pool.push(particle);
  }
}
        
      

粒子系统:

ini 复制代码
        
const particlePool = new ParticlePool(1000);
const activeParticles = [];

function emit(x, y, count) {
  for (let i = 0; i < count; i++) {
    const particle = particlePool.acquire(x, y);

    if (particle) {
      activeParticles.push(particle);
    }
  }
}

function update(deltaTime) {
  for (let i = activeParticles.length - 1; i >= 0; i--) {
    const particle = activeParticles[i];

    particle.x += particle.vx * deltaTime;
    particle.y += particle.vy * deltaTime;
    particle.life -= deltaTime;

    if (particle.life <= 0) {
      activeParticles.splice(i, 1);
      particlePool.release(particle);
    }
  }
}
        
      

这种模式适合粒子数量相对可控、粒子结构固定、生命周期明确的系统。


五、应用场景二:游戏中的子弹和敌人对象

子弹通常具有明显的生命周期:

rust 复制代码
        
创建子弹 -> 飞行 -> 命中或超出范围 -> 销毁
        
      

可以使用对象池避免频繁创建和销毁。

ini 复制代码
        
class BulletPool {
  constructor(maxSize = 500) {
    this.pool = [];

    for (let i = 0; i < maxSize; i++) {
      this.pool.push(this.createBullet());
    }
  }

  createBullet() {
    return {
      x: 0,
      y: 0,
      speed: 0,
      angle: 0,
      damage: 0,
      active: false
    };
  }

  acquire(config) {
    const bullet = this.pool.pop();

    if (!bullet) {
      return null;
    }

    bullet.x = config.x;
    bullet.y = config.y;
    bullet.speed = config.speed;
    bullet.angle = config.angle;
    bullet.damage = config.damage;
    bullet.active = true;

    return bullet;
  }

  release(bullet) {
    bullet.active = false;
    bullet.x = 0;
    bullet.y = 0;
    bullet.speed = 0;
    bullet.angle = 0;
    bullet.damage = 0;

    this.pool.push(bullet);
  }
}
        
      

使用:

php 复制代码
        
const bulletPool = new BulletPool(500);
const bullets = [];

function fire() {
  const bullet = bulletPool.acquire({
    x: 100,
    y: 200,
    speed: 800,
    angle: 0,
    damage: 10
  });

  if (bullet) {
    bullets.push(bullet);
  }
}
        
      

在游戏中,也可以用"固定数组 + active 标记"替代 push 和 splice,减少数组操作:

javascript 复制代码
        
const bullets = Array.from({ length: 500 }, () => ({
  x: 0,
  y: 0,
  active: false
}));

function acquireBullet() {
  return bullets.find(bullet => !bullet.active);
}

function releaseBullet(bullet) {
  bullet.active = false;
}
        
      

不过 find 每次都要遍历数组,性能要求高时可以维护空闲索引,或者使用专门的空闲列表。


六、应用场景三:高频数学计算对象

在动画、物理计算、地图渲染中,经常会产生大量临时向量:

css 复制代码
        
function add(a, b) {
  return {
    x: a.x + b.x,
    y: a.y + b.y
  };
}
        
      

如果该函数位于高频循环中,可能产生大量临时对象。

6.1 通过输出参数避免分配

一种简单方案是不返回新对象,而是将结果写入指定对象:

ini 复制代码
        
function addTo(out, a, b) {
  out.x = a.x + b.x;
  out.y = a.y + b.y;
  return out;
}

const result = { x: 0, y: 0 };

addTo(result, { x: 1, y: 2 }, { x: 3, y: 4 });

console.log(result);
// { x: 4, y: 6 }
        
      

这种方式通常比对象池更简单,也更容易控制对象生命周期。

6.2 使用向量对象池

ini 复制代码
        
const vectorPool = new ObjectPool(
  () => ({ x: 0, y: 0 }),

  vector => {
    vector.x = 0;
    vector.y = 0;
  },

  32,
  128
);

function addVector(a, b) {
  const result = vectorPool.acquire();

  result.x = a.x + b.x;
  result.y = a.y + b.y;

  return result;
}

const result = addVector(
  { x: 10, y: 20 },
  { x: 5, y: 8 }
);

console.log(result);

vectorPool.release(result);
        
      

但要特别注意:调用者必须明确知道什么时候释放 result。如果业务代码无法可靠管理生命周期,直接使用输出参数通常更安全。


七、应用场景四:网络协议和日志处理

在 WebSocket、消息队列、日志解析等场景中,可能每秒处理大量消息:

lua 复制代码
        
function parseMessage(raw) {
  return {
    type: raw.type,
    id: raw.id,
    payload: raw.payload,
    receivedAt: Date.now()
  };
}
        
      

如果消息处理非常频繁,可以复用消息对象:

ini 复制代码
        
const messagePool = new ObjectPool(
  () => ({
    type: "",
    id: 0,
    payload: null,
    receivedAt: 0
  }),

  message => {
    message.type = "";
    message.id = 0;
    message.payload = null;
    message.receivedAt = 0;
  },

  100,
  1000
);

function handleRawMessage(raw) {
  const message = messagePool.acquire();

  message.type = raw.type;
  message.id = raw.id;
  message.payload = raw.payload;
  message.receivedAt = Date.now();

  try {
    processMessage(message);
  } finally {
    messagePool.release(message);
  }
}
        
      

这里使用 try...finally 可以保证即使处理过程中抛出异常,对象也能归还对象池。

但是,这种方式有一个限制:processMessage 不能异步持有该对象。

错误示例:

javascript 复制代码
        
function processMessage(message) {
  setTimeout(() => {
    console.log(message.type);
  }, 100);
}
        
      

如果 message 在 setTimeout 执行前已经被归还池中并重新使用,那么定时器中读取到的可能是另一个消息的数据。

对于异步场景,应该复制数据,或者延迟归还:

javascript 复制代码
        
function processMessageAsync(message) {
  const snapshot = {
    type: message.type,
    id: message.id,
    payload: message.payload
  };

  setTimeout(() => {
    console.log(snapshot.type);
  }, 100);
}
        
      

八、对象池的核心设计原则

1. 对象必须具有明确的生命周期

适合对象池的对象通常是:

rust 复制代码
        
获取 -> 使用 -> 释放
        
      

例如:

  • 粒子;
  • 子弹;
  • 临时向量;
  • 渲染命令;
  • 网络消息;
  • 短生命周期任务对象。

不适合对象池的对象通常是:

  • 生命周期不确定的业务实体;
  • 会被多个模块长期持有的对象;
  • 经常异步传递的对象;
  • 状态复杂且难以完全重置的对象。

2. 获取对象后不能长期持有

对象池对象本质上是"借来的对象"。

ini 复制代码
        
const item = pool.acquire();

// 使用 item

pool.release(item);
        
      

释放以后不能继续使用:

scss 复制代码
        
pool.release(item);

// 错误:item 已经归还
console.log(item.name);
        
      

更严重的情况是:

ini 复制代码
        
const cache = [];

const item = pool.acquire();
cache.push(item);

pool.release(item);
        
      

此时 cache 仍然保存着一个已经被归还的对象。对象池再次复用它后,cache 中的数据可能发生变化。


3. 清理引用,避免内存被意外保留

如果对象中保存了大型数组、DOM 节点或复杂数据,释放时应该清理引用:

ini 复制代码
        
const taskPool = new ObjectPool(
  () => ({
    callback: null,
    data: null
  }),

  task => {
    task.callback = null;
    task.data = null;
  }
);
        
      

否则对象池本身会一直持有这些引用,反而造成内存占用。


4. 保持对象结构稳定

JavaScript 引擎会根据对象结构进行优化。对象池中的对象最好保持一致的属性结构:

yaml 复制代码
        
function createParticle() {
  return {
    x: 0,
    y: 0,
    vx: 0,
    vy: 0,
    life: 0,
    active: false
  };
}
        
      

尽量避免这样动态添加或删除属性:

ini 复制代码
        
particle.extraData = someData;
delete particle.extraData;
        
      

更好的方式是固定属性:

yaml 复制代码
        
function createParticle() {
  return {
    x: 0,
    y: 0,
    vx: 0,
    vy: 0,
    life: 0,
    active: false,
    extraData: null
  };
}
        
      

固定对象结构通常有利于 JavaScript 引擎优化访问性能。


九、对象池并不一定总能提升性能

对象池虽然可以减少对象分配,但也会带来额外成本:

  • 获取和释放逻辑;
  • 对象状态重置;
  • 空闲对象长期占用内存;
  • 代码复杂度增加;
  • 生命周期管理困难;
  • 使用不当可能产生数据污染。

现代 JavaScript 引擎对短生命周期对象有专门优化。很多普通代码中的临时对象,会在新生代垃圾回收中快速回收,实际成本可能并不高。

例如下面的代码不一定需要对象池:

javascript 复制代码
        
function calculate(x, y) {
  return {
    x: x * 2,
    y: y * 2
  };
}
        
      

如果它调用次数不多,或者调用不在动画、物理、渲染等高频路径上,直接创建对象可能更简单、更安全。


十、什么时候适合使用对象池

适合使用对象池的典型场景:

  1. 每帧创建大量对象;
  2. 对象生命周期很短;
  3. 对象结构固定;
  4. 对象创建和销毁次数非常高;
  5. 能够明确控制获取与释放;
  6. 性能分析显示 GC 是主要瓶颈;
  7. 应用运行在内存或 CPU 较弱的设备上。

常见领域包括:

  • Canvas、WebGL 游戏;
  • 粒子系统;
  • 物理引擎;
  • 实时数据可视化;
  • 高频日志解析;
  • WebSocket 消息处理;
  • 音频处理;
  • 动画系统;
  • 大规模列表或任务调度。

十一、什么时候不应该使用对象池

以下情况通常不建议使用:

1. 对象创建频率不高

arduino 复制代码
        
const config = {
  theme: "dark",
  language: "zh-CN"
};
        
      

这种对象没有必要放入对象池。

2. 对象生命周期复杂

对象在多个组件之间传递、缓存、异步使用时,很难准确释放。

3. 对象状态复杂

如果对象包含几十个字段,并且部分字段之间存在复杂依赖,重置过程容易遗漏状态。

4. 对象必须长期保存

例如用户信息、页面状态、业务实体等,这些对象不符合"短生命周期、重复使用"的特征。

5. 没有经过性能测试

不要因为"创建对象会触发 GC"就盲目使用对象池。应先通过性能工具确认问题。


十二、对象池与数组、TypedArray 的选择

如果数据结构非常固定,甚至可以不使用普通对象,而使用数组或 TypedArray。

例如粒子数据:

ini 复制代码
        
const maxParticles = 1000;

const positionsX = new Float32Array(maxParticles);
const positionsY = new Float32Array(maxParticles);
const velocitiesX = new Float32Array(maxParticles);
const velocitiesY = new Float32Array(maxParticles);
const lifes = new Float32Array(maxParticles);
const active = new Uint8Array(maxParticles);
        
      

这种"结构数组"(Structure of Arrays,简称 SoA)具有以下特点:

  • 不创建大量对象;
  • 数据连续,缓存友好;
  • 适合批量计算;
  • 适合 WebGL 和物理计算;
  • 内存布局更加稳定。

相比之下,普通对象数组属于"数组中的结构"(Array of Structures,简称 AoS):

yaml 复制代码
        
const particles = [
  { x: 0, y: 0, life: 1 },
  { x: 10, y: 20, life: 0.5 }
];
        
      

在数据规模很大、计算非常频繁时,TypedArray 可能比对象池更适合。


十三、如何正确评估对象池是否有效

不要只根据理论判断,应通过实际测试。

可以重点观察:

  • performance.now() 测量的执行时间;
  • Chrome DevTools 的 Performance 面板;
  • Memory 面板中的堆变化;
  • GC 事件是否频繁;
  • 帧率是否稳定;
  • 内存是否持续增长;
  • 移动设备上的实际表现。

简单测试:

ini 复制代码
        
function benchmark(name, fn, count = 1000000) {
  const start = performance.now();

  for (let i = 0; i < count; i++) {
    fn(i);
  }

  const end = performance.now();

  console.log(`${name}: ${(end - start).toFixed(2)}ms`);
}
        
      

测试普通创建:

ini 复制代码
        
benchmark("普通对象", i => {
  const item = {
    x: i,
    y: i * 2
  };

  item.x += 1;
});
        
      

测试对象池:

ini 复制代码
        
const pool = new ObjectPool(
  () => ({ x: 0, y: 0 }),
  item => {
    item.x = 0;
    item.y = 0;
  },
  100
);

benchmark("对象池", i => {
  const item = pool.acquire();

  item.x = i;
  item.y = i * 2;
  item.x += 1;

  pool.release(item);
});
        
      

需要注意,简单基准测试容易受到 JIT 优化、浏览器状态和机器负载影响,最好进行多轮测试,并使用真实业务场景验证。


十四、一个更实用的对象池封装

可以增加使用状态检查,帮助发现重复释放或释放非法对象的问题:

ini 复制代码
        
class SafeObjectPool {
  constructor(factory, reset, initialSize = 0, maxSize = Infinity) {
    this.factory = factory;
    this.reset = reset;
    this.maxSize = maxSize;
    this.pool = new Set();

    for (let i = 0; i < initialSize; i++) {
      const object = factory();
      object.__inPool = true;
      this.pool.add(object);
    }
  }

  acquire() {
    const iterator = this.pool.values();
    const object = iterator.next().value;

    if (object) {
      this.pool.delete(object);
      object.__inPool = false;
      return object;
    }

    const created = this.factory();
    created.__inPool = false;

    return created;
  }

  release(object) {
    if (!object || object.__inPool) {
      throw new Error("对象无效或已经被释放");
    }

    this.reset(object);

    if (this.pool.size < this.maxSize) {
      object.__inPool = true;
      this.pool.add(object);
    }
  }
}
        
      

生产环境中不一定需要加入这些检查,但在开发阶段可以帮助发现对象生命周期错误。

不过更推荐不要把 __inPool 这类调试字段永久加入核心对象,因为它会改变对象结构。可以通过 WeakSet 记录状态,或者仅在开发环境开启检查。


十五、实际开发建议

建议一:优先减少不必要的对象创建

例如:

css 复制代码
        
function add(a, b) {
  return {
    x: a.x + b.x,
    y: a.y + b.y
  };
}
        
      

可以改成:

css 复制代码
        
function addTo(out, a, b) {
  out.x = a.x + b.x;
  out.y = a.y + b.y;
}
        
      

这通常比引入完整对象池更简单。

建议二:在热点路径使用对象池

只在以下代码中考虑对象池:

  • 每帧执行的代码;
  • 大量循环;
  • 物理和渲染计算;
  • 高频网络消息;
  • 粒子、子弹等重复出现的实体。

建议三:设置对象池上限

不要让对象池无限增长:

ini 复制代码
        
const pool = new ObjectPool(
  () => ({ x: 0, y: 0 }),
  item => {
    item.x = 0;
    item.y = 0;
  },
  100,
  1000
);
        
      

如果对象池没有上限,异常流量可能导致池长期持有大量对象。

建议四:不要释放仍然被使用的对象

对象池最严重的问题通常不是性能问题,而是数据错误。要明确:

markdown 复制代码
        
谁获取,谁负责释放;
释放之后,不能继续使用;
异步任务不能直接持有池中对象。
        
      

总结

对象池的本质是:

markdown 复制代码
        
通过复用对象,减少重复内存分配和垃圾回收压力
        
      

它非常适合:

  • 粒子;
  • 子弹;
  • 敌人;
  • 临时向量;
  • 高频消息;
  • 动画和物理计算对象。

但对象池并不是万能优化方案。现代 JavaScript 引擎对短生命周期对象已经进行了大量优化,在普通业务代码中,直接创建对象往往更简单、更可靠。

比较推荐的优化顺序是:

  1. 先确认是否存在性能问题;
  2. 使用 Performance 和 Memory 工具定位瓶颈;
  3. 优先减少不必要的对象创建;
  4. 尝试复用输出对象或使用固定数组;
  5. 在确实存在高频分配和 GC 压力时引入对象池;
  6. 明确对象生命周期,并完善重置、释放和容量限制;
  7. 对比优化前后的真实数据,而不是只看理论。

只有在"对象创建频繁、生命周期短、结构固定、生命周期可控"的场景下,对象池才真正具有较高的应用价值。

相关推荐
陈随易2 小时前
纯 Go 写的 JS 引擎,跑分超过了 C 语言的 QuickJS
前端·后端·程序员
子兮曰3 小时前
Chrome 权限元素实战:144 与 151 的渐进增强写法
前端·javascript·chrome
IT_陈寒4 小时前
Redis连接池泄漏那次,我差点把生产环境搞崩了
前端·人工智能·后端
强尼银手4 小时前
我花一天做了个格斗游戏原型,判定全对,然后被「美术」劝退了
前端·javascript
石小石Orz4 小时前
AI 正在蚕食我们的编码能力
前端·后端·算法
陈随易4 小时前
专注飞机 FocusFlight,两个人把番茄钟做成了一趟航班
前端·后端·程序员
总要学点什么5 小时前
第98篇:三维特效-材质线效果——给一条线换十种材质,虚线的图案只填了一半
开发语言·前端·javascript·ecmascript·gis·webgl·材质
10share5 小时前
基于大模型的 i18n 方案:把中文原文当 Key,轻松搞定多语言维护
前端·vue.js
古茗前端团队8 小时前
由 key 引发的思考
前端·javascript·react.js