Zvals and Runtime Values: A Quick Recap
Recall what a zval is, why reference counting and copy-on-write make PHP's value semantics work the way they do, and how the cycle collector reclaims memory that plain refcounting can't -- as a fast summary pointing to the full mechanics in the 'PHP Memory Model' lesson.
Every PHP value you assign, pass, or return -- an int, a string, an array -- is stored internally in a zval, and the way zvals share memory (or don't) explains a surprising amount of PHP's runtime behavior, from why copying a huge array is usually cheap to why memory_limit errors sometimes seem to appear out of nowhere. This is a fast recap of the four load-bearing ideas -- zvals, refcounting, copy-on-write, and cyclic garbage collection -- for readers who already met them in depth in the 'PHP Memory Model' lesson and just need the shape refreshed; that lesson remains the place to go for the full internal struct layout, GC algorithm, and tuning guidance.
The zval: value plus type plus refcount
A zval ("Zend value") is the internal container that holds every PHP value: the actual data (or a pointer to it, for a reference-counted type), a type tag saying what kind of value it is (int, string, array, object, null, ...), and -- for reference-counted types like strings, arrays, and objects -- a refcount tracking how many places currently point at that same underlying data. Simple scalar types like int, float, and bool are stored directly in the zval and copied by value with essentially no overhead; strings, arrays, and objects are the types where the refcounting and copy-on-write story actually matters.
When you assign $b = $a for an array or string, PHP does not immediately duplicate the underlying data -- it makes $b's zval point at the same underlying value and increments its refcount. Both variables share one copy of the data until something forces them to diverge.
• zval = value/pointer + type tag + refcount (for refcounted types). • Scalars (int, float, bool, null) are stored directly and copied cheaply with no refcounting involved. • Strings, arrays, and objects are reference-counted -- an assignment shares one underlying copy until it's mutated.
Copy-on-write and cyclic garbage collection, in one paragraph each
Copy-on-write (COW) is what makes the sharing above safe: as long as two variables merely point at the same array or string, sharing is invisible and free. The instant either variable is *mutated* -- appending to the array, changing a character -- PHP checks the refcount, and if it's greater than one, duplicates the underlying data first so the other variable's copy is left untouched. This is why passing even a huge array into a function that never modifies it costs almost nothing, while modifying that same array inside the function triggers a real, possibly-expensive duplication.
Plain refcounting alone can't free memory involved in a reference cycle -- for example two objects that hold references to each other, where each one's refcount never drops to zero even after nothing outside the cycle refers to either of them. PHP's cycle collector runs periodically (its threshold configurable via zend.gc_high_water_mark / triggered when a root buffer fills) to detect exactly these cycles and free them; without it, code that builds circular object graphs (common with certain observer or doubly-linked structures) would leak memory for the life of the process.
• COW: sharing a value between variables is free; the first mutation after a refcount > 1 triggers an actual copy. • Refcounting alone frees a value the instant its refcount hits zero -- but never for objects/arrays only reachable through a cycle. • PHP's cycle collector runs periodically to find and free cyclic garbage that refcounting can't reach on its own. • Practical implication: a long-running worker (queue consumer, daemon) that builds cyclic structures relies on the cycle collector actually running -- see the 'PHP Memory Model' lesson for tuning and diagnosing this in long-running processes.
Code Examples
$b = $a does not duplicate a million-element array -- it just shares the same underlying zval and bumps the refcount to 2. Only the append to $b, which mutates it while the refcount is greater than one, forces PHP to actually copy the array first so $a is left with its original million elements untouched. Expected result: 1000000 vs 1000001
$b[] = 'extra'; // this line is what actually triggers the copy
// Assuming $b = $a; always copies a large array immediately $big = buildHugeArray(); $copy = $big; // "this must be expensive"
// $b = $a is cheap until either variable is mutated -- copy-on-write defers the real cost $big = buildHugeArray(); $copy = $big; // shares data, refcount 2 -- no copy has happened yet
Treating every array assignment as an immediate deep copy leads to unnecessary defensive contortions (manually avoiding assignments, or reaching for references where none are needed) -- the actual cost only appears at the first mutation of one of the sharing variables, not at assignment time.
[multiple-choice] After $b = $a; where $a is a large array, what has actually happened to the underlying array data at that point? A. The array has been fully duplicated immediately so $a and $b are independent B. Nothing has been duplicated yet -- $b's zval points at the same underlying data as $a's, and the refcount is incremented; a real copy only happens if one of them is later mutated C. $a is deleted and its data is moved to $b D. PHP throws a warning because arrays cannot be assigned this way
B. Nothing has been duplicated yet -- $b's zval points at the same underlying data as $a's, and the refcount is incremented; a real copy only happens if one of them is later mutated — Copy-on-write means an assignment like $b = $a shares the same underlying array data and bumps the refcount rather than duplicating anything. The actual duplication is deferred until either variable is mutated while the refcount is still greater than one.
[multiple-choice] Two objects hold references to each other and nothing else in the program refers to either of them anymore. Why doesn't PHP's plain refcounting free them? A. Refcounting only works on arrays, never on objects B. Each object's refcount never reaches zero, because the two objects still reference each other even though nothing outside the pair does -- this is exactly the case PHP's cycle collector exists to detect and clean up C. PHP automatically converts object cycles into arrays D. This situation is impossible in PHP
B. Each object's refcount never reaches zero, because the two objects still reference each other even though nothing outside the pair does -- this is exactly the case PHP's cycle collector exists to detect and clean up — Plain refcounting frees a value only when its refcount drops to zero. In a reference cycle, each object is still referenced by the other object in the cycle, so neither refcount ever reaches zero on its own -- freeing this kind of garbage requires the separate cycle collector, which periodically scans for and reclaims exactly this pattern.
<?php
declare(strict_types=1);
$a = range(1, 1_000_000); // one large array, refcount 1
$b = $a; // $b points at the SAME data, refcount 2 -- no copy yet
$b[] = 'extra'; // mutation triggers copy-on-write: $b now has its own copy
echo count($a) . ' vs ' . count($b) . PHP_EOL;