PHP Memory Model: zvals, References, and Garbage Collection

Build an accurate mental model of how PHP actually stores and manages values in memory: the zval container, the difference between a normal copy and a true reference, reference counting, copy-on-write for arrays and strings, how objects behave by handle, why reference cycles need a separate garbage collector, and the practical tools (memory_get_usage(), memory_get_peak_usage(), gc_collect_cycles()) for measuring and fixing memory problems.

PHP hides memory management almost entirely from day-to-day code -- you rarely allocate or free anything explicitly, and most PHP developers can write years of working code without ever thinking about how $b = $a; is actually implemented under the hood. But 'hidden' isn't the same as 'nonexistent', and the underlying mechanics -- reference counting, copy-on-write, cycle collection -- explain a lot of behavior that otherwise looks like magic or, worse, like a bug: why assigning an array to a new variable is cheap even for a huge array, why two object variables can silently share the same underlying object, and why a long-running worker process can leak memory even though nothing looks obviously wrong in the code. This lesson builds that model from the ground up, with real, runnable code demonstrating reference counting directly and, critically, a genuine cyclic-reference memory leak and its fix.

The zval: PHP's Universal Value Container

Every PHP value -- an int, a float, a bool, null, a string, an array, an object reference -- is represented internally by a zval ('Zend value'). A zval is a small structure with a type tag plus a value slot. For simple scalar types (int, float, bool, null), the value is typically stored directly inside the zval itself -- no separate allocation, no reference counting needed, because copying a plain integer is already as cheap as copying could possibly be.

For the 'complex' types -- strings, arrays, objects, resources -- the zval's value slot instead holds a pointer to a separately allocated structure (a zend_refcounted header, followed by the actual data: the string's bytes, the array's hash table, the object's properties). That separate structure is what carries a reference count. This split is the whole basis for the rest of this lesson: scalars are copied by value trivially and cheaply; refcounted types are shared by reference-counted pointer until something forces an actual duplication.

This is why is_int($x) && $x === $x style scalar comparisons and copies never show up as a memory concern in profiling, while large arrays and long-lived objects are exactly where reference counting, copy-on-write, and garbage collection actually matter -- and exactly where they're worth understanding in detail.

• zval: universal container -- type tag + either an inline scalar value or a pointer to refcounted data. • Scalars (int, float, bool, null) live directly inside the zval -- copying them needs no reference counting. • Strings, arrays, objects, resources are refcounted -- the zval holds a pointer to a shared, counted structure. • This split is the foundation for copy-on-write and reference counting, both covered next.

Values vs True References: = vs =&

$b = $a; for a refcounted type (say, an array) does NOT copy the array's data. It makes $b's symbol table slot point at the exact same underlying refcounted structure $a points at, and increments that structure's reference count. $a and $b are, at this instant, two independent variables that happen to currently share the same underlying data -- mutating one does not, by itself, affect the other, because of copy-on-write (covered next).

$b =& $a; is fundamentally different: it makes $a and $b into true aliases of the same variable, marked internally as is_ref. After this, they are not 'two variables sharing equal data' -- they are two names for the *same* variable slot. Mutating through either name is visible through the other, immediately, with no copy ever happening, because there was never a separate copy to make in the first place.

This distinction trips people up because both $b = $a; (for a moment, before any mutation) and $b =& $a; can look identical in a var_dump() -- both show the same value. The difference only becomes observable the moment you mutate one of them: with =, the other is unaffected (COW kicks in); with =&, the other changes too, because there was only ever one underlying variable to begin with.

• $b = $a; -- $b gets its own symbol table entry pointing at the SAME shared data (refcount++), not a copy yet. • $b =& $a; -- $a and $b become true aliases of one variable (is_ref); no separate data ever exists for either. • Mutating after = triggers copy-on-write, leaving the other variable untouched. • Mutating after =& is visible through both names immediately -- there is nothing to separate.

Reference Counting and Copy-on-Write (COW)

Reference counting is how PHP knows when it's safe to free a refcounted structure: every zend_refcounted header carries a count of how many zvals currently point at it. Assigning $b = $a; for an array increments that count from 1 to 2 -- no array data is duplicated, just a pointer copy plus an increment, which is O(1) regardless of how large the array is. When a variable holding that structure goes out of scope or is unset(), the count decrements; when it reaches zero, the structure is freed immediately.

Copy-on-write is what makes sharing safe without true references leaking mutations between variables: the moment code attempts to *mutate* a refcounted structure whose count is greater than 1 (meaning something else is also pointing at it), PHP first duplicates ('separates') the underlying data into a fresh structure with its own count of 1, decrements the original's count, and only then applies the mutation -- to the new, now-exclusive copy. $a never sees the change, because by the time the mutation happens, $b is pointing at genuinely separate data.

The practical payoff: passing a huge array into a function that never modifies it, or assigning it to several variables that are all just read, costs essentially nothing extra in memory or time -- no duplication happens until (and unless) an actual write occurs on a shared structure. This is also exactly why premature 'optimize with references to avoid copies' is usually pointless or actively harmful: COW already avoids the copy for pure reads; forcing a real reference instead removes the safety of independent variables for no performance benefit in the common case.

• Refcount tracks how many zvals point at one shared structure; hits zero -> freed immediately. • $b = $a; for arrays/strings is O(1): pointer copy + refcount increment, no data duplication. • COW: a write to shared (refcount > 1) data first duplicates it, then mutates the new copy -- the original is untouched. • Read-only sharing (passing arrays to functions, assigning without mutating) is essentially free thanks to COW.

Arrays as Hash Tables, and Objects by Handle

A PHP array is, internally, a HashTable -- an ordered hash map supporting both integer and string keys simultaneously, maintaining insertion order via an internal doubly linked structure (which is why foreach always visits keys in the order they were inserted, regardless of key type). For the common case of purely sequential integer keys starting at 0, the engine can use a more compact 'packed' representation that skips full hashing overhead; the moment a non-sequential key, a string key, or a gap is introduced, the array converts to the full hashed representation.

Objects behave completely differently from arrays with respect to =. A PHP object variable's zval doesn't hold the object's data directly -- it holds an object *handle*, a reference to an entry in a global object store where the actual zend_object (properties and all) lives. $b = $a; for an object copies the handle and increments its refcount, meaning $a and $b now both point at the exact same object -- mutating a property through $b is visible through $a too, with no copy-on-write involved at all, because objects are always shared by handle, never duplicated implicitly.

Getting an independent copy of an object requires explicitly calling clone $a, which creates a genuinely new zend_object with copied property values (and lets a class customize the process via a __clone() magic method for deep-copy needs). This is the single most common source of 'I changed a copy but the original changed too' confusion for developers coming from languages where object assignment behaves like value assignment.

• Array: a HashTable -- ordered, supports int/string keys together, 'packed' fast-path for sequential int keys. • Object variable's zval holds a handle into a global object store, not the object's data directly. • $b = $a; for objects always shares the same underlying object (handle + refcount) -- no COW, no implicit copy. • clone $a is required for an independent object copy; __clone() customizes what happens during that copy.

Garbage Collection: Why Cyclic References Need Help

Plain reference counting has one structural blind spot: a cycle. If object A holds a reference to object B, and object B holds a reference back to object A (a doubly linked list node's ->next/->prev, a parent/child pair that both point at each other, an event emitter that a listener closure captures which itself references the emitter), then even after every *external* variable pointing at A or B is unset, A and B still reference each other -- their reference counts never reach zero through pure counting, because they're each other's last remaining referrer. Without anything else, that memory would simply never be freed for the lifetime of the process.

PHP's garbage collector exists specifically to close this gap. It's a separate cycle-collecting algorithm (based on synchronous, trial-deletion-style cycle detection) that runs periodically (roughly: when a 'root buffer' of candidate structures -- ones whose refcount decreased but didn't hit zero -- fills up, or when explicitly triggered) and looks for groups of refcounted structures that are only reachable from within the group itself, meaning nothing outside the cycle can ever reach them again. When it finds such a group, it frees the whole group together, since none of them are truly reachable from live code anymore.

This is why a long-running PHP process (a queue worker, a daemon, a CLI script processing millions of rows) building and discarding many linked/cyclic structures over its lifetime can show real, measurable memory growth if the GC's periodic sweeps aren't keeping up, even though every individual object was, in isolation, correctly unset(). Understanding this is the difference between correctly diagnosing 'we have a reference cycle the GC hasn't collected yet' versus incorrectly concluding 'PHP has a memory leak bug'.

• A reference cycle (A -> B -> A) never reaches refcount zero through pure reference counting alone. • PHP's GC is a separate cycle-collector, run periodically, that detects and frees groups only reachable from within themselves. • It runs based on a 'root buffer' of candidate structures filling up, or when explicitly triggered. • Cyclic structures are a common, legitimate cause of memory growth in long-running PHP processes -- not a bug in refcounting itself.

Measuring Memory in Practice

memory_get_usage(real_usage: false) returns the amount of memory currently allocated to your script by the Zend memory manager (by default, only memory actually used by live zvals/structures, not the full chunk of memory the OS handed the process, which real_usage: true reports instead). Calling it before and after a suspect block of code is the most direct way to see whether that code is actually growing memory usage, versus just producing output that looks memory-heavy.

memory_get_peak_usage() reports the single highest memory-usage value seen so far during the script's execution, which matters because usage can spike temporarily (building a huge intermediate array before filtering it down) even if the final, steady-state usage is modest -- a process that OOMs at its peak doesn't care that its average usage looked fine.

gc_collect_cycles() forces an immediate cycle-collection pass instead of waiting for PHP's normal periodic trigger, and returns the number of cycles it collected. It's the right tool for exactly two situations: diagnosing whether a suspected leak is actually an uncollected cycle (call it and see if memory drops), and, in specific long-running-process scenarios, proactively running a collection pass at a natural checkpoint (say, between processing batches in a worker) rather than trusting the automatic trigger's timing. gc_enabled() and gc_enable()/gc_disable() round out the toolkit for inspecting and controlling whether the collector runs automatically at all.

• memory_get_usage() -- current allocation; real_usage: true includes memory manager overhead/OS-level chunks. • memory_get_peak_usage() -- the highest usage seen so far, catching transient spikes a final reading would miss. • gc_collect_cycles() -- forces an immediate cycle-collection pass; returns how many cycles it actually freed. • gc_enabled()/gc_enable()/gc_disable() -- inspect and control whether the collector runs automatically.

Assignment vs Reference vs Clone, at a Glance

Only the write to $b (after =) triggers copy-on-write; the assignment itself never copies array data. =& means there was never a second array to separate from in the first place -- both names are the same variable. Objects need clone, not =, to get an independent copy -- = always shares the same object by handle.

$a = [1, 2, 3];
$b = $a;          // shares data (refcount 2) until a write forces separation (COW)
$b[] = 4;          // write triggers COW: $b now has its OWN array, $a is untouched
var_dump($a);      // [1, 2, 3]
var_dump($b);      // [1, 2, 3, 4]

$c = [1, 2, 3];
$d =& $c;          // true alias -- same variable, two names, no separate data ever
$d[] = 4;
var_dump($c);      // [1, 2, 3, 4]  <-- $c changed too, because $c and $d are the SAME variable

class Box { public function __construct(public array $items = []) {} }
$e = new Box();
$f = $e;           // objects always share the same underlying object (handle + refcount)
$f->items[] = 'x';
var_dump($e->items); // ['x']  <-- $e sees it too; use clone $e for an independent object

Code Examples

debug_zval_dump() is a built-in core function (no extension required) that prints a value's type, contents, and its current reference count. Assigning $b = $a increases the count because both variables point at the same underlying array data; unset($b) decreases it again, all without any array data ever being copied. Expected result: just $a: array(3) refcount(2){...} after $b = $a (both refer to same data): array(3) refcount(3){...} after unset($b): array(3) refcount(2){...} The exact numbers include an extra count from the function call's own argument passing, which is why they read as 2/3/2 rather than 1/2/1 -- the pattern of it going up on sharing and down on unset() is what matters here, not the precise absolute figures.

The assignment itself barely moves memory usage at all -- $copy and $big share the same 500,000-element array. Only the mutation on the next line causes a real jump in memory usage, because that's the moment PHP actually duplicates the array's storage before applying the change, leaving $big completely unaffected. Expected result: After plain assignment: +48 bytes After first mutation: +16799792 bytes $big[0] is still: 1 $copy[0] is now: changed

$copy[0] = 'changed'; // THIS write triggers copy-on-write Each call to makeCyclicPair() creates two Node objects that reference each other and then goes out of scope -- with GC disabled, none of those 2,000 pairs are ever freed, because their mutual references keep both refcounts above zero forever. gc_collect_cycles() is the fix: it explicitly scans for and frees exactly these unreachable cycles, which is visible as a real, measurable drop in memory_get_usage() immediately afterward. Expected result: Before: 412136 bytes After leaking: 168732136 bytes (grew by 168320000) Cycles freed by gc_collect_cycles(): 4000 After collect: 972136 bytes (dropped by 167760000) In normal operation (GC left enabled, the default), PHP would have triggered these same collection passes automatically as its internal root buffer filled up during the loop -- gc_disable() here exists purely to make the leak's full size visible in one measurement instead of it being cleaned up incrementally in the background.

$collected = gc_collect_cycles(); // force a cycle-collection pass

Common mistake: Assuming unset($a); unset($b); immediately frees a cyclic structure the way it would a non-cyclic one -- with a genuine reference cycle, that memory is only reclaimed once the cycle collector actually runs, not the instant the last external variable is unset.

Processing in explicit, unset()-ed batches keeps peak memory bounded to roughly one batch's worth of data at a time, instead of ever holding all 1,000,000 rows in memory simultaneously. memory_get_peak_usage() is what actually proves this -- a final memory_get_usage() reading alone wouldn't catch a transient spike if one had occurred. Expected result: Peak usage before: 412136 bytes Peak usage after: 838216 bytes Peak grew by only: 426080 bytes (bounded by ONE batch, not the full 1,000,000 rows) $endPeak = memory_get_peak_usage();

<?php
declare(strict_types=1);

function showRefcount(string $label, array $value): void
{
    echo "$label: ";
    debug_zval_dump($value);
}

$a = [1, 2, 3];
showRefcount('just $a', $a);

$b = $a; // refcount goes up -- no data duplicated yet
showRefcount('after $b = $a (both refer to same data)', $a);

unset($b); // refcount goes back down
showRefcount('after unset($b)', $a);
<?php
declare(strict_types=1);

$big = range(1, 500_000); // a genuinely large array

$before = memory_get_usage();
$copy = $big; // O(1) -- pointer + refcount increment, no duplication yet
$afterAssign = memory_get_usage();

$copy[0] = 'changed'; // THIS write triggers copy-on-write: $copy now has its own array
$afterMutate = memory_get_usage();

printf("After plain assignment: +%d bytes\n", $afterAssign - $before);
printf("After first mutation:   +%d bytes\n", $afterMutate - $afterAssign);
printf("\$big[0] is still: %s\n", $big[0]);
printf("\$copy[0] is now:  %s\n", $copy[0]);
<?php
declare(strict_types=1);

final class Node
{
    public ?Node $next = null;
    public ?Node $prev = null;
    public string $payload;

    public function __construct(string $payload)
    {
        // Waste some memory per node so the leak is measurable.
        $this->payload = str_repeat($payload, 10_000);
    }
}

function makeCyclicPair(): void
{
    $a = new Node('a');
    $b = new Node('b');

    $a->next = $b; // a -> b
    $b->prev = $a; // b -> a  (cycle: neither refcount can reach zero via counting alone)

    // $a and $b go out of scope when this function returns, but the cycle
    // means their refcounts never hit zero through plain reference counting.
}

gc_disable(); // disable automatic cycle collection so the leak is fully visible

$before = memory_get_usage();
for ($i = 0; $i < 2_000; $i++) {
    makeCyclicPair();
}
$afterLeak = memory_get_usage();

$collected = gc_collect_cycles(); // force a cycle-collection pass
$afterCollect = memory_get_usage();

printf("Before:        %d bytes\n", $before);
printf("After leaking: %d bytes (grew by %d)\n", $afterLeak, $afterLeak - $before);
printf("Cycles freed by gc_collect_cycles(): %d\n", $collected);
printf("After
…
<?php
declare(strict_types=1);

function processInBatches(int $total, int $batchSize): void
{
    for ($offset = 0; $offset < $total; $offset += $batchSize) {
        $batch = range($offset, min($offset + $batchSize, $total) - 1);
        // ... process $batch ...
        unset($batch); // release this batch before the next one is built
    }
}

$startPeak = memory_get_peak_usage();
processInBatches(total: 1_000_000, batchSize: 10_000);
$endPeak = memory_get_peak_usage();

printf("Peak usage before: %d bytes\n", $startPeak);
printf("Peak usage after:  %d bytes\n", $endPeak);
printf("Peak grew by only: %d bytes (bounded by ONE batch, not the full 1,000,000 rows)\n", $endPeak - $startPeak);

From a Variable to a Freed Cycle

Ordinary (non-cyclic) data is freed the instant its refcount hits zero; cyclic data needs the separate GC pass at the bottom of this chain.

Variable ($a)
    |
    v
   zval          (type tag + inline scalar OR pointer to refcounted data)
    |
    v
Value/Reference   ($b = $a shares data; $b =& $a makes $a/$b the SAME variable)
    |
    v
Reference Count   (shared data's refcount++ on share, refcount-- on unset/scope end)
    |
    v
Copy-on-Write     (a WRITE to refcount > 1 data duplicates it first, then mutates)
    |
    v
Garbage Collection (cycles that never reach refcount 0 are found and freed by the GC)

Important Rules

$b = $a; for arrays/strings shares data via refcount, not a copy -- the copy only happens on the next write (COW).

$b =& $a; makes $a and $b the same variable -- there is no separate data to ever diverge from.

Objects are always shared by handle on =; use clone for an independent object copy.

A true reference cycle never reaches refcount zero through counting alone -- only the GC's cycle collector frees it.

Use memory_get_peak_usage() (not just memory_get_usage()) to catch transient spikes, and gc_collect_cycles() to force/verify cycle collection.

Why This Matters in Production

Long-running PHP processes -- queue workers, WebSocket servers, CLI scripts iterating over millions of database rows -- are exactly where this model stops being trivia and starts being the actual explanation for real incidents: a worker whose memory climbs steadily over hours despite every object being correctly unset() is very often building reference cycles (event emitters holding listener closures that capture the emitter, parent/child object graphs, doubly linked structures) faster than the GC's automatic triggers are collecting them. Knowing to reach for memory_get_usage()/memory_get_peak_usage() to confirm growth and gc_collect_cycles() to test whether it's cycle-related turns a vague 'PHP is leaking memory' incident into a concrete, fixable diagnosis.

$a = null; $b = null; // "that's it, the two Node objects they pointed at are freed now" -- when $a->next referenced $b and $b->prev referenced $a

$a = null; $b = null; gc_collect_cycles(); // explicitly force cycle collection if you need the memory reclaimed on a known schedule, e.g. between batches in a long-running worker

A reference cycle keeps both objects' refcounts above zero even after every external variable pointing at them is cleared -- reclaiming that memory needs the separate cycle collector, either running automatically on its own periodic trigger or forced explicitly with gc_collect_cycles().

function process(array &$data): void {... } // passed by reference purely to "avoid copying a big array", even though the function never mutates $data function process(array $data): void {... } // pass by value -- copy-on-write already avoids duplicating the array for a purely read-only pass

Copy-on-write means a large array passed by value and never mutated costs essentially nothing extra -- forcing pass-by-reference for a function that only reads its argument removes the caller's safety (the function COULD now mutate their array) for zero real performance benefit.

[multiple-choice] What actually happens when you write $b = $a; where $a is a large array? A. The entire array is immediately duplicated in memory B. $b's symbol table entry points at the same underlying refcounted array data as $a (refcount increments); no data is copied yet C. $b becomes a true reference alias of $a, identical to =& D. PHP throws an error because arrays cannot be assigned this way

B. $b's symbol table entry points at the same underlying refcounted array data as $a (refcount increments); no data is copied yet — Assignment for refcounted types like arrays shares the underlying data via a reference count increment -- it's an O(1) operation regardless of array size. Actual duplication only happens later, if and when either variable is mutated (copy-on-write).

[multiple-choice] Why can't plain reference counting ever free two objects that reference each other in a cycle? A. PHP forbids objects from referencing each other B. Each object's refcount never reaches zero through counting alone, because the other object in the cycle is always still referencing it, even after all external variables are unset C. Reference counting only works for arrays, not objects D. PHP automatically converts cyclic references into weak references

B. Each object's refcount never reaches zero through counting alone, because the other object in the cycle is always still referencing it, even after all external variables are unset — In a cycle (A references B, B references A), each object always has at least one referrer -- the other object in the cycle -- so neither refcount ever hits zero through plain counting, even once nothing outside the cycle can reach either of them. A separate cycle-collecting GC is required to detect and free this case.

[multiple-choice] What does gc_collect_cycles() do, and when is it useful to call explicitly? A. It disables PHP's garbage collector entirely B. It forces an immediate cycle-collection pass and returns how many cycles were freed -- useful for diagnosing suspected reference-cycle leaks or proactively collecting at a known checkpoint in a long-running process C. It converts all references in the current scope into true references D. It only works if gc_disable() was called first

B. It forces an immediate cycle-collection pass and returns how many cycles were freed -- useful for diagnosing suspected reference-cycle leaks or proactively collecting at a known checkpoint in a long-running process — gc_collect_cycles() triggers the cycle collector immediately instead of waiting for its normal periodic trigger, and reports how many cycles it freed -- a direct way to confirm a suspected leak is cycle-related, or to run a collection pass at a controlled point (e.g. between batches in a worker).

Write a function that builds a tree of 1,000 Node objects where each child holds a reference back to its parent (a classic parent/child cycle), discards all external references to the tree, and uses memory_get_usage() before and after an explicit gc_collect_cycles() call to demonstrate the leak and its fix.

class Node { public ?Node $parent = null; public array $children = []; } function buildTree(int $count): Node { $root = new Node(); for ($i = 0; $i < $count; $i++) { $child = new Node(); $child->parent = $root; $root->children[] = $child; } return $root; } (function () { buildTree(1000); })(); $before = memory_get_usage(); $freed = gc_collect_cycles(); $after = memory_get_usage(); // $before - $after shows the reclaimed bytes, $freed shows the cycle count.

Give Node a public ?Node $parent = null; and a public array $children = [];, link each child's $parent back to its creator, then let the whole tree go out of scope before measuring.