科普文:常見垃圾回收算法與 JS GC 原理
一、前言
在程序運行過程中,幾乎每時每刻都在為進(jìn)程分配新的內(nèi)存,但計算機(jī)的內(nèi)存空間總是有限的,內(nèi)存空間總有被占滿的時候,所以我們需要進(jìn)行 「垃圾數(shù)據(jù)回收」 ,以釋放內(nèi)存空間。
不同的編程語言會有著不一樣的垃圾回收策略,通常情況下,可以分為 「手動回收」 和 「自動回收」 兩種。
比如,C/C++ 就是使用 「手動回收」 策略,內(nèi)存空間的分配、銷毀等操作都由開發(fā)人員自行通過代碼控制。若數(shù)據(jù)使用完后,沒有主動釋放的無用內(nèi)存,就會隨著程序運行時間的增加,內(nèi)存逐漸被占滿,這種情況被稱為 「內(nèi)存泄漏」 。
而 JavaScript/Java/Python 等使用自動回收策略,產(chǎn)生的垃圾數(shù)據(jù)由 「垃圾回收器」 主動釋放,工程師無需手動通過代碼釋放內(nèi)存。不過雖然是自動回收,但工程師若完全不關(guān)心內(nèi)存管理,還是很容易產(chǎn)生內(nèi)存泄漏的。接下來,讓我們看看自動垃圾回收的基本原理。
二、自動垃圾回收算法
隨著時間的演進(jìn),垃圾回收算法也在不斷地完善,說是完善其實不算準(zhǔn)確,應(yīng)該說是根據(jù)不同的需求而有了不同的取舍,從而產(chǎn)生了不同的算法。其實不論哪個垃圾回收算法,都有一套共同的流程:
標(biāo)記內(nèi)存空間中的活動對象(在使用中的對象)和非活動對象(可以回收的對象)。
刪除非活動對象,釋放內(nèi)存空間。
整理內(nèi)存空間,避免頻繁回收后產(chǎn)生的大量內(nèi)存碎片(不連續(xù)內(nèi)存空間)。
2.1 標(biāo)記-清除法
標(biāo)記-清除法由 John McCarthy 于 1960 年發(fā)表的一篇論文提出,其主要分兩個階段:
第一階段是標(biāo)記,從一個 GC root 集合出發(fā),沿著「指針」找到所有對象,將其標(biāo)記為活動對象。
第二階段是清除,將內(nèi)存中未被標(biāo)記的對象刪除,釋放內(nèi)存空間。
從上面的描述來看,標(biāo)記-清除算法可以說是非常簡單的,現(xiàn)在的各類垃圾回收算法也都是它的思想的延續(xù)。
雖然簡單,但其也有著很明顯的缺點,即在多次回收操作后,會產(chǎn)生大量的內(nèi)存碎片,由于算法沒有再整理內(nèi)存空間,內(nèi)存空間將變得很碎,此時如果需要申請一個較大的內(nèi)存空間,即使剩余內(nèi)存總大小足夠,也很容易因為沒有足夠的連續(xù)內(nèi)存而分配失敗。
2.2 復(fù)制算法
為了解決以上問題,Marvin L. Minsky 在 1963 年提出了著名的 「復(fù)制算法」 :
將整個空間平均分成 from 和 to 兩部分。
先在 from 空間進(jìn)行內(nèi)存分配,當(dāng)空間被占滿時,標(biāo)記活動對象,并將其復(fù)制到 to 空間。
復(fù)制完成后,將 from 和 to 空間互換。
由于直接將活動對象復(fù)制到另一半空間,沒有了清除階段的開銷,所以能在較短時間內(nèi)完成回收操作,并且每次復(fù)制的時候,對象都會集中到一起,相當(dāng)于同時做了整理操作,避免了內(nèi)存碎片的產(chǎn)生。
雖然復(fù)制算法有吞吐量高、沒有碎片的優(yōu)點,但其缺點也非常明顯。首先,復(fù)制操作也是需要時間成本的,若堆空間很大且活動對象很多,則每次清理時間會很久。其次,將空間二等分的操作,讓可用的內(nèi)存空間直接減少了一半。
2.3 引用計數(shù)
該算法由 George E. Collins 于 1960 年提出,主要操作為:
實時統(tǒng)計指向?qū)ο蟮囊脭?shù)(指針數(shù)量)。
當(dāng)引用數(shù)為 0 時,實時回收對象。
該算法可以即時回收垃圾數(shù)據(jù),對程序的影響時間很短,效率很高。高性能、實時回收,看似完美的方案其實也有個問題,當(dāng)對象中存在循環(huán)引用時,由于引用數(shù)不會降到 0,所以對象不會被回收。
上面三大算法的出現(xiàn),基本奠定了垃圾回收的根本性內(nèi)容,后續(xù)出現(xiàn)的垃圾回收算法,基本都是基于上面三個算法的取舍和組合。
2.4 標(biāo)記-壓縮算法
該算法于 1970 年出現(xiàn),其結(jié)合了標(biāo)記-清除法和復(fù)制算法的優(yōu)點,主要操作如下:
從一個 GC root 集合出發(fā),標(biāo)記所有活動對象。
將所有活動對象移到內(nèi)存的一端,集中到一起。
直接清理掉邊界以外的內(nèi)存,釋放連續(xù)空間。
可以發(fā)現(xiàn),該算法既避免了標(biāo)記-清除法產(chǎn)生內(nèi)存碎片的問題,又避免了復(fù)制算法導(dǎo)致可用內(nèi)存空間減少的問題。當(dāng)然,該算法也不是沒有缺點的,由于其清除和整理的操作很麻煩,甚至需要對整個堆做多次搜索,故而堆越大,耗時越多。
2.5 代際假設(shè)和分代收集
「代際假說」:
It has been empirically observed that in many programs, the most recently created objects are also those most likely to become unreachable quickly.
經(jīng)過調(diào)查發(fā)現(xiàn),大多數(shù)應(yīng)用程序內(nèi)的數(shù)據(jù)有以下兩個特點:
- 大多數(shù)對象的生命周期很短,很快就不再被需要了
- 那些一直存活的對象通常會存在很久
簡單講就是對象的生存時間有點兩極化的情況:
「分代收集:」 所以可以將對象進(jìn)行分代,從而對不同分代實施不同的垃圾回收算法,以達(dá)到更高的效率(如 Java GC: https://plumbr.io/handbook/garbage-collection-in-java/generational-hypothesis)。
三、JavaScript 垃圾回收
JavaScript 的原始數(shù)據(jù)類型存在棧中,引用數(shù)據(jù)類型存在堆中,所以討論 JavaScript 的垃圾回收即討論其棧中數(shù)據(jù)的回收以及堆中數(shù)據(jù)的回收。
3.1 棧中垃圾回收
ESP(Extended Stack Pointer): 擴(kuò)展棧指針寄存器,用于存放函數(shù)棧頂指針。
JavaScript 在執(zhí)行函數(shù)時,會將其上下文壓入棧中,ESP 上移,而當(dāng)函數(shù)執(zhí)行完成后,其執(zhí)行上下文可以銷毀了,此時僅需將 ESP下移到下一個函數(shù)執(zhí)行上下文即可,當(dāng)下一個函數(shù)入棧時,會將 ESP 以上的空間直接覆蓋。
所以 JavaScript 引擎是通過下移 ESP 來完成棧的垃圾回收的。
3.2 堆中垃圾回收
不同于棧中的垃圾回收,堆中的垃圾數(shù)據(jù)回收需要用到 JavaScript 的垃圾回收器。
JavaScript 堆中垃圾數(shù)據(jù)回收就使用到了分代收集的思想,引擎將堆空間分為 「新生代(young-space)」 和 「老生代(old-space)」 ,并且對兩個區(qū)域?qū)嵤┎煌睦厥詹呗浴?/p>
「新生代:」 新生代用于存放生存時間短的對象,大多數(shù)新創(chuàng)建的小的對象都會被分配到該區(qū)域,該區(qū)域的垃圾回收會比較頻繁。
在新生代中,引擎使用 Scavenge 算法(https://v8.dev/blog/trash-talk) 進(jìn)行垃圾回收,即上面提到的復(fù)制算法。
其將新生代空間對半分為 from-space 和 to-space 兩個區(qū)域。新創(chuàng)建的對象都被存放到 from-space,當(dāng)空間快被寫滿時觸發(fā)垃圾回收。先對 from-space 中的對象進(jìn)行標(biāo)記,完成后將標(biāo)記對象復(fù)制到 to-space 的一端,然后將兩個區(qū)域角色反轉(zhuǎn),就完成了回收操作。
由于每次執(zhí)行清理操作都需要復(fù)制對象,而復(fù)制對象需要時間成本,所以新生代空間會設(shè)置得比較小(1~8M)。
「老生代:」 老生代被用于存放生存時間長的對象和大的對象:
- 即一些大的對象會被直接分配到老生代空間
- 新生代中經(jīng)過兩次垃圾回收后仍然存活的對象,會晉升到老生代空間
引擎在該空間主要使用上面提到的 「標(biāo)記-壓縮算法」 。首先對活動對象進(jìn)行標(biāo)記,標(biāo)記完成后,將所有存活對象移到內(nèi)存的一段,然后清理掉邊界外的內(nèi)存。
由于 JavaScript 是單線程運行的,意味著垃圾回收算法和腳本任務(wù)在同一線程內(nèi)運行,在執(zhí)行垃圾回收邏輯時,后續(xù)的腳本任務(wù)需要等垃圾回收完成后才能繼續(xù)執(zhí)行。若堆中的數(shù)據(jù)量非常大,一次完整垃圾回收的時間會非常長,將導(dǎo)致應(yīng)用的性能和響應(yīng)能力都直線下降。
為了避免垃圾回收影響應(yīng)用的性能,V8 將標(biāo)記的過程拆分成多個子標(biāo)記,讓垃圾回收標(biāo)記和應(yīng)用邏輯交替執(zhí)行,避免腳本任務(wù)等待較長時間。