1. 程式人生 > >「中高階前端必須瞭解的」JS中的記憶體管理

「中高階前端必須瞭解的」JS中的記憶體管理

前言

像C語言這樣的底層語言一般都有底層的記憶體管理介面,比如 malloc()和free()用於分配記憶體和釋放記憶體。
而對於JavaScript來說,會在建立變數(物件,字串等)時分配記憶體,並且在不再使用它們時“自動”釋放記憶體,這個自動釋放記憶體的過程稱為垃圾回收。
因為自動垃圾回收機制的存在,讓大多Javascript開發者感覺他們可以不關心記憶體管理,所以會在一些情況下導致記憶體洩漏。

記憶體生命週期

JS 環境中分配的記憶體有如下宣告週期:

  1. 記憶體分配:當我們申明變數、函式、物件的時候,系統會自動為他們分配記憶體
  2. 記憶體使用:即讀寫記憶體,也就是使用變數、函式等
  3. 記憶體回收:使用完畢,由垃圾回收機制自動回收不再使用的記憶體

JS 的記憶體分配

為了不讓程式設計師費心分配記憶體,JavaScript 在定義變數時就完成了記憶體分配。

var n = 123; // 給數值變數分配記憶體
var s = "azerty"; // 給字串分配記憶體

var o = {
  a: 1,
  b: null
}; // 給物件及其包含的值分配記憶體

// 給陣列及其包含的值分配記憶體(就像物件一樣)
var a = [1, null, "abra"]; 

function f(a){
  return a + 2;
} // 給函式(可呼叫的物件)分配記憶體

// 函式表示式也能分配一個物件
someElement.addEventListener('click', function(){
  someElement.style.backgroundColor = 'blue';
}, false);

有些函式呼叫結果是分配物件記憶體:

var d = new Date(); // 分配一個 Date 物件

var e = document.createElement('div'); // 分配一個 DOM 元素

有些方法分配新變數或者新物件:

var s = "azerty";
var s2 = s.substr(0, 3); // s2 是一個新的字串
// 因為字串是不變數,
// JavaScript 可能決定不分配記憶體,
// 只是儲存了 [0-3] 的範圍。

var a = ["ouais ouais", "nan nan"];
var a2 = ["generation", "nan nan"];
var a3 = a.concat(a2); 
// 新陣列有四個元素,是 a 連線 a2 的結果

JS 的記憶體使用

使用值的過程實際上是對分配記憶體進行讀取與寫入的操作。
讀取與寫入可能是寫入一個變數或者一個物件的屬性值,甚至傳遞函式的引數。

var a = 10; // 分配記憶體
console.log(a); // 對記憶體的使用

JS 的記憶體回收

JS 有自動垃圾回收機制,那麼這個自動垃圾回收機制的原理是什麼呢?
其實很簡單,就是找出那些不再繼續使用的值,然後釋放其佔用的記憶體。

大多數記憶體管理的問題都在這個階段。
在這裡最艱難的任務是找到不再需要使用的變數。

不再需要使用的變數也就是生命週期結束的變數,是區域性變數,區域性變數只在函式的執行過程中存在,
當函式執行結束,沒有其他引用(閉包),那麼該變數會被標記回收。

全域性變數的生命週期直至瀏覽器解除安裝頁面才會結束,也就是說全域性變數不會被當成垃圾回收。

因為自動垃圾回收機制的存在,開發人員可以不關心也不注意記憶體釋放的有關問題,但對無用記憶體的釋放這件事是客觀存在的。
不幸的是,即使不考慮垃圾回收對效能的影響,目前最新的垃圾回收演算法,也無法智慧回收所有的極端情況。

接下來我們來探究一下 JS 垃圾回收的機制。

垃圾回收

引用

垃圾回收演算法主要依賴於引用的概念。

在記憶體管理的環境中,一個物件如果有訪問另一個物件的許可權(隱式或者顯式),叫做一個物件引用另一個物件。

例如,一個Javascript物件具有對它原型的引用(隱式引用)和對它屬性的引用(顯式引用)。

在這裡,“物件”的概念不僅特指 JavaScript 物件,還包括函式作用域(或者全域性詞法作用域)。

引用計數垃圾收集

這是最初級的垃圾回收演算法。

引用計數演算法定義“記憶體不再使用”的標準很簡單,就是看一個物件是否有指向它的引用。
如果沒有其他物件指向它了,說明該物件已經不再需了。

var o = { 
  a: {
    b:2
  }
}; 
// 兩個物件被建立,一個作為另一個的屬性被引用,另一個被分配給變數o
// 很顯然,沒有一個可以被垃圾收集


var o2 = o; // o2變數是第二個對“這個物件”的引用

o = 1;      // 現在,“這個物件”的原始引用o被o2替換了

var oa = o2.a; // 引用“這個物件”的a屬性
// 現在,“這個物件”有兩個引用了,一個是o2,一個是oa

o2 = "yo"; // 最初的物件現在已經是零引用了
           // 他可以被垃圾回收了
           // 然而它的屬性a的物件還在被oa引用,所以還不能回收

oa = null; // a屬性的那個物件現在也是零引用了
           // 它可以被垃圾回收了

由上面可以看出,引用計數演算法是個簡單有效的演算法。但它卻存在一個致命的問題:迴圈引用。

如果兩個物件相互引用,儘管他們已不再使用,垃圾回收不會進行回收,導致記憶體洩露。

來看一個迴圈引用的例子:

function f(){
  var o = {};
  var o2 = {};
  o.a = o2; // o 引用 o2
  o2.a = o; // o2 引用 o  這裡

  return "azerty";
}

f();

上面我們申明瞭一個函式 f ,其中包含兩個相互引用的物件。
在呼叫函式結束後,物件 o1 和 o2 實際上已離開函式範圍,因此不再需要了。
但根據引用計數的原則,他們之間的相互引用依然存在,因此這部分記憶體不會被回收,記憶體洩露不可避免了。

再來看一個實際的例子:

var div = document.createElement("div");
div.onclick = function() {
    console.log("click");
};

上面這種JS寫法再普通不過了,建立一個DOM元素並繫結一個點選事件。
此時變數 div 有事件處理函式的引用,同時事件處理函式也有div的引用!(div變數可在函式內被訪問)。
一個循序引用出現了,按上面所講的演算法,該部分記憶體無可避免的洩露了。

為了解決迴圈引用造成的問題,現代瀏覽器通過使用標記清除演算法來實現垃圾回收。

標記清除演算法

標記清除演算法將“不再使用的物件”定義為“無法達到的物件”。
簡單來說,就是從根部(在JS中就是全域性物件)出發定時掃描記憶體中的物件。
凡是能從根部到達的物件,都是還需要使用的。
那些無法由根部出發觸及到的物件被標記為不再使用,稍後進行回收。

從這個概念可以看出,無法觸及的物件包含了沒有引用的物件這個概念(沒有任何引用的物件也是無法觸及的物件)。
但反之未必成立。

工作流程:

  1. 垃圾收集器會在執行的時候會給儲存在記憶體中的所有變數都加上標記。
  2. 從根部出發將能觸及到的物件的標記清除。
  3. 那些還存在標記的變數被視為準備刪除的變數。
  4. 最後垃圾收集器會執行最後一步記憶體清除的工作,銷燬那些帶標記的值並回收它們所佔用的記憶體空間。

迴圈引用不再是問題了

再看之前迴圈引用的例子:

function f(){
  var o = {};
  var o2 = {};
  o.a = o2; // o 引用 o2
  o2.a = o; // o2 引用 o

  return "azerty";
}

f();

函式呼叫返回之後,兩個迴圈引用的物件在垃圾收集時從全域性物件出發無法再獲取他們的引用。
因此,他們將會被垃圾回收器回收。

記憶體洩漏

什麼是記憶體洩漏

程式的執行需要記憶體。只要程式提出要求,作業系統或者執行時(runtime)就必須供給記憶體。

對於持續執行的服務程序(daemon),必須及時釋放不再用到的記憶體。
否則,記憶體佔用越來越高,輕則影響系統性能,重則導致程序崩潰。

本質上講,記憶體洩漏就是由於疏忽或錯誤造成程式未能釋放那些已經不再使用的記憶體,造成記憶體的浪費。

記憶體洩漏的識別方法

經驗法則是,如果連續五次垃圾回收之後,記憶體佔用一次比一次大,就有記憶體洩漏。
這就要求實時檢視記憶體的佔用情況。

在 Chrome 瀏覽器中,我們可以這樣檢視記憶體佔用情況

  1. 開啟開發者工具,選擇 Performance 面板
  2. 在頂部勾選 Memory
  3. 點選左上角的 record 按鈕
  4. 在頁面上進行各種操作,模擬使用者的使用情況
  5. 一段時間後,點選對話方塊的 stop 按鈕,面板上就會顯示這段時間的記憶體佔用情況

來看一張效果圖:

我們有兩種方式來判定當前是否有記憶體洩漏:

  1. 多次快照後,比較每次快照中記憶體的佔用情況,如果呈上升趨勢,那麼可以認為存在記憶體洩漏
  2. 某次快照後,看當前記憶體佔用的趨勢圖,如果走勢不平穩,呈上升趨勢,那麼可以認為存在記憶體洩漏

在伺服器環境中使用 Node 提供的 process.memoryUsage 方法檢視記憶體情況

console.log(process.memoryUsage());
// { 
//     rss: 27709440,
//     heapTotal: 5685248,
//     heapUsed: 3449392,
//     external: 8772 
// }

process.memoryUsage返回一個物件,包含了 Node 程序的記憶體佔用資訊。

該物件包含四個欄位,單位是位元組,含義如下:

  • rss(resident set size):所有記憶體佔用,包括指令區和堆疊。
  • heapTotal:"堆"佔用的記憶體,包括用到的和沒用到的。
  • heapUsed:用到的堆的部分。
  • external: V8 引擎內部的 C++ 物件佔用的記憶體。

判斷記憶體洩漏,以heapUsed欄位為準。

常見的記憶體洩露案例

意外的全域性變數

function foo() {
    bar1 = 'some text'; // 沒有宣告變數 實際上是全域性變數 => window.bar1
    this.bar2 = 'some text' // 全域性變數 => window.bar2
}
foo();

在這個例子中,意外的建立了兩個全域性變數 bar1 和 bar2

被遺忘的定時器和回撥函式

在很多庫中, 如果使用了觀察者模式, 都會提供回撥方法, 來呼叫一些回撥函式。
要記得回收這些回撥函式。舉一個 setInterval的例子:

var serverData = loadData();
setInterval(function() {
    var renderer = document.getElementById('renderer');
    if(renderer) {
        renderer.innerHTML = JSON.stringify(serverData);
    }
}, 5000); // 每 5 秒呼叫一次

如果後續 renderer 元素被移除,整個定時器實際上沒有任何作用。
但如果你沒有回收定時器,整個定時器依然有效, 不但定時器無法被記憶體回收,
定時器函式中的依賴也無法回收。在這個案例中的 serverData 也無法被回收。

閉包

在 JS 開發中,我們會經常用到閉包,一個內部函式,有權訪問包含其的外部函式中的變數。
下面這種情況下,閉包也會造成記憶體洩露:

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing) // 對於 'originalThing'的引用
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log("message");
    }
  };
};
setInterval(replaceThing, 1000);

這段程式碼,每次呼叫 replaceThing 時,theThing 獲得了包含一個巨大的陣列和一個對於新閉包 someMethod 的物件。
同時 unused 是一個引用了 originalThing 的閉包。

這個範例的關鍵在於,閉包之間是共享作用域的,儘管 unused 可能一直沒有被呼叫,但是 someMethod 可能會被呼叫,就會導致無法對其記憶體進行回收。
當這段程式碼被反覆執行時,記憶體會持續增長。

DOM 引用

很多時候, 我們對 Dom 的操作, 會把 Dom 的引用儲存在一個數組或者 Map 中。

var elements = {
    image: document.getElementById('image')
};
function doStuff() {
    elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
    document.body.removeChild(document.getElementById('image'));
    // 這個時候我們對於 #image 仍然有一個引用, Image 元素, 仍然無法被記憶體回收.
}

上述案例中,即使我們對於 image 元素進行了移除,但是仍然有對 image 元素的引用,依然無法對齊進行記憶體回收。

另外需要注意的一個點是,對於一個 Dom 樹的葉子節點的引用。
舉個例子: 如果我們引用了一個表格中的td元素,一旦在 Dom 中刪除了整個表格,我們直觀的覺得記憶體回收應該回收除了被引用的 td 外的其他元素。
但是事實上,這個 td 元素是整個表格的一個子元素,並保留對於其父元素的引用。
這就會導致對於整個表格,都無法進行記憶體回收。所以我們要小心處理對於 Dom 元素的引用。

如何避免記憶體洩漏

記住一個原則:不用的東西,及時歸還。

  1. 減少不必要的全域性變數,使用嚴格模式避免意外建立全域性變數。
  2. 在你使用完資料後,及時解除引用(閉包中的變數,dom引用,定時器清除)。
  3. 組織好你的邏輯,避免死迴圈等造成瀏覽器卡頓,崩潰的問題。

參考

  • MDN-記憶體管理
  • JavaScript高階程式設計
  • JavaScript權威指南
  • JavaScript 記憶體洩漏教程
  • 一種有趣的JavaScript記憶體洩漏

寫在最後

  • 文中如有錯誤,歡迎在評論區指正,如果這篇文章幫到了你,歡迎點贊關注
  • 本文同步首發與github,可在github中找到更多精品文章,歡迎Watch & Star ★

歡迎關注微信公眾號【前端小黑屋】,每週1-3篇精品優質文章推送,助你走上進階之旅

相關推薦

中高階前端必須瞭解JS記憶體管理

前言 像C語言這樣的底層語言一般都有底層的記憶體管理介面,比如 malloc()和free()用於分配記憶體和釋放記憶體。 而對於JavaScript來說,會在建立變數(物件,字串等)時分配記憶體,並且在不再使用它們時“自動”釋放記憶體,這個自動釋放記憶體的過程稱為垃圾回收。 因為自動垃圾回收機制的存在,讓大

中高階前端必須瞭解的--陣列亂序

引言 陣列亂序指的是:將陣列元素的排列順序隨機打亂。 將一個數組進行亂序處理,是一個非常簡單但是非常常用的需求。 比如,“猜你喜歡”、“點選換一批”、“中獎方案”等等,都可能應用到這樣的處理。 sort 結合 Math.random 微軟曾在browserchoice.eu上做過一個關於不同瀏覽器使用情況的調

前端學習筆記之jsapply()和call()方法詳解

經過網上的大量搜尋,漸漸明白了apply()和call方法的使用,為此寫一篇文章記錄一下。 定義 apply()方法: Function.apply(obj,args)

前端 mvc 框架 Vue.js 堆疊記憶體深度 copy

Vue.js 是當下前端最主流的框架之一,強大的 mvc 模式,讓我們可以放棄傳統式通過 JS 去操作 DOM ,我們只需要關注資料層就可以了,只要資料更新,我們的 view 層就會自動更新渲染。 可是這樣的模式,在一些特殊場景下卻並不是特別好用,當元件間傳遞物件時,由於此物件的引用型別

Lua記憶體管理和釋放的理解

Lua記憶體是自動收集的, 這點跟Java類似, 不被任何物件或全域性變數引用的資料,將被首先標記為回收,不需要開發者做任何事情.但是,正如Java也會有記憶體洩露一樣, Lua也會有, 只不過,跟C++的不同,它是由於程式碼執行所裝載的資源,並沒有被徹底銷燬而導致,其中,最臭名昭著的就是不

對iOS開發記憶體管理的一點總結與理解

做iOS開發也已經有兩年的時間,覺得有必要沉下心去整理一些東西了,特別是一些基礎的東西,雖然現在有ARC這種東西,但是我一直也沒有去用過,個人覺得對記憶體操作的理解是衡量一個程式設計師成熟與否的一個標準。好了,閒話不說,下面進入正題。 眾所周知,ObjectiveC的記憶體

經驗分享零基礎學習web前端瞭解的企業最新行情+學習路線

下面是我給自學web前端的幾個建議: 建議一:有系統的學習方案,系統的學習教程,先把web前端學了一遍之後才是真正的入門,然後就是不斷的練習,不斷的鞏固,為之後的工作打下堅實的基礎。 建議二:學習web前端不要先看書學,一定要先把一塊的知識點學完一遍,並且自己多多少少會動手操作,然後

前端進階完全吃透async/await,深入JavaScript非同步

完全吃透async/await 導論: 首先,必須瞭解Promise 主要研究基本語法 對比Promise與Async 異常處理 參考: Async +Await 理解 as

前端進階完全吃透Promise,深入JavaScript非同步

完全吃透Promise Promise晉級,需要的全部都在這 主要內容: promise基本實現原理 promise 使用中難點(鏈式呼叫,API基本上返回都是一個新Promise,及引數傳遞) promise 對異常處理 pr

web前端 一條“不歸路”

本文屬於職業解惑系列,讀完此文要麼生,要麼死。要麼充滿鬥志,要麼頹廢放棄。 沒錯,此文的觀點可以讓你極端,但極端的選擇,完全取決於你個人! 付出就有回報,做好現在,技術只是為了改變生活! —— [ 小北哥哥 ] 好的,我x裝完了,情懷也寫好了,那麼我們進入【相守】的話題。

前端頁面的js對string去空格

想要在前端頁面(jsp、html等)的javascript中對string去空格:使用正則表示式 假設str為要去除空格的字串: <script > 去除所有空格: str = str.replace(/\s+/g,""

Node.js開發者必須瞭解的4個JS要點

Node.js是一個面向伺服器的框架,立足於Chrome強大的V8 JS引擎。儘管它由C++編寫而成,但是它及其應用是執行在JS上的。本文為開發者總結了4個Node.js要點。 1. 非阻塞(Non-blocking)或非同步I/O 由於Node.js一個伺服器端框架,所以它主要工作之一是處理瀏覽器

前端js的函式

函式 函式就是重複執行的程式碼片。   函式定義與執行 <script type="text/javascript">     // 函式定義     fu

中高階前端應該必會,js實現事件委託代理、切換樣式、元素獲取相對於文件位置等

1、介紹   隨著元件開發大流行,現在三大框架已經基本佔領了整個前端。   這時候,我們要是引入一個 jq 是不是先得你的專案非常臃腫,jq 也很不適合。   這個時候,你就需要來增加你 js 的功底。 2、各種操作   1、事件委託   案例分析: <ul id= "list

前端全棧工程化開發專題 — JS回撥函式的深入解讀

1、回撥函式核心原理分析 js中的定時器及動畫 完整版動畫庫封裝 回撥函式初步講解 擴充套件更多的運動方式(非勻速) options物件引數的應用 ... 什麼是回撥函式? 把一個函式當做實參值傳遞給函式的形參變數(或者傳遞給函式,通過函式arguments獲取),在另外一個函

前端---js的call和apply方法用法

最近看到JavaScript中關於call()和apply()方法可以用來呼叫函式的動態呼叫和實現偽繼承兩種功能,今天在這裡給大家詳細介紹一下. 1.call()函式 函式引用.call(呼叫者,引數1,引數2...) 等同於:呼叫者.函式(引數1,引數2...)=函

JS 例項必須使用 new 關鍵字生成的寫法

this instanceof xx 在 JS 中一個例項物件的建立必須使用 new 操作符。但是限於 JS 的語法特徵, 實際上 建構函式 同樣可以像普通函式那樣直接執行,這就使用了 函式作為建構函式的意義,為了避免這種情況的發生,很多 JS 庫使用下面的

前端二:js更新html

根據id document.getElementById("name").innerHTML="hello!"; 更新id為name的結點的內容: <p class="name" id="name"></p> 根據class $('.

VCGDI繪圖上手必須瞭解清楚的幾個概念

如果我們使用過GDI+繪圖,那麼理解GDI繪圖就很容易,不論在GDI還是GDI+中繪圖,都需要一個繪圖的“畫板”,如果沒有這個“畫板”那麼我們所繪製的圖就沒有地方承載,自然也就不能顯示出來給人看見。 GDI+的繪圖畫板物件是一個Graphics物件,看起來,這個非常好理解,那是因為微軟在

前端---js在製作頁面比較常用的幾個css屬性

今天在這裡給大家總結幾個我在製作一個頁面時所用到的一些不太常用的屬性,但有時候使用這些屬效能增加頁面的可觀性以及減少其他複雜的操作使用. z-index屬性 z-index 屬性設定元素的堆疊順序。擁有更高堆疊順序的元素總是會處於堆疊順序較低的元素的前面。 該屬性設定一個定位元素沿