lua垃圾收集


    Lua語言使用自動內存管理。程序可以創建對象,但卻沒有函數來刪除對象。Lua語言通過垃圾收集自動刪除稱爲垃圾的對象,從而將程序員從內存管理的絕大部分負擔中解放出來。更重要的是,將程序員從與內存管理相關的大多數Bug中解放出來。例如無效指針和內存泄露等問題。
    在一個理想的環境中,垃圾收集器對程序員來說是不可見的,就像一個好的清潔工不會和其他工人打交道一樣。不過,有時即使是智能的垃圾收集器也會需要我們的輔助。在某些關鍵的性能階段,我們可能需要將其停止,或者讓只在特定的時間運行。另外,一個垃圾收集器只能收集它確定是垃圾的內容,而不能猜測我們把什麼當作垃圾。沒有垃圾收集器能夠做到讓我們完全不用操心資源管理的問題,比如駐留內存和外部資源。
    弱引用表、析構器和函數collectgarbage是在Lua語言中用來輔助垃圾收集器的主要機制。弱引用表允許收集Lua語言中還可以被程序訪問的對象;析構器允許收集不在垃圾收集器直接控制下的外部對象;函數collectgarbage則允許我們控制垃圾收集器的步長。

弱引用表

    我們知道,數組的有效部分總是向頂部擴展的,但Lua語言卻不知道。如果彈出一個元素時只是簡單地遞減頂部索引,那麼這個仍然留在數組中的對象對於Lua語言來說並不是垃圾。同理,即使是程序不會再用到的、存儲在全局變量中的對象,對於Lua語言來說也不是垃圾。在這兩種情況下,都需要我們將這些對象所在的位置賦值爲nil,以便這些位置不會鎖定可釋放的對象。
    不過,簡單地清除引用可能還不夠。在有些情況下,還需要程序和垃圾收集器之間的協作。一個典型的例子是,當我們要保存某些類型的活躍對象的列表時。這個需要看上去簡單,我們只需要把每個新對象插入數組即可;但是,一旦一個對象成爲了數組的一部分,它就再也無法被回收!雖然已經沒有其他任何地方在引用它,但數組依然在引用它。除非我們告訴Lua語言數組對該對象的引用不應該阻礙對此對象的回收,否則Lua語言本身是無從知曉的。
    弱引用表就是這樣一個用來告知Lua語言一個引用不應阻止一個對象回收的機制。所謂所引用是一種不在垃圾收集器考慮範圍內的對象引用。如果一個對象的所有引用都是弱引用,弱引用表就是元素均爲弱引用的表,這意味着如果一個對象只被一個弱引用持有,那麼Lua語言最終會回收這個對象。
    表由鍵值對組成,其兩者都可以容納任何類型的對象。在正常情況下,垃圾收集器不會回收一個在可訪問的表中作爲鍵或值的對象。也就是說,鍵和值都是強引用,它們會阻止對其所指向對象的回收。在一個弱引用表中,鍵和值都可以是弱引用的。這就意味着有三種類型的弱引用表,即具有弱引用鍵的表、具有弱引用值的表及同時具有弱引用鍵和值的表。不論是哪種類型的弱引用表,只要有一個鍵或值被回收了,那麼對應的整個鍵值對都會被從表中刪除。
    一個表是否爲弱引用表是由其元表中的__mode字段決定的。當這個字段存在時,其值應爲一個字符串:如果這個字符串是"k",那麼這個表的鍵是弱引用的;如果這個字符串是"v",那麼這個表的值是弱引用的;如果這個字符串是"kv",那麼這個表的鍵和值都是弱引用的。下面的示例雖然有些可以,但演示了弱引用表的基本行爲:

a = {}
mt = {__mode = "k"}
setmetatable(a,mt)		-- 現在'a'的鍵是弱引用的了
key = {}
a[key] = 1				-- 創建第一個鍵
key = {}
a[key] = 2				-- 創建第二個鍵
collectgarbage()		-- 強制進行垃圾回收
for k, v in pairs(a) do print(v) end		-- 2

在本例中,第二句賦值key = {}覆蓋了指向第一個鍵的索引。調用collectgarbage強制垃圾收集器進行一次完整的垃圾手機。由於已經沒有指向第一個鍵的其他引用個,因此Lua語言會回收這個鍵並從表中刪除對應的元素。然而,由於第二個鍵仍然被變量key所引用,因此Lua不會回收它。
    請注意,只有對象可以從弱引用表中被移除,而像數字和布爾這樣的“值”是不可回收的。例如,如果我們在表a中插入一個數值類型的鍵,那麼垃圾收集器永遠不會回收它。當然,如果在一個值爲弱引用的弱引用表中,一個數值類型鍵相關聯的值被回收了,那麼整個元素都會從這個弱引用表中被刪除。
    字符串在這裏表現了一些細微的差別,雖然從實現的角度看字符串是可回收的,但字符串又與其他的可回收對象不同。其他的對象,例如表和閉包,都是被顯式創建的。例如,當Lua語言對表達式{}求值時會創建一個新表。

記憶函數

    空間換時間是一種常見的編程技巧。我們可以通過記憶函數的執行結果,在後續使用相同參數再次調用該函數時直接返回之前記憶的結果,來加快函數的運行速度。
    假設有一個通用的服務器,該服務器接收的請求是以字符串形式表示的Lua語言代碼。每當服務器接收到一個請求時,它就對字符串運行load函數,然後再調用編譯後的函數。不過,函數load的開銷很昂貴,而且發送給服務器的某些命令的出現頻率可能很高。這樣,與其每次收到一條諸如"closeconnection()"這樣的常見命令就重複地調用函數load,還不如讓服務器用一個輔助表記憶所有函數load的執行結果。在調用函數load前,服務器先在表中檢查指定的字符串是否已經被處理過。如果沒有,就調用函數load並將返回值保存到表中。我們可以將這種行爲封裝爲一個新的函數:

local results = {}
function mem_loadstring(s)
	local res = results[s]
	if res == nli then
		res = assert(laod(s))
		results[s] = res
	end
	return res
end

    這種模式節省的開銷非常可觀。但是,它也可能導致不易察覺的資源浪費。雖然有些命令會重複出現,但也有很多命令可能就出現一次。漸漸地,表results會堆積上服務器收到的所有命令及編譯結果;在運行一段足夠長的時間後,這種行爲會耗盡服務器的內存。
    弱引用表爲解決這個問題提供了一種簡單的方案,如果表results具有弱引用的值,那麼每個垃圾收集週期都會刪除所有那個時刻未使用的編譯結果:

local results = {}
setmetatable(results,{__mode = "v"})		-- 讓值稱爲弱引用
funciton mem_loadstring(s)
--下面內容同前

實際上,因爲索引永遠是字符串,所以如果願意的話,我們可以讓這個表變成完全弱引用的:

setmetatable(results,{__mode = "kv"})

最終達到的效果是完全一樣的。
    記憶技術還可以用來確保某類對象的唯一性。例如,假設一個系統用具有三個相同取值範圍的字段red、green和blue的表來表示顏色,一個簡單的顏色工廠函數每被調用一次就生成一個顏色:

function createRGB(r,g,b)
	return {red = r, green = g ,blue = b}
end

使用記憶技術,我們就可以爲相同的顏色複用相同的表。要爲每一種顏色創建一個唯一的鍵,只需要使用分隔符把顏色的索引連接起來即可:

local results = {}
setmetatable(results,{__mode = "v"})		-- 讓值稱爲弱引用
function createRGB( r,g,b )
	local key = string.format("%d-%d-%d",r,g,b)
	local color = results[key]
	if color == nil then
		color = {red = r, green = g, blue = b}
		results[key] = color
	end
	return color
end

這種實現的一個有趣結果是,由於兩種顏色存在的顏色必定是由同一個表來表示,所以用戶可以使用基本的相等運算符比較兩種顏色。因爲隨着時間的遷移垃圾收集器會清理表results,所以一種指定的顏色在不同的時間內可能由不同的表來表示。不過,只要一種顏色正在被使用,它就不會從results中被移除。因此,一種顏色與一種新顏色相比已經存在了多長時間,這種顏色對應的表也存在了對應長度的時間,也可以被新顏色複用。

對象屬性

    弱引用表的另外一種重要應用是將屬性與對象關聯起來。在各種各樣的情況下,我們都需要把某種屬性綁定到某個對象,例如函數名、表的默認值及數組的大小等。
    當對象是一個表時,可以通過適當的唯一鍵把屬性存儲在這個表自身中。不過,如果對象不是一個表,那麼它就不能保存它自己的屬性。另外,即使是表,有時我們也不想把屬性保存在原始的對象。例如,當想保持屬性的私有性時,或不想讓屬性干擾表的遍歷時,就需要用其他辦法來關聯對象和屬性。
    當然,外部表爲對象和屬性的映射提供了一種理性的方法,即對偶表示,其中將對象用作鍵、將對象的屬性用作值。由於Lua語言允許使用任意類型的對象作爲鍵,因此一個外部表可以保存任意類型對象的屬性。此外,存儲在外部表中的屬性不會干擾其他對象,並且可以像表本身一樣是私有的。
    不過,這個看似完美的方案有一個重大缺陷:一旦我們把一個對象當作表中的一個鍵,那麼就是引用了它。Lua語言無法回收一個正在被用作鍵的對象。例如,如果使用一個普通的表來映射函數和函數名,那麼這些函數就永遠無法被回收。

回顧具有默認值的表

    在第一種解決方案中,我們使用了一個弱引用表來映射每一個表和它的默認值:

local defaults = {}
setmetatable(defaults,{__mode = "k"})
local mt = {__index = function(t) return defaults[t] end}
function setDefault (t,d)
	defaults[t] = d
	setmetatable(t,mt)
end

這是對偶表示的一種典型應用,其中使用了defaults[t]來表示t.defaults。如果表defaults沒有弱引用的鍵,那麼所有具有默認值的表就會永遠存在下去。
    在第二種解決方案中,我們對不同的默認值使用了不同的元素,在遇到重複的默認值時會複用相同的元表。這是記憶技術的一種典型應用:

local metas = {}
setmetatable(metas,{__mode = "v"})
function setDefault(t,d)
	local mt = metas[d]
	if mt == nil then
		mt = {__index = function() return d end}
		metas[d] = mt
	end
	setmetatable(t,mt)
end

在這種情況下,我們使用弱引用的值使得不再被使用的元表能夠回收。
    這兩種實現哪種更好取決於具體的情況。這兩種實現具有類似的複雜度和性能表現,第一種實現需要爲每個具有默認值的表分配幾個字節的內存,而第二種實現則需要爲每個不同的默認值分配若干內存。因此,如果應用中有上千個具有少量不同默認值的表,那麼第二種實現明顯更好。不過,如果只有少量共享默認值的表,那麼就應該選擇第一種實現。

瞬表

    一種棘手的情況是,一個具有弱引用鍵的表中的值又引用了對應的鍵。
    這種情況看上去更加常見。一個典型的示例是常量函數工廠。這種工廠的參數是一個對象,返回值是一個被調用時返回傳入對象的函數:

function factory(o)
	return (function() return o end)
end

這種工廠時實現記憶的一種很好的手段,可以避免在閉包已經存在時又創建新的閉包。

示例 使用記憶技術的常量函數工廠

do 
	local mem = {}
	setmetatable(mem, {__mode = "k"})
	function factory(o)
		local res = mem[o]
		if not res then
			res = (function() return o end)
			mem[o] = res
		end
		return res
	end
end

    不過,這裏另有玄機。請注意,表mem中與一個對象關聯的值回指了它自己的鍵。雖然表中的鍵是弱引用的,但是表中的值卻不是弱引用的。從一個弱引用表的標準理解看,記憶表中沒有任何東西會被移除。由於值不是弱引用的,所以對於每一個函數來說都存在一個強引用。每一個函數都指向其對應的對象,因而對於每一個鍵來說都存在一個強應用。因此,即使有弱引用的鍵,這些對象也不會被回收。
    不過,這種嚴格的理解不是特別有用。大多數人希望一個表中的值只能通過對應的鍵來訪問。我們可以認爲之前的情況是某種環,其中閉包引用了指回閉包的對象。
    Lua語言通過瞬表的概念來解決上述問題。在Lua語言中,一個具有弱引用鍵和強引用值的表是一個瞬表。在一個順表中,一個鍵的可訪問性控制着對應值的可訪問性。更確切地說,考慮瞬表中的一個元素,指向的v的引用只有當存在某些指向k的其他外部引用存在時纔是強引用,否則,即使v引用了k,垃圾收集器最終會收集k並把元素從表中移除。

析構器

    雖然垃圾收集器的目標是回收對象,但是它可以幫助程序員來釋放外部資源。處於這種目的,幾種編程語言提供了析構器。析構器是一個與對象關聯的函數,當該對象即將被回收時該函數會被調用。
    Lua語言通過元方法__gc實現析構器,如下例所示:

o = {x = "hi"}
setmetatable(o,{__gc = function(o) print(o.x) end})
o = nil
collectgarbage()		-- nil

在本例中,我們首先創建一個帶有__gc元方法元表的表。然後,抹去與這個表的唯一聯繫,再強制進行一次完整的垃圾回收。在垃圾回收期間,Lua語言發現表已經不再是可訪問的了,因此調用表的析構器,也就是元方法__gc。
    Lua語言中,析構器的一個微妙之處在於“將一個對象標記爲需要析構”的概念。通過給對象設置一個具有非空__gc元方法的元表,就可以把一個對象標記爲需要進行析構處理。如果不標記對象,那麼對象就不會被析構。我們編寫的大多數代碼會正常運行,但會發生某些奇怪的行爲,比如:

o = {x = "hi"}
mt = {}
setmetatable(o,mt)
mt.__gc = function(o) print(o.x) end
o = nil
collectgarbage()		-- (print nothing)

這裏,我們確實要給對象o設置了元表,但是這個元表沒有__gc方法,因此對象沒有被標記爲需要進行析構處理。即使我們後續給元表增加了元方法__gc,Lua語言也發現不了這種賦值的特殊之處,因此不會把對象標記爲需要進行析構處理。
    正如我們所提到的,這很少會有問題。在設置元表後,很少會改變元方法。如果真的需要在後續設置元方法,那麼可以給字段__gc先賦一個任意值作爲佔位符:

o = {x = "hi"}
mt = {__gc = true}
setmetatable(o,mt)
mt.__gc = function(o) print(o.x) end
o = nil
collectgarbage()		-- nil

現在,由於元表有了__gc字段,因此對象會被正確地標記爲需要析構處理。如果後續再設置元方法也不會有問題,只要元方法時一個正確的函數,Lua語言就能夠調用它。
    當垃圾收集器在同一個週期中析構多個對象時,它會按照對象被標記爲需要析構處理的順序逆序調用這些對象的析構器。請考慮如下的示例,該示例創建了一個由帶有析構器的對象所組成的鏈表:

mt = {__gc = function(o) print(o[1]) end}
list = nil
for i = 1 , 3 do
	list = setmetatable({i,link = list},mt)
end
list = nil
collectgarbage()

第一個被析構的對象是3,也就是最後一個被標記的對象。
    一種常見的誤解是認爲正在被回收的對象之間的關聯會影響對象析構的順序。例如,有些人可能認爲上例中的對象2必須在對象1之前被析構,因爲存在從2到1的關聯。但是,關聯會行程環。所以,關聯並不會影響析構器執行的順序。
    有關析構器的另個一微妙之處是復甦。當一個析構器被調用時,它的參數是正在被析構的對象。因此,這個對象會至少在析構期間重新編程活躍的。筆者把這稱爲臨時復甦。在析構器執行期間,我們無法阻止析構器把該對象存儲在全局變量中,使得該對象在析構器返回後仍然可以訪問,筆者把這稱爲永久復甦。
    復甦必須是可傳遞的。考慮如下代碼:

A = {x = "this is A"}
B = {f = A}
setmetatable(B,{__gc = function(o) print(o.f.x) end})
A,B = nil
collectgarbage() 		-- this is A

B的析構器訪問了A,因此A在B析構前不能被回收,Lua語言在運行析構器之前必須同時復甦B和A。
    由於復甦的存在,Lua語言會在兩個階段中回收具有析構器的對象。當垃圾收集器首次發生某個具有析構器的對象不可達時,垃圾收集器就把這個對象復甦並將其放入等待被析構的隊列中。一旦析構器開始執行,Lua語言就將對該對象標記爲已被析構。當下一次垃圾收集器又發現這個對象不可達時,它就將這個對象刪除。如果想保證我們程序中的所有垃圾都被真正地釋放了的話,那麼必須調用collectgarbage兩次,第二次調用纔會刪除第一次調用中被析構的對象。
    由於Lua語言在析構對象上設置了標記,每一個對象的析構器都會精確地運行一次。如果一個對象直到程序運行結束還沒有被回收,那麼Lua語言就會在整個Lua虛擬機關閉後調用它的析構器。這種特行在Lua語言中實現了某種形式的atexit函數,即在程序終結前立即運行的函數。我們所要做的就是創建一個帶有析構器的表,然後把它錨定在某處,例如錨定到全局表中:

local t = {__gc = function()
	print("finishing Lua program")
	end}
setmetatable(t,t)
_G["*AA*"] = t

    另外一個有趣的技巧會允許程序在每次完成垃圾回收後調用指定的函數。由於析構器只運行一次,所以這種技巧是讓每個析構器創建一個用來運行下一個析構器的新對象,參考示例:

示例 在每次GC後運行一個函數

do
	local mt = {__gc = function(o)
		print("new cycle")
		setmetatable({},getmetatable(o))
	end}
	setmetatable({},mt)
end

collectgarbage()		-- 一次垃圾收集
collectgarbage()		-- 一次垃圾收集
collectgarbage()		-- 一次垃圾收集

    具有析構器的對象和弱引用表之間的交互也有些微妙。在每個垃圾收集週期內,垃圾收集器會在調用析構器前清理弱應用表中的值,在調用析構器之後再清理鍵。這種行爲的原理在於我們經常使用帶有弱引用鍵的表來保存對象的屬性,因此,析構器可能需要訪問那些屬性。不過,我們也會使用具有弱引用值的表來重用活躍的對象,在這種情況下,正在被析構的對象就不再有用了。

垃圾收集器

    一直到Lua5.0,Lua語言使用的都是一個簡單的標記-清除式垃圾收集器。這種收集器又被稱爲“stop-the-world(全局暫停)”式的收集器,意味着Lua語言會時不時地停止主程序的運行來執行一次完整的垃圾收集週期。每一個垃圾收集週期由四個階段組成:標記、清理、清除和析構。
    標記階段把根節點集合標記爲活躍,根結點集合由Lua語言可以直接訪問的對象組成。在Lua語言中,這個集合只包括C註冊表。
    保存在一個活躍對象中的對象是程序可達的,因此也會被標記活躍。當所有可達對象都被標記爲活躍後,標記階段完成。
    在開始清除階段前,Lua語言先執行清理階段,在這個階段中處理析構器和弱引用表。首先,Lua語言遍歷所有被標記爲需要進行析構、但又沒有被標記爲活躍狀態的對象。這些沒有標記爲活躍狀態的對象會被標記爲活躍,並被放在一個單獨的列表中,這個列表會在析構階段用到。然後,Lua語言遍歷弱引用表並從中移除鍵或值未被標記的元素。
    清理階段遍歷所有對象。如果一個對象沒有被標記爲活躍,Lua語言就將其回收,否則,Lua語言清理標記,然後準備進行下一個清理週期。
    最後,在析構階段,Lua語言調用清理階段被分離出的對象的析構器。
    使用真正的垃圾收集器意味着Lua語言能夠處理對象引用之間的環。在使用環形數據結構時,我們不需要花費外的精力,它們會像其他數據一樣被回收。
    Lua5.1使用了增量式垃圾收集器。這種垃圾收集器像老版的垃圾收集器一樣執行相同的步驟,但是不需要在垃圾收集期間停止主程序的運行。相反,它與解釋器一起交替運行。每當解釋器分配了一定數量的內存時,垃圾收集器也執行一小步(這意味着,在垃圾收集器工作期間,解釋器可能會改變一個對象的可達性。爲了保證垃圾收集器的正確性,垃圾收集器中的有些操作具有發現危險改動和糾正所涉及對象標記的內存屏障)。
    Lua5.2引入了緊急垃圾收集。當內存分配失敗時,Lua語言會強制進行一次完整的垃圾收集,然後再次嘗試分配。這些緊急情況可以發生在Lua語言進行內存分配的任意時刻,包括Lua語言處於不一致的代碼執行狀態時,因此,這些收集動作不能運行析構器。

控制垃圾收集的步長

    通過函數collectgarbage可以對垃圾收集器進行一些額外的控制,該函數實際上是幾個函數的集合體:第一個參數是一個可選的字符串,用來說明進行何種操作;有些選項使用一個整型作爲第二個參數,稱爲data。
    第一個參數的選項包括如下七個。
    “stop”:停止垃圾收集器,知道使用選項"restart"再次調用collectgarbage。
    “restart”:重啓垃圾收集器。
    “collect”:執行一次完整的垃圾收集,回收和析構所有不可達的對象。這是默認的選項。
    “step”:執行某些垃圾收集工作,第二個參數data指明工作量,即在分配了data字節後垃圾收集器應該做什麼。
    “count”:以KB爲單位返回當前自己已用內存數,該結果是一個浮點數,乘以1024得到的就是精確的字節數。該值包括了尚未被回收的死對象。
    “setpause”:設置收集器的stepmul參數。參數data給出新值,也是以百分比爲單位。
    兩個參數pause和stepmul控制着垃圾收集器的角色。任何垃圾收集器都是使用CPU時間換內存空間。在極端情況下,垃圾收集器可能根本不會運行。但是,不耗費CPU時間是以巨大的內存消耗爲代價的。在另外一種極端的情況下,收集器可能每進行一次賦值就得運行一次完整的垃圾收集。程序能夠使用儘可能少的內存,但是是以巨大的CPU消耗爲代價的。pause和stepmul的默認值正式試圖在這兩個極端之間找到的對大多數應用來說足夠好的平衡點。不過,在某些情況下,還是值得試着對它們進行優化。
    參數pause用於控制垃圾收集器在一次手機完成後等待多久再開始新的一次手機。當值爲零時,表示Lua語言在上一次垃圾回收結束後立即開始一次新的收集。當值爲200%時表示在重啓垃圾收集器前等待內存使用翻番。如果想消耗更多的CPU時間換取更低的內存消耗,那麼可以把這個值設置得小一點。通常,我們應該把這個值設在0到200%之間。
    參數stepmul控制對於每分配1KB內存,垃圾收集器應該進行多少工作。這個值越高,垃圾收集器使用的增量越小。一個像10000000%一樣巨大的值會讓收集器表現得像一個非增量的垃圾收集器。默認值是200%,地於100%的值會讓收集器運行得很慢,以至於可能一次收集也完不成。
    函數collectgarbage的另外一些參數用來在垃圾收集器運行時控制它的行爲。同樣,對於大多數程序員來說,默認值已經足夠好了,但是對於一些特殊的應用,用手工控制可能會更好,遊戲就經常需要這種類型的控制。例如,如果我們不想讓垃圾收集在某些階段運行,那麼可以通過調用函數collectgarbage(“stop”)停止垃圾收集器,然後再調用collectgarbage(“restart”)重新啓動垃圾收集器。在一些具有週期性休眠階段的代碼中,可以讓垃圾收集器停止,然後在程序休眠期間調用collectgarbage(“step”,n)。要設置在每一個休眠期間進行多少工作,要麼爲n實驗性地選擇一個恰當的值,要麼把n設成零,然後在一個循環中調用函數collectgarbage直到休眠結束。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章