博客 / 詳情

返回

JavaScript 內存泄漏

生活可能不像你想象的那麼好,但是也不會像你想象的那麼糟糕。人的脆弱和堅強都超乎了自己的想象,有時候可能脆弱的一句話就淚流滿面,有時候你發現自己咬着牙,已經走過了很長的路

如何避免 JavaScript 中的內存泄漏

像 C 語言這樣的底層語言一般都有底層的內存管理接口,比如 malloc()和free()。相反,JavaScript 是在創建變量(對象,字符串等)時自動進行了分配內存,並且在不使用它們時“自動”釋放。釋放的過程稱為垃圾回收。這個“自動”是混亂的根源,並讓 JavaScript(和其他高級語言)開發者錯誤的感覺他們可以不關心內存管理。

什麼是內存泄漏?

簡而言之,內存泄漏是 JavaScript 引擎無法回收的已分配內存。當您在應用程序中創建對象和變量時,JavaScript 引擎會分配內存,當您不再需要這些對象時,它會非常聰明地清除內存。內存泄漏是由於邏輯缺陷引起的,它們會導致應用程序性能不佳。

在深入探討不同類型的內存泄漏之前,讓我們先了解一下JavaScript 中的內存管理和垃圾回收。

內存生命週期

在任何編程語言中,內存生命週期都包含三個步驟:

  • 內存分配:操作系統在執行過程中根據需要為程序分配內存
  • 使用內存:您的程序使用以前分配的內存,您的程序可以對內存執行read和操作write
  • 釋放內存:任務完成後,分配的內存將被釋放並變為空閒。在 JavaScript 等高級語言中,內存釋放由垃圾收集器處理
    如果您瞭解 JavaScript 中的內存分配和釋放是如何發生的,那麼解決應用程序中的內存泄漏就非常容易。

內存分配

JavaScript 有兩種用於內存分配的存儲選項。一個是棧,一個是堆。所有基本類型,如number、Boolean和undefined都將存儲在堆棧中。堆是對象、數組和函數等引用類型存儲的地方。

靜態分配和動態分配

編譯代碼時,編譯器可以檢查原始數據類型,並提前計算它們所需內存。然後將所需的數量分配給調用堆棧中的程序。這些變量分配的空間稱為堆棧空間(stack space),因為函數被調用,它們的內存被添加到現有內存(存儲器)的頂部。它們終止時,它們將以LIFO(後進先出)順序被移除。

引用類型變量需要多少內存無法在編譯時確定,需要在運行時根據實際使用情況分配內存,此內存是從堆空間(heap space) 分配的。

Static allocation Dynamic 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內存泄漏的危險

user avatar zhaoshuaiqiang 頭像 lzjc 頭像 xuxiaocong_5e947e5ce588a 頭像 boywus 頭像
4 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.