對於前端攻城師來說,JS的記憶體機制不容忽視。如果想成為行業專家,或者打造高效能前端應用,那就必須要弄清楚JavaScript的記憶體機制了
先看栗子
function foo (){
let a = 1
let b = a
a = 2
console.log(a) // 2
console.log(b) // 1
let c = { name: '掘金' }
let d = c
c.name = '沐華'
console.log(c) // { name: '沐華' }
console.log(d) // { name: '沐華' }
}
foo()
可以看出在我們修改不同資料型別的值後,結果有點不一樣。
這是因為不同資料型別在記憶體中儲存的位置不一樣,在JS執行過程中,主要有三種記憶體空間:程式碼空間、棧、堆
程式碼空間主要就是儲存可執行程式碼,關於這個內容有點多,可以看我另一篇文章有詳細介紹
我們們先看一下棧和堆
棧和堆
在JS中,每一個資料都需要一個記憶體空間。而不同的記憶體空間有什麼區別特點呢?,如圖
呼叫棧也叫執行棧,它的執行原則是先進後出,後執行的會先出棧,如圖
棧:
儲存基礎型別
:Number, String, Boolean, null, undefined, Symbol, BigInt- 儲存和使用方式
後進先出
(就像一個瓶子,後放進去的東西先拿出來) - 自動分配記憶體空間,自動釋放,佔固定大小的空間
- 儲存引用型別的變數,但實際上儲存的不是變數本身,而是指向該物件的
指標
(在堆記憶體中存放的地址) - 所有方法中定義的變數存在棧中,方法執行結束,這個方法的記憶體棧也自動銷燬
- 可以遞迴呼叫方法,這樣隨著棧深度增加,JVW維持一條長長的方法呼叫軌跡,記憶體不夠分配,會產生棧溢位
堆:
儲存引用型別
:Object(Function/Array/Date/RegExp)- 動態分配記憶體空間,大小不定也不會自動釋放
- 堆記憶體中的物件不會因為方法執行結束就銷燬,因為有可能被另一個變數引用(引數傳遞等)
為什麼會有棧和堆之分
通常與垃圾回收機制
有關。每一個方法執行時都會建立自己的記憶體棧,然後將方法裡的變數逐個放入這個記憶體棧中,隨著方法執行結束,這個方法的記憶體棧也會自動銷燬
為了使程式執行時佔用的記憶體最小,棧空間都不會設定太大,而堆空間則很大
每建立一個物件時,這個物件會被儲存到堆中,以便反覆複用,即使方法執行結束,也不會銷燬這個物件,因為有可能被另一個變數(引數傳遞等)引用,直到物件沒有任何引用時才會被系統的垃圾回收機制銷燬
而且JS引擎需要用棧來維護程式執行期間上下文的狀態,如果所有的資料都在棧裡在,棧空間大了的話,會影響到上下文切換的效率,進而影響整個程式的執行效率
記憶體洩露和垃圾回收
上面說了在JS中建立變數(物件,字串等)時都分配記憶體,並且在不再使用它們時“自動”釋放記憶體,這個自動釋放記憶體的過程稱為垃圾回收
。也正是因為垃圾回收機制的存在,讓很多開發者在開發中不太關心記憶體管理,所在在一些情況下導致記憶體洩露
記憶體生命週期:
- 記憶體分配:當我們宣告變數,函式,物件的時候,系統會自動為它們分配記憶體
- 記憶體使用:即讀寫記憶體,也就是使用變數,函式,引數等
- 記憶體回收:使用完畢,由垃圾回收機制自動回收不再使用的記憶體
區域性變數(函式內部的變數),當函式執行結束,沒有其他引用(閉包),該變數就會被回收
全域性變數的生命週期直到瀏覽器解除安裝頁面才會結束,也就是說全域性變數不會被垃圾回收
記憶體洩露
程式的執行需要記憶體,對於持續執行的服務程式,必須及時釋放不再用到的記憶體,否則記憶體佔用越來越大,輕則影響系統效能,嚴重的會導致程式崩潰
記憶體洩露就是由於疏忽或者錯誤,導致程式不能及時釋放那些不再使用的記憶體,造成記憶體的浪費
判斷記憶體洩露
在Chrome瀏覽器
中,可以這樣檢視記憶體佔用情況
開發者工具
=> Performance
=> 勾選Memory
=> 點左上角Record
=> 頁面操作後點stop
然後就會顯示這段時間內的記憶體使用情況了
- 一次檢視記憶體佔用情況後,看當前記憶體佔用趨勢圖,走勢呈上升趨勢,可以認為存在記憶體洩露
- 多次檢視記憶體佔用情況後截圖對比,比較每次記憶體佔用情況,如果呈上升趨勢,也可以認為存在記憶體洩露
在Node
中,使用 process.memoryUsage 方法檢視記憶體情況
console.log(process.memoryUsage());
- heapUsed:用到的堆的部分。
- rss(resident set size):所有記憶體佔用,包括指令區和堆疊。
- heapTotal:"堆"佔用的記憶體,包括用到的和沒用到的。
- external: V8 引擎內部的 C++ 物件佔用的記憶體
判斷記憶體洩露以heapUsed欄位為準
什麼情況下會造成記憶體洩露
- 沒有宣告而意外建立的全域性變數
- 被遺忘的定時器和回撥函式,沒有及時關閉定時器中的引用會一直留在記憶體中
- 閉包
- DOM操作引用(比如引用了td卻刪了整個table,記憶體會保留整個table)
記憶體洩露如何避免
所以記住一個原則:不用的東西,及時歸還,有道是,有借有還,再借不難
- 減少不必要的全域性變數,比如使用嚴格模式避免建立意外的全域性變數
- 減少生命週期較長的物件,避免過多的物件
- 使用完資料後,及時解除引用(閉包中的變數,DOM引用,定時器清除)
- 組織好邏輯,避免死迴圈造成瀏覽器卡頓,崩潰
垃圾回收
JS是有自動垃圾回收機制的,那麼這個自動垃圾收集機制是怎麼工作的呢?
回收執行棧中資料
看栗子
function foo(){
let a = 1
let b = { name: '沐華' }
function showName(){
let c = 2
let d = { name: '沐華' }
}
showName()
}
foo()
執行過程:
- JS引擎先為foo函式建立執行上下文,並將執行上下文壓入執行棧
- 執行遇到showName函式,再為showName函式建立執行上下文,並將執行上下文壓入執行棧中,所以在棧中showName壓在foo的上面
- 然後先執行showName函式的執行上下文,JS引擎中有一個記錄當前執行狀態的指標(ESP),會指向正在執行的上下文,也就是showName
- 當showName執行結束之後,執行流程就進入下一個執行上下文,也就是foo函式,這時就需要銷燬showName執行上下文了。 主要就是JS引擎將ESP指標下移,指向showName下面的執行上下文,也就是foo,
這個下移操作就是銷燬showName函式執行上下文的過程
如圖
回收堆中的資料
其實就是找出那些不再繼續使用的值,然後釋放其佔用的記憶體。
比如剛才的栗子,當foo函式和showName函式執行上下文都執行結束就清理了,但是裡面的兩個物件還依然佔用著空間,因為物件的資料是存在堆中的,清理掉的棧中的只是物件的引用地址,並不是物件資料
這就需要垃圾回收器
了
垃圾回收階段最艱難的任務就是找到不需要的變數,所以垃圾回收演算法有很多種,並沒有哪一種能勝任所有場景,需要根據場景權衡選擇
引用計數
引用計數是以前的垃圾回收演算法,該演算法定義"記憶體不再使用"的標準很簡單,就是看一個物件是否有指向它的引用,如果沒有其他物件指向它,就說明該物件不再需要了
但它卻有一個致命的問題:迴圈引用
就是如果有兩個物件互相引用,儘管他們已不再使用,但是垃圾回收不會進行回收,導致記憶體洩露
為了解決迴圈引用造成的問題,現代瀏覽器都沒有采用引用計數的方式
在V8中會把堆分為新生代和老生代兩個區域
新生代和老生代
V8實現了GC演算法,採用了分代式垃圾回收機制,所以V8將堆記憶體分為新生代
(副垃圾回收器)和老生代
(主垃圾回收器)兩個部分
新生代
新生代中通常只支援1~8M的容量,所以主要存放生存時間較短的物件
新生代中使用Scavenge GC
演算法,將新生代空間分為兩個區域:物件區域和空閒區域。如圖:
顧名思義,就是說這兩塊空間只使用一個,另一個是空閒的。工作流程是這樣的
- 將新分配的物件存入物件區域中,當物件區域存滿了,就會啟動GC演算法
- 對物件區域內的垃圾做標記,標記完成之後將物件區域中還存活的物件複製到空閒區域中,已經不用的物件就銷燬。這個過程不會留下記憶體碎片
- 複製完成後,再將物件區域和空閒互換。既回收了垃圾也能讓新生代中這兩塊區域無限重複使用下去
正因為新生代中空間不大,所以就容易出現被塞滿的情況,所以
- 經歷過兩次垃圾回收依然還存活的物件會被移到老生代空間中
- 如果空閒空間物件的佔比超過25%,為了不影響記憶體分配,就會將物件轉移到老生代空間
老生代
老生代特點就是佔用空間大,所以主要存放存活時間長的物件
老生代中使用標記清除演算法
和標記壓縮演算法
。因為如果也採用Scavenge GC演算法的話,複製大物件就比較花時間了
標記清除
在以下情況下會先啟動標記清除演算法:
- 某一個空間沒有分塊的時候
- 物件太多超過空間容量一定限制的時候
- 空間不能保證新生代中的物件轉移到老生代中的時候
標記清除的流程是這樣的
- 從根部(js的全域性物件)出發,遍歷堆中所有物件,然後標記存活的物件
- 標記完成後,銷燬沒有被標記的物件
由於垃圾回收階段,會暫停JS指令碼執行,等垃圾回收完畢後再恢復JS執行,這種行為稱為全停頓(stop-the-world)
比如堆中資料超過1G,那一次完整的垃圾回收可能需要1秒以上,這期間是會暫停JS執行緒執行的,這就導致頁面效能和響應能力下降
增量標記
所以在2011年,V8從 stop-the-world 標記切換到增量標記
。使用增量標記演算法,GC 可以將回收任務分解成很多小任務,穿插在JS任務中間執行,這樣避免了應用出現卡頓的情況
併發標記
然後在2018年,GC 技術又有重大突破,就是併發標記
。讓 GC 掃描和標記物件時,允許JS同時執行
標記壓縮
清除後會造成堆記憶體出現記憶體碎片的情況,當碎片超過一定限制後會啟動標記壓縮演算法
,將存活的物件向堆中的一端移動,到所有物件移動完成,就清理掉不需要的記憶體
結語
點贊支援、手留餘香、與有榮焉