這次帶給大家怎樣使用垃圾回收器,使用垃圾回收器的注意事項有哪些,以下就是實戰案例,一起來看一下。
垃圾回收器是一把十足的雙面刃。其好處是可以大幅簡化程式的記憶體管理程式碼,因為記憶體管理無需程式設計師來操作,由此也減少了(但沒有根除)長時間運轉的程式的記憶體洩漏。對於某些程式設計師來說,它甚至能夠提升程式碼的效能。
另一方面,選擇垃圾回收器也意味著程式當中無法完全掌控內存,而這正是行動終端開發的癥結。對於JavaScript,程式中沒有任何記憶體管理的可能-ECMAScript標準中沒有暴露任何垃圾回收器的介面。網頁應用既沒有辦法管理內存,也沒辦法給垃圾回收器提示。
嚴格來講,使用垃圾回收器的語言在效能上不一定比不使用垃圾回收器的語言好或差。在C語言中,分配和釋放記憶體有可能是非常昂貴的操作,為了使分配的記憶體能夠在將來釋放,堆的管理會趨於複雜。而在託管記憶體的語言中,分配記憶體往往只是增加一個指標。但隨後我們就會看到,當記憶體耗盡時,垃圾回收器介入回收所產生的巨大代價。一個未經琢磨的垃圾回收器,會致使程式在運行中出現長時間、無法預期的停頓,這直接影響到互動系統(特別是帶有動畫效果的)在使用上的體驗。引用計數系統時常被吹捧為垃圾回收機制的替代品,但當大型子圖中的最後一個物件的引用解除後,同樣也會有無法預期的停頓。而且引用計數系統在頻繁執行讀取、改寫、儲存操作時,也會有可觀的效能負擔。
或好或壞,JavaScript需要一個垃圾回收器。 V8的垃圾回收器實現現在已經成熟,其性能優異,停頓短暫,性能負擔也非常可控。
垃圾回收器要解決的最基本問題就是,辨別需要回收的記憶體。一旦辨別完畢,這些記憶體區域即可在未來的分配中重複使用,或是返還給作業系統。一個物件當它不是處於活躍狀態的時候它就死了(廢話)。一個物件處於活躍狀態,當且僅當它被一個根物件或另一個活躍物件指向。根物件被定義為處於活躍狀態,是瀏覽器或V8所引用的物件。比方說,被局部變數所指向的對象屬於根對象,因為它們的棧被視為根對象;全域對象屬於根對象,因為它們總是可被存取;瀏覽器對象,如DOM元素,也屬於根對象,儘管在某些場合下它們只是弱引用。
從側面來說,上面的定義非常寬鬆。實際上我們可以說,當一個物件可被程式引用時,它就是活躍的。例如:
function f() { var obj = {x: 12}; g(); // 可能包含一个死循环 return obj.x; }
def scavenge(): swap(fromSpace, toSpace) allocationPtr = toSpace.bottom scanPtr = toSpace.bottom for i = 0..len(roots): root = roots[i] if inFromSpace(root): rootCopy = copyObject(&allocationPtr, root) setForwardingAddress(root, rootCopy) roots[i] = rootCopy while scanPtr < allocationPtr: obj = object at scanPtr scanPtr += size(obj) n = sizeInWords(obj) for i = 0..n: if isPointer(obj[i]) and not inOldSpace(obj[i]): fromNeighbor = obj[i] if hasForwardingAddress(fromNeighbor): toNeighbor = getForwardingAddress(fromNeighbor) else: toNeighbor = copyObject(&allocationPtr, fromNeighbor) setForwardingAddress(fromNeighbor, toNeighbor) obj[i] = toNeighbor def copyObject(*allocationPtr, object): copy = *allocationPtr *allocationPtr += size(object) memcpy(copy, object, size(object)) return copy
在這個演算法的執行過程中,我們總是維護兩個出區中的指標:allocationPtr指向我們即將為新物件分配記憶體的地方,scanPtr指向我們即將進行活躍檢查的下一個對象。 scanPtr所指向地址之前的對像是處理過的對象,它們及其鄰接都在出區,其指針都是更新過的,位於scanPtr和allocationPtr之間的對象,會被複製至出區,但這些對象內部所包含的指標如果指向入區中的對象,則這些入區中的物件不會被複製。邏輯上,你可以將scanPtr和allocationPtr之間的物件想像為一個廣度優先搜尋用到的物件佇列。
譯註:廣度優先搜尋中,通常會將節點從佇列頭部取出並展開,將展開得到的子節點存入佇列末端,周而復始進行。這個過程與更新兩個指標間物件的過程相似。
我們在演算法的初始時,複製新區所有可從根對象達到的對象,之後進入一個大的循環。在循環的每一輪,我們都會從佇列中刪除一個對象,也就是對scanPtr增量,然後追蹤存取對象內部的指針。如果指針不指向入區,則不管它,因為它必然指向老生區,而這就不是我們的目標了。而如果指標指向入區中某個對象,但我們還沒有複製(未設定轉送位址),則將這個物件複製至出區,也就是增加到我們佇列的末端,同時也就是對allocationPtr增量。這時我們也會將一個轉送位址存到出區物件的首字,替換掉Map指標。這個轉址就是物件複製後所存放的位址。垃圾回收器可以輕易將轉送位址與Map指標分清,因為Map指標經過了標記,而這個位址則未標記。如果我們發現一個指針,而其指向的物件已經複製過了(設定過轉送位址),我們就把這個指標更新為轉送位址,然後打上標記。
演算法在所有物件都處理完畢時終止(即scanPtr和allocationPtr相遇)。這時入區的內容都可視為垃圾,未來可能會被釋放或重複使用。
秘密武器:寫入屏障
上面有一個細節被忽略了:如果新生區中某個對象,只有一個指向它的指針,而這個指針恰好是在老生區的對象當中,我們如何知道新生區中那個對像是活躍的呢?顯然我們並不希望將老生區再遍歷一次,因為老生區中的對像很多,這樣做一次消耗太大。
為了解決這個問題,實際上在寫緩衝區中有一個列表,列表中記錄了所有老生區物件指向新生區的情況。新物件誕生的時候,並不會有指向它的指針,而當有老生區中的物件出現指向新生區物件的指針時,我們便記錄下來這樣的跨區指向。由於這種記錄行為總是發生在寫入操作時,它被稱為寫入屏障——因為每個寫入操作都要經歷這樣一關。
你可能好奇,如果每次進行寫入操作都要經過寫屏障,豈不是會多出大量的程式碼麼?沒錯,這就是我們這種垃圾回收機制的代價之一。但情況沒你想像的那麼嚴重,寫操作畢竟比讀取操作少。某些垃圾回收演算法(不是V8的)會採用讀取屏障,而這需要硬體來輔助才能確保一個較低的消耗。 V8也有一些最佳化來降低寫入屏障帶來的消耗:
大多數的腳本執行時間都是發生在Crankshaft當中的,而Crankshaft常常能靜態地判斷出某個物件是否處於新生區。對於指向這些物件的寫入操作,可以無需寫入屏障。
Crankshaft中新出現了一種最佳化,即當物件不存在指向它的非局部參考時,該物件會被指派在堆疊上。而一個棧上物件的相關寫入操作顯然無需寫屏障。 (譯註:新生區和老生區在堆上。)
「老→新」這樣的情況相對較為少見,因此透過將「新→新」和「老→老」兩種常見情況的程式碼做優化,可以相對提升多數情形下的效能。每個頁都以1MB對齊,因此給定一個物件的記憶體位址,透過將低20bit濾除來快速定位其所在的頁;而頁頭有相關的標識來表明其屬於新生區還是老生區,因此透過判斷兩個物件所屬的區域,也可以快速確定是否為「老→新」。
一旦我們找到「老→新」的指針,我們就可以將其記錄在寫緩衝區的末端。經過一定的時間(寫緩衝區滿的時候),我們將其排序,合併相同的項目,然後再除去已經不符合“老→新”這一情形的指針。 (譯註:這樣指標的數量就會減少,寫入屏障的時間相應也會縮短)
「標記-清除」演算法與「標記-緊縮」演算法
Scavenge演算法對於快速回收、緊密縮小片記憶體效果很好,但對於大片記憶體則消耗過大。因為Scavenge演算法需要出區和入區兩個區域,這對於小片記憶體尚可,而對於超過數MB的記憶體就開始變得不切實際了。老生區包含有數百MB的數據,對於這麼大的區域,我們採取另外兩種相互較為接近的演算法:「標記-清除」演算法與「標記-緊縮」演算法。
這兩個演算法都包含兩個階段:標記階段,清除或緊縮階段。
在标记阶段,所有堆上的活跃对象都会被标记。每个页都会包含一个用来标记的位图,位图中的每一位对应页中的一字(译注:一个指针就是一字大小)。这个标记非常有必要,因为指针可能会在任何字对齐的地方出现。显然,这样的位图要占据一定的空间(32位系统上占据3.1%,64位系统上占据1.6%),但所有的内存管理机制都需要这样占用,因此这种做法并不过分。除此之外,另有2位来表示标记对象的状态。由于对象至少有2字长,因此这些位不会重叠。状态一共有三种:如果一个对象的状态为白,那么它尚未被垃圾回收器发现;如果一个对象的状态为灰,那么它已被垃圾回收器发现,但它的邻接对象仍未全部处理完毕;如果一个对象的状态为黑,则它不仅被垃圾回收器发现,而且其所有邻接对象也都处理完毕。
如果将堆中的对象看作由指针相互联系的有向图,标记算法的核心实际是深度优先搜索。在标记的初期,位图是空的,所有对象也都是白的。从根可达的对象会被染色为灰色,并被放入标记用的一个单独分配的双端队列。标记阶段的每次循环,GC会将一个对象从双端队列中取出,染色为黑,然后将它的邻接对象染色为灰,并把邻接对象放入双端队列。这一过程在双端队列为空且所有对象都变黑时结束。特别大的对象,如长数组,可能会在处理时分片,以防溢出双端队列。如果双端队列溢出了,则对象仍然会被染为灰色,但不会再被放入队列(这样他们的邻接对象就没有机会再染色了)。因此当双端队列为空时,GC仍然需要扫描一次,确保所有的灰对象都成为了黑对象。对于未被染黑的灰对象,GC会将其再次放入队列,再度处理。
以下是标记算法的伪码:
markingDeque = [] overflow = false def markHeap(): for root in roots: mark(root) do: if overflow: overflow = false refillMarkingDeque() while !markingDeque.isEmpty(): obj = markingDeque.pop() setMarkBits(obj, BLACK) for neighbor in neighbors(obj): mark(neighbor) while overflow def mark(obj): if markBits(obj) == WHITE: setMarkBits(obj, GREY) if markingDeque.isFull(): overflow = true else: markingDeque.push(obj) def refillMarkingDeque(): for each obj on heap: if markBits(obj) == GREY: markingDeque.push(obj) if markingDeque.isFull(): overflow = true return
标记算法结束时,所有的活跃对象都被染为了黑色,而所有的死对象则仍是白的。这一结果正是清理和紧缩两个阶段所期望的。
标记算法执行完毕后,我们可以选择清理或是紧缩,这两个算法都可以收回内存,而且两者都作用于页级(注意,V8的内存页是1MB的连续内存块,与虚拟内存页不同)。
清理算法扫描连续存放的死对象,将其变为空闲空间,并将其添加到空闲内存链表中。每一页都包含数个空闲内存链表,其分别代表小内存区(<256字)、中内存区(<2048字)、大内存区(<16384字)和超大内存区(其它更大的内存)。清理算法非常简单,只需遍历页的位图,搜索连续的白对象。空闲内存链表大量被scavenge算法用于分配存活下来的活跃对象,但也被紧缩算法用于移动对象。有些类型的对象只能被分配在老生区,因此空闲内存链表也被它们使用。
紧缩算法会尝试将对象从碎片页(包含大量小空闲内存的页)中迁移整合在一起,来释放内存。这些对象会被迁移到另外的页上,因此也可能会新分配一些页。而迁出后的碎片页就可以返还给操作系统了。迁移整合的过程非常复杂,因此我只提及一些细节而不全面讲解。大概过程是这样的。对目标碎片页中的每个活跃对象,在空闲内存链表中分配一块其它页的区域,将该对象复制至新页,并在碎片页中的该对象上写上转发地址。迁出过程中,对象中的旧地址会被记录下来,这样在迁出结束后V8会遍历它所记录的地址,将其更新为新的地址。由于标记过程中也记录了不同页之间的指针,此时也会更新这些指针的指向。注意,如果一个页非常“活跃”,比如其中有过多需要记录的指针,则地址记录会跳过它,等到下一轮垃圾回收再进行处理。
增量标记与惰性清理
你应该想到了,当一个堆很大而且有很多活跃对象时,标记-清除和标记-紧缩算法会执行的很慢。起初我研究V8时,垃圾回收所引发的500-1000毫秒的停顿并不少见。这种情况显然很难接受,即使是对于移动设备。
2012年年中,Google引入了两项改进来减少垃圾回收所引起的停顿,并且效果显著:增量标记和惰性清理。
增量標記允許堆的標記發生在幾次5-10毫秒(行動裝置)的小停頓中。增量標記在堆的大小達到一定的閾值時啟用,啟用之後每當一定量的記憶體分配後,腳本的執行就會停頓並進行一次增量標記。就像普通的標記一樣,增量標記也是一個深度優先搜索,並同樣採用白灰黑機制來分類物件。
但增量標記和普通標記不同的是,物件的圖譜關係可能會改變!我們需要特別注意的是,那些從黑對象指向白對象的新指標。回想一下,黑對象表示已完全被垃圾回收器掃描,並不會再進行二次掃描。因此如果有「黑→白」這樣的指標出現,我們就有可能將那個白物件漏掉,錯當死對象處理掉。 (譯註:標記過程結束後剩餘的白對像都被認為是死對象。)於是我們必須再度啟用寫入屏障。現在寫入屏障不只記錄「舊→新」指針,同時還要記錄「黑→白」指針。一旦發現這樣的指針,黑對象會被重新染色為灰對象,重新放回雙端隊列。當演算法將該物件取出時,其包含的指標會被重新掃描,這樣活躍的白物件就不會漏掉。
增量標記完成後,惰性清理就開始了。所有的物件都已被處理,因此非死即活,堆上多少空間可以變成空閒成為定局。此時我們可以不急著釋放那些空間,而將清理的過程延遲一下也並無大礙。因此無需一次清理所有的頁,垃圾回收器會視需要逐一清理,直到所有的頁都清理完畢。這時增量標記又蓄勢待發了。
Google近期也新增了平行清理支援。由於腳本的執行緒不會再觸及死對象,頁的清理任務可以放在另一個單獨的執行緒中進行並且只需極少的同步工作。同樣的支援工作也正在並行標記上開展著,但目前仍處於早期試驗階段。
總結
垃圾回收真的很複雜。我在文章中已經略過了大量的細節,而文章仍然變得很長。我有一個同事說他覺得研究垃圾回收器比暫存器分配還要可怕,我表示確實如此。也就是說,我寧可將這些繁瑣的細節交給執行時間來處理,也不想交給所有的應用程式開發者來做。儘管垃圾回收存在一些性能問題而且偶爾會出現靈異現象,它還是將我們從大量的細節中解放了出來,以便讓我們集中精力於更重要的事情上。
相信看了本文案例你已經掌握了方法,更多精彩請關注php中文網其它相關文章!
推薦閱讀:
#以上是怎樣使用垃圾回收器的詳細內容。更多資訊請關注PHP中文網其他相關文章!