JavaScript js調用堆棧(三)


本文主要深入介紹JavaScript內存機制

內存模型

JS內存空間分為棧(stack)堆(heap)池(一般也會歸類為棧中),其中存放變量,存放復雜對象,存放常量。

注:閉包中的變量並不保存在棧內存中,而是保存在堆內存中,這就是函數之后為什么閉包還能引用函數內的變量的原因。

 

function A() {
  let a = 1
  function B() {
      console.log(a)
  }
  return B
}

 

閉包的簡單定義是:函數 A 返回了一個函數 B,並且函數 B 中使用了函數 A 的變量,函數 B 就被稱為閉包。

函數 A Push調用棧后,函數 A 中的變量這時候是存儲在堆上的,所以函數B依舊能引用到函數A中的變量。現在的 JS 引擎可以通過逃逸分析辨別出哪些變量需要存儲在堆上,哪些需要存儲在棧上。

下面重點講解內存回收內存泄漏

內存回收

JavaScript有自動垃圾收集機制,就是找出那些不在繼續使用的值。然后釋放其占用的內存。垃圾收集器會每隔固定的時間段就執行一次釋放操作。最常用的是標記清除法,例如 a=null 其實只是一個釋放引用的操作,讓 a 原本對應的值失去引用,脫離執行環境,這個值會在下一次垃圾收集器執行操作時被找到並釋放。並且在適當的時候解除引用,是為頁面獲得更好性能的一個重要方式。

局部變量全局變量的回收

  • 局部變量:在局部作用域中,當函數執行完畢,局部變量也就沒有存在的必要了,因此垃圾收集器很容易做出判斷並回收;
  • 全局變量:全局變量什么時候需要自動釋放內存內存空間很難判斷,因此在開發中應盡量避免使用全局變量,以確保性能問題。

Google的V8引擎為例,在V8引擎中所有的JAVASCRIPT對象都是通過來進行內存分配的

  • 初始分配:當我們在代碼中聲明變量並賦值時,V8引擎就會在堆內存中分配一部分給這個變量;
  • 繼續申請:如果已申請的內存不足以存儲這個變量時,V8引擎就會繼續申請內存,直到堆的大小達到了V8引擎的內存上限為止(默認情況下,V8引擎的堆內存的大小上限在64位系統中為1464MB,在32位系統中則為732MB)。

V8引擎對堆內存中的JAVASCRIPT對象進行分代管理

  • 新生代:新生代即存活周期較短的JAVASCRIPT對象,如臨時變量、字符串等;
  • 老生代:老生代則為經過多次垃圾回收仍然存活,存活周期較長的對象,如主控制器、服務器對象等。

一段代碼分析一下垃圾回收:

 

function fun1() {
    var obj = {name: 'lhh', age: 21};
}
 
function fun2() {
    var obj = {name: 'coder', age: 2}
    return obj;
}
 
var f1 = fun1();
var f2 = fun2();

 

在上述代碼中,當執行 var f1 = fun1(); 的時候,執行環境會創建一個 {name:'lhh',age:21} 這個對象,當執行 var f2 = fun2(); 的時候,執行環境會創建一個 {name:'coder',age:2} 這個對象,然后在下一次垃圾回收執行的時候,會釋放 {name:'lhh',age:21} 這個對象的內存,但並不會釋放 {name:'coder',age:100} 這個對象的內存。這就是因為在 fun2() 函數中將 {name:'coder',age:2} 這個對象返回,並且將其引用賦值給了 f2 變量,又由於 f2 這個對象屬於全局變量,所以在頁面存在的情況下, f2 所指向的對象 {name:'coder',age:2} 是不會被回收的。(由於JavaScript語言的特殊性,例如閉包...,導致如何判斷一個對象是否會被回收比較困難,以上僅供參考)

 

垃圾回收算法

對垃圾回收算法來說,核心思想就是如何判斷內存已經不再使用,常用垃圾回收算法有下面兩種。

  • 引用計數(現代瀏覽器不再使用)

  • 標記清除(常用)

引用計數

引用計數算法定義“內存不再使用”的標准很簡單,就是看一個對象是否有指向它的引用。如果沒有其他對象指向它了,說明該對象已經不再需要了。

// 創建一個對象person,他有兩個指向屬性age和name的引用
var person = {
    age: 12,
    name: 'aaaa'
};

person.name = null; // 雖然設置為null,但因為person對象還有指向name的引用,因此name不會回收

var p = person; 
person = 1;         //原來的person對象被賦值為1,但因為有新引用p指向原person對象,因此它不會被回收

p = null;           //原person對象已經沒有引用,很快會被回收

由上面可以看出,引用計數算法是個簡單有效的算法。但它卻存在一個致命的問題:循環引用。如果兩個對象相互引用,盡管他們已不再使用,垃圾回收器不會進行回收,導致內存泄露。

function cycle() {
    var o1 = {};
    var o2 = {};
    o1.a = o2;
    o2.a = o1; 

    return "Cycle reference!"
}

cycle();

cycle函數執行完成之后,對象o1o2實際上已經不再需要了,但根據引用計數的原則,他們之間的相互引用依然存在,因此這部分內存不會被回收。所以現代瀏覽器不再使用這個算法。但IE依舊使用。

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

上面寫法創建一個DOM元素並綁定一個點擊事件,這里的變量div有事件處理函數的引用,同時事件處理函數也有div的引用(變量div可在函數內被訪問),所以循環引用就出現了。

標記清除(常用)

標記清除算法將“不再使用的對象”定義為“無法達到的對象”。簡單來說,就是從根部(在JS中就是全局對象)出發定時掃描內存中的對象。凡是能從根部到達的對象,都是還需要使用的,給予保留。那些無法由根部出發觸及到的對象被標記為不再使用,稍后進行回收。

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

算法由以下幾步組成:

  1. 垃圾回收器創建了一個“roots”列表。roots 通常是代碼中全局變量的引用。JavaScript 中,“window” 對象是一個全局變量,被當作 root 。window 對象總是存在,因此垃圾回收器可以檢查它和它的所有子對象是否存在(即不是垃圾);
  2. 所有的 roots 被檢查和標記為激活(即不是垃圾)。所有的子對象也被遞歸地檢查。從 root 開始的所有對象如果是可達的,它就不被當作垃圾;
  3. 所有未被標記的內存會被當做垃圾,收集器現在可以釋放內存,歸還給操作系統了。

現代的垃圾回收器改良了算法,但是本質是相同的:可達內存被標記,其余的被當作垃圾回收。

根據這個概念,上面的例子可以正確被垃圾回收處理了。

當div與其事件處理函數不能再從全局對象出發觸及的時候,垃圾回收器就會標記並回收這兩個對象。

內存泄漏

例子:

對於主流瀏覽器來說,只需要切斷需要回收的對象與根部的聯系。最常見的內存泄露一般都與DOM元素綁定有關:

 

email.message = document.createElement(“div”);
displayList.appendChild(email.message);

// 稍后從displayList中清除DOM元素
displayList.removeAllChildren();

 

上面代碼中,div元素已經從DOM樹中清除,但是該div元素還綁定在email對象中,所以如果email對象存在,那么該div元素就會一直保存在內存中。

概念:

對於持續運行的服務進程(daemon),必須及時釋放不再用到的內存。否則,內存占用越來越高,輕則影響系統性能,重則導致進程崩潰。 對於不再用到的內存,沒有及時釋放,就叫做內存泄漏(memory leak)。

內存泄漏識別方法:

  1. 瀏覽器方法
    • 打開開發者工具,選擇 Memory
    • 在右側的Select profiling type字段里面勾選 timeline
    • 點擊左上角的錄制按鈕
    • 在頁面上進行各種操作,模擬用戶的使用情況。
    • 一段時間后,點擊左上角的 stop 按鈕,面板上就會顯示這段時間的內存占用情況。
  2. 命令行方法

      使用Node提供的process.memoryUsage方法

console.log(process.memoryUsage());

// 輸出
{ 
  rss: 27709440,        // resident set size,所有內存占用,包括指令區和堆棧
  heapTotal: 5685248,   // "堆"占用的內存,包括用到的和沒用到的
  heapUsed: 3449392,    // 用到的堆的部分
  external: 8772         // V8 引擎內部的 C++ 對象占用的內存
}

判斷內存泄漏,以heapUsed字段為准

WeakMap

ES6 新出的兩種數據結構:WeakSet 和 WeakMap,表示這是弱引用,它們對於值的引用都是不計入垃圾回收機制的。

 

const wm = new WeakMap();
const element = document.getElementById('example');

wm.set(element, 'some information');
wm.get(element) // "some information"

 

先新建一個 Weakmap 實例,然后將一個 DOM 節點作為鍵名存入該實例,並將一些附加信息作為鍵值,一起存放在 WeakMap 里面。這時,WeakMap 里面對element的引用就是弱引用,不會被計入垃圾回收機制。

 四種常見的JS內存泄漏

1.意外的全局變量

未定義的變量會在全局對象創建一個新變量,如下:

function foo(arg) {
    bar = "this is a hidden global variable";
}

真相是:

function foo(arg) {
    window.bar = "this is an explicit global variable";
}

函數 foo 內部忘記使用 var ,實際上JS會把bar掛載到全局對象上,意外創建一個全局變量。

另一個意外的全局變量可能由 this 創建:

function foo() {
    this.variable = "potential accidental global";
}

// Foo 調用自己,this 指向了全局對象(window)
// 而不是 undefined
foo();

解決方法:在 JavaScript 文件頭部加上 'use strict',使用嚴格模式避免意外的全局變量,此時上例中的this指向undefined。如果必須使用全局變量存儲大量數據時,確保用完以后把它設置為 null 或者重新定義。與全局變量相關的增加內存消耗的一個主因是緩存。緩存數據是為了重用,緩存必須有一個大小上限才有用。高內存消耗導致緩存突破上限,因為緩存內容無法被回收。

2.被遺忘的計時器或回調函數

在 JavaScript 中使用 setInterval 非常常見:

var someResource = getData();
setInterval(function() {
    var node = document.getElementById('Node');
    if(node) {
        // 處理 node 和 someResource
        node.innerHTML = JSON.stringify(someResource));
    }
}, 1000);

上面的例子表明,在節點node或者數據不再需要時,定時器依舊指向這些數據。所以哪怕當node節點被移除后,interval 仍舊存活並且垃圾回收器沒辦法回收,它的依賴也沒辦法被回收,除非終止定時器。

var element = document.getElementById('button');
function onClick(event) {
    element.innerHTML = 'text';
}

element.addEventListener('click', onClick);

對於上面觀察者的例子,一旦它們不再需要(或者關聯的對象變成不可達),明確地移除它們非常重要。老的 IE 6 是無法處理循環引用的。因為老版本的 IE 是無法檢測 DOM 節點與 JavaScript 代碼之間的循環引用,會導致內存泄漏。

3.脫離DOM的引用

有時,保存 DOM 節點內部數據結構很有用。假如你想快速更新表格的幾行內容,把每一行 DOM 存成字典(JSON 鍵值對)或者數組很有意義。此時,同樣的 DOM 元素存在兩個引用:一個在 DOM 樹中,另一個在字典中。將來你決定刪除這些行時,需要把兩個引用都清除。

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image'),
    text: document.getElementById('text')
};
function doStuff() {
    image.src = 'http://some.url/image';
    button.click();
    console.log(text.innerHTML);
    // 更多邏輯
}
function removeButton() {
    // 按鈕是 body 的后代元素
    document.body.removeChild(document.getElementById('button'));
    // 此時,仍舊存在一個全局的 #button 的引用
    // elements 字典。button 元素仍舊在內存中,不能被 GC 回收。
}

此外還要考慮 DOM 樹內部或子節點的引用問題。假如你的 JavaScript 代碼中保存了表格某一個 <td> 的引用。將來決定刪除整個表格的時候,直覺認為 GC 會回收除了已保存的 <td> 以外的其它節點。實際情況並非如此:此 <td> 是表格的子節點,子元素與父元素是引用關系。由於代碼保留了 <td> 的引用,導致整個表格仍待在內存中。保存 DOM 元素引用的時候,要小心謹慎。

4.閉包

閉包的關鍵是匿名函數可以訪問父級作用域的變量。

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing)
      console.log("hi");
  };

  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log(someMessage);
    }
  };
};

setInterval(replaceThing, 1000);

每次調用 replaceThing theThing 得到一個包含一個大數組和一個新閉包(someMethod)的新對象。同時,變量 unused 是一個引用 originalThing 的閉包(先前的 replaceThing 又調用了 theThing )。最重要的事情是,閉包的作用域一旦創建,它們有同樣的父級作用域,作用域是共享的。someMethod 可以通過 theThing 使用,someMethod 與 unused 分享閉包作用域,盡管 unused從未使用,它引用的 originalThing 迫使它保留在內存中(防止被回收)。當這段代碼反復運行,就會看到內存占用不斷上升,垃圾回收器(GC)並無法降低內存占用。本質上,閉包的鏈表已經創建,每一個閉包作用域攜帶一個指向大數組的間接的引用,造成嚴重的內存泄漏。

解決方法:在 replaceThing 的最后添加 originalThing = null

 

推薦閱讀:

https://juejin.im/post/5b10ba336fb9a01e66164346#comment

https://auth0.com/blog/four-types-of-leaks-in-your-javascript-code-and-how-to-get-rid-of-them//

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM