In PHP versions prior to 5.3, memory cleanup relied solely on basic reference counting without a dedicated garbage collector (GC). It simply checked if a variable’s zval (Zend value container) had a refcount of 0; if so, it freed the memory, otherwise it retained the block until the PHP process terminated. This naive approach worked for simple cases but introduced circular reference memory leaks, documented at https://bugs.php.net/bug.php?id=33595, prompting the introduction of a specialized GC module in PHP 5.3.
PHP Reference Counting Fundamentals
Every PHP variable is stored in a zval container, which tracks two critical properties:
refcount: An integer indicating how many active symbols (variable names) point to this containeris_ref: A boolean flag set to 1 if any symbol references the container via a PHP reference (&), otherwise 0
To inspect these properties directly, install and enable the Xdebug extension.
String Vairables
$greeting = "warm morning breeze";
xdebug_debug_zval('greeting');
Output:
greeting:(refcount=1, is_ref=0),string 'warm morning breeze' (length=19)
Assigning the string to a second variable increases the refcount:
$greeting = "warm morning breeze";
$casual_hello = $greeting;
xdebug_debug_zval('greeting');
Output:
greeting:(refcount=2, is_ref=0),string 'warm morning breeze' (length=19)
Using reference assignment flips the is_ref flag:
$greeting = "warm morning breeze";
$linked_hello = &$greeting;
xdebug_debug_zval('greeting');
Output:
greeting:(refcount=2, is_ref=1),string 'warm morning breeze' (length=19)
Array Variables
Arrays act as composite zvals, with each key-value pair maintaining its own independent zval:
$snack = ['type' => 'matcha cookie', 'quantity' => 3];
xdebug_debug_zval('snack');
Output:
snack:
(refcount=1, is_ref=0),
array (size=2)
'type' => (refcount=1, is_ref=0),string 'matcha cookie' (length=13)
'quantity' => (refcount=2, is_ref=0),int 3
Variable Destruction
Calling unset() only breaks a symbol’s connection to its zval and decrements refcount by 1. Memory is not freed unless refcount reaches 0:
$greeting = "warm morning breeze";
$casual_hello = $greeting;
xdebug_debug_zval('greeting');
unset($casual_hello);
xdebug_debug_zval('greeting');
Output:
greeting:(refcount=2, is_ref=0),string 'warm morning breeze' (length=19)
greeting:(refcount=1, is_ref=0),string 'warm morning breeze' (length=19)
PHP Memory Management Overview
To observe external memory changes, use memory_get_usage()—pass true to retrieve actual system-allocated memory, or omit it for PHP’s internal allocation tracking:
var_dump(memory_get_usage());
$snack = "matcha cookie";
var_dump(memory_get_usage());
unset($snack);
var_dump(memory_get_usage());
Typical output:
int 1612320
int 1612456
int 1612320
This simple example shows clear memory allocation and recovery, but underlying behavior is more complex. PHP splits variable storage into two parts:
- Memory for the symbol name, stored in a core HashTable symbol table
- Memory for the zval and its data
To see subtle HashTable behavior, run this loop:
var_dump(memory_get_usage());
for ($idx = 0; $idx < 100; $idx++) {
$key_base = "dynamic_var";
$var_name = $key_base . $idx;
$$var_name = "placeholder content";
}
var_dump(memory_get_usage());
for ($idx = 0; $idx < 100; $idx++) {
$key_base = "dynamic_var";
$var_name = $key_base . $idx;
unset($$var_name);
}
var_dump(memory_get_usage());
Typical output:
int 1615936
int 1631208
int 1616744
Notice memory is not fully recovered. PHP’s HashTable allocates small initial memory blocks and expands incrementally as needed, but never shrinks. unset() only frees zval/data memory; symbol table entries leave residual allocated space even after removal.
PHP also uses a pooled memory strategy: it requests large contiguous blocks from the OS, then distributes smaller chunks to variable allocations to minimize frequent OS system calls. Similarly, freed memory is added to an internal free list for reuse instead of being immediately returned to the OS.
Defining Garbage in PHP
A zval is considered garbage only when no active symbols in the symbol table point to it. Even if a variable is no longer needed in code, it remains non-garbage until refcount reaches 0 (or it’s identified as a circular reference by the GC).
Circular Reference Leaks (Pre-PHP 5.3)
Circular references occur when a zval directly or indirectly points back to itself. This creates a situation where refcount never drops to 0, even after all external symbols are removed:
$container = ['initial item'];
$container[] = &$container;
xdebug_debug_zval('container');
Partial output:
container:
(refcount=2, is_ref=1),
array (size=2)
0 => (refcount=1, is_ref=0),string 'initial item' (length=12)
1 => (refcount=2, is_ref=1),
&array<
After unsetting $container, no external symbols access the array, but its refcount stays at 1, causing a permanent memory leak in PHP <5.3.
PHP 5.3+ Circular Reference GC Algorithm
The modern GC handles circular references by following these rules:
- If
refcountincreases, the zval is active and non-garbage - If
refcountdrops to 0, the zval is immediately freed (non-garbage) - If
refcountdecreases but remains >0, the zval is added to a root buffer as a potential garbage candidate (marked purple)
To avoid performance overhead, the GC only runs when the root buffer (default capacity: 10,000 nodes, each unique zval candidate once) fills up, or when manually triggered. The cleanup process has four steps:
A. Root Buffer Collection: Collect candidate zvals into the buffer, marking them purple, ensuring no duplicates
B. Simulated Refcount Decrement: Traverse each candidate’s internal zvals with depth-first search (DFS), decrementing their refcount by 1 and marking them gray. The root candidate itself is only decremented if another internal zval points back to it
C. Garbage Identification: Re-traverse with DFS. If a zval’s refcount is now 0, mark it white (garbage). If refcount >0, restore all decremented refcounts and mark the zval (and its children) black
D. Garbage Collection: Iterate the root buffer and free all white-marked zvals
For the earlier circular reference example after unset($container):
- The candidate zval’s internal elements (index 0 and 1) are decremented. Index 1 points directly to the root candidate, so the root’s
refcountdrops to 0 - The root is marked white and freed
GC Configuration and Functions
Control the GC via php.ini and built-in functions:
zend.enable_gc: Set toOn(default) orOffto toggle the GC- Root Buffer Size: Adjust
GC_ROOT_BUFFER_MAX_ENTRIESinZend/zend_gc.cand recompile PHP to change the buffer threshold gc_enable(): Manually enable the GC at runtimegc_disable(): Manually disable the GC at runtimegc_collect_cycles(): Force a GC cycle even if the root buffer is not full
Key Garbage Collection Notes
unset($var)only breaks the symbol-zval link and decrementsrefcount; it does not guarantee immediate memory release$var = nullclears the zval’s data and resetsrefcountto 0, triggering immediate release if no other symbols exist- All memory alllocated by a PHP script is automatically freed when the script terminates, regardless of circular references