【瀏覽器】(內附面試題)瀏覽器中堆疊記憶體的底層處理機制

小小晴_發表於2020-02-10

寫在前面

我們寫的JS程式碼瀏覽器是如何解析的?瀏覽器在執行JS程式碼的過程中發生了什麼?…這些問題可能在完成業務功能程式碼的過程中並沒有多麼重要,但是作為一名前端開發工程師,瞭解這些會提升自己的思維能力、會讓自己更深層次的理解JavaScript語言。這篇文章將詳細闡述瀏覽器中堆疊記憶體的底層處理機制、垃圾回收機制以及記憶體洩漏的幾種情況。

瀏覽器執行程式碼需要經歷什麼

編譯

  • 詞法解析:這個過程會將由字元組成的字串分解成有意義的程式碼塊(詞法單元)。

  • 語法分析:這個過程將詞法單元流轉換成一個由元素逐級巢狀所組成的代表了程式語法的樹(抽象語法樹 => AST)。

  • 程式碼生成:將AST轉換為可執行程式碼的過程被稱為程式碼生成。

引擎編譯執行程式碼

然後將構建出的程式碼交給引擎(V8),這個時候可能會遇到變數提升作用域和作用域鏈/閉包變數物件堆疊記憶體GO/VO/AO/EC/ECStack、…。

引擎在編譯執行程式碼的過程中,首先會建立一個執行棧,也就是棧記憶體(ECStack => 執行環境棧),然後執行程式碼。

在程式碼執行會建立EC(執行上下文),執行上下文分為全域性執行上下文(EC(G))和函式執行上下文(EC(...)),其中函式的執行上下文是私有的。

建立執行上下文的過程中,可能會建立:

  • GO(Global Object):全域性物件 瀏覽器端,會把GO賦值給window
  • VO(Varible Object):變數物件,儲存當前上下文中的變數。
  • AO(Active Object):活動物件

然後將進棧執行,建立好的上下文將壓縮到棧中執行,執行後一些沒用的上下文將出棧,有用的上下文會壓縮到棧底(閉包)。棧底永遠是全域性執行上下文,棧頂則永遠是當前執行上下文。

下面的一張圖表達了整個流程

變數賦值的三步操作

第一步,建立變數,這個過程叫做宣告(declare)。

第二步,建立值。基本型別值會直接在棧中建立和儲存;由於引用型別值是複雜的結構,所以需開闢一個儲存物件中鍵值對(儲存函式中程式碼)的記憶體空間,這個記憶體就是堆記憶體,所有的堆記憶體都有可被後續查詢的16進位制地址,後續關聯賦值時,是把堆記憶體地址給予變數操作。

最後一步,將變數和值關聯,這個過程叫做定義(defined)。這裡,如果值只經過了宣告,而沒有進行賦值操作,這個值就是未定義(undefined)。

一道題理解這個過程

我將以畫圖的形式展示

// 1.
let a = 12;
let b = a;
b = 13;
console.log(a);

// 2.
let a = {n:12};
let b = a;
b['n'] = 13;
console.log(a.n);

// 3.
let a = {n:12};
let b = a;
b = {n:13};
console.log(a.n);
複製程式碼
  • 第一問

建立執行棧,形成全域性執行上下文,並且建立GO,進入棧中執行程式碼

基本型別直接在棧中建立和儲存

所以本問最終輸出的a值為12

  • 第二問

建立執行棧,形成全域性執行上下文,並且建立GO,進入棧中執行程式碼

引用型別值比較複雜,將建立堆記憶體

所以本問最終輸出的a.n13

  • 第三問

建立執行棧,形成全域性執行上下文,並且建立GO,進入棧中執行程式碼

引用型別值比較複雜,將建立堆記憶體

所以本問最終輸出的a.n的值為12

幾道面試題讓你更深次理解瀏覽器堆疊記憶體的底層處理機制

  • 第一個題
let a = {
    n10
};
let b = a;
b.m = b = {
    n20
};
console.log(a);
console.log(b);
複製程式碼

建立執行棧,形成全域性執行上下文,並且建立GO,進入棧中執行程式碼

引用型別值比較複雜,將建立堆記憶體

所以最終輸出的a{n: 10, m: {n: 20}};b{n: 20}

  • 第二個題:
let x = [1223];
function fn(y{
    y[0] = 100;
    y = [100];
    y[1] = 200;
    console.log(y);
}
fn(x);
console.log(x);
複製程式碼

首先會建立ECStack,形成全域性執行上下文,建立VO(變數物件), 然後進入棧中執行程式碼

變數賦值

接下來會執行fn(x)函式,函式執行會形成一個全新的執行上下文,會產生AO。上面說過,棧頂永遠是當前執行上下文,棧底是全域性執行上下文,所以函式執行,函式執行上下文將進棧,會將全域性執行上下文壓入棧底。

然後進行程式碼的執行操作,執行後會出棧

繼續執行,列印出x,經過上述分析:

答案是:[100, 200] [100, 23]

  • 第三個題
var x = 10;
function (x{
    console.log(x);
    x = x || 20 && 30 || 40;
    console.log(x);
}();
console.log(x);
複製程式碼

所以,最終的結果為:undefined 30 10

  • 第四題
let x = [12],
    y = [34];
function (x{
    x.push('A');
    x = x.slice(0);
    x.push('B');
    x = y;
    x.push('C');
    console.log(x, y);
}(x);
console.log(x, y);
複製程式碼

所以本題最終的輸出結果為[3, 4, 'C'] [3, 4, 'C'] [1, 2, 'A'] [3, 4, 'C']

垃圾回收機制

瀏覽器的Javascript具有自動垃圾回收機制(GC:Garbage Collecation),垃圾收集器會定期(週期性)找出那些不在繼續使用的變數,然後釋放其記憶體。

標記清除

js中,最常用的垃圾回收機制是標記清除:當變數進入執行環境時,被標記為“進入環境”,當變數離開執行環境時,會被標記為“離開環境”。垃圾回收器會銷燬那些帶標記的值並回收它們所佔用的記憶體空間。

function demo({
    var a = 1;     // 標記"進入環境"
    var b = 2;     // 標記"進入環境"


demo();            // 函式執行完畢,a和b標記為"離開環境"
複製程式碼

引用計數

瀏覽器會跟蹤記錄值的引用次數,每多引用一次,引用次數就會加1,取消佔用,引用次數就會減1,當引用次數為0時,瀏覽器會進行垃圾回收。

下面的例子說明a引用次數的變化

function demo({
    var a = {};     // +1
    var b = a;      // +1 => 2
    b = null;       // -1 => 1
    a = null;       // -1 => 0   ====> 此時會回收
}
複製程式碼

記憶體洩漏

記憶體洩露是指程式中的某些函式或者任務執行完畢之後,本該釋放的記憶體空間,由於各種原因,沒有被釋放,導致程式越來越耗記憶體,最終可能引發程式崩潰等各種嚴重後果。

在 JS 中,常見的記憶體洩露主要有 4 種

全域性變數

一個例子直接說明:

 var obj = null
 function foo(){
      obj = { name:"小紅" }; 
 }
 foo();
複製程式碼

上述程式碼中,obj是一個全域性變數,這樣,所有和obj作用域同層級的函式都可以訪問到obj物件,所以obj物件不會被回收。

閉包

閉包是 JS 中最容易引起記憶體洩露的特性

function foo(){
    var obj = {name:"小紅"}
    return function(){
         return obj.name;
    }
}
var func = foo();   // foo返回的值是一個函式,func也變成了一個外部函式
func();             // 外部函式func能狗訪問foo內部的user物件。
複製程式碼

上述程式碼中,foo函式執行完後,因為在func()中依然能夠訪問到obj,所以變數obj沒有被釋放,這就導致了記憶體洩漏,我們可以用下面的方法解決

function foo(){
    var obj = {name:"小紅"}
    return function(){
        var obj1 = obj;
        obj = null;
        return obj1.name;
    }
}
var func = foo();   // foo返回的值是一個函式,func也變成了一個外部函式
func();          
複製程式碼

上面的程式碼中,在foo函式返回的函式中,及時將obj釋放了,這個時候,在func函式執行時,就不會訪問到區域性變數obj了。

DOM 元素的引用

DOM元素的引用中,會出現記憶體洩漏

  • DOM元素刪除了,但是JS物件中的引用沒刪除
<body>
    <div id="app"></div>
    <script>
        var appDom = document.getElementById("app");

        appDom.onclick = function({
            document.body.removeChild(document.getElementById("app"));
        }
    
</script>
</body>
複製程式碼

上面的例子中,點選#app時,清除了該DOM節點,但是appDom依然保留對其的引用,導致#app沒有被釋放。

  • 使用第三方庫

定時器

setInterval函式的定時器會一直迴圈,除非手動清除,這就出現了記憶體隱患,所以我們應該在使用完定時器時對定時器及時清除。

var count = setInterval(() => {
    console.log(1);
}, 1000);

// 使用完成
clearInterval(count)
複製程式碼

以上是導致記憶體洩漏的四種情況(例子不只有文中的幾個,在平時的開發工作中還會有很多的例子),在我們的日常開發工作中,應該避免這四種情況的發生,所以我們寫程式碼的額過程中,要多多注意。

總結

本文詳細講解了在瀏覽器中是如何對堆疊記憶體進行處理的,也簡單說了一下垃圾回收機制的幾種方法和造成記憶體洩漏的幾種情況。還希望大家在仔細閱讀後(自動忽略掉我寫的醜字?),能夠指出其中不合理甚至錯誤的地方,我們共同學習,共同進步~

最後,分享一下我的公眾號「web前端日記」,希望大家多多關注

相關文章