我們首先帶着這 4 個問題,來了解瀏覽器垃圾回收的過程,後面會逐一解答:
- 瀏覽器怎麼進行垃圾回收?
- 瀏覽器中不同類型變量的內存都是何時釋放?
- 哪些情況會導致內存泄露?如何避免?
weakMapweakSet和MapSet有什麼區別?
ok, let's go!
什麼是垃圾數據?
生活中你買了一瓶可樂,喝完之後可樂瓶就變成了垃圾,應該被回收處理。
同樣地,我們在寫 js 代碼的時候,會頻繁地操作數據。
在一些數據不被需要的時候,它就是垃圾數據,垃圾數據佔用的內存就應該被回收。
變量的生命週期
比如這麼一段代碼:
let dog = new Object()let dog.a = new Array(1)
當 JavaScript 執行這段代碼的時候,
會先在全局作用域中添加一個dog 屬性,並在堆中創建了一個空對象,將該對象的地址指向了 dog。
隨後又創建一個大小為 1 的數組,並將屬性地址指向了 dog.a。此時的內存佈局圖如下所示:
如果此時,我將另外一個對象賦給了 a 屬性,代碼如下所示:
dog.a = new Object()複製代碼
此時的內存佈局圖:
a 的指向改變了, 此時堆中的數組對象就成為了不被使用的數據,專業名詞叫「不可達」的數據。
這就是需要回收的垃圾數據。
垃圾回收算法
可以將這個過程想象成從根溢出一個巨大的油漆桶,它從一個根節點出發將可到達的對象標記染色, 然後移除未標記的。
第一步:標記空間中「可達」值。
V8 採用的是可達性 (reachability) 算法來判斷堆中的對象應不應該被回收。
這個算法的思路是這樣的:
- 從根節點(Root)出發,遍歷所有的對象。
- 可以遍歷到的對象,是可達的(reachable)。
- 沒有被遍歷到的對象,不可達的(unreachable)。
在瀏覽器環境下,根節點有很多,主要包括這幾種:
- 全局變量
window,位於每個iframe中 - 文檔
DOM樹 - 存放在棧上的變量
- ...
這些根節點不是垃圾,不可能被回收。
第二步:回收「不可達」的值所佔據的內存。
在所有的標記完成之後,統一清理內存中所有不可達的對象。
第三步,做內存整理。
- 在頻繁回收對象後,內存中就會存在大量不連續空間,專業名詞叫「內存碎片」。
- 當內存中出現了大量的內存碎片,如果需要分配較大的連續內存時,就有可能出現內存不足的情況。
- 所以最後一步是整理內存碎片。(但這步其實是可選的,因為有的垃圾回收器不會產生內存碎片,比如接下來我們要介紹的副垃圾回收器。)
什麼時候垃圾回收?
瀏覽器進行垃圾回收的時候,會暫停 JavaScript 腳本,等垃圾回收完畢再繼續執行。
對於普通應用這樣沒什麼問題,但對於 JS 遊戲、動畫對連貫性要求比較高的應用,如果暫停時間很長就會造成頁面卡頓。
這就是我們接下來談的關於垃圾回收的問題:什麼時候進行垃圾回收,可以避免長時間暫停。
分代收集
瀏覽器將數據分為兩種,一種是「臨時」對象,一種是「長久」對象。
- 臨時對象:
- 大部分對象在內存中存活的時間很短。
- 比如函數內部聲明的變量,或者塊級作用域中的變量。當函數或者代碼塊執行結束時,作用域中定義的變量就會被銷燬。
- 這類對象很快就變得不可訪問,應該快點回收。
- 長久對象:
- 生命週期很長的對象,比如全局的
window、DOM、Web API等等。 - 這類對象可以慢點回收。
這兩種對象對應不同的回收策略,所以,V8 把堆分為新生代和老生代兩個區域, 新生代中存放臨時對象,老生代中存放持久對象。
並且讓副垃圾回收器、主垃圾回收器,分別負責新生代、老生代的垃圾回收。
這樣就可以實現高效的垃圾回收啦。
一般來説,面試回答到這就夠了。如果想和麪試官深入交流,可以繼續聊聊兩個垃圾回收器。
主垃圾回收器
負責老生代的垃圾回收,有兩個特點:
- 對象佔用空間大。
- 對象存活時間長。
它使用「標記-清除」的算法執行垃圾回收。
- 首先是標記。
- 從一組根元素開始,遞歸遍歷這組根元素。
- 在這個遍歷過程中,能到達的元素稱為活動對象,沒有到達的元素就可以判斷為垃圾數據。
- 然後是垃圾清除。
- 直接將標記為垃圾的數據清理掉。
- 多次標記-清除後,會產生大量不連續的內存碎片,需要進行內存整理。
副垃圾回收器
負責新生代的垃圾回收,通常只支持 1~8 M 的容量。
新生代被分為兩個區域:一般是對象區域,一半是空閒區域。
新加入的對象都被放入對象區域,等對象區域快滿的時候,會執行一次垃圾清理。
- 先給對象區域所有垃圾做標記。
- 標記完成後,存活的對象被複制到空閒區域,並且將他們有序的排列一遍。
- 這就回到我們前面留下的問題 -- 副垃圾回收器沒有碎片整理。因為空閒區域裏此時是有序的,沒有碎片,也就不需要整理了。
- 複製完成後,對象區域會和空閒區域進行對調。將空閒區域中存活的對象放入對象區域裏。
- 這樣,就完成了垃圾回收。
因為副垃圾回收器操作比較頻繁,所以為了執行效率,一般新生區的空間會被設置得比較小。
一旦檢測到空間裝滿了,就執行垃圾回收。
分代收集
一句話總結分代回收就是:將堆分為新生代與老生代,多回收新生代,少回收老生代。
這樣就減少了每次需遍歷的對象,從而減少每次垃圾回收的耗時。
增量收集
如果腳本中有許多對象,引擎一次性遍歷整個對象,會造成一個長時間暫停。
所以引擎將垃圾收集工作分成更小的塊,每次處理一部分,多次處理。
這樣就解決了長時間停頓的問題。
閒時收集
垃圾收集器只會在 CPU 空閒時嘗試運行,以減少可能對代碼執行的影響。
面試題1:瀏覽器怎麼進行垃圾回收?
從三個點來回答什麼是垃圾、如何撿垃圾、什麼時候撿垃圾。
- 什麼是垃圾
- 不再需要,即為垃圾
- 全局變量隨時可能用到,所以一定不是垃圾
- 如何撿垃圾(遍歷算法)
- 標記空間中「可達」值。
-
- 從根節點(Root)出發,遍歷所有的對象。 -
- 可以遍歷到的對象,是可達的(reachable)。 -
- 沒有被遍歷到的對象,不可達的(unreachable)
- 回收「不可達」的值所佔據的內存。
- 做內存整理。
- 什麼時候撿垃圾
- 前端有其特殊性,垃圾回收的時候會造成頁面卡頓。
- 分代收集、增量收集、閒時收集。
面試題2:瀏覽器中不同類型變量的內存都是何時釋放?
Javascritp 中類型:值類型,引用類型。
- 引用類型
- 在沒有引用之後,通過 V8 自動回收。
- 值類型
- 如果處於閉包的情況下,要等閉包沒有引用才會被 V8 回收。
- 非閉包的情況下,等待 V8 的新生代切換的時候回收。
面試題3:哪些情況會導致內存泄露?如何避免?
內存泄露是指你「用不到」(訪問不到)的變量,依然佔居着內存空間,不能被再次利用起來。
以 Vue 為例,通常有這些情況:
- 監聽在
window/body等事件沒有解綁 - 綁在
EventBus的事件沒有解綁 Vuex的$store,watch了之後沒有unwatch- 使用第三方庫創建,沒有調用正確的銷燬函數
解決辦法:beforeDestroy 中及時銷燬
- 綁定了
DOM/BOM對象中的事件addEventListener,removeEventListener。 - 觀察者模式
$on,$off處理。 - 如果組件中使用了定時器,應銷燬處理。
- 如果在
mounted/created鈎子中使用了第三方庫初始化,對應的銷燬。 - 使用弱引用
weakMap、weakSet。
閉包會導致內存泄露嗎?
順便説一個我在瞭解垃圾回收之前對閉包的誤解。
閉包會導致內存泄露嗎?正確的答案是不會。
內存泄露是指你「用不到」(訪問不到)的變量,依然佔居着內存空間,不能被再次利用起來。
閉包裏面的變量就是我們需要的變量,不能説是內存泄露。
這個誤解是如何來的?因為 IE。IE 有 bug,IE 在我們使用完閉包之後,依然回收不了閉包裏面引用的變量。這是 IE 的問題,不是閉包的問題。參考這篇文章
面試題4:weakMap weakSet 和 Map Set 有什麼區別?
在 ES6 中為我們新增了兩個數據結構 WeakMap、WeakSet,就是為了解決內存泄漏的問題。
它的鍵名所引用的對象都是弱引用,就是垃圾回收機制遍歷的時候不考慮該引用。
只要所引用的對象的其他引用都被清除,垃圾回收機制就會釋放該對象所佔用的內存。
也就是説,一旦不再需要,WeakMap 裏面的鍵名對象和所對應的鍵值對會自動消失,不用手動刪除引用。
更全面的介紹可以看這裏:第 4 題:介紹下 Set、Map、WeakSet 和 WeakMap 的區別