前言:該篇說明:請見 說明 —— 瀏覽器工作原理與實踐 目錄
對於前端開發者來說,JavaScript 的內存機制是一個不被經常提及的概念 ,因此很容易被忽視。特別是一些非計算機專業的同學,對內存機制可能沒有非常清晰的認識,甚至有些同學根本就不知道 JavaScript 的內存機制是什么。
但是如果你想成為行業專家,並打造高性能前端應用,那么你就必須要搞清楚 JavaScript 的內存機制了。
其實,要搞清楚 JavaScript 的內存機制並不是一件很困難的事,在接下來的三篇文章(數據在內存中的存放、JavaScript 處理垃圾回收以及 V8 執行代碼)中,我們將通過內存機制的介紹,循序漸進帶你走進 JavaScript 內存的世界。
今天我們講述第一部分的內容——JavaScript 中的數據是如何存儲在內存中的。雖然 JavaScript 並不需要直接去管理內存,但是在實際項目中為了能避開一些不必要的坑,你還是需要了解數據在內存中的存儲方法的。
讓人疑惑的代碼
首先,我們先看下面這兩段代碼:
function foo(){ var a = 1 var b = a a = 2 console.log(a) console.log(b) } foo()
function foo(){ var a = {name:"極客時間"} var b = a a.name = "極客邦" console.log(a) console.log(b) } foo()
若執行上述這兩段代碼,你知道它們輸出的結果是什么嗎?下面我們就來一個一個分析下。
執行第一段代碼,打印出來 a 的值是 2,b 的值是 1,這沒什么難以理解的。
接着,在執行第二段代碼,你會發現,僅僅改變了 a 中的 name 的屬性值,但是最終 a 和 b 打印出來的值都是 { name: “極客邦” }。這就和我們預期的不一致了,因為我們想改變的僅僅是 a 的內容,但 b 的內容也同時被改變了。
要徹底弄清楚這個問題,我們就得先從 “JavaScript 是什么類型的語言” 講起。
JavaScript 是什么類型的語言
每種編程語言都具有內建的數據類型,但它們的數據類型常有不同之處,使用方式也很不一樣,比如 C 語言在定義變量之前,就需要確定變量的類型,你可以看下面這段 C 代碼:
int main() { int a = 1; char* b = "極客時間"; bool c = true; return 0; }
上述代碼聲明變量的特點是:在聲明變量之前需要先定義變量類型。我們把這種在使用之前就需要確認其變量數據類型的稱為靜態語言。
相反地,我們把在運行過程中需要檢查數據類型的語言稱為動態語言。比如我們所講的 JavaScript 就是動態語言,因為在聲明變量之前並不需要確認其數據類型。
雖然 C 語言是靜態,但是在 C 語言中,我們可以把其他類型數據賦予給一個聲明好的變量,如:
c = a
前面代碼中,我們把 int 型的變量 a 賦值給了 bool 型的變量 c ,這段代碼也是可以編譯執行的,因為在賦值過程中,C 編譯器會把 int 型的變量悄悄轉換為 bool 型的變量,我們通常把這種偷偷轉換的操作稱為隱式類型轉換。而支持隱式類型轉換的語言稱為弱類型語言,不支持隱式類型轉換的語言稱為強類型語言。在這點上,C 和 JavaScript 都是弱類型語言。
對於各種語言的類型,你可以參考下圖:
語言類型圖
JavaScript 的數據類型
現在我們知道了,JavaScript 是一種弱類型的、動態的語言。那這些特點意味着什么呢?
- 弱類型,意味着你不需要告訴 JavaScript 引擎這個或那個變量是什么數據類型,JavaScript 引擎在運行代碼的時候自己會計算出來。
- 動態,意味着你可以使用同一個變量保持不同類型的數據。
那么接下來,我們再來看看 JavaScript 的數據類型,你可以看下面這段代碼:
var bar bar = 12 bar = " 極客時間 " bar = true bar = null bar = {name:" 極客時間 "}
從上述代碼中你可以看出,我們聲明了一個 bar 變量,然后可以使用各種類型的數據值賦予給該變量。
在JavaScript 中,如果你想要查看一個變量到底是什么類型,可以使用 “typeof” 運算符。具體使用方式如下所示:
var bar console.log(typeof bar) //undefined bar = 12 console.log(typeof bar) //number bar = " 極客時間 " console.log(typeof bar)//string bar = true console.log(typeof bar) //boolean bar = null console.log(typeof bar) //object bar = {name:" 極客時間 "} console.log(typeof bar) //object
執行這段代碼,你可以看到打印出來了不同的數據類型,有 undefined、number、boolean、object 等。那么接下來我們就來談談 JavaScript 到底有多少種數據類型。
其實 JavaScript 中的數據類型一共有 8 種,它們分別是:
注:JavaScript 中的數據結構,此處應該還要加上 function。function 是獨立的,雖說也是一個 對象,但是 函數堆內存中不止存有鍵值對,還有函數提供的代碼字符串和 constructor 方法,而在 對象堆內存中存的都是鍵值對。
了解這些類型之后,還有三點需要你注意一下。
第一點,使用 typeof 檢測 Null 類型時,返回的是 Object。這是當初 JavaScript 語言的一個 Bug,一直保留至今,之所以一直沒有修改過來,主要是為了兼容老的代碼。
第二點,Object 類型比較特殊,它是由上述 7 種類型組成的一個包含了 key-value 對的數據類型。如下所示:
let myObj = { name:'極客時間', update:function(){....} }
從中你可以看出來,Object 是由 key-value 組成的,其中的 value 可以是任何類型,包含函數,這也就意味着你可以通過 Object 來存儲函數,Object 中的函數又稱為方法,比如上述代碼中的 update 方法。
第三點,我們把前面的 7 種數據類型稱為原始類型,把最后一個對象類型稱為引用類型,之所以把它們區分為兩種不同的類型,是因為它們在內存中存放的位置不一樣。到底怎么個不一樣法呢“?接下來,我們就來講解一下 JavaScript 的原始類型和引用類型到底是怎么儲存的。
內存空間
要理解 JavaScript 在運行過程中數據是如何存儲的,你就得先搞清楚其存儲空間的種類。下面是我畫的 JavaScript 的內存模型,你可以參考下:
JavaScript 內存模型
從圖中可以看出,在 JavaScript 的執行過程中,主要有三種類型內存空間,分別是代碼空間、棧空間和堆空間。
其中的代碼空間主要是存儲可執行代碼的,這個我們后面在做介紹,今天主要來說說棧空間和堆空間。
棧空間和堆空間
這里的棧空間就是我們之前反復提及的調用棧,是用來存儲執行上下文的。為了搞清楚棧空間是如何存儲數據的,我們還是先看下下面這段代碼:
function foo(){ var a = "極客時間" var b = a var c = {name:"極客時間"} var d = c } foo()
前面文章我們已經講解過了,當執行一段代碼時,需要先編譯,並創建執行上下文,然后再按照順序執行代碼。那么下面我們來看看,當執行到第 3 行代碼時,其調用棧是狀態,你可以參考下面這張調用棧狀態圖:
執行到第 3 行時的調用棧狀態圖
從圖中可以看出來,當執行到第 3 行時,變量 a 和 變量 b 的值都被保存在執行上下文中,而執行上下文又被壓入到棧中,所以你也可以認為變量 a 和 變量 b 的值都是存放在棧中的。
接下來繼續執行第 4 行代碼,由於 JavaScript 引擎判斷右邊的值是一個引用類型,這時候處理的情況就不一樣了,JavaScript 引擎並不是直接將該對象存放到變量環境中,而是將它分配到堆空間里面,分配后該對象會有一個在 ”堆“ 中的地址,然后再將該數據的地址寫進 c 的變量值,最終分配好內存的示意圖如下所示:
對象類型是 ”堆“ 來存儲
從上圖你可以清晰地觀察到,對象類型是存放在堆空間的,在棧空間中只是保留了對象的引用地址,當 JavaScript 需要訪問該數據的時候,是通過棧中的引用地址來訪問的,相當於多了一道轉手流程。
好了,現在你應該知道了原始類型的數據值都是直接保存在 ”棧“ 中的,引用類型的值是存放在 ”堆“ 中的。不過你也許會好奇,為什么一定要分 ”堆“ 和 ”棧“ 兩個存儲空間呢?所有數據直接存放在 ”棧“ 中不就可以了嗎?
答案是不可以的。這是因為 JavaScript 引擎需要用棧來維護程序執行期間上下文的狀態,如果棧空間大了話,所有的數據都存放在棧空間里面,那么會影響到上下文切換的效率,進而又影響到整個程序的執行效率。比如文中的 foo 函數執行結束了,JavaScript 引擎需要離開當前的執行上下文,只需要將指針下移到上個執行上下文的地址就可以了,foo 函數執行上下文棧區空間全部回收,具體過程你可以參考下圖:
調用棧中切換執行上下文狀態
所以通常情況下,棧空間都不會設置太大,主要用來存放一些原始類型的小數據。而引用類型的數據占用的空間都比較大,所以這一類數據會被存放到堆中,堆空間很大,能存放很多大的數據,不過缺點是分配內存和回收內存都會占用一定的時間。
解釋了程序在執行過程中為什么需要堆和棧兩種數據結構后,我們還是回到示例代碼那里,看看它最后一步將變量 c 賦值給 變量 d 是怎么執行的?
在 JavaScript 中,賦值操作和其他語言有很大的不同,原始類型的賦值會完整復制變量值,而引用類型的賦值是賦值引用地址。
所以 d = c 的操作就是把 c 的引用地址賦值給 d,你可以參考下圖:
引用賦值
從圖中你可以看到,變量 c 和 變量 d 都指向了同一個堆中的對象,所以這就很好地解釋了文章開頭的那個問題,通過 c 修改 name 的值,變量 d 的值也跟着改變,歸根結底它們是同一個對象。
再談閉包
現在你知道了作用域內的原始類型數據都被存儲到棧空間,引用類型會被存儲到堆空間,基於這兩個點的認知,我們再深入一步,探討下閉包的內存模型。
這里以《10 | 作用域鏈和閉包:代碼中出現相同的變量,JavaScript 引擎是如何選擇的?》中關於閉包的一段代碼為例:
function foo() { var myName = "極客時間" let test1 = 1 const test2 = 2 var innerBar = { setName:function(newName){ myName = newName }, getName:function(){ console.log(test1) return myName } } return innerBar } var bar = foo() bar.setName("極客邦") bar.getName() console.log(bar.getName())
當執行這段代碼的時候,你應該有過這樣的分析:由於變量 myName、test1、test2 都是原始類型數據,所以在執行 foo 函數的時候,它們會被壓入到調用棧中;當 foo 函數執行結束之后,調用棧中 foo 函數的執行上下文會被銷毀,其內部變量 myName、test1、test2 也應該一同被銷毀。
但是在 那篇文章中,我們介紹了當 foo 函數的執行上下文銷毀時,由於 foo 函數產生了閉包,所以變量 myName 和 test1 並沒有被銷毀,而是保存在內存中,那么應該如何解釋這個現象呢?
要解釋這個現象,我們就得站在內存模型的角度來分析這段代碼的執行流程。
1、當 JavaScript 引擎執行到 foo 函數時,首先會編譯,並創建一個空執行上下文。
2、在編譯過程中,遇到內部函數 setName ,JavaScript 引擎還要對內部函數做一次快速的詞法掃描,發現該內部函數引用了 foo 函數中的 myName 變量,由於是內部函數引用了外部函數的變量,所以 JavaScript 引擎判斷這是一個閉包,於是在堆空間創建換一個 ”closure(foo)“ 的對象(這是一個內部對象,JavaScript 是無法訪問的),用來保存 myName 變量。
3、接着繼續掃描到 getName 方法時,發現該函數內部還引用變量 test1,於是 JavaScript 引擎又將 test1 添加到 ”closure(foo)“ 對象中。這時候堆中的 ”closure(foo)“ 對象中就包含了 myName 和 test1 兩個變量了。
4、由於 test2 並沒有被內部函數引用,所以 test2 依然保存着調用棧中。
通過上面的分析,我們可以畫出執行到 foo 函數中的 ”return innerBar“ 語句時的調用棧狀態,如下圖所示:
閉包的產生過程
從上圖你可以清晰地看出,當執行到 foo 函數時,閉包就產生了;當 foo 函數執行結束之后,返回的 getName 和 setName 方法都引用 ”clourse(foo)“ 對象,所以即使 foo 函數退出了, ”clourse(foo)“ 依然被其內部的 getName 和 setName 方法引用。所以在下次調用 bar.setName 或者 bar.getName 時,創建的執行上下文中就包含了 "clourse(foo)"。
總的來說,產生閉包的核心有兩步:第一步是需要預掃描內部函數;第二部是把內部函數引用的外部變量保存到堆中。
總結
好了,今天就講到這里,下面我來簡單總結下今天的要點。
我們介紹了 JavaScript 中的 8 種數據類型,它們可以分為兩大類——原始類型和引用類型。
其中,原始類型的數據是存放在棧中,引用類型的數據是存放在堆中的。堆中的數據是通過引用和變量關聯起來的。也就是說,JavaScript 的變量是沒有數據類型的,值才有數據類型,變量可以隨時持有任何類型的數據。
然后我們分析了,在 JavaScript 中將一個原始類型的變量 a 賦值給 b,那么 a 和 b 會相互獨立、互不影響;但是將引用類型的變量 a 賦值給變量 b,那會導致 a、b 兩個變量都同時指向了堆中的同一塊數據。
最后,我們還站在內存模型的視角分析了閉包的產生過程。
思考時間
在實際的項目中,經常需要完整地拷貝一個對象,也就是說拷貝完成之后兩個對象之間就不能互相影響。那該如何實現呢?
結合下面這段代碼,你可以分析下它是如何將對象 jack 拷貝給 jack2,然后在完成拷貝操作時兩個 jack 還互不影響的呢。
let jack = { name : "jack.ma", age:40, like:{ dog:{ color:'black', age:3, }, cat:{ color:'white', age:2 } } } function copy(src){ let dest //實現拷貝代碼,將src的值完整地拷貝給dest //在這里實現 return dest } let jack2 = copy(jack) //比如修改jack2中的內容,不會影響到jack中的值 jack2.like.dog.color = 'green' console.log(jack.like.dog.color) //打印出來的應該是 "black"
問題記錄
1、從內存模型角度分析執行代碼的執行流程第二步看,在堆空間創建closure(foo)對象,它是存儲在foo函數的執行上下文中的。
那么closure(foo)創建開始時是空對象,執行第三步的時候,才會逐漸把變量添加到其中。
2、當foo函數執行結束后,foo的執行上下文是不是銷毀了?如果銷毀了,產生一下兩個疑問:
a、如果foo函數執行上下文銷毀了,closure(foo)並沒有銷毀,那foo函數執行上下文是怎么銷毀的呢?就比如銷毀一個盒子,盒子毀里,里面的東西應該也是毀掉的
b、既然closure(foo)既然沒有銷毀,那它存儲在堆中的什么地方呢?必定它所依賴的foo執行上下文已經不存在了
作者回復: 關於foo函數執行上下文銷毀過程:foo函數執行結束之后,當前執行狀態的指針下移到棧中的全局執行上下文的位置,foo函數的執行上下文的那塊數據就挪出來,這也就是foo函數執行上下文的銷毀過程,這個文中有提到,你可以參考“調用棧中切換執行上下文狀態“圖。 第二個問題:innerBar返回后,含有setName和getName對象,這兩個對象里面包含了堆中的closure(foo)的引用。雖然foo執行上下文銷毀了,foo函數中的對closure(foo)的引用也斷開了,但是setName和getName里面又重新建立起來了對closure(foo)引用。 你可以: 1:打開“開發者工具” 2:在控制台執行上述代碼 3:然后選擇“Memory”標簽,點擊"take snapshot" 獲取V8的堆內存快照。 4:然后“command+f"(mac) 或者 "ctrl+f"(win),搜索“setName”,然后你就會發現setName對象下面包含了 raw_outer_scope_info_or_feedback_metadata,對閉包的引用數據就在這里面。
最近面試老問這個問題,什么是深拷貝和淺拷貝以及如何實現一個深拷貝?
1、JSON.parse(JSON.stringify(obj)) 2、遞歸遍歷對象 3、Object.assigin() 這種方法只能拷貝一層,有嵌套的情況就不適用了。
老師,我有幾個疑問:
1、Function 函數類型也是繼承於Object,聲明函數后是不是也是存在堆空間中的,那么瀏覽器編譯函數時是不是會同時創建執行上下文和向堆空間中壓入一個值
2、function a(){
var b = 1;
var c = {
d: 2
};
}
當 a 的執行上下文銷毀后,c 對象在堆空間中的引用會跟着銷毀么,將 c 返回出去或不返回,會不會是不一樣的情況
作者回復: 函數就是一種特別的對象,所以會保存在堆上,編譯函數時,這個函數的已經存在於堆中了!
第二個問題返回了c對象的話,那么說明全局環境對c對象有引用,既然有引用那么就不會被垃圾回收器標記出來,所以c對象也就不會回收!
“JavaScript 中的數據類型一共有 8 種。”
TypedArray,Blob,FIle,Promise這些呢?
作者回復: 這些都屬於object類型