JS 變數儲存?棧 & 堆?NONONO!

斑碼發表於2019-11-15

前言

這次的 why what or how 主題:JavaScript 變數儲存。

無論那門語言,變數是組成一切的基礎,一個數字是一個變數,一個物件也是一個變數,在 JavaScript 中甚至連一個函式都是一個變數。

那麼如此重要的變數,在 JavaScript 中究竟是如何進行儲存的?

棧 & 堆 ?

棧(Stack)又名堆疊,它是一種運算受限的線性表。限定僅在表尾進行插入和刪除操作的線性表。

在百度上搜尋 JavaScript 變數儲存,能看到很多文章,無外乎一個結論:

對於原始型別,資料本身是存在棧內,對於物件型別,在棧中存的只是一個堆內地址的引用。

但是,我突然想到一個問題:如果說原始型別存在在棧中,那麼 JavaScript 中的閉包是如何實現的?

當然想要深究這個問題,有必要先把棧(Stack)和堆(Heap)給說說清楚。

那好,先說說棧。

棧是記憶體中一塊用於儲存區域性變數和函式引數的線性結構,遵循著先進後出的原則。資料只能順序的入棧,順序的出棧。當然,棧只是記憶體中一片連續區域一種形式化的描述,資料入棧和出棧的操作僅僅是棧指標在記憶體地址上的上下移動而已。如下圖所示(以 C 語言為例):

變數在棧中儲存

如圖所示,棧指標剛開始指向記憶體中 0x001 的位置,接著 sum 函式開始呼叫,由於宣告瞭兩個變數,往棧中存放了兩個數值,棧指標也對應開始移動,當 sum 函式呼叫結束時,僅僅是把棧指標往下移動而已,並不是真正的資料彈出,資料還在,只不過下次賦值時會被覆蓋。

挺簡單的不是麼,但需要註明一點的是:記憶體中棧區的資料,在函式呼叫結束後,就會自動的出棧,不需要程式進行操作,作業系統會自動執行,換句話說:棧中的變數在函式呼叫結束後,就會消失。

因此棧的特點:輕量,不需要手動管理,函式調時建立,呼叫結束則消失。

堆可以簡單的認為是一大塊記憶體空間,就像一個籃子,你往裡面放什麼都沒關係,但是籃子是私人物品,作業系統並不會管你的籃子裡都放了什麼,也不會主動去清理你的籃子,因此在 C 語言中,堆中內容是需要程式設計師手動清理的,不然就會出現記憶體溢位的情況。

為了一定程度的解決堆的問題,一些高階語言(如 JAVA)提出了一個概念:GCGarbage Collection,垃圾回收,用於協助程式管理記憶體,主動清理堆中已不被使用的資料。

既然堆是一個大大的籃子,那麼在棧中儲存不了的資料(比如一個物件),就會被儲存在堆中,棧中就僅僅保留一個對該資料的引用(也就是該塊資料的首地址)。

問題!

OK 棧和堆內容如上,現在我們再來看看大家的結論:

對於原始型別,資料本身是存在棧內,對於物件型別,在棧中存的只是一個堆內地址的引用。

感覺很符合邏輯啊,按照定義基礎型別存在棧中,物件存在堆中,沒毛病啊!

但是,請大家思考一個問題:

既然棧中資料在函式執行結束後就會被銷燬,那麼 JavaScript 中函式閉包該如何實現,先簡單來個閉包:

function count () {
    let num = -1;
    return function () {
        num++;
        return num;
    }
}

let numCount = count();
numCount();
// 0
numCount();
// 1
複製程式碼

按照結論,num 變數在呼叫 count 函式時建立,在 return 時從棧中彈出。

既然是這樣的邏輯,那麼呼叫 numCount 函式如何得出 0 呢?num 在函式 return 時已經在記憶體中被銷燬了啊!

因此,在本例中 JavaScript 的基礎型別並不儲存在棧中,而應該儲存在堆中,供 numCount 函式使用。

那麼網上大家的結論就是錯的了?非也!接下來談談我對 JavaScript 變數儲存的理解。

拋開棧

既然在 JavaScript 中有閉包的問題,拋開棧(Stack),僅用堆能否實現變數儲存?我們來看一個特殊的例子:

function test () {
    let num = 1;
    let string = 'string';
    let bool = true;
    let obj = {
        attr1: 1,
        attr2: 'string',
        attr3: true,
        attr4: 'other'
    }
    return function log() {
        console.log(num, string, bool, obj);
    }
}
複製程式碼

伴隨著 test 的呼叫,為了保證變數不被銷燬,在堆中先生成一個物件就叫 Scope 吧,把變數作為 Scope 的屬性給存起來。堆中的資料結構大致如下所示:

使用 Scope 儲存變數

那麼,這樣就能解決閉包的問題了嗎?

當然可以,由於 Scope 物件是儲存在堆中,因此返回的 log 函式完全可以擁有 Scope 物件 的訪問。下圖是該段程式碼在 Chrome 中的執行效果:

Chrome 中 Scope 的表示

紅框部分,與上述一致,同時也反應出了之前提及的問題:例子中 JavaScript 的變數並沒有存在棧中,而是在堆裡,用一個特殊的物件(Scope)儲存。

那麼在 JavaScript 變數到底是如何程式儲存的?這和變數的型別直接掛鉤,接下來就談談在 JavaScript 中變數的型別。

三種型別

JavaScript 中,變數分為三種型別:

  1. 區域性變數
  2. 被捕獲變數
  3. 全域性變數

區域性變數

區域性變數很好理解:在函式中宣告,且在函式返回後不會被其他作用域所使用的物件。下面程式碼中的 local* 都是區域性變數。

function test () {
    let local1 = 1;
    var local2 = 'str';
    const local3 = true;
    let local4 = {a: 1};
    return;
}
複製程式碼

被捕獲變數

被捕獲變數就是區域性變數的反面:在函式中宣告,但在函式返回後仍有未執行作用域(函式或是類)使用到該變數,那麼該變數就是被捕獲變數。下面程式碼中的 catch* 都是被捕獲變數。

function test1 () {
    let catch1 = 1;
    var catch2 = 'str';
    const catch3 = true;
    let catch4 = {a: 1};
    return function () {
        console.log(catch1, catch2, catch3, catch4)
    }
}

function test2 () {
    let catch1 = 1;
    let catch2 = 'str';
    let catch3 = true;
    var catch4 = {a: 1};
    return class {
        constructor(){
            console.log(catch1, catch2, catch3, catch4)
        }
    }
}

console.dir(test1())
console.dir(test2())
複製程式碼

複製程式碼到 Chrome 即可檢視輸出物件下的 [[Scopes]] 下有對應的 Scope

全域性變數

全域性變數就是 global,在 瀏覽器上為 windownode 裡為 global。全域性變數會被預設新增到函式作用域鏈的最低端,也就是上述函式中 [[Scopes]] 中的最後一個。

全域性變數需要特別注意一點:varlet/const 的區別。

var

全域性的 var 變數其實僅僅是為 global 物件新增了一條屬性。

var testVar = 1;

// 與下述程式碼一致
windows.testVar = 1;
複製程式碼

let / const

全域性的 let/const 變數不會修改 windows 物件,而是將變數的宣告放在了一個特殊的物件下(與 Scope 類似)。

let testLet = 1;

console.dir(() => {})
複製程式碼

複製到 Chrome 有以下結果:

let/const 全域性變數

兩種方式

那麼變數的型別確定了,如何進行儲存呢?有兩種:

  1. 棧(Stack
  2. 堆(Heap

相信看到這裡,大家心裡應該都清楚了:除了區域性變數,其他的全都存在堆中!根據變數的資料型別,分為以下兩種情況:

  1. 如果是基礎型別,那棧中存的是資料本身。
  2. 如果是物件型別,那棧中存的是堆中物件的引用。

但這是理想情況,再問大家一個問題:JavaScript 解析器如何判斷一個變數是區域性變數呢?

判斷出是否被內部函式引用即可!

那如果 JavaScript 解析器並沒有判斷呢?那就只能存在堆裡!

那麼你一定想問,ChromeV8 能否判斷出,從結果看應該是可以的。

Chrome 下的區域性變數

紅框內僅有變數 a,而變數 b 已經消失不見了。由於 FireFox 列印不出 [[Scopes]] 屬性,因此,筆者判斷不出。當然,如果有大佬能深入瞭解並補充的話,感激不盡。

好,瞭解瞭如何儲存,接下來我們看看如何賦值。

變數賦值

其實不論變數是存在棧內,還是存在堆裡(反正都是在記憶體裡),其結構和存值方式是差不多的,都有如下的結構:

變數儲存

那好現在我們來看看賦值,根據 = 號右邊變數的型別分為兩種方式:

賦值為常量

何為常量?常量就是一宣告就可以確定的值,比如 1"string"true{a: 1},都是常量,這些值一旦宣告就不可改變,有些人可能會犟,物件型別的這麼可能是常量,它可以改變啊,這個問題先留著,等下在解釋。

假設現在有如下程式碼:

let foo = 1;
複製程式碼

JavaScript 宣告瞭一個變數 foo,且讓它的值為 1,記憶體中就會發生如下變化

常量儲存

如果現在又宣告瞭一個 bar 變數:

let bar = 2;
複製程式碼

那麼記憶體中就會變成這樣:

foo & bar

現在回顧下剛剛的問題:物件型別算常量嗎?

比如有以下程式碼:

let obj = {
    foo: 1,
    bar: 2
}
複製程式碼

記憶體模型如下:

JavaScript Object儲存

通過該圖,我們就可以知道,其實 obj 指向的記憶體地址儲存的也是一個地址值,那好,如果我們讓 obj.foo = 'foo' 其實修改的是 0x1021 所在的記憶體區域,但 obj 指向的記憶體地址不會發生改變,因此,物件是常量!

賦值為變數

何為變數?在上述過程中的 foobarobj,都是變數,變數代表一種引用關係,其本身的值並不確定。

那麼如果我將一個變數的值賦值給另一變數,會發生什麼?

let x = foo;
複製程式碼

x 賦值為 foo 變數

如上圖所示,僅僅是將 x 引用到與 foo 一樣的地址值而已,並不會使用新的記憶體空間。

OK 賦值到此為止,接下來是修改。

變數修改

與變數賦值一樣,變數的修改也需要根據 = 號右邊變數的型別分為兩種方式:

修改為常量

foo = 'foo';
複製程式碼

foo 變數修改為另一常量

如上圖所示,記憶體中儲存了 'foo' 並將 foo 的引用地址修改為 0x0204

修改為變數

foo = bar;
複製程式碼

foo 變數修改為另一變數

如上圖所示,僅僅是將 foo 引用的地址修改了而已。

const 的工作機制

constES6 新出的變數宣告的一種方式,被 const 修飾的變數不能改變。

其實對應到 JavaScript 的變數儲存圖中,就是變數所指向的記憶體地址不能發生變化。也就是那個箭頭不能有改變。

比如說以下程式碼:

const foo = 'foo';
foo = 'bar'; // Error
複製程式碼

const 不允許重新賦值

如上圖的關係圖所示,foo 不能引用到別的地址值。

那好現在是否能解決你對下面程式碼的困惑:

const obj = {
    foo: 1,
    bar: 2
};
obj.foo = 2;
複製程式碼

obj 所引用的地址並沒有發生變化,發生變的部分為另一區域。如下圖所示

const 物件型別修改

物件的修改

OK 進入一個面試時極度容易問到的問題:

let obj1 = {
    foo: 'foo',
    bar: 'bar'
}

let obj2 = obj1;
let obj3 = {
    foo: 'foo',
    bar: 'bar'
}

console.log(obj1 === obj2);
console.log(obj1 === obj3);

obj2.foo = 'foofoo';

console.log(obj1.foo === 'foofoo');
複製程式碼

請依次說出 console 的結果。

我們不討論結果,先看看記憶體中的結構。

js 中物件儲存

所以你現在知道答案了嗎?

總結

JavaScript 中變數並非完完全全的存在在棧中,早期的 JavaScript 編譯器甚至把所有的變數都存在一個名為閉包的物件中,JavaScript 是一門以函式為基礎的語言,其中的函式變化無窮,因此使用棧並不能解決語言方面的問題,希望大家能看到,並真正的瞭解 JavaScript 在記憶體中的模型吧。

按照慣例,提幾個問題:

  1. JavaScript 變數的型別都有哪些?
  2. JavaScript 對於基礎型別和物件型別是如何儲存的?

參考

最後的最後

該系列所有問題由 minimo 提出,愛你喲~~~

相關文章