0

Memory management — zval refcount, garbage collector, circular refs

Expert5 min read·eng-09-003
interviewperformance

Concept

Every PHP value is internally represented as a zval — a tagged union containing the value and a type tag. Since PHP 7, scalar zvals (integers, floats, booleans, null) are stored directly inside the zval struct, with no heap allocation. Strings, arrays, and objects live on the heap and are reference-counted via a shared zend_refcounted header. The engine increments the refcount when a value is assigned or passed, and decrements it when the variable goes out of scope. When the refcount reaches zero, the value is freed immediately — no garbage collector needed for the common case.

Copy-on-write (CoW) is how PHP avoids copying large arrays on every assignment. When you write $b = $a where $a is an array, PHP does not copy the array's bucket table. Instead it increments the refcount and marks both $a and $b as sharing the same zend_array. Only when you modify $b (a write operation) does PHP perform the actual copy — a "copy on first write." This makes passing large arrays to read-only functions essentially free, which is counterintuitive if you come from a C background.

The dedicated garbage collector (gc_collect_cycles) only exists to handle cyclic references — the case where object A holds a reference to object B which holds a reference back to A. Since both refcounts are always > 0, they would never be freed by the refcounting alone. PHP's GC uses a purple-colouring tri-colour mark-sweep algorithm on a dedicated root buffer. When the root buffer fills up (10,000 possible-cycle roots by default), or when you call gc_collect_cycles(), the GC scans the graph and collects unreachable cycles.

Code Example

php
<?php
declare(strict_types=1);

// --- CoW demonstration ---
$a = range(1, 100_000); // one heap allocation, refcount=1

$b = $a;                 // refcount becomes 2, NO copy yet
// xdebug_debug_zval('a') would show refcount=2, is_ref=false

$b[] = 99;               // NOW the copy happens — $b gets its own zend_array
// $a is unchanged, refcount of original drops back to 1

// --- Circular reference memory leak without GC ---
class Node {
    public ?Node $next = null;
}

$first = new Node();   // refcount($first) = 1
$second = new Node();  // refcount($second) = 1
$first->next = $second; // refcount($second) = 2
$second->next = $first; // refcount($first) = 2

unset($first);  // refcount($first's zval) drops to 1, NOT freed
unset($second); // refcount($second's zval) drops to 1, NOT freed
// Both objects are now unreachable but refcount > 0
// They sit in the GC root buffer until gc_collect_cycles() runs

$freed = gc_collect_cycles(); // returns number of freed objects (2)

// --- When NOT to worry ---
// Simple object hierarchies without back-references need no GC intervention.
// The GC is only triggered by cycles, and most application code does not
// create cycles intentionally.

Interview Q&A

Q: Explain exactly when PHP's reference counting is not enough and why the cycle collector exists. What is the performance cost of having it enabled?

Refcounting alone cannot collect cyclic data structures because when two objects reference each other, their refcounts never reach zero even when no application code can reach them. Consider a doubly-linked list or any parent-child relationship where the child holds a reference back to the parent — both survive indefinitely in memory until the request ends, or until gc_collect_cycles is called. The cycle collector is separate from refcounting: it maintains a root buffer of zend_refcounted values whose refcount dropped but did not reach zero (a heuristic for "might be in a cycle"). When the buffer hits 10,000 entries, it runs a mark-sweep over the graph. The cost is a pause proportional to the number of suspected-cycle roots. For most web request workloads, cycles are rare and the GC rarely fires, so the overhead is negligible. In tight loops that create many short-lived objects, it can spike. You can disable it with gc_disable() for CPU-critical sections where you know you are not creating cycles, then re-enable afterward.


Q: Describe a real scenario where CoW does NOT save you and you get an unexpected copy.

CoW breaks when you pass an array to a function that takes it by reference, or when the array is already a reference ($ref = &$a). If $a is a reference, it cannot be shared via CoW because a write to $b (the other reference) must propagate back — so PHP copies immediately on assignment to $b rather than deferring. Another gotcha: in PHP 8.1+, readonly properties trigger a copy when the class is cloned because they cannot be mutated after construction. The most common production surprise is passing a large array to a foreach with a reference in the body (foreach ($items as &$item)) — this breaks CoW for the entire array for the duration of the loop and you must unset($item) after the loop to break the reference. Failing to do so means the last element of $items is aliased to $item, which causes subtle mutation bugs when the variable is reused.


Q: How would you hunt down a memory leak in a long-running PHP process (like a queue worker)?

A queue worker runs for thousands of requests in the same PHP process, so refcounting bugs and cycles accumulate. I would start by logging memory_get_usage(true) at the start and end of each job. If memory grows monotonically, it is leaking. The next step is to isolate which class or data structure is growing using a memory profiling tool like Blackfire or by dumping gc_status()['roots'] between jobs to see if the cycle buffer is filling up. If cycles are the cause, gc_collect_cycles() at the end of each job may be a band-aid, but the real fix is breaking the cycles — for example by using weak references (WeakReference::create($parent)) for back-pointers in parent-child object trees. Eloquent's event listeners are a well-known source of leaks in long-running processes because the model's static $dispatcher holds closures that close over model instances, preventing GC. Laravel Horizon's workers call $this->resetScope() between jobs partly for this reason.