當前位置: 妍妍網 > 碼農

PHP程式語言垃圾回收是什麽?

2024-03-09碼農

概念

PHP的垃圾回收機制是自動的,它透過內建的垃圾回收器(Garbage Collector)來實作。當一個PHP物件不再被參照時,它就成為垃圾。垃圾回收器會定期掃描記憶體中的所有物件,將沒有參照的物件標記為垃圾,並釋放它們占用的記憶體空間,以便其他物件可以使用這些空間。

PHP的垃圾回收機制使用了 參照計數(reference counting) 的演算法來跟蹤物件的參照情況。每個物件都有一個參照計數器,它記錄著物件當前被參照的次數。當一個物件被賦給一個變量時,它的參照計數器會 增加1 ;當一個變量不再參照該物件時,它的參照計數器會 減少1 。當參照計數器降為0時,這個物件就成為垃圾,垃圾回收器就會釋放它所占用的記憶體。

PHP的垃圾回收機制是自動的,程式設計師無需手動管理記憶體。但是,如果程式中存在迴圈參照的情況,垃圾回收器就無法釋放這些物件。為了避免這種情況的發生,PHP提供了一種手動解除參照的方法,即將物件賦值為 null ,這樣就可以讓物件的參照計數器降為 0 ,從而被垃圾回收器釋放。

參照計數基礎

PHP 變量儲存在稱為 zval 的容器中。 zval 容器除了變量的型別和值之外,還包含兩個額外的資訊位。第一個是 is_ref ,是布爾值,表示變量是否是「參照集合」的一部份。透過這個位,PHP 引擎知道如何區分普通變量和參照。由於 PHP 允許使用者自訂參照,透過 & 運算子建立參照,zval 容器還有內部參照計數機制來最佳化記憶體使用。第二個是 refcount ,表示有多少個變量名(也稱為符號)指向這個 zval 容器。所有符號都儲存在一個符號表中,每個作用域都有一個符號表。主指令碼(即透過瀏覽器請求的指令碼)有一個作用域,每個函式或方法也有一個作用域。

當使用常量值建立新變量時,也會建立 zval 容器,例如

範例 #1 建立新 zval 容器

<?php
$a = "new string";

在這種情況下,新的符號名稱 a 會在當前作用域中建立,並且會建立新的變量容器,其型別為 string ,值為 new string 。由於沒有建立使用者定義的參照, is_ref 位預設設定為 false refcount 設定為 1,因為只有一個符號使用了這個變量容器。請註意,具有 refcount 為 1 的參照(即 is_ref 為 true)會視為非參照(即 is_ref false )。如果安裝了 » Xdebug,可以透過呼叫 xdebug_debug_zval() 來顯示此資訊。

範例 #2 顯示 zval 資訊

<?php
$a = "new string";
xdebug_debug_zval('a');

以上範例會輸出:

a: (refcount=1, is_ref=0)='new string'

將這個變量賦值給另一變量名將增加 refcount 的計數。

範例 #3 增加 zval 的 ``refcount

<?php
$a = "new string";
$b = $a;
xdebug_debug_zval( 'a' );

以上範例會輸出:

a: (refcount=2, is_ref=0)='new string'

這裏的 refcount 是 2,因為同一個變量容器連結到 a b 。PHP 很聰明,當沒有必要的時候,不會復制實際的變量容器。當 refcount 0 時,就會銷毀變量容器。當連結到變量容器的任何符號離開作用域(例如函式結束時)或取消符號賦值(例如透過呼叫 unset() )時, refcount 會減少 1 。以下是範例:

範例 #4 減少 zval refcount

<?php
$a = "new string";
$c = $b = $a;
xdebug_debug_zval( 'a' );
$b = 42;
xdebug_debug_zval( 'a' );
unset$c );
xdebug_debug_zval( 'a' );

以上範例會輸出:

a: (refcount=3, is_ref=0)='new string'
a: (refcount=2, is_ref=0)='new string'
a: (refcount=1, is_ref=0)='new string'

如果現在呼叫 unset($a) ;,變量容器,包含型別和值,會從記憶體中移除。

復合型別

對於 array 和 object 這樣的復合型別,情況會稍微復雜一些。與 scalar 值不同,array 和 object 的內容儲存在自己的符號表中。這意味著以下範例將建立三個 zval 容器:

範例 #5 建立 array zval

<?php
$a = array( 'meaning' => 'life''number' => 42 );
xdebug_debug_zval( 'a' );

以上範例的輸出類似於:

a: (refcount=1, is_ref=0)=array (
'meaning' => (refcount=1, is_ref=0)='life',
'number' => (refcount=1, is_ref=0)=42
)

圖示:

這三個 zval 變量容器是 a meaning number 。增加和減少 refcounts 的規則也適用於此。下面,再向陣列添加一個元素,並將其值設定為已存在元素的內容:

範例 #6 添加已存在的元素到陣列

<?php
$a = array( 'meaning' => 'life''number' => 42 );
$a['life'] = $a['meaning'];
xdebug_debug_zval( 'a' );

以上範例的輸出類似於:

a: (refcount=1, is_ref=0)=array (
'meaning' => (refcount=2, is_ref=0)='life',
'number' => (refcount=1, is_ref=0)=42,
'life' => (refcount=2, is_ref=0)='life'
)

圖示:

從上面的 Xdebug 輸出中,可以看到新舊的陣列元素現在都指向 refcount 2 zval 容器。盡管 Xdebug 的輸出顯示了兩個值為 'life' 的 zval 容器,但它們實際上是同一個。 xdebug_debug_zval() 函式沒有顯示這一點,但可以透過顯示記憶體指標來看到它。

從陣列中刪除元素就像從作用域中刪除符號一樣。刪除後,陣列元素指向的容器的 refcount 會減少。同樣,當 refcount 0 時,變量容器就會從記憶體中刪除。再舉個例子來說明這一點:

範例 #7 從陣列中刪除元素

<?php
$a = array( 'meaning' => 'life''number' => 42 );
$a['life'] = $a['meaning'];
unset$a['meaning'], $a['number'] );
xdebug_debug_zval( 'a' );

以上範例的輸出類似於:

a: (refcount=1, is_ref=0)=array (
'life' => (refcount=1, is_ref=0)='life'
)

現在,如果將陣列本身作為陣列的一個元素添加進去,情況就會變得有趣起來。在下一個例子中這樣做,並且偷偷加入參照運算子,否則 PHP 會建立副本:

範例 #8 將陣列本身作為其自身的一個元素添加進去

<?php
$a = array( 'one' );
$a[] =& $a;
xdebug_debug_zval( 'a' );

以上範例的輸出類似於:

a: (refcount=2, is_ref=1)=array (
0 => (refcount=1, is_ref=0)='one',
1 => (refcount=2, is_ref=1)=...
)

圖示:

可以看到陣列變量( a )以及第二個元素( 1 )現在都指向 refcount 為 2 的變量容器。上面顯示的 ... 表示存在遞迴,這在這種情況下意味著 ... 指向原陣列。

就像之前一樣,清除變量會刪除符號,並且指向的變量容器的參照計數會減少 1。因此,如果在執行上述程式碼後清除變量 $a ,那麽 $a 和元素 1 所指向的變量容器的參照計數會減少 1 ,從 2 變為 1 。可以表示為:

範例 #9 清除 $a

(refcount=1, is_ref=1)=array (
0 => (refcount=1, is_ref=0)='one',
1 => (refcount=1, is_ref=1)=...
)

圖示:

清理問題

雖然在任何作用域中都沒有指向這個結構的符號,卻無法清理它,因為陣列元素「1」仍然指向同一個陣列。由於沒有外部符號指向它,使用者無法清理該結構;因此會出現記憶體泄漏。幸運的是,PHP 會在請求結束時清理這個數據結構,但在此之前,它會占用寶貴的記憶體空間。如果你正在實作解析演算法或其他需要子級元素指向"父級"元素的情況,會經常發生。當然,object 也可能出現相同的情況,因為 object 始終隱式參照。

如果這種情況只發生一兩次,可能不是問題,但如果出現數千次,甚至數百萬次的記憶體損失,顯然就成了問題。這在長時間執行的指令碼中尤為棘手,比如守護行程,其中請求基本上永遠不會結束,或者在大量的單元測試集中。後者在執行 eZ Components 庫的樣版元件的單元測試時出現了問題。在某些情況下,它需要超過 2GB 的記憶體,而測試伺服器並沒有那麽多記憶體可用。

回收迴圈

傳統上,像 PHP 之前使用的參照計數記憶體機制無法解決迴圈參照記憶體泄漏的問題;然而,從 5.3.0 版本開始,PHP 實施了» 參照計數系統中的同步迴圈回收 論文中的同步演算法來解決這個問題。

對演算法的完全說明有點超出這部份內容的範圍,將只介紹其中基礎部份。首先,需要確立一些基本規則。如果 refcount 增加,則該變量仍在使用中,因此不是垃圾。如果 refcount 減少到 0 ,則 zval 可以釋放。這意味著只有當參照計數參數減少到非零值時,才能建立垃圾迴圈。其次,在垃圾迴圈中,可以透過檢查是否可以將 refcount 減少 1 ,並檢查哪些 zval refcount 0 來確定哪些部份是垃圾。

為避免不得不檢查所有參照計數可能減少的垃圾迴圈,這個演算法把所有可能根(possible roots 都是zval變量容器),放在根緩沖區(root buffer)中(用紫色來標記,稱為疑似垃圾),這樣可以同時確保每個可能的垃圾根(possible garbage root)在緩沖區中只出現一次。僅僅在根緩沖區滿了時,才對緩沖區內部所有不同的變量容器執行垃圾回收操作。看上圖的步驟 A。

在步驟 B 中,模擬刪除每個紫色變量。模擬刪除時可能將不是紫色的普通變量參照數減 "1" ,如果某個普通變量參照計數變成 0 了,就對這個普通變量再做一次模擬刪除。每個變量只能被模擬刪除一次,模擬刪除後標記為灰(原文說確保不會對同一個變量容器減兩次 "1" ,不對的吧)。

在步驟 C 中,模擬恢復每個紫色變量。恢復是有條件的,當變量的參照計數大於0時才對其做模擬恢復。同樣每個變量只能恢復一次,恢復後標記為黑,基本就是步驟 B 的逆運算。這樣剩下的一堆沒能恢復的就是該刪除的藍色節點了,在步驟 D 中遍歷出來真的刪除掉。

演算法中都是模擬刪除、模擬恢復、真的刪除,都使用簡單的遍歷即可(最典型的深搜遍歷)。復雜度為執行模擬操作的節點數正相關,不只是紫色的那些疑似垃圾變量。

對演算法的工作原理有了基本的了解後,現在可以回顧一下如何與 PHP 整合。預設情況下,PHP 的垃圾回收器是開啟的。然而,有個 php.ini 設定可以進行更改: zend.enable_gc

當開啟垃圾回收器時,如上所述的迴圈尋找演算法將在根緩沖區滿時執行。根緩沖區的大小是固定的,可以容納 10,000 個可能的根(盡管可以透過更改 PHP 原始碼中的 Zend/zend_gc.c 中的 GC_THRESHOLD_DEFAULT 常量並重新編譯 PHP 來修改這個值)。當關閉垃圾回收器時,迴圈尋找演算法將永不執行。然而,無論是否使用此配置啟用垃圾回收機制,可能根都將始終記錄在根緩沖區中。

如果在垃圾回收機制關閉時,根緩沖區存滿了可能的根,那麽將不會記錄進一步的可能根。演算法永遠不會分析那些沒有記錄的可能根。如果他們是迴圈參照的一部份,將永不會清除從而導致記憶體泄漏的產生。

即使在垃圾回收機制不可用時,可能根也被記錄的原因是,相對於每次找到可能根後檢查垃圾回收機制是否開啟而言,記錄可能根的操作更快。不過垃圾回收和分析機制本身要耗不少時間。

除了改變配置中的 zend.enable_gc 之外,還可以透過呼叫 gc_enable() gc_disable() 來啟用/禁用垃圾回收機制。呼叫這些函式與透過配置開啟或關閉機制的效果相同。即使可能的根緩沖區尚未滿,還可以強制回收迴圈。為此,可以使用 gc_collect_cycles() 函式。該函式將返回演算法回收的迴圈數量。

允許開啟和關閉垃圾回收機制並且允許自主的初始化的原因,是由於你的應用程式的某部份可能是高時效性的。在這種情況下,你可能不想使用垃圾回收機制。當然,對你的應用程式的某部份關閉垃圾回收機制,是在冒著可能記憶體泄漏的風險,因為一些可能根也許存不進有限的根緩沖區。因此,就在你呼叫gc_disable()函式釋放記憶體之前,先呼叫 gc_collect_cycles() 函式可能比較明智。因為這將清除已存放在根緩沖區中的所有可能根,然後在垃圾回收機制被關閉時,可留下空緩沖區以有更多空間儲存可能根。