Understanding Memory Management and Garbage Collection in PHP
AI generated
<?php
8.4
PHP 8.4 · Memory Management · Garbage Collection · Performance
Understanding Memory Management and Garbage Collection in PHP
from the Zend Memory Manager to the Cycle Collector

PHP manages memory seemingly invisibly in the background, but understanding how Reference Counting, the Zend Memory Manager, and Garbage Collection actually work together lets you spot memory leaks in long-running processes before they become a production incident. This article explains the zval structure, circular references, the Cycle Collector algorithm, and the practical tools for diagnosis, for PHP developers who want to think beyond just memory_limit.

18 min read Zend Memory Manager · zval · Cycle Collector · WeakMap PHP 8.1 - 8.4

1. The PHP Memory Model: Zend Memory Manager and Heap Segments

Before you can talk about Garbage Collection in PHP, you need to understand that PHP does not work directly with malloc and free. Between the Zend Engine and the operating system sits the Zend Memory Manager (ZMM), which requests and releases memory through emalloc(), efree(), and erealloc(). The ZMM requests large, contiguous blocks from the operating system, so-called heap segments (chunks, typically 2 MB), and subdivides them internally using a size-class based free list, similar to a slab allocator. The effect: the vast majority of emalloc calls never reach the operating system kernel at all, and are served entirely from within an already reserved segment.

The difference from classic malloc/free is fundamental: malloc is a generic, process-wide allocator with no concept of a request lifecycle, every request can potentially trigger a system call (brk or mmap). The ZMM, on the other hand, knows the request as a unit in classic SAPIs like PHP-FPM: at the end of every request, the entire heap is returned to the operating system in a single step (zend_mm_shutdown), regardless of whether every individual variable within the request was properly freed. This is the reason many memory problems in classic PHP historically remained "invisible". For structures that need to survive across requests, such as the opcode cache or persistent database connections, pemalloc() provides a separate, persistent memory region that explicitly bypasses this reset.

The heap segments are the substrate on which Garbage Collection operates, though only indirectly: the Cycle Collector only manages the reachability of zval values, not the raw memory blocks themselves. Only once a refcount drops to zero and efree() is called does the ZMM release the corresponding slot within a segment for reuse. Fragmentation within a segment can therefore only be resolved after the actual memory management at the zval level has finished its work, which illustrates the tight coupling between reference counting and the underlying allocator.

2. Reference Counting: the zval Structure in Detail

Every PHP value is represented internally as a zval, a tagged union of a value part and a type tag. For refcounted types such as arrays, objects, strings (past a certain length), and references, the value part points to a shared zend_refcounted header structure, which among other things contains the refcount field. On an assignment like $b = $a, the actual value is not copied, only the refcount of the underlying structure is incremented, and both variables then point to the same memory region. Arrays additionally use copy-on-write: only an actual mutation of $b triggers a real copy, as long as the refcount is greater than one. Objects behave differently than arrays, PHP always works with handles here, every variable that "contains" an object holds a pointer to the same zend_object, an assignment always only increments the refcount, the object itself is never copied.

When a variable is removed with unset() or leaves a scope, the engine decrements the refcount of the referenced structure. Once it reaches zero, the memory is freed immediately, for objects via the free_obj handler including a call to the destructor, then returned to the Zend Memory Manager via efree(). The following example makes these refcount changes visible with debug_zval_refcount():


<?php
declare(strict_types=1);

class Payload
{
    public function __construct(public array $data) {}
}

$a = new Payload(['id' => 1]);
// Refcount of the zend_object is 1 (only $a points to it)
echo debug_zval_refcount($a), PHP_EOL; // 1

$b = $a; // No copy: refcount of the shared zend_object is incremented
echo debug_zval_refcount($a), PHP_EOL; // 2

unset($b); // Refcount decremented back to 1, object stays alive via $a
echo debug_zval_refcount($a), PHP_EOL; // 1

unset($a); // Refcount reaches 0: destructor runs, memory returned via efree()

Reference Counting alone handles the complete memory management for the vast majority of PHP objects: most object graphs in typical application code are trees, or short-lived, function-scoped values without cycles, the refcount deterministically reaches zero as soon as the last reference disappears, and the memory is freed immediately, with no deferred collection pass at all. This is exactly why the actual Garbage Collection in PHP is designed as a fallback mechanism rather than the primary release path, it only kicks in for the special case where pure refcounting structurally fails.

3. Circular References: the Core Problem of Pure Refcounting

The fundamental weakness of pure reference counting becomes apparent as soon as two or more objects reference each other. If object A holds a reference to object B, and B simultaneously holds a reference back to A, then both objects retain a refcount of at least one even once no external variable points to them anymore: each keeps the other artificially alive. Pure refcounting cannot detect this state, because it decides locally per object and has no global view of reachability from the outside.

Such cycles are not an exception in practice, but a recurring pattern: doubly linked lists, parent-child tree structures with a back-reference to the parent, or event listeners as closures that capture $this, while the subject itself holds the list of its listeners. The following example demonstrates the problem concretely:


<?php
declare(strict_types=1);

class Node
{
    public ?Node $parent = null;
    public ?Node $child = null;
}

function createLeakingCycle(): void
{
    $parent = new Node();
    $child = new Node();

    // Circular reference: parent points to child, child points back to parent
    $parent->child = $child;
    $child->parent = $parent;

    // Both objects still reference each other here, so unset() alone
    // never brings either refcount down to zero via reference counting.
}

// Each call leaves an unreachable but non-freed cycle behind
for ($i = 0; $i < 100_000; $i++) {
    createLeakingCycle();
}

echo memory_get_usage(), PHP_EOL; // grows steadily without the Garbage Collection cycle collector

Without an additional mechanism, this code would continuously consume memory in a long-running process, even though both Node objects are completely unreachable from the outside after every function call. This exact scenario, linked structures and closures with mutual references, was the central trigger for PHP introducing a real Cycle Collector as of version 5.3, as a complement to pure reference-counting memory management.

4. The Zend Garbage Collector (Cycle Collector)

The Zend Garbage Collector works with what is called a root buffer: whenever the refcount of a potential cycle candidate (array, object, or reference) is decremented without dropping to zero, the engine adds this candidate to the root buffer. This buffer has a fixed default size of 10,000 entries (GC_ROOT_BUFFER_MAX_ENTRIES). Once the buffer is full, the engine automatically triggers a full collection pass, this is the concrete trigger for when automatic Garbage Collection actually kicks in, independent of how much memory the process has consumed overall.

The algorithm itself is based on synchronized cycle detection according to Bacon and Rajan, and runs in three phases. In the mark-gray phase, the collector tentatively decrements the refcounts of all references within the subgraph under inspection, to simulate what would happen if all external references were removed. In the scan phase, it then distinguishes between nodes that still have a positive refcount from outside the subgraph (marked "black", meaning genuinely reachable), and nodes whose refcount has dropped to zero (marked "white", meaning kept alive only by the cycle itself). In the final collect phase, all white nodes are freed together as a single unit.

Important for understanding the performance characteristics: this pass is synchronous and blocking, it is a stop-the-world operation within the current request or process, whose duration is proportional to the size of the traversed object graph. This is what clearly distinguishes PHP's Garbage Collection from generational or incremental collectors, as known from languages with a dedicated runtime system, and is the reason why a large root buffer can cause noticeable latency spikes in memory-intensive applications.

5. gc_collect_cycles(), gc_enable(), and zend.enable_gc

PHP provides three central controls for manually steering Garbage Collection. gc_collect_cycles() immediately forces a full collection pass over the current root buffer, regardless of whether it is already full, and returns the number of collected cycles. gc_enable() and gc_disable() control whether new candidates are added to the root buffer at all. Important: gc_disable() does not disable reference counting itself, only the additional bookkeeping for cycle detection, which incurs a small but measurable overhead on every refcount decrement. The zend.enable_gc ini directive defines the initial state a process starts up with.

Manual control becomes relevant above all in long-running CLI workers, for example queue consumers processing thousands of jobs without the classic request boundary that would reset the entire heap. In latency-sensitive hot loops, it can make sense to explicitly disable automatic Garbage Collection and instead explicitly call gc_collect_cycles() at defined checkpoints, for instance between two jobs rather than in the middle of one. This way, you determine yourself when the stop-the-world pause occurs, instead of suffering it unpredictably in the middle of a time-critical operation.


; php.ini: Garbage Collection tuning for long-running worker processes

; Enable the cycle collector at process startup (default: On)
zend.enable_gc = On

; Memory ceiling per process / worker (see section 8 for sizing guidance)
memory_limit = 256M

; Recommended for long-lived CLI workers (Swoole, RoadRunner, FrankenPHP):
; keep zend.enable_gc = On globally, but call gc_disable() / gc_collect_cycles()
; explicitly in the worker loop for deterministic pause placement.

6. WeakMap and WeakReference: Avoiding Cycles From the Start

WeakReference::create($object) has, since PHP 7.4, created a reference that does not increase the refcount of the target object and does not affect its lifetime. Calling get() returns the object as long as it still exists, and null once it has already been destructed elsewhere. WeakMap, introduced in PHP 8.0, goes one step further and associates arbitrary data with objects as keys, without keeping those objects alive. Once the key is destructed, PHP automatically removes the corresponding entry from the WeakMap. The decisive advantage over classic Garbage Collection via the Cycle Collector: no strong circular reference is created in the first place that would even need to be detected and resolved, the problem is structurally avoided rather than fixed after the fact.

A typical practical case is an event listener registry in the observer pattern: a subject holds a list of its listeners, while listeners as closures often capture $this of the subject, a classic cycle candidate. With a WeakMap as the storage structure for the listener association, the registry no longer artificially keeps the subject alive, the entry disappears automatically once the subject is no longer referenced anywhere else, without the Cycle Collector ever having to intervene:


<?php
declare(strict_types=1);

final class EventListenerRegistry
{
    /** @var WeakMap<object, array<callable>> */
    private WeakMap $listeners;

    public function __construct()
    {
        // Keys are held weakly: registering a subject here does not
        // increment its refcount and does not prevent it from being freed.
        $this->listeners = new WeakMap();
    }

    public function register(object $subject, callable $listener): void
    {
        $this->listeners[$subject] ??= [];
        $this->listeners[$subject][] = $listener;
    }

    public function notify(object $subject, mixed $payload): void
    {
        foreach ($this->listeners[$subject] ?? [] as $listener) {
            $listener($payload);
        }
    }
}

$registry = new EventListenerRegistry();
$subject = new class { public string $name = 'order.created'; };

// The closure captures $subject, and the registry stores it too,
// but as a WeakMap key, so no reference cycle is ever created.
$registry->register($subject, function (mixed $payload) use ($subject): void {
    echo "Handled {$subject->name}: {$payload}" . PHP_EOL;
});

unset($subject); // Freed immediately via reference counting, no cycle to collect

7. Memory Leaks in Long-Running Processes: Worker Queues, Swoole, RoadRunner, FrankenPHP

Classic PHP under PHP-FPM rests on an implicit assumption: at the end of every request, the entire heap is discarded in one step, regardless of whether every variable within the request was cleanly freed. Even undetected circular references or data accumulated in global structures "heal themselves" on every new request. This assumption no longer holds under Swoole, RoadRunner, and FrankenPHP in worker mode: the PHP process, or worker, lives across thousands or tens of thousands of requests, every class construction happens only once at worker startup, not per request anymore. Anything that is not explicitly freed, or that is not collectible due to a cycle, accumulates over the entire lifetime of the process.

Concrete leak vectors in this context are static properties and singletons that accumulate data across requests, global caches with unboundedly growing arrays, unclosed resources such as database connections or file handles in dependency containers that are not reset between requests, as well as event dispatchers where listeners are registered but never removed again. Because constructors in worker processes run only once, application code must explicitly ensure that state is reset between requests, otherwise memory consumption creeps up over hours or days until the process needs to be restarted, a behavior that simply does not exist in classic request-based PHP.

8. Sizing memory_limit and Monitoring With memory_get_usage() and gc_status()

The memory_limit ini directive is a hard ceiling per process or worker, meant to protect against uncontrollably growing scripts, but it is not identical to actual memory consumption at the operating system level. A sensible sizing is based on peak values actually measured under production-like load, obtained via memory_get_peak_usage(true), where the true argument returns the memory actually requested from the operating system by the Zend Memory Manager, including internal fragmentation, not just the bytes occupied by PHP values. A safety margin should be added on top of this value, and for worker-based SAPIs the process-wide RSS value should additionally be monitored over time, since memory_limit is not automatically reset per request there.

For diagnosing actual Garbage Collection, gc_status() returns an associative array with fields such as runs, collected, threshold, and above all roots, the current number of entries in the root buffer. If this value grows continuously without dropping back down through automatic or manual collection passes, that indicates either that cycles are being created faster than they can be collected, or that gc_disable() was accidentally left active:


<?php
declare(strict_types=1);

/**
 * Prints a compact memory / GC snapshot, useful as a checkpoint
 * inside a long-running worker loop (Swoole, RoadRunner, FrankenPHP).
 */
function memorySnapshot(string $label): void
{
    $status = gc_status();

    printf(
        "[%s] used=%.2fMB peak=%.2fMB gc_runs=%d collected=%d roots=%d%s",
        $label,
        memory_get_usage(true) / 1_048_576,
        memory_get_peak_usage(true) / 1_048_576,
        $status['runs'],
        $status['collected'],
        $status['roots'],
        PHP_EOL
    );
}

memorySnapshot('startup');

for ($job = 0; $job < 1000; $job++) {
    processJob($job); // application-specific work happens here

    if ($job % 100 === 0) {
        memorySnapshot("after job {$job}");
    }
}

// Force a collection pass at a safe checkpoint and compare roots before/after
$collected = gc_collect_cycles();
memorySnapshot("after gc_collect_cycles ({$collected} cycles freed)");

9. Practical Debugging: Systematically Narrowing Down a Memory Leak

Systematically narrowing down a memory leak starts with regular snapshots at fixed checkpoints, for example after every hundredth processed job, consisting of memory_get_usage(true) and gc_status()['roots']. Plotting these values over time reveals a characteristic pattern: a sawtooth or stable curve, where memory consumption periodically drops back down, indicates healthy behavior, Garbage Collection is reliably cleaning up cycles. A curve that keeps rising monotonically even after a forced gc_collect_cycles(), on the other hand, is a strong indicator of a real leak: what you're dealing with then is not unresolved cycles, but references that are genuinely never released, for example because a global cache or a static property is growing without bound.

To further narrow down suspect object graphs, Reflection helps: since PHP offers no built-in function to list all currently live object instances without an additional extension, you specifically inspect static properties and global registries via ReflectionClass::getStaticProperties(), to check which objects accumulate there over time. The return value of gc_collect_cycles() also serves as an indirect signal: a sudden jump in the number of collected cycles at a particular point in the code points to the responsible code path. A bisection strategy has also proven effective: selectively disable parts of the request or job handling and re-measure the memory curve, in order to narrow the leak down to a specific subsystem.

For deeper analysis, specialized tools such as Xdebug's profiling or Blackfire make full object graphs and allocation paths visible. In production worker processes, it has also proven useful to expose an internal debug endpoint that returns gc_status() and memory_get_usage() on request, to catch regressions right after a deployment, before memory consumption reaches critical thresholds.

Reference Counting, Cycle Collector, and WeakMap Compared

The three memory management mechanisms in PHP solve different sub-problems and differ significantly in overhead and the point at which they intervene. The following table compares them:

Mechanism Resolves Circular References Performance Overhead When It Kicks In
Reference Counting No Very low, per assignment/unset Immediately, on every refcount change to 0
Cycle Collector (Garbage Collection) Yes, after the fact Noticeable for large graphs (stop-the-world) Automatically when root buffer is full, or manually via gc_collect_cycles()
WeakMap / WeakReference Yes, preventively Minimal, no refcount increment on the key From the start, no collection needed

In practice, the three mechanisms complement each other: Reference Counting remains the primary release path for the vast majority of values, the Cycle Collector catches the remaining cycles, and WeakMap/WeakReference avoid cycles already at design time, wherever they are architecturally predictable, for example in registries, caches, and observer patterns.

10. Summary

Garbage Collection in PHP is not a single mechanism, but an interplay of three layers: the Zend Memory Manager manages heap segments below emalloc/efree and releases them in a batch at request end in classic SAPIs. Reference Counting via the zval structure handles the actual memory management for the vast majority of values immediately and deterministically. The Cycle Collector, as Garbage Collection in the narrower sense, only steps in where refcounting structurally fails, with circular references, recognizable by a growing root buffer and controllable via gc_collect_cycles(), gc_enable()/gc_disable(), and zend.enable_gc.

WeakMap and WeakReference solve the problem preventively, by never letting cycles arise in the first place, ideal for registries, caches, and observer patterns. In long-running worker processes under Swoole, RoadRunner, or FrankenPHP, the classic assumption that "memory is fully freed at the end of a request" no longer holds, here monitoring via memory_get_usage(), memory_get_peak_usage(), and gc_status(), along with deliberate, checkpoint-based control of Garbage Collection, is not optional polish but a prerequisite for stable, sustained operation.

Garbage Collection in PHP: The Essentials at a Glance

Reference Counting

zval-based, decrements immediately on unset() or scope end. Handles memory management for most values without Garbage Collection.

Cycle Collector

Root buffer with 10,000 slots, synchronized cycle detection after Bacon & Rajan. Kicks in automatically when the buffer is full, or via gc_collect_cycles().

WeakMap & WeakReference

Structurally avoid circular references, ideal for event registries and observer patterns without a memory leak.

Monitoring

memory_get_usage(true), memory_get_peak_usage(true), and gc_status()['roots'] as checkpoints in long-running workers.

11. FAQ: Garbage Collection in PHP

1Reference Counting vs. Garbage Collection?
Reference Counting counts references and frees immediately once the counter hits 0. Garbage Collection in the PHP sense is the Cycle Collector, which only resolves cycles.
2When does the Garbage Collector kick in automatically?
As soon as the root buffer reaches its default size of 10,000 candidates, the engine automatically triggers a pass.
3What does gc_collect_cycles() do?
Forces an immediate full collection pass and returns the number of freed cycles. Useful for checkpoints in workers.
4When to use gc_disable()?
In latency-sensitive hot loops, to place stop-the-world pauses yourself via gc_collect_cycles() at safe checkpoints.
5WeakMap vs. WeakReference?
WeakReference holds a weak reference to a single object. WeakMap associates data with multiple objects as keys and removes entries automatically.
6Does WeakMap solve all leak problems?
No, only circular references at the point of use. Growing caches or static properties are not solved by WeakMap.
7Why are Swoole/RoadRunner workers different?
PHP-FPM discards the heap per request. Worker runtimes keep the process alive across thousands of requests, state that is not reset accumulates.
8Sizing memory_limit correctly?
Based on memory_get_peak_usage(true) under real load plus a safety margin, for workers also monitor RSS.
9What does gc_status()['roots'] tell me?
Current number of candidates in the root buffer. Continuous growth despite gc_collect_cycles() points to a real leak.
10Does the Cycle Collector manage int/string too?
No, only arrays, objects, and references, since only these types can form circular structures.

Mironsoft

PHP performance, memory diagnostics, and long-running worker processes

Need to reliably track down memory leaks in PHP workers?

We analyze long-running PHP processes for circular references, uncontrolled root buffers, and growing caches, and bring Garbage Collection, memory_limit, and monitoring into a solid balance for your production operations.

Memory Analysis

Integrating gc_status() and memory_get_usage() monitoring into existing workers

Refactoring

Structurally resolving circular references with WeakMap and WeakReference

Worker Tuning

memory_limit, zend.enable_gc, and checkpoint strategies for Swoole, RoadRunner, FrankenPHP