0

Object memory model — references, garbage collection, refcount

Expert5 min read·php-07-020
interviewperformance

Concept

Objects in PHP are managed differently from scalar values. Understanding the object memory model — how objects are stored, referenced, and garbage collected — prevents memory leaks and explains behavior that surprises developers coming from other languages.

Object store: All objects live in a global EG(objects_store) — a dynamic array of zend_object structs. Each object has a unique integer handle (index in this array). Zvals holding objects store this handle, not a pointer or the object data itself.

Reference counting for objects: Each zend_object has a gc_refcount. When you assign $b = $a (both holding an object), the handle is copied to $b's zval but the zend_object's refcount increments. When a variable is unset or goes out of scope, refcount decrements. When it reaches zero, the destructor is called and the object is freed.

Cyclic reference problem: If $a->b = $b and $b->a = $a, both objects hold references to each other. When external variables to both are unset, their individual refcounts drop to 1 (not 0, because they reference each other). Reference counting alone can't detect this cycle — PHP's cycle collector (gc_collect_cycles()) runs periodically to detect and free such cycles. Laravel's long-lived worker processes can accumulate unreachable cycles if gc is disabled.

WeakReference (PHP 8.0): Holds a reference to an object WITHOUT incrementing its refcount. If the object is freed elsewhere, WeakReference::get() returns null. Used to observe objects without preventing their garbage collection — ideal for caches, event listeners, and observer patterns.

Code Example

php
<?php
declare(strict_types=1);

// Object handle vs object data
$a = new stdClass();
$a->x = 1;
$b = $a;         // $b holds the SAME handle as $a
$b->x = 99;
echo $a->x;      // 99 — same underlying object

$b = new stdClass(); // $b now holds a DIFFERENT handle
$b->x = 0;
echo $a->x;      // still 99 — $a unchanged

// Cyclic reference — objects reference each other
class Node
{
    public ?Node $next = null;
    public function __construct(public string $label) {}
    public function __destruct() { echo "Destroying {$this->label}\n"; }
}

$nodeA = new Node('A');
$nodeB = new Node('B');
$nodeA->next = $nodeB;
$nodeB->next = $nodeA; // cycle!

unset($nodeA, $nodeB);  // refcounts: A→1 (B still refs it), B→1 (A still refs it)
// Neither destructor called here — they're stuck in a cycle

$freed = gc_collect_cycles(); // manually trigger cycle collector
echo "Freed $freed objects\n"; // "Freed 2 objects" — destructors now run

// WeakReference — observe without preventing GC
$obj = new stdClass();
$obj->name = 'Ephemeral';

$weak = WeakReference::create($obj);
echo $weak->get()?->name; // "Ephemeral"

unset($obj); // refcount drops to 0, object freed (WeakRef doesn't increment refcount)
var_dump($weak->get()); // NULL — object was freed

// Memory monitoring
function measureObjectMemory(int $n): void
{
    $before = memory_get_usage();
    $objects = [];
    for ($i = 0; $i < $n; $i++) {
        $objects[] = new stdClass();
    }
    $perObject = (memory_get_usage() - $before) / $n;
    echo "Memory per object: {$perObject} bytes\n"; // ~64-72 bytes
    unset($objects); // refcounts → 0, all freed
}
measureObjectMemory(10_000);