引言
在我們的前端日常工作中,無時無刻不在進行着變量的聲明和賦值,你是否也曾碰到過變量聲明報錯或變量被污染的問題,如果你跟筆者一樣碰到過,那么我們應該暫時停下來好好思考問題發生的原因以及如何采取相應的補救措施。當然排查問題最好的方式就是深入其底層細節,了解在JavaScript中的內存分配方式。只有我們對底層細節有一定的了解之后,才能輕而易舉地化解在寫代碼過程中遇到的各種問題。本文基於JavaScript的內存模型繼續衍生出let
和const
的差異性對比,若文中有錯誤的地方,還請指出。
1、內存是什么
在講解JavaScript中的內存模型之前,我們先從硬件層面來簡單了解下內存是什么。
內存是計算機中重要的部件之一,它是外存與CPU進行溝通的橋梁。計算機中所有程序的運行都是在內存中進行的,因此內存的性能對計算機的影響非常大。內存(Memory)也被稱為內存儲器和主存儲器,其作用是用於暫時存放CPU中的運算數據,以及與硬盤等外部存儲器交換的數據。只要計算機在運行中,CPU就會把需要運算的數據調到內存中進行運算,當運算完成后CPU再將結果傳送出來,內存的運行也決定了計算機的穩定運行。
內存條是計算機組成結構中的關鍵部分,其本身是一個非常精密的部件,內部包含了上億個電子元器件,它們很小,達到了納米級別。這些元器件,實際上也就是電路,電路的電壓會發生變化,但只有兩種可能,要么0V(低電平),要么5V(高電平),0V是斷電,用0來表示,5V是通電,用1來表示,因此一個元器件包含了兩個狀態0和1,即表示一位(bit)。但是作為人類,我們並不擅長使用bit來思考和計算,因此我們會將它們划分成更大的組,例如8位表示1個byte(字節),16位表示2個byte(字節),32位表示4個byte(字節)。有很多東西都是存儲在內存中的,比如我們的程序代碼,程序中所聲明的變量以及操作系統的代碼等。
2、內存的生命周期
了解了內存的基本概念后,我們來簡單聊聊內存的生命周期。JavaScript作為一門高級編程語言,不像其他語言(例如C語言)中需要開發人員手動地去管理內存,系統會自動為你分配內存。但是無論是哪種編程語言,內存的生命周期都主要分為三個階段:
分配內存
:由操作系統來分配內存,供程序使用。在JavaScript中,這一步由操作系統來自動分配,無需開發人員手動操作。使用內存
:程序獲得操作系統所分配的內存之后,在內存中發生讀和寫操作。釋放內存
:程序使用完內存之后,會將這部分內存釋放出來供其他程序使用。在JavaScript中,這一步同樣不需要開發人員手動操作,由操作系統自動釋放。
我們知道,在JavaScript中的數據類型分為基本數據類型和引用數據類型,其中基本數據類型包括String
、Number
、Boolean
、Null
、Undefined
,ES6中新增的Symbol
以及最新的BigInt
,除了這些以外,其他的均為引用數據類型,例如Array
、Date
、Function
、RegExp
、Error
,Object
等。那么這兩種數據類型的其中一個區別就是,基本數據類型的內存大小都是固定的,而引用數據類型的內存大小都是動態不固定的,可能會隨時發生變化。因此在內存分配階段這兩種數據類型會有一定的差異。
編譯器在編譯代碼時,對於基本數據類型,由於其空間大小固定,編譯器在檢查時會提前計算它們需要的內存大小,並插入與操作系統交互的代碼,向操作系統申請存儲變量所需的堆棧字節數,然后將申請到的內存分配給調用堆棧中的程序,稱為靜態內存分配。例如在調用函數時,函數中的變量所需的內存會被添加到現有的內存之上,當函數執行完畢后,這部分內存又會以后進先出(LIFO)的順序被移除。但是對於引用數據類型,其空間大小是動態的,在編譯階段無法直接確定其需要多少內存,因此不能在堆棧上為其分配內存,相反,需要在運行時向操作系統申請適當的內存,並且這部分內存是在堆空間進行分配的,稱為動態內存分配。靜態內存分配和動態內存分配的區別如下表所示:
靜態內存分配 | 動態內存分配 |
---|---|
編譯階段可確定大小 | 編譯階段無法確定大小 |
在編譯時執行 | 在運行時執行 |
分配給堆棧 | 分配給堆 |
順序分配,后進先出(LIFO) | 無序分配 |
3、JavaScript中的內存分配
在我們的前端開發日常工作中,幾乎每天都在做着變量的聲明和賦值,這些變量最終都會被存放到內存中,所以我們還是有必要了解一下在JavaScript中的內存分配方式,這里使用基本數據類型和引用數據類型來分別講述一下內存的分配過程,幫助我們理解JavaScript的底層細節。
首先我們從一個簡單的基本數據類型的賦值開始,代碼如下:
let num = 1;
當JavaScript引擎在執行到這行代碼時,會執行如下操作:
- 為變量
num
創建一個唯一標識符(identifier),該標識符用於與棧內存中的地址A1
形成映射關系。 - 在棧內存中為其分配一個地址
A1
。 - 將值
1
存儲到分配的地址。
示例圖如下:
通常我們說num
變量的值等於1
,但其實嚴格意義上來講,num
變量的值等於棧內存中存放對應值的內存地址(如圖中的A1
)。接下來我們創建一個新的變量newNum
並將num
賦值給它:
let newNum = num;
經過以上賦值之后,通常說newNum
的值為1
,同樣從嚴格意義上來講的話是指newNum
和num
指向同一個內存地址A1
,如下圖所示:
如果接下來我們執行以下操作,看會發生什么:
num = num + 1;
我們對num
變量進行自增長,很顯然num
變量的值為2
。由於newNum
和num
指向同一個內存地址A1
,那么此時newNum
的值是否也為2
呢,在回答這個問題之前,我們先來看一下當前內存地址發生的變化:
在上圖中我們可以發現,num
變量的內存地址發生了改變,由原來的A1
變為A2
,這是因為在JS中的基本數據類型都是不可變的,一旦修改,只會為其分配新的內存地址並將修改后的新值存入到新的地址中,因此回答上面的那個問題,newNum
的值保持不變,依舊為1
,因為它的內存地址沒有發生改變。再看如下示例:
let str = 'ab';
str = str + 'c';
因為字符串也是屬於基本數據類型,基本數據類型都是不可變的,所以即使上述代碼中只是簡單的將c
拼接到了原來的字符串ab
后面,但是依舊會為其分配新的內存地址,變量str
最終會指向這個新的內存地址,如下圖所示:
了解了基本數據類型的內存分配方式之后,接下來我們來了解下引用數據類型的內存分配方式。同樣我們從一個簡單的引用數據類型的賦值開始:
let arr = [];
當JavaScript引擎在執行到這行代碼時,會執行如下操作:
- 為變量
arr
創建一個唯一標識符(identifier),該標識符用於與棧內存中的地址A3
形成映射關系。 - 在棧內存中為其分配一個地址
A3
。 - 棧內存中存儲在堆中分配的內存地址的值
H1
。 - 在堆中存儲分配的值
空數組[]
。
示例圖如下:
在JavaScript引擎(例如Chrome和Node的V8引擎)中主要是由兩個部件組成,一個叫內存堆(Memory Heap),一個叫調用堆棧(Call Stack)。其中調用堆棧除了函數調用之外,主要用於存放基本數據類型的值,而引用數據類型的值一般都存放在內存堆中,堆中存放的數據都是無序的並且可以動態地增長,所以非常適合用於存儲數組和對象。
4、let
和const
的差異性對比
在了解完以上兩種數據類型的內存分配方式后,我們這里對let
和const
的使用方式進行一下對比,通常來說,我們建議在寫代碼的過程中能使用const
的地方盡量減少使用let
,這樣可以在某種程度上避免變量被無端修改而引發的一系列問題。如下代碼:
let num = 1;
num = num + 1;
let arr = [];
arr.push(1);
arr.push(2);
arr.push(3);
在上述代碼中,變量num
因為使用let
的方式聲明,所以允許其被修改,因為基本類型的值是不可變的,所以會為num
變量分配新的內存地址。對於arr
變量,這里同樣使用let
方式進行聲明,表示允許其修改,但是對於push
操作其實並沒有修改arr
變量的內存地址,只是將新的值推入了堆內存的數組中,所以此處建議修改為使用const
進行聲明。
筆者的觀點是:將修改理解為修改內存地址,若允許修改內存地址,則使用
let
進行聲明,否則使用const
進行聲明。
如下示例:
const num = 1;
num = num + 1;
由在上一小節中了解到的基本數據類型的內存分配方式,我們知道為變量num
在棧內存中分配了一個地址來保存對應的值。
但是這里我們是使用const
的方式來進行聲明的,當我們重新為變量num
進行賦值時,JS嘗試為其分配新的內存地址,那么這里也就是拋出錯誤的地方,因為我們明確不允許對其進行修改。
因此在控制台中我們會看到對應的報錯信息。
再看如下示例:
const arr = [];
對於引用數據類型,我們知道會在棧內存上為其分配內存地址,存儲的是堆中的內存地址的值。
我們做如下操作:
arr.push(1);
arr.push(2);
arr.push(3);
執行push
操作實際上是將新值推入堆中的數組,內存地址並沒有發生改變。這也就是為什么雖然使用const
聲明變量,但是依舊沒有報錯的原因。但是如果我們使用如下方式:
arr = 1;
arr = undefined;
arr = null;
arr = [];
arr = {};
這些方式都會修改原數組的內存地址,const
聲明是不允許修改內存地址的,所以很明顯會拋出錯誤。因此這里也是建議默認情況下使用const
聲明變量,除非需要修改內存地址,const
聲明的變量必須在聲明時進行初始化,也方便了其他前端人員能一眼看出哪些變量是不可變的。
5、總結
在本篇中主要總結了一下JavaScript中的內存模型,並針對基本數據類型和引用數據類型分別講述了其在JavaScript中的內存分配方式,然后對let
和const
這兩種在代碼中的變量聲明方式進行對比以了解其中的差異性,下篇基於內存模型繼續講解JavaScript引擎中的垃圾回收機制以及在寫代碼過程中的幾種有效避免內存泄漏的方式,和大家一起了解JavaScript的底層細節。
6、交流
若覺得筆者的文章對你有幫助的話,不妨關注下筆者的公眾號,每周都會原創和整理一些前端技術干貨,關注公眾號后可以邀你入群,我們一起交流前端,相互學習,共同進步。
文章已同步更新至Github博客,若覺文章尚可,歡迎前往star!
你的一個點贊,值得讓我付出更多的努力!
逆境中成長,只有不斷地學習,才能成為更好的自己,與君共勉!