3
生活可能不像你想象的那么好,但是也不会像你想象的那么糟糕。人的脆弱和坚强都超乎了自己的想象,有时候可能脆弱的一句话就泪流满面,有时候你发现自己咬着牙,已经走过了很长的路

如何避免 JavaScript 中的内存泄漏

像 C 语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()和free()。相反,JavaScript 是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。释放的过程称为垃圾回收。这个“自动”是混乱的根源,并让 JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理。

什么是内存泄漏?

简而言之,内存泄漏是 JavaScript 引擎无法回收的已分配内存。当您在应用程序中创建对象和变量时,JavaScript 引擎会分配内存,当您不再需要这些对象时,它会非常聪明地清除内存。内存泄漏是由于逻辑缺陷引起的,它们会导致应用程序性能不佳。

在深入探讨不同类型的内存泄漏之前,让我们先了解一下JavaScript 中的内存管理和垃圾回收。

内存生命周期

在任何编程语言中,内存生命周期都包含三个步骤:

  • 内存分配:操作系统在执行过程中根据需要为程序分配内存
  • 使用内存:您的程序使用以前分配的内存,您的程序可以对内存执行read和操作write
  • 释放内存:任务完成后,分配的内存将被释放并变为空闲。在 JavaScript 等高级语言中,内存释放由垃圾收集器处理
    如果您了解 JavaScript 中的内存分配和释放是如何发生的,那么解决应用程序中的内存泄漏就非常容易。

内存分配

JavaScript 有两种用于内存分配的存储选项。一个是栈,一个是堆。所有基本类型,如number、Boolean和undefined都将存储在堆栈中。堆是对象、数组和函数等引用类型存储的地方。

静态分配和动态分配

编译代码时,编译器可以检查原始数据类型,并提前计算它们所需内存。然后将所需的数量分配给调用堆栈中的程序。这些变量分配的空间称为堆栈空间(stack space),因为函数被调用,它们的内存被添加到现有内存(存储器)的顶部。它们终止时,它们将以LIFO(后进先出)顺序被移除。

引用类型变量需要多少内存无法在编译时确定,需要在运行时根据实际使用情况分配内存,此内存是从堆空间(heap space) 分配的。

Static allocationDynamic allocation
编译时内存大小确定编译时内存大小不确定
编译阶段执行运行时执行
分配给栈(stack space)分配给堆(heap stack)
FILO没有特定的顺序

Stack 遵循 LIFO 方法分配内存。所有基本类型,如number、Boolean和undefined都可以存储在栈中:
image.png

对象、数组和函数等引用类型存储在堆中。引用类型的大小无法在编译时确定,因此内存是根据对象的使用情况分配的。对象的引用存储在栈中,实际对象存储在堆中:

image.png

在上图中,otherStudent变量是通过复制student变量创建的。在这种情况下,otherStudent是在堆栈上创建的,但它指向堆上的student引用。

我们已经看到,内存周期中内存分配的主要挑战是何时释放分配的内存并使其可用于其他资源。在这种情况下,垃圾回收就派上用场了。

垃圾回收器

应用程序内存泄漏的主要原因是不需要的引用造成的。而垃圾回收器的作用是找到程序不再使用的内存并将其释放回操作系统以供进一步分配。

要知道什么是不需要的引用,首先,我们需要了解垃圾回收器是如何识别一块内存是不可用的。垃圾回收器主要使用两种算法来查找不需要的引用和无法访问的代码,那就是引用计数和标记清除。

引用计数

引用计数算法查找没有引用的对象。如果不存在指向对象的引用,则可以释放该对象。
让我们通过下面的示例更好地理解这一点。共有三个变量,student, otherStudent,它是 student 的副本,以及sports,它从student对象中获取sports数组:

let student = {
    name: 'Joe',
    age: 15,
    sports: ['soccer', 'chess']
}
let otherStudent = student;
const sports = student.sports;
student = null;
otherStudent = null;

image.png

在上面的代码片段中,我们将student和otherStudent变量分配给空值,告诉我们这些对象没有对它的引用。在堆中为它们分配的内存(红色)可以轻松释放,因为它是零引用。

另一方面,我们在堆中还有另一块内存,它不能被释放,因为它有对象sports引用。

当两个对象都引用自己时,引用计数算法就有问题了。简单来说,如果存在循环引用,则该算法无法识别空闲对象。

在下面的示例中,person和employee变量相互引用:

let person = {
    name: 'Joe'
};
let employee = {
    id: 123
};
person.employee = employee;
employee.person = person;
person = null;
employee = null;

image.png

创建这些对象后null,它们将失去堆栈上的引用,但对象仍然留在堆上,因为它们具有循环引用。引用计数算法无法释放这些对象,因为它们具有引用。循环引用问题可以使用标记清除算法来解决。

标记清除

mark-and-sweep 算法将不需要的对象定义为“不可到达”的对象。如果对象不可到达,则算法认为该对象是不必要的:

image.png

标记清除算法遵循两个步骤。首先,在 JavaScript 中,根是全局对象。垃圾收集器周期性地从根开始,查找从根引用的所有对象。它会标记所有可达的对象active。然后,垃圾回收器会释放所有未标记为active的对象的内存,将内存返回给操作系统。

内存泄漏的类型

我们可以通过了解在 JavaScript 中如何创建不需要的引用来防止内存泄漏,以下情况会导致不需要的引用。

未声明或意外的全局变量

JavaScript 允许的方式之一是它处理未声明变量的方式。对未声明变量的引用会在全局对象中创建一个新变量。如果您创建一个没有任何引用的变量,它的根将是全局对象。

正如我们刚刚在标记清除算法中看到的,直接指向根的引用总是active,垃圾回收器无法清除它们,从而导致内存泄漏:

function foo(){
    this.message = 'I am accidental variable';
}
foo();

作为解决方案,尝试在使用后使这些变量无效,或者启用JavaScript的严格模式(use strict)以防止意外的全局变量。

use strict

严格模式可以消除Javascript语法的一些不合理、不严谨之处,减少一些怪异行为,比如以下示例:

"use strict";
x = 3.14;       // 报错 (x 未定义)
"use strict";
myFunction();

function myFunction() {
    y = 3.14;   // 报错 (y 未定义)
}
x = 3.14;       // 不报错
myFunction();

function myFunction() {
   "use strict";
    y = 3.14;   // 报错 (y 未定义)
}

闭包

闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment,词法环境)的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。在 JavaScript 中,闭包会随着函数的创建而被同时创建。

闭包的作用主要是实现函数式编程中的柯里化、模块化、私有变量等特性。柯里化是将一个接受多个参数的函数转换为接受单个参数的函数序列,这是通过把参数格式化成一个数组或对象并返回一个新闭包实现的。模块化是通过利用闭包的私有变量特性,把暴露给外部的接口和私有变量封装在一个函数作用域内,防止外部作用域污染、变量重复定义等问题。

尽管闭包有诸多优点,但同时也存在内存泄漏的问题。闭包会在函数执行完毕之后仍然持有对外部变量的引用,从而导致这些变量无法被垃圾回收。这种情况通常发生在循环中定义的函数或者事件绑定等场景中。为避免内存泄漏,我们需要手动解除对外部变量的引用,方式包括解除事件绑定、使用局部变量替代全局变量等技巧。

下面通过代码例子来进一步说明闭包的应用和内存泄漏问题:

// 例子1:柯里化
function add(x) {
  return function(y) {
    return x + y;
  };
}

const add5 = add(5);
console.log(add5(3)); // 8

// 例子2:模块化
const counter = (function() {
  let value = 0;

  return {
    increment() {
      value++;
      console.log(value);
    },

    decrement() {
      value--;
      console.log(value);
    }
  };
})();

counter.increment(); // 1
counter.increment(); // 2
counter.decrement(); // 1

// 例子3:内存泄漏
for (var i = 1; i <= 3; i++) {
  (function(j) {
    document.getElementById('button' + j).addEventListener('click', function() {
      console.log('Button ' + j + ' clicked.');
    });
  })(i);
}

以上代码展示了柯里化和模块化两种闭包的应用场景,同时也包括了一个事件绑定场景下的内存泄漏问题。我们在使用闭包时需要格外注意内存泄漏的风险,以确保程序性能和稳定性。

计时器

setTimeout和setInterval是 JavaScript 中可用的两个计时事件。该setTimeout函数在给定时间过去后执行,而在setInterval给定时间间隔内重复执行,这些计时器是内存泄漏的最常见原因。

如果在代码中设置循环计时器,计时器回调函数会一直保持对numbers对象的引用,直到计时器停止:

function generateRandomNumbers(){
    const numbers = []; // huge increasing array
    return function(){
        numbers.push(Math.random());
    }
}
setInterval((generateRandomNumbers(), 2000));

要解决此问题,最佳实践就是在不需要计时器的时候清除它:

const timer = setInterval(generateRandomNumbers(), 2000); // save the timer
// on any event like button click or mouse over etc
clearInterval(timer); // stop the timer

Out of DOM reference

Out of DOM reference 表示已从 DOM 中删除但在内存中仍然可用的节点。垃圾回收器无法释放这些 DOM 对象,让我们通过下面的示例来理解这一点:

let parent = document.getElementById("#parent");
let child = document.getElementById("#child");
parent.addEventListener("click", function(){
    child.remove(); // removed from the DOM but not from the object memory
});

在上面的代码中,在单击父元素时从 DOM 中删除了子元素,但是子变量仍然持有内存,因为事件侦听器始终保持对child变量的饮用。为此,垃圾回收器无法释放child,会继续消耗内存。

一旦不再需要事件侦听器,应该立即注销它们:

function removeChild(){
    child.remove();
}
parent.addEventListener("click", removeChild);
// after completing required action
parent.removeEventListener("click", removeChild);

实际案例

在实际项目开发中,内存泄漏和内存溢出是非常常见的问题。下面分享一些实际案例,讲讲我是如何分析内存溢出的。

死循环,局部变量导致内存溢出

当有些循环没有充分考虑到边界条件时,很容易陷入死循环,比如下面示例:

const getParentClassName = (element, fatherClassName) => {
  const classNames = [];
  let currentElement = element;

  if (fatherClassName) {
    while (
      !(currentElement?.className || "").includes(fatherClassName) &&
      currentElement !== document.body
    ) {
      classNames.push(currentElement?.className || "");
      currentElement = currentElement?.parentElement;
    }
  } else {
    while (currentElement !== document.body) {
      classNames.push(currentElement?.className || "");
      currentElement = currentElement?.parentElement;
    }
  }

  return classNames;
};

getParentClassName(null);

这段代码功能是收集两个元素间的类名,当参数element=null时,就陷入了死循环,每次遍历都会向classNames数组追加新值,最终导致内存溢出。

那这种情况要如何分析定位呢?不妨先使用 Performance 可视化检测内存泄漏,如下:

image.png

从火焰图中可以看出,getParentClassName函数被多次调用,导致JS Heap内存占用不断攀升,而且内存得不到释放。这可能是因为getParentClassName函数中引用了某些变量,这些变量的内存占用很高,并且得不到释放回收,导致内存泄漏和内存占用问题。

为了识别出哪些变量引发内存泄漏,可以使用内存快照来分析应用程序的内存分配情况。但是,在应用程序崩溃时,可能无法采集到内存快照,导致无法进行内存分析,因此在程序中加上循环控制:

let count = 0;
const getParentClassName = (element, fatherClassName) => {
  const classNames = [];
  let currentElement = element;

  if (fatherClassName) {
    while (
      !(currentElement?.className || "").includes(fatherClassName) &&
      currentElement !== document.body
    ) {
      classNames.push(currentElement?.className || "");
      currentElement = currentElement?.parentElement;
    }
  } else {
    while (currentElement !== document.body) {
      classNames.push(currentElement?.className || "");
      currentElement = currentElement?.parentElement;
      count++;
      if (count > 10000000) break;
    }
  }

  return classNames;
};

getParentClassName(null);

image.png

分别在代码第30行和第36行设置断点,代码执行到第36行,选择“Heap snapshot”,点击“take snapshot”,生成第一个snapshot。继续调试,代码运行到第30行,点击“take snapshot”,生成第二个snapshot,记录两个断点执行过程的内存分配。

image.png

image.png

我们可以看到这么一些堆照信息:

  • Constructor 表示使用此构造函数创建的所有对象。
  • Distance通常指的是对象之间的引用路径长度,即从根对象到目标对象的最短路径长度。在内存分析中,Distance越长通常表示内存泄漏或内存占用问题越严重。‘
  • Shallow size 指的是一个对象本身的内存占用大小,即该对象的所有属性占用的内存大小之和。
  • Retained size 指的是一个对象及其所有子孙对象在内存中的总占用大小。在计算Retained size时,会考虑到对象之间的引用关系,即如果一个对象引用了其他对象,则被引用的对象也会被计算在内。举个例子,如果有一个包含多个对象的数据结构,其中某个对象被多个其他对象引用,则该对象的Retained size将会比其Shallow size大得多,因为该对象被多个其他对象所引用。

通常情况下,优化Shallow size可以减少单个对象的内存使用,而优化Retained size可以减少整个应用程序的内存使用。

仔细排查不难发现,变量classNames对象的Retained size比其Shallow size大得多。。

image.png

除了Summary模式,内存快照工具还有其他一些模式,可以帮助开发者更全面地了解应用程序的内存使用情况,从而识别和解决内存泄漏和内存占用问题。

以下是一些常见的内存快照模式:

image.png

  • Summary模式:以简洁的方式显示内存中的对象数、内存占用量、字符串数、DOM节点数、事件侦听器数、闭包数、构造函数数等汇总信息,以及内存中占用最多的前20个对象。这个模式可以帮助开发者快速了解应用程序的内存使用情况。
  • Comparison模式:比较两个内存快照之间对象的差异,并显示新增对象、删除对象和修改对象等信息。这个模式可以帮助开发者了解应用程序在不同时间点的内存使用情况,从而识别内存泄漏和内存占用问题。
    image.png
  • Containment模式:显示一个对象所包含的所有子对象,并显示每个子对象的类型、大小和引用数等信息。这个模式可以帮助开发者了解对象之间的引用关系,从而更好地优化内存使用。
    image.png
  • Statistics模式:提供各种内存使用的统计信息,如对象类型数量、构造函数数量、字符串数量、DOM节点数量、事件侦听器数量、闭包数量等。这个模式可以帮助开发者了解应用程序中各种对象类型的内存使用情况,从而识别和解决内存泄漏和内存占用问题。
    image.png

另外,我们还可以启用“Allocation instrumentation on timeline”模式,获取时间线内存分配情况:

image.png

Mobx将属性转换成可观察,导致内存溢出

image.png

为了检测穿梭框组件在使用过程中产生的内存泄漏,可以使用Performance可视化工具进行检测

image.png

可以看出内存占用飞速增长,我们再放大看看具体是哪些脚本执行导致的:

image.png

image.png

根据具体观察,发现有一些重复的可疑代码片段,这些代码可能会导致内存占用的增长。为了更好地定位哪些变量导致了内存泄漏问题,我们可以选择一个重复执行比较频繁的函数,然后进行如下的改造:

var defineObservablePropertyExeCount = 0;
function defineObservableProperty(target, propName, newValue, enhancer) {
    defineObservablePropertyExeCount += 1;
    if (defineObservablePropertyExeCount > 1000000) {
        return null;
    }
    var adm = asObservableObject(target);
    assertPropertyConfigurable(target, propName);
    if (hasInterceptors(adm)) {
        var change = interceptChange(adm, {
            object: target,
            name: propName,
            type: "add",
            newValue: newValue
        });
        if (!change)
            return;
        newValue = change.newValue;
    }
    var observable = (adm.values[propName] = new ObservableValue(newValue, enhancer, adm.name + "." + propName, false));
    newValue = observable.value; // observableValue might have changed it
    Object.defineProperty(target, propName, generateObservablePropConfig(propName));
    if (adm.keys)
        adm.keys.push(propName);
    notifyPropertyAddition(adm, target, propName, newValue);
}

image.png

加上一个执行次数的控制,在此处打上断点,然后等代码执行到此处,点击“take snapshot”录制就可以得到下面内存分配情况:

image.png

从图中可以发现,ObservableValue类型和ObservableObjectAdministration类型对象占用内存很高,基本可以断定由它们引发内存泄漏的。

image.png

image.png

点击查看每一个ObservableValue类型对象,发现都是next和nextBrother对象,在项目全局搜索这两个关键字,基本都是指向IFlatTree树状结构:

image.png

export interface IFlatTree extends ITree {
    parent?: IFlatTree;
    level?: number;
    next?: IFlatTree;
    nextBrother?: IFlatTree;
    show?: boolean;
}

这个树状数据是由穿梭框组件onchange回调函数抛出的,然后代码将其存入Mobx状态管理仓库中

image.png

image.png

Mobx会对存入的变量深度遍历,每个属性都进行Observable封装

image.png

但是,这个树状结构有37层,每个节点对象的每个属性都要Observable封装,执行过程中产生内存消耗足以导致内存溢出

image.png

进一步分析发现,next和nextBrother节点对象并不会在实际业务逻辑中使用到,而且也不会改动,所以我们可以只将树状数据的单层进行Observable封装,不对其深度遍历。

image.png

根据上面处理后,想着在onchange回调打印values,发现console.log打印也可能会导致内存溢出,打印的变量不会被垃圾回收器回收。原因可以参考这篇文章,千万别让 console.log 上生产!用 Performance 和 Memory 告诉你为什么

co库引发的内存泄漏

最近我们将 Node.js 应用上了阿里 Node.js 性能平台,监控数据显示,TerminalApi 的内存占用持续上升,初步判断可能发生了内存泄漏。

image.png

为了避免对线上用户产生感知,我们选择在晚上抓取应用堆快照。

image.png

image.png

我们发现 Generator 对象持有了 100 多兆字节的内存,Retained Size 远远大于 Shallow Size,足以说明这可能是内存泄漏的点。

image.png

我们根据对象视图分析,可以定位到下面这段代码块:

router.get('/page', co.wrap(function* getOrderPage(req: any, res: any, next: any) {
  const uid = req.query.uid;
  const storeId = req.query.storeId;
  const pager = parseInt(req.query.pager, 10) || 1;
  const size = parseInt(req.query.size, 10) || 10;
  try {
    const data = yield orderService.getOrdersByPage(uid, storeId, pager, size);
    const result: any = { orders: [] };
    result.totalPages = Math.ceil(data.total / size);
    const rawOrders = data.orders || [];
    // eslint-disable-next-line
    for (const rawOrder of rawOrders) {
      const convertResult = converter.getImOrderByPage(rawOrder);
      logger.info('转换结果', convertResult);
      const store = yield storeService.getStore(rawOrder.productStoreId);
      result.orders.push(Object.assign({}, convertResult, { storeName: store.storeName }));
   }
    logger.info(`获取用户${uid}第${pager}页的订单成功`, result);
    res.json(result);
 } catch (e) {
    logger.warn(`获取用户${uid}第${pager}页的订单失败`, e);
    next(e);
 }
}));

我们举个简单例子分析一下:

// co-gc.js
const express = require("express");
const co = require("co");
const router = express.Router();
const app = express();

router.get("/", (req, res) => {
  co(function* () {
    const users = yield getUsersFromDatabase();
    for(user of users) {
        const info = yield getUserByIdFromDatabase(user.id);
        Object.assign(user, info);
    }
    res.send(users);
    console.log(JSON.stringify(process.memoryUsage()));
  }).catch((err) => {
    console.error(err);
    res.sendStatus(500);
  });
});

function getUsersFromDatabase() {
  return new Promise((resolve, reject) => {
    // 模拟从数据库获取数据
    setTimeout(() => {
      const users = [
        { id: 1, name: "Alice" },
        { id: 2, name: "Bob" },
        { id: 3, name: "Charlie" },
      ];
      resolve(users);
    }, 1000);
  });
}

function getUserByIdFromDatabase(id) {
  return new Promise((resolve, reject) => {
    // 模拟从数据库获取数据
    setTimeout(() => {
      const user = {
        id: id,
        age: Math.floor(Math.random() * 100),
        gender: Math.random() > 0.5 ? "male" : "female",
        job: "engineer",
        city: "New York",
        score: Math.floor(Math.random() * 100),
        isMarried: Math.random() > 0.5,
      };
      resolve(user);
    }, 1000);
  });
}

app.use("/users", router);

app.listen(3003, () => {
  /** 启动服务先垃圾回收一次 */
  global.gc();
  console.log("Server running on port 3003");
});

执行以下命令,查看堆内存使用情况:

node --expose-gc ./co-gc.js // 启动服务
autocannon -c 1 -d 120 http://localhost:3003/users/ // 1个并发120秒钟

image.png

这些是 Node.js 进程的内存使用情况的 JSON 格式输出,其中包含了以下几个属性:

  • rss: 进程的常驻内存集大小,即进程当前使用的物理内存大小(以字节为单位)。
  • heapTotal: V8 引擎的堆内存总大小,即 V8 引擎当前分配给 Node.js 进程的堆内存大小(以字节为单位)。
  • heapUsed: V8 引擎的堆内存使用情况,即 V8 引擎当前已经使用的堆内存大小(以字节为单位)。
  • external: V8 引擎管理的 JavaScript 对象的内存使用情况,包括通过 C++ 插件加载的内存等(以字节为单位)。
  • arrayBuffers: 分配给 ArrayBuffer 对象的内存大小(以字节为单位)。

不难看出 V8 引擎分配给 Node.js 进程的堆内存中途增加了,heapUsed 属性的值也增加了,表明 Node.js 进程使用的堆内存大小增加了。

Heap dump 是一种记录应用程序内存分配情况的快照,可以帮助我们识别内存泄漏和内存占用过高等问题。在 Node.js 中,可以使用 heapdump 模块来生成 Heap dump 文件。

下面是使用 heapdump 模块的示例代码:

const heapdump = require('heapdump');

// 生成 Heap dump 文件
heapdump.writeSnapshot((err, filename) => {
  if (err) {
    console.error(err);
  } else {
    console.log(`Heap dump written to ${filename}`);
  }
});

image.png

可以看到生成器函数自动执行时会生成很多Promise对象,并且没法及时被垃圾回收器回收。下面我们具体分析下原因:

co 是一个基于生成器函数的异步流程控制库,它可以让我们用同步的方式编写异步代码。co 库将生成器函数包装成一个 Promise 对象,然后自动执行生成器函数中的异步操作,并将结果传递给生成器函数的下一个 yield 表达式。使用 co 库可以使异步代码的编写更加简单和易读。

function* asyncFunction() {
  const result1 = yield someAsyncOperation1();
  const result2 = yield someAsyncOperation2(result1);
  return result2;
}

co 函数将异步函数 asyncFunction 包装成一个 Promise 对象,并使用 then 方法来处理异步操作的结果:

co(asyncFunction()).then(result => {
  console.log(result);
}).catch(error => {
  console.error(error);
});

在异步函数中,我们使用 yield 表达式来等待异步操作的完成,并将结果传递给下一个 yield 表达式。co 库会自动执行异步操作,并将结果传递给生成器函数的下一个 yield 表达式,从而完成异步操作的执行。

以下是 co@4.6.0 库的简化源:

function co(gen) {
  var ctx = this;
  var args = slice.call(arguments, 1)

  // we wrap everything in a promise to avoid promise chaining,
  // which leads to memory leak errors.
  // see https://github.com/tj/co/issues/180
  return new Promise(function(resolve, reject) {
    if (typeof gen === 'function') gen = gen.apply(ctx, args);
    if (!gen || typeof gen.next !== 'function') return resolve(gen);

    onFulfilled();

    /**
     * @param {Mixed} res
     * @return {Promise}
     * @api private
     */

    function onFulfilled(res) {
      var ret;
      try {
        ret = gen.next(res);
      } catch (e) {
        return reject(e);
      }
      next(ret);
    }

    /**
     * @param {Error} err
     * @return {Promise}
     * @api private
     */

    function onRejected(err) {
      var ret;
      try {
        ret = gen.throw(err);
      } catch (e) {
        return reject(e);
      }
      next(ret);
    }

    /**
     * Get the next value in the generator,
     * return a promise.
     *
     * @param {Object} ret
     * @return {Promise}
     * @api private
     */

    function next(ret) {
      if (ret.done) return resolve(ret.value);
      var value = toPromise.call(ctx, ret.value);
      if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
      return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, '
        + 'but the following object was passed: "' + String(ret.value) + '"'));
    }
  });
}

在 co 库中,我们可以使用 co 函数来包装一个生成器函数,并将其转换成一个 Promise 对象。co 函数会自动执行生成器函数,并使用 next 函数来执行生成器函数的每一个 yield 表达式,直到生成器函数中的所有 yield 表达式都已经执行完成。当生成器函数中的所有 yield 表达式都已经执行完成后,co 函数会返回一个包含生成器函数返回值的 Promise 对象。

在执行生成器函数的过程中,如果遇到了异步操作,co 函数会将异步操作包装成一个 Promise 对象,并将其传递给生成器函数的下一个 yield 表达式。当异步操作完成后,Promise 对象的状态会被改变,并将异步操作的结果传递给生成器函数的下一个 yield 表达式。

image.png

根据上面 co 库执行流程图来看,在使用 co 库时,如果生成器函数中存在大量异步操作,会产生大量next递归,导致生成复杂的 Promise 嵌套执行链,大量的 Promise 对象被创建并添加到 JavaScript 引擎内部的 Promise 队列中。如果这些 Promise 对象没有被及时解决,它们会一直存在于 Promise 队列中占用内存资源,可能导致内存泄漏问题。

在 JavaScript 中,当我们使用 new Promise() 构造函数创建一个 Promise 对象时,该对象会被添加到 JavaScript 引擎内部的 Promise 队列中等待被解决。当 Promise 对象被解决(fulfilled 或 rejected)时,它会从队列中移除,并将解决结果传递给 then 方法中指定的回调函数,从而完成异步操作的执行。如果 Promise 对象一直处于 pending 状态而没有被解决,它会一直存在于 Promise 队列中,占用内存资源,从而可能导致内存泄漏。

如果一个 Promise 对象没有被及时解决,我们可以采用一些手段来避免内存泄漏问题。例如,我们可以使用 Promise.race() 方法创建一个新的 Promise 对象,该对象与原始的 Promise 对象竞争,如果原始的 Promise 对象在一定的时间内没有被解决,则会被强制解决。另外,我们也可以定期调用 Promise.resolve() 函数来清空 Promise 队列中等待解决的对象,从而确保 Promise 对象能够被及时清理,避免内存泄漏的问题。

为了避免 co 库的内存溢出问题,我们可以采取一些措施来优化异步操作的执行。例如,我们可以将异步操作分批执行,每次执行一定数量的异步操作,避免一次性创建过多的 Promise 对象。另外,我们也可以使用一些性能更好的异步流程控制库,例如 async/await 或 rxjs 等,来避免 co 库的内存溢出问题。

下面是一个使用 co 库可能导致内存溢出的示例代码:

const co = require('co');

function* asyncFunction() {
  for (let i = 0; i < 1000000; i++) {
    yield Promise.resolve(i);
  }
}

co(asyncFunction()).then(() => {
  console.log('All operations completed');
}).catch(error => {
  console.error(error);
});

在上述代码中,我们定义了一个生成器函数 asyncFunction,其中包含了 100 万个异步操作,每个异步操作返回一个 Promise 对象,会导致大量的 Promise 对象被添加到 Promise 队列中,从而可能导致内存溢出的风险。

使用 async/await 可以更好地避免内存溢出的问题,因为 async/await 可以将异步操作转换为类似同步代码的形式,不会像 co 库一样一次性创建大量的 Promise 对象。

以下是一个使用 async/await 优化异步操作的示例代码:

async function asyncFunction() {
  for (let i = 0; i < 1000000; i++) {
    await Promise.resolve(i);
  }
  console.log('All operations completed');
}

asyncFunction().catch(error => {
  console.error(error);
});

在上述代码中,我们定义了一个异步函数 asyncFunction,其中包含了 100 万个异步操作,每个异步操作返回一个 Promise 对象。我们使用 await 关键字来等待每个异步操作完成,被解决的 Promise 对象就会被垃圾回收器回收,从而避免内存溢出问题的发生。

参考

内存管理
如何避免 JavaScript 中的内存泄漏
调试 JavaScript 内存泄漏
使用 Chrome 查找 JavaScript 内存泄漏
修复内存问题
什么是闭包?闭包的作用? 闭包会导致内存泄漏吗?
JavaScript的工作原理:内存管理+如何处理4个常见的内存泄漏
使用 Chrome Devtools 分析内存问题
手把手教你排查Javascript内存泄漏
Aggressive Memory Leak
Promise内存泄漏的危险


记得要微笑
1.9k 声望4.5k 粉丝

知不足而奋进,望远山而前行,卯足劲,不减热爱。