0

PHP garbage collection — cyclic references and gc_collect_cycles()

Expert5 min read·php-08-019
performanceinterview

Concept

PHP uses reference counting as its primary garbage collection mechanism. Each value (zval or zend_object) has a refcount — the number of variables/data structures pointing to it. When refcount drops to zero, the value is immediately freed. This is fast and predictable — memory is released as soon as it's no longer reachable.

The reference counting problem — cyclic references: If object A holds a reference to object B, and object B holds a reference to object A, both have refcount >= 1 even when no external variable references them. They're unreachable but not freed. This is a memory leak in reference-counted systems.

PHP's cycle collector: PHP has a secondary garbage collector specifically for cycles. It runs automatically when the number of potential cycle roots exceeds a threshold (default 10,000). You can trigger it manually with gc_collect_cycles(). The algorithm: mark all potential cycle roots, do a simulated reference decrement, anything that reaches zero is part of a cycle and can be freed.

Long-running processes: In traditional PHP (one request per process), cycles are freed when the process terminates. In Laravel Octane, FrankenPHP, or Swoole, processes handle many requests. Cycles accumulate over time — eventually causing out-of-memory crashes unless you understand and manage them.

gc_disable() / gc_enable(): Disabling the cycle collector for a batch process that creates many objects and then frees them all at once is sometimes faster than letting the collector run periodically. Profile to verify.

Code Example

php
<?php
declare(strict_types=1);

// Cyclic reference — memory leak without cycle collector
class Node
{
    public ?Node $parent = null;
    public array $children = [];
    public string $label;

    public function __construct(string $label)
    {
        $this->label = $label;
    }

    public function addChild(Node $child): void
    {
        $child->parent = $this; // cycle: parent→child, child→parent
        $this->children[] = $child;
    }

    public function __destruct()
    {
        echo "Freeing: {$this->label}\n";
    }
}

// Create a tree with cyclic back-references
$root = new Node('root');
$child1 = new Node('child1');
$child2 = new Node('child2');
$root->addChild($child1);
$root->addChild($child2);

$before = memory_get_usage();
unset($root, $child1, $child2); // refcounts don't reach 0 due to cycles
$after_unset = memory_get_usage();
echo "Memory still in use (cycles): " . ($after_unset - $before) . "\n";

$freed = gc_collect_cycles(); // manually trigger cycle collection
$after_gc = memory_get_usage();
echo "Freed $freed objects, memory recovered: " . ($after_gc - $after_unset) . "\n";
// Destructors now run: "Freeing: root", "Freeing: child1", "Freeing: child2"

// Monitoring GC in long-running processes
$gcStatus = gc_status();
echo "Runs: {$gcStatus['runs']}, Collected: {$gcStatus['collected']}\n";

// Pattern for long-running workers: reset state between jobs
// In Laravel Queue workers (simplified):
class WorkerLoop
{
    public function process(): void
    {
        while (true) {
            $job = $this->fetchNextJob();
            $job->handle();
            unset($job);
            gc_collect_cycles(); // ensure cycles from job are freed
        }
    }
}

// WeakReference to avoid cycles in observer patterns
$subject = new stdClass();
$subject->name = 'Observable';

$weak = WeakReference::create($subject);
// Observer holds a WeakReference — won't prevent GC of $subject
echo $weak->get()?->name ?? 'freed';