Javascript的垃圾回收機制知多少?
1 寫在前面
本文主要圍繞JS引擎相關知識,來深入了解底層運行邏輯,這對于日常開發維護高性能Javascript代碼以及排查代碼性能問題有著很好的幫助。關于JS引擎底層的垃圾回收機制,后面才能理解內存泄漏的問題以及手動預防和優化,實現對JS內存管理以及內存溢出的處理。
- 那么我們需要考慮幾個問題:
- 什么是垃圾回收機制(GC)?
- 垃圾是怎樣產生的?
- 為什么要進行垃圾回收?
- Javascript的內存是如何管理的?
- Chrome瀏覽器又是如何進行垃圾回收的?
2 內存管理
在Javascript編程中,內存管理大概分成三個步驟,也是內存的生命周期:
- 分配你所需系統內存的空間
- 使用分配到的內存進行讀寫操作
- 不需要使用內存時,將空間進行釋放和歸還
內存的生命周期
與其它手動管理內存的語言不一樣的是,在Javascript中,當我們創建變量時,系統會給對象進行自動分配對應的內存空間以及閑置資源回收,也就是不需要我們手動進行分配。但是,正是因為垃圾回收機制導致開發者有著錯誤的感覺,就是他們不用關心內存管理。
- const name = "yichuan";//給字符串分配棧內存
- const age = 18;//給數值分配棧內存
- //給對象以及包含的值分配堆內存
- const user = {
- name: "onechuan",
- age: 19
- }
- //給數組以及包含的值分配堆內存
- const arr = ["yichuan","onechuan",18];
- //給函數對象分配堆內存
- function sum(x,y){
- return x + y;
- }
在前面《Javascript的數據類型知多少》文中,我們知道了基礎數據類型和引用數據類型的分配機制,即:
- 簡單數據類型內存保存在固定的棧空間中,可直接通過值進行訪問
- 引用數據類型的值大小不固定,其引用地址保存在棧空間、引用所指向的值保存在堆空間中,需要通過引用進行訪問
棧內存中的基本數據類型,可以直接通過操作系統進行處理,而堆內存中的引用數據類型的值大小不確定,因此需要JS的引擎通過垃圾回收機制進行處理。
3 內存回收機制(GC)
Javascript的V8引擎被限制了內存的使用,因此根據不同操作系統的內存大小會不一樣。
V8引擎最初設計是作為瀏覽器的引擎,并未考慮占據過多的內存空間,隨著web技術工程化的發展,占據了越來越多的內存空間。又由于被v8的會回收機制所限制,這樣就引起了js執行的線程被掛起,會影響當前執行的頁面應用性能。
垃圾回收算法:就是垃圾收集器按照固定的時間間隔,周期性地尋找那些不再使用的變量,然后將其清楚或釋放內存。但是垃圾回收算法是個不完美的方案,因為某塊內存是否還可用,屬于不可預判的問題,也就意味著單純依靠算法是解決不了的。還有為什么不是實時的找出無用內存并釋放呢?其實很簡單,實時開銷太大了。
我們知道了垃圾是如何產生的,那么我們應該如何清除呢?在瀏覽器的發展歷史上有兩種解決策略:
- 標記清除
- 引用計數
標記清除
標記清除分為:標記階段和清除階段。
首先它會遍歷堆內存上所有的對象,分別給它們打上標記,然后在代碼執行過程結束之后,對所使用過的變量取消標記。在清除階段再把具有標記的內存對象進行整體清除,從而釋放內存空間。
整個標記清除算法大致過程就像下面這樣
- 垃圾收集器在運行時會給內存中的所有變量都加上一個標記
- 然后從各個根對象開始遍歷,把還在被上下文變量引用的變量標記去掉標記
- 清理所有帶有標牌機的變量,銷毀并回收它們所占用的內存空間
- 最后垃圾回收程序做一次內存清理
使用標記清除策略的最重要的優點在于簡單,無非是標記和不標記的差異。通過標記清除之后,剩余的對象內存位置是不變的,也會導致空閑內存空間是不連續的,這就造成出現內存碎片的問題。內存碎片多了后,如果要存儲一個新的需要占據較大內存空間的對象,就會造成影響。對于通過標記清除產生的內存碎片,還是需要通過標記整理策略進行解決。
簡而言之:
- 優點:簡單
- 缺點:內存碎片化、分配速度慢
標記整理
經過標記清除策略整理后,老生代內存中因此產生了許多內存碎片,如果不進行清理內存碎片,就會對存儲造成影響。
標記整理(Mark-Compact)算法 就可以有效地解決標記清除的兩個缺點。它的標記階段和標記清除算法沒有什么不同,只是標記結束后,標記整理算法會將活著的對象(即不需要清理的對象)向內存的一端移動,最后清理掉邊界的內存。
引用計數
引用計數是一種不常見的垃圾回收策略,其思路就是對每個值都記錄其的引用次數。具體的:
- 當變量進行聲明并賦值后,值的引用數為1。
- 當同一個值被賦值給另一個變量時,引用數+1
- 當保存該值引用的變量被其它值覆蓋時,引用數-1
- 當該值的引用數為0時,表示無法再訪問該值了,此時就可以放心地將其清除并回收內存。
- let a = new Object() // 此對象的引用計數為 1(a引用)
- let b = a // 此對象的引用計數是 2(a,b引用)
- a = null // 此對象的引用計數為 1(b引用)
- b = null // 此對象的引用計數為 0(無引用)
- ... // GC 回收此對象
這種回收策略看起來很方便,但是當其進行循環引用時就會出現問題,會造成大量的內存不會被釋放。當函數結束后,兩個對象都不在作用域中,A 和 B 都會被當作非活動對象來清除掉,相比之下,引用計數則不會釋放,也就會造成大量無用內存占用,這也是后來放棄引用計數,使用標記清除的原因之一。
4 V8對于垃圾回收機制的優化
大多數瀏覽器都是基于標記清除算法,不同的只是在運行垃圾回收的頻率具有差異。V8 對其進行了一些優化加工處理,那接下來我們主要就來看 V8 中對垃圾回收機制的優化。
分代式垃圾回收
V8 的垃圾回收策略主要基于分代式垃圾回收機制,V8 中將堆內存分為新生代和老生代兩區域,采用不同的垃圾回收器也就是不同的策略管理垃圾回收。
新生代的對象為存活時間較短的對象,簡單來說就是新產生的對象,通常只支持 1~8M 的容量,而老生代的對象為存活事件較長或常駐內存的對象,簡單來說就是經歷過新生代垃圾回收后還存活下來的對象,容量通常比較大。
V8 整個堆內存的大小就等于新生代加上老生代的內存,對于新老兩塊內存區域的垃圾回收,V8 采用了兩個垃圾回收器來管控。
新生代和老生代
新生代內存回收
在64操作系統下分配為32MB,因為新生代中的變量存活時間短,不太容易產生太大的內存壓力,因此不夠大也是能夠理解。
對于新生代內存的回收,通常是通過Scavenge 的算法進行垃圾回收,就是將新生代內存進行一分為二,正在被使用的內存空間稱為使用區,而限制狀態的內存空間稱為空閑區。
新生代內存回收的原理是:
- 新加入的對象都會存放在使用區,當使用區快寫滿時就進行一次垃圾清理操作。
- 在開始進行垃圾回收時,新生代回收器會對使用區內的對象進行標記
- 標記完成后,需要對使用區內的活動對象拷貝到空閑區進行排序
- 而后進入垃圾清理階段,將非活動對象占用的內存空間進行清理
- 最后對使用區和空閑區進行交換,使用區->空閑區,空閑區->使用區
新生代中的變量如果經過回收之后依然一直存在,那么會放入到老生代內存中,只要是已經經歷過一次Scavenge算法回收的,就可以晉升為老生代內存的對象。
老生代內存回收
當然,Scavenge算法也有其適用場景范圍,對于內存空間較大的就不適合使用Scavenge算法。此時應該使用Mark-Sweep(標記清除)和Mark-Compact(標記整理)的策略進行老生代內存中的垃圾回收。
首先是標記階段,從一組根元素開始,遞歸遍歷這組根元素,遍歷過程中能到達的元素稱為活動對象,沒有到達的元素就可以判斷為非活動對象。清除階段老生代垃圾回收器會直接將非活動對象,也就是數據清理掉。
同樣的標記清除策略會產生內存碎片,因此還需要進行標記整理策略進行優化。
5 內存泄漏與優化
內存泄漏,指在JS中已經分配內存地址的對象由于長時間未進行內存釋放或無法清除,造成了長期占用內存,使得內存資源浪費,最終導致運行的應用響應速度變慢以及最終崩潰的情況。
在代碼中創建對象和變量時會占據內存,但是JS基于自己的內存回收機制是可以確定哪些變量不再需要,并將其進行清除。但是,當你的代碼中存在邏輯缺陷時,你以為你已經不需要,但是程序中還存在這引用,這就導致程序運行完后并沒有進行合適的回收所占有的內存空間。運行時間越長占用內存越多,隨之出現的問題就是:性能不佳、高延遲、頻繁崩潰。
造成內存泄漏的常見原因有:
- 過多的緩存。及時清理過多的緩存。
- 濫用閉包。盡量避免使用大量的閉包。
- 定時器或回調太多。與節點或數據相關聯的計時器不再需要時,DOM節點對象可以清除,整個回調函數也不再需要。可是,計時器回調函數仍然沒有被回收(計時器停止才會被回收)。當不需要setTimeout或setInterval時,定時器沒有被清除,定時器的糊掉函數以及其內部依賴的變量都不能被回收,會造成內存泄漏。解決方法:在定時器完成工作時,需要手動清除定時器。
- 太多無效的DOM引用。DOM刪除了,但是節點的引用還在,導致GC無法實現對其所占內存的回收。解決方法:給刪除的DOM節點引用設置為null。
- 濫用全局變量。全局變量是根據定義無法被垃圾回收機制進行收集的,因此需要特別注意臨時存儲和處理大量信息的全局變量。如果必須使用全局變量來存儲數據,請確保將其指定為null或在完成后重新分配它。解決方法:使用嚴格模式。
- 從外到內執行appendChild。此時即使調用removeChild也無法進行釋放內存。解決方法:從內到外appendChild。
- 反復重寫同一個數據會造成內存大量占用,但是IE瀏覽器關閉后會被釋放。
- 注意程序邏輯,避免編寫『死循環』之類的代碼。
- DOM對象和JS對象相互引用。
關于內存泄漏,如果你想要更好地排查以及提前避免問題的發生,最好的解決方法是通過熟練使用Chrome的內存剖析工具,多分析多定位Chrome幫你分析保留的內存快照,來查看持續占用大量內存的對象。
6 參考文章
- 《「硬核JS」你真的了解垃圾回收機制嗎》
- 《Javascript核心原理精講》
- 《Javascript高級程序設計》
7 寫在后面
本篇文章聊了JS的內存管理機制,以及v8垃圾回收機制,最后我們也分析了一些日常編碼中經常遇到內存泄漏問題,根據不同的原因給出對應的解決方案。
【編輯推薦】