引言 在我們的前端日常工作中,無時無刻不在進行著變數的聲明和賦值,你是否也曾碰到過變數聲明報錯或變數被污染的問題,如果你跟筆者一樣碰到過,那麼我們應該暫時停下來好好思考問題發生的原因以及如何採取相應的補救措施。當然排查問題最好的方式就是深入其底層細節,瞭解在JavaScript中的記憶體分配方式。只有 ...
引言
在我們的前端日常工作中,無時無刻不在進行著變數的聲明和賦值,你是否也曾碰到過變數聲明報錯或變數被污染的問題,如果你跟筆者一樣碰到過,那麼我們應該暫時停下來好好思考問題發生的原因以及如何採取相應的補救措施。當然排查問題最好的方式就是深入其底層細節,瞭解在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!
你的一個點贊,值得讓我付出更多的努力!
逆境中成長,只有不斷地學習,才能成為更好的自己,與君共勉!