Understanding PHP's Garbage Collection and Memory Management Mechanisms

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 container
  • is_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:

  1. Memory for the symbol name, stored in a core HashTable symbol table
  2. 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:

  1. If refcount increases, the zval is active and non-garbage
  2. If refcount drops to 0, the zval is immediately freed (non-garbage)
  3. If refcount decreases 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 refcount drops 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 to On (default) or Off to toggle the GC
  • Root Buffer Size: Adjust GC_ROOT_BUFFER_MAX_ENTRIES in Zend/zend_gc.c and recompile PHP to change the buffer threshold
  • gc_enable(): Manually enable the GC at runtime
  • gc_disable(): Manually disable the GC at runtime
  • gc_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 decrements refcount; it does not guarantee immediate memory release
  • $var = null clears the zval’s data and resets refcount to 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

Tags: PHP garbage collection Memory Management reference counting Xdebug

Posted on Fri, 17 Jul 2026 16:24:36 +0000 by MasK