對于開發者來說,JavaScript 的內存管理是自動的、無形的。我們創建的原始值、對象、函數……這壹切都會占用內存。
當我們不再需要某個東西時會發生什麽?JavaScript 引擎如何發現它並清理它?
JavaScript 中主要的內存管理概念是 可達性。
簡而言之,“可達”值是那些以某種方式可訪問或可用的值。它們被存儲在內存中。
這裏列出固有的可達值的基本集合,這些值明顯不能被釋放。
比方說:
這些值被稱作 根(roots)。
當前執行的函數,它的局部變量和參數。
當前嵌套調用鏈上的其他函數、它們的局部變量和參數。
全局變量。
(還有壹些其他的,內部實現)
如果壹個值可以從根通過引用或者引用鏈進行訪問,則認爲該值是可達的。
比方說,如果全局變量中有壹個對象,並且該對象有壹個屬性引用了另壹個對象,則 該 對象被認爲是可達的。而且它引用的內容也是可達的。下面是詳細的例子。
在 JavaScript 引擎中有壹個被稱作 垃圾回收器 的東西在後台執行。它監控著所有對象的狀態,並刪除掉那些已經不可達的。
這裏是壹個最簡單的例子:
// user 具有對這個對象的引用 let user = { name: "John" };
這裏的箭頭描述了壹個對象引用。全局變量 "user"
引用了對象 {name:"John"}
(爲簡潔起見,我們稱它爲 John)。John 的 "name"
屬性存儲壹個原始值,所以它被寫在對象內部。
如果 user
的值被重寫了,這個引用就沒了:
user = null;
現在 John 變成不可達的了。因爲沒有引用了,就不能訪問到它了。垃圾回收器會認爲它是垃圾數據並進行回收,然後釋放內存。
現在讓我們想象下,我們把 user
的引用複制給 admin
:
// user 具有對這個對象的引用 let user = { name: "John" }; let admin = user;
現在如果執行剛剛的那個操作:
user = null;
……然後對象仍然可以被通過 admin
這個全局變量訪問到,因此它必須被保留在內存中。如果我們又重寫了 admin
,對象就會被刪除。
現在來看壹個更複雜的例子。這是個家庭:
function marry(man, woman) { woman.husband = man; man.wife = woman; return { father: man, mother: woman } } let family = marry({ name: "John" }, { name: "Ann" });
marry
函數通過讓兩個對象相互引用使它們“結婚”了,並返回了壹個包含這兩個對象的新對象。
由此産生的內存結構:
到目前爲止,所有對象都是可達的。
現在讓我們移除兩個引用:
delete family.father; delete family.mother.husband;
僅刪除這兩個引用中的壹個是不夠的,因爲所有的對象仍然都是可達的。
但是,如果我們把這兩個都刪除,那麽我們可以看到再也沒有對 John 的引用了:
對外引用不重要,只有傳入引用才可以使對象可達。所以,John 現在是不可達的,並且將被從內存中刪除,同時 John 的所有數據也將變得不可達。
經過垃圾回收:
幾個對象相互引用,但外部沒有對其任意對象的引用,這些對象也可能是不可達的,並被從內存中刪除。
源對象與上面相同。然後:
family = null;
內存內部狀態將變成:
這個例子展示了可達性概念的重要性。
顯而易見,John 和 Ann 仍然連著,都有傳入的引用。但是,這樣還不夠。
前面說的 "family"
對象已經不再與根相連,沒有了外部對其的引用,所以它變成了壹座“孤島”,並且將被從內存中刪除。
垃圾回收的基本算法被稱爲 “mark-and-sweep”。
定期執行以下“垃圾回收”步驟:
垃圾收集器找到所有的根,並“標記”(記住)它們。
然後它遍曆並“標記”來自它們的所有引用。
然後它遍曆標記的對象並標記 它們的 引用。所有被遍曆到的對象都會被記住,以免將來再次遍曆到同壹個對象。
……如此操作,直到所有可達的(從根部)引用都被訪問到。
沒有被標記的對象都會被刪除。
例如,使我們的對象有如下的結構:
我們可以清楚地看到右側有壹個“無法到達的島嶼”。現在我們來看看“標記和清除”垃圾收集器如何處理它。
第壹步標記所有的根:
然後,我們跟隨它們的引用標記它們所引用的對象:
……如果還有引用的話,繼續標記:
現在,無法通過這個過程訪問到的對象被認爲是不可達的,並且會被刪除。
我們還可以將這個過程想象成從根溢出壹大桶油漆,它流經所有引用並標記所有可到達的對象。然後移除未標記的。
這是垃圾收集工作的概念。JavaScript 引擎做了許多優化,使垃圾回收運行速度更快,並且不會對代碼執行引入任何延遲。
壹些優化建議:
分代收集(Generational collection)—— 對象被分成兩組:“新的”和“舊的”。在典型的代碼中,許多對象的生命周期都很短:它們出現、完成它們的工作並很快死去,因此在這種情況下跟蹤新對象並將其從內存中清除是有意義的。那些長期存活的對象會變得“老舊”,並且被檢查的頻次也會降低。
增量收集(Incremental collection)—— 如果有許多對象,並且我們試圖壹次遍曆並標記整個對象集,則可能需要壹些時間,並在執行過程中帶來明顯的延遲。因此,引擎將現有的整個對象集拆分爲多個部分,然後將這些部分逐壹清除。這樣就會有很多小型的垃圾收集,而不是壹個大型的。這需要它們之間有額外的標記來追蹤變化,但是這樣會帶來許多微小的延遲而不是壹個大的延遲。
閑時收集(Idle-time collection)—— 垃圾收集器只會在 CPU 空閑時嘗試運行,以減少可能對代碼執行的影響。
還有其他垃圾回收算法的優化和風格。盡管我想在這裏描述它們,但我必須打住了,因爲不同的引擎會有不同的調整和技巧。而且,更重要的是,隨著引擎的發展,情況會發生變化,所以在沒有真實需求的時候,“提前”學習這些內容是不值得的。當然,除非妳純粹是出于興趣。我在下面給妳提供了壹些相關鏈接。
主要需要掌握的內容:
垃圾回收是自動完成的,我們不能強制執行或是阻止執行。
當對象是可達狀態時,它壹定是存在于內存中的。
被引用與可訪問(從壹個根)不同:壹組相互連接的對象可能整體都不可達,正如我們在上面的例子中看到的那樣。
現代引擎實現了垃圾回收的高級算法。
《The Garbage Collection Handbook: The Art of Automatic Memory Management》(R. Jones 等人著)這本書涵蓋了其中壹些內容。
如果妳熟悉底層(low-level)編程,關于 V8 引擎垃圾回收器的更詳細信息請參閱文章 V8 之旅:垃圾回收。
V8 博客 還不時發布關于內存管理變化的文章。當然,爲了學習更多垃圾收集的相關內容,妳最好通過學習 V8 引擎內部知識來進行准備,並閱讀壹個名爲 Vyacheslav Egorov 的 V8 引擎工程師的博客。我之所以說 “V8”,因爲網上關于它的文章最豐富的。對于其他引擎,許多方法是相似的,但在垃圾收集上許多方面有所不同。
當妳需要底層的優化時,對引擎有深入了解將很有幫助。在熟悉了這門編程語言之後,把熟悉引擎作爲下壹步計劃是明智之選。