js中的內存管理

目錄

 

前言

一、內存結構

二、對象及數組的存儲

Object 存儲

Array 存儲

三、內存生命週期

四、垃圾回收

五、V8引擎內存限制

六、內存泄露

內存泄漏的識別方法

常見的內存泄露案例

如何避免內存泄漏

七、視圖類型(連續內存)

八、參考


前言

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

一、內存結構

內存分爲堆(heap)和棧(stack),堆內存存儲引用數據類型(Object, Array,Function...),棧內存則存儲基本數據類型和引用類型的地址索引,方便快速寫入和讀取數據。在訪問數據時,如果是引用數據類型,先從棧內尋找相應數據的存儲地址,再根據獲得的地址,找到堆內該變量真正存儲的內容讀取出來。

基本數據類型由於存儲在棧中,讀取寫入速度相對引用類型(存在堆中)會更快些。

堆棧緩存方式

棧:一級緩存, 調用完畢立即釋放。

堆:二級緩存,一般由程序員主動分配釋放,如果沒有主動釋放會由虛擬機的垃圾回收算法來決定是否回收(並不是一旦成爲孤兒對象就能被回收)。

二、對象及數組的存儲

在JS中,一個對象可以任意添加和移除屬性,似乎沒有限制(實際上需要不能大於 2^32 個屬性)。而JS中的數組,不僅是變長的,可以隨意添加刪除數組元素,每個元素的數據類型也可以完全不一樣,更不一般的是,這個數組還可以像普通的對象一樣,在上面掛載任意屬性,這都是爲什麼呢?

Object 存儲

首先了解一下,JS是如何存儲一個對象的。

JS在設計複雜類型存儲的時候面臨的最直觀的問題就是,選擇一種數據結構,需要在讀取,插入和刪除三個方面都有較高的性能。

數組形式的結構,讀取和順序寫入的速度最快,但插入和刪除的效率都非常低下;

鏈表結構,移除和插入的效率非常高,但是讀取效率過低,也不可取;

複雜一些的樹結構等等,雖然不同的樹結構有不同的優點,但都繞不過建樹時較複雜,導致初始化效率低下;

綜上所屬,JS 選擇了一個初始化,查詢和插入刪除都能有較好,但不是最好的性能的數據結構 -- 哈希表。

哈希表

哈希表存儲是一種常見的數據結構。所謂哈希映射,是把任意長度的輸入通過散列算法變換成固定長度的輸出。

對於一個 JS 對象,每一個屬性,都按照一定的哈希映射規則,映射到不同的存儲地址上。在我們尋找該屬性時,也是通過這個映射方式,找到存儲位置。當然,這個映射算法一定不能過於複雜,這會使映射效率低下;但也不能太簡單,過於簡單的映射方式,會導致無法將變量均勻的映射到一片連續的存儲空間內,而造成頻繁的哈希碰撞。

對象生命週期

當創建一個對象時,JavaScript 會自動爲該對象分配適當的內存。從這一刻起,垃圾回收器就會不斷對該對象進行評估,以查看它是否仍是有效的對象。

垃圾回收器定期掃描對象,並計算引用了每個對象的其他對象的數量。如果一個對象的引用數量爲 0(沒有其他對象引用過該對象),或對該對象的惟一引用是循環的,那麼該對象的內存即可回收。

Array 存儲

JS 的數組爲何也比其他語言的數組更加靈活呢?因爲 JS 的 Array 的對象,就是一種特殊類型的數組!

所謂特殊類型,就是指在 Array 中,每一個屬性的 key 就是這個屬性的 index;而這個對象還有 .length 屬性;還有 concat, slice, push, pop 等方法;

於是這就解釋了:

  • 爲何 JS 的數組每個數據類型都可以不一樣?因爲他就是個對象,每條數據都是一個新分配的類型連入鏈表中;

  • 爲何 JS 的數組無需提前設置長度,是可變數組?答案同上;

  • 爲何數組可以像 Object 一樣掛載任意屬性?因爲他就是個對象;

  • 爲何數組可以直接根據索引取得對應的元素,不管取第1個值還是第n個值的速度都是一樣的(時間複雜度都是 O(1));

等等一系列的問題。

三、內存生命週期

JS 環境中分配的內存有如下生命週期:

  1. 內存分配:當我們聲明變量、函數、對象的時候,系統會自動爲他們分配內存
  2. 內存使用:即讀寫內存,也就是使用變量、函數等
  3. 內存回收:使用完畢,由垃圾回收機制自動回收不再使用的內存

JS 的內存分配

爲了不讓程序員費心分配內存,JavaScript 在定義變量時就完成了內存分配。

JS 的內存使用

使用值的過程實際上是對分配內存進行讀取與寫入的操作。 讀取與寫入可能是寫入一個變量或者一個對象的屬性值,甚至傳遞函數的參數。

var a = 10; // 分配內存
console.log(a); // 對內存的使用

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();

函數調用返回之後,兩個循環引用的對象在垃圾收集時從全局對象出發無法再獲取他們的引用。 因此,他們將會被垃圾回收器回收。

五、V8引擎內存限制

谷歌瀏覽器的V8引擎只能使用系統的一部分內存,具體來說,在64位系統下,V8最多隻能分配1.4G, 在 32 位系統中,最多隻能分配0.7G。

V8 爲什麼要給它設置內存上限?明明我的機器大幾十G的內存,只能讓我用這麼一點?

究其根本,是由兩個因素所共同決定的,一個是JS單線程的執行機制,另一個是JS垃圾回收機制的限制。

首先JS是單線程運行的,這意味着一旦進入到垃圾回收,那麼其它的各種運行邏輯都要暫停; 另一方面垃圾回收其實是非常耗時間的操作,V8 官方是這樣形容的:

以 1.5GB 的垃圾回收堆內存爲例,V8 做一次小的垃圾回收需要50ms 以上,做一次非增量式的垃圾回收甚至要 1s 以上。

可見其耗時之久,而且在這麼長的時間內,我們的JS代碼執行會一直沒有響應,造成應用卡頓,導致應用性能和響應能力直線下降。因此,V8 做了一個簡單粗暴的選擇,那就是限制堆內存,也算是一種權衡的手段,因爲大部分情況是不會遇到操作幾個G內存這樣的場景的。

不過,如果你想調整這個內存的限制也不是不行。配置命令如下:

// 這是調整老生代這部分的內存,單位是MB。後面會詳細介紹新生代和老生代內存
node --max-old-space-size=2048 xxx.js 

V8 把堆內存分成了兩部分進行處理——新生代內存和老生代內存。顧名思義,新生代就是臨時分配的內存,存活時間短, 老生代是常駐內存,存活的時間長。V8 的堆內存,也就是兩個內存之和。

六、內存泄露

內存泄漏,指任何對象在你不再擁有或需要它之後未能釋放仍然存在,造成內存的浪費。

內存泄漏的識別方法

經驗法則是,如果連續五次垃圾回收之後,內存佔用一次比一次大,就有內存泄漏。 這就要求實時查看內存的佔用情況。

在 Chrome 瀏覽器中,我們可以這樣查看內存佔用情況

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

來看一張效果圖:

我們有兩種方式來判定當前是否有內存泄漏:

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

使用 Chrome 瀏覽器控制檯 Memory 提供的 Heap Profile 管理內存

在服務器環境中使用 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();

被遺忘的定時器和回調函數

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. 組織好你的邏輯,避免死循環等造成瀏覽器卡頓,崩潰的問題。

七、視圖類型(連續內存)

通過上面的介紹可以知道,我們使用的數組實際上是僞數組。這種僞數組給我們的操作帶來了極大的方便性,但這種實現方式也帶來了另一個問題,及無法達到數組快速索引的極致,像文章開頭時所說的上百萬的數據量的情況下,每次新添加一條數據都需要動態分配內存空間,數據索引時都要遍歷鏈表索引造成的性能浪費會變得異常的明顯。

好在 ES6 中,JS 新提供了一種獲得真正數組的方式:ArrayBuffer,TypedArray 和 DataView。

ArrayBuffer

ArrayBuffer 代表分配的一段定長的連續內存塊。但是我們無法直接對該內存塊進行操作,只能通過 TypedArray 和 DataView 來對其操作。

TypedArray

TypeArray 是一個統稱,他包含 Int8Array / Int16Array / Int32Array / Float32Array等等。

DataView

DataView 相對 TypedArray 來說更加的靈活。每一個 TypedArray 數組的元素都是定長的數據類型,如 Int8Array 只能存儲 Int8 類型;但是 DataView 卻可以在傳遞一個 ArrayBuffer 後,動態分配每一個元素的長度,即存不同長度及類型的數據。

八、參考

    

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章