PHP Static Cache Memory Leak — Worker OOM
Worker RSS grew from 50 MB to 500 MB in 12 hours - no error logs.
20+ years shipping production PHP systems at scale. Written from production experience, not tutorials.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- PHP stores every value in a zval — compact union; scalars live on stack in PHP 7+.
- Reference counting frees memory instantly when refcount hits zero; Copy-on-Write avoids copying until mutation.
- Circular references (A->B->A) defeat refcounting — cyclic GC marks and sweeps them.
- Static caches and global arrays are the #1 cause of unbounded growth in CLI workers.
- Use gc_status() + memory_get_usage(true) across iterations — monotonic growth = leak.
PHP's memory management is a reference-counted system built on zval containers, where each variable holds a type, value, and reference count. When you assign a value to a static variable inside a function or class method, that zval persists across requests in long-running processes like PHP-FPM or ReactPHP.
The problem: static caches accumulate data over time—each request adds entries without ever releasing them—because the reference count never drops to zero. This turns your static array into a permanent memory sink that grows until the worker hits memory_limit and OOMs.
The engine's garbage collector only handles cyclic references (e.g., circular object graphs), not the linear accumulation of static cache entries. In production, this manifests as workers that start at ~20MB and climb to 256MB+ over thousands of requests, then crash.
Real-world tools like Xdebug's trace files, Blackfire, or simple memory_get_usage() snapshots at request boundaries reveal the pattern: a static property that grows monotonically. The fix isn't tuning memory_limit—that just delays the crash—it's either clearing the static cache explicitly (e.g., on a timer or after N requests) or avoiding static persistence entirely in favor of request-scoped caches like Redis or APCu with TTLs.
PHP's memory fragmentation from repeated allocations in static arrays compounds the issue, as the engine can't reuse freed blocks efficiently, leading to premature OOM even when total allocated memory is below the limit.
Think of PHP's memory like a whiteboard in a busy office. Every time someone writes a variable on it, they claim a small section. When they're done, they erase it so someone else can use that space. The tricky part is when two people point to the same note on the board — PHP has to track how many people are still looking at it before it's safe to erase. If tracking breaks down and nobody erases old notes, the whiteboard fills up and the office grinds to a halt. That's a memory leak.
Memory management isn't glamorous — until your production server starts throwing 'Allowed memory size exhausted' errors at 2 AM and your on-call phone won't stop buzzing. PHP abstracts most of memory handling away from you, which is wonderful for productivity and dangerous for performance engineering. The moment you start building long-running workers, processing large CSV imports, or handling thousands of concurrent requests under FPM, the hidden mechanics of how PHP allocates, tracks, and frees memory become the difference between a stable service and a ticking time bomb.
The core problem PHP memory management solves is automatic resource reclamation — so you don't have to manually free every string, array, and object like you would in C. PHP uses a hybrid strategy: reference counting for the fast path and a cyclic garbage collector for the edge cases that reference counting can't handle on its own. Both mechanisms have real costs and real failure modes that surface in production code all the time.
By the end of this article you'll understand how PHP's zval structure stores every value in memory, exactly how the reference count rises and falls, why circular references defeat the simple counter, how the mark-and-sweep cycle collector rescues you, and — most importantly — which coding patterns silently bleed memory in long-running scripts so you can detect and fix them before they hit production.
How PHP Memory Management Actually Works — And Why Static Caches Break It
PHP memory management is request-scoped: every request gets its own clean memory space, and all memory is freed when the request ends. There is no shared heap between requests, no garbage collector that runs across requests. The engine uses a reference-counted zval system with a cycle collector, but the key point is that memory is allocated per-request and released per-request. This means a single request cannot leak memory that persists to another request — unless you use a persistent resource like a static variable or a global cache.
Static variables in PHP are not truly static in the C sense. They persist within a single request's execution scope, not across requests. When you store data in a static array inside a function or method, that array grows as the request processes more data. If the request is long-lived (e.g., a worker in a web server like Apache's mod_php or PHP-FPM), that static cache accumulates memory until the request ends. In a worker model, the request ends only when the worker dies, so a single request can OOM the worker if the static cache grows unbounded.
This matters in production because PHP-FPM workers are recycled slowly. A worker that processes thousands of requests per minute can accumulate a static cache over minutes or hours, consuming more memory with each request. The result is a slow, creeping OOM that kills workers one by one, degrading throughput until the pool is exhausted. The fix is simple: never use static variables as unbounded caches in long-running workers, or explicitly clear them at the end of each request.
How PHP Stores Every Value: The zval Internals
Every single value in PHP — a string, an integer, an array, an object — lives inside a structure called a zval (Zend Value). In PHP 7+ this structure was completely redesigned to be far more cache-friendly, dropping from a heap-allocated pointer maze to a compact, stack-friendly union.
A modern zval holds three things: a type tag (one byte that says 'this is a string' or 'this is an object'), a value union (the actual data, or a pointer to it for heap types), and a reference count embedded inside the pointed-to structure itself rather than in the zval. This is a critical PHP 7 optimisation — small integers and certain booleans are stored directly in the zval with no heap allocation at all, making them essentially free to copy.
For heap types (strings, arrays, objects) PHP allocates a separate structure on the Zend Memory Manager's heap. That structure carries a refcount field and a type_info bitfield. The type_info encodes whether the value is reference-counted at all, whether it's immutable (interned strings), and whether it may contain cycles. Understanding this lets you reason about when PHP actually allocates memory versus when it just copies a tiny stack value.
<?php declare(strict_types=1); /** * Demonstrates how PHP handles zval internals through observable behaviour. * We can't inspect raw zvals from userland, but xdebug_debug_zval() exposes * the refcount and is_ref flag — the two most important zval properties. * * Run with: php -d extension=xdebug.so ZvalInspection.php * (xdebug must be installed; the output below is what you'll see) */ // --- Integer: no heap allocation in PHP 7+ --- $temperature = 42; // Integers fit inside the zval union directly — no malloc, refcount=1 logically echo "=== Integer (stack value) ===\n"; xdebug_debug_zval('temperature'); // refcount=1, is_ref=false // --- String: heap-allocated, refcount starts at 1 --- $cityName = 'London'; echo "\n=== String after first assignment ===\n"; xdebug_debug_zval('cityName'); // refcount=1, is_ref=false // --- Copy-on-Write: assigning to a new variable does NOT copy the string yet --- $copiedCity = $cityName; echo "\n=== After $copiedCity = $cityName (CoW — same string, refcount bumped) ===\n"; xdebug_debug_zval('cityName'); // refcount=2, is_ref=false — SHARED, not copied! // --- Mutation triggers the actual copy --- $copiedCity = strtoupper($copiedCity); // 'LONDON' — a new string is created here echo "\n=== After mutating copiedCity (CoW copy triggered) ===\n"; xdebug_debug_zval('cityName'); // refcount=1, is_ref=false — back to exclusive xdebug_debug_zval('copiedCity'); // refcount=1, is_ref=false — new string // --- Reference (&): forces is_ref=true, bypasses CoW --- $originalScore = 100; $scoreAlias = &$originalScore; // now BOTH point to a zend_reference wrapper echo "\n=== After creating a reference ===\n"; xdebug_debug_zval('originalScore'); // refcount=2, is_ref=true echo "\noriginalScore: $originalScore
memory_get_usage() + gc_status() for production telemetry instead.Reference Counting and Copy-on-Write: The Engine's Fast Path
Reference counting is PHP's primary memory reclamation strategy. Every heap-allocated value carries a refcount. When you assign a variable, pass it to a function, or store it in an array, the count goes up. When a variable goes out of scope, is unset, or is reassigned, the count goes down. When it hits zero, PHP immediately frees the memory — no garbage collection pause required.
Copy-on-Write (CoW) is the companion optimisation that makes reference counting cheap. When you write $b = $a, PHP doesn't copy the actual data — it just bumps the refcount and lets both variables share the same memory. Only when one of them tries to modify the value does PHP perform the actual copy. This means passing a 50MB string to a function costs almost nothing if the function only reads it.
But there's a subtle trap: passing by reference (&) opts you out of CoW. PHP wraps the value in a zend_reference container so both sides always see mutations. This sounds convenient but it can force copies at unexpected moments in other parts of the code that were happily sharing the original value. Profiling often reveals that overuse of & in hot loops actually increases memory pressure, not decreases it.
<?php declare(strict_types=1); /** * Demonstrates Copy-on-Write (CoW) memory behaviour with a large dataset. * Shows how PHP avoids copying until it absolutely must, and what breaks CoW. */ function getMemoryUsageMB(): float { return round(memory_get_usage(true) / 1024 / 1024, 2); } // --- Build a large array (simulates loading records from DB) --- $productCatalogue = []; for ($i = 0; $i < 100_000; $i++) { $productCatalogue[] = [ 'id' => $i, 'name' => 'Product ' . $i, 'price' => mt_rand(100, 9999) / 100, ]; } $beforeCopy = getMemoryUsageMB(); echo "Memory after building catalogue: {$beforeCopy} MB\n"; // --- CoW: assigning to a new variable does NOT duplicate the array in memory --- $catalogueSnapshot = $productCatalogue; // refcount bumped to 2 — no malloc $afterCoWAssign = getMemoryUsageMB(); echo "Memory after CoW assign (expect near zero increase): {$afterCoWAssign} MB\n"; echo "Increase: " . ($afterCoWAssign - $beforeCopy) . " MB\n"; // --- Triggering a real copy: mutating the snapshot separates the two arrays --- $catalogueSnapshot[0]['price'] = 0.01; // 'SALE!' — this forces the full array copy $afterMutation = getMemoryUsageMB(); echo "\nMemory after mutating snapshot (full copy created): {$afterMutation} MB\n"; echo "Increase: " . ($afterMutation - $beforeCopy) . " MB\n"; // --- Passing to a read-only function: still uses CoW, no copy --- function countExpensiveProducts(array $catalogue, float $threshold): int { // We only READ the array — CoW means no copy happened when $catalogue was received return count(array_filter( $catalogue, fn(array $product): bool => $product['price'] > $threshold )); } $expensiveCount = countExpensiveProducts($productCatalogue, 50.00); $afterReadOnlyCall = getMemoryUsageMB(); echo "\nExpensive products over $50: {$expensiveCount}\n"; echo "Memory after read-only function call: {$afterReadOnlyCall} MB (CoW — no spike)\n"; // --- Danger: unintentionally breaking CoW with & in foreach --- echo "\n--- CoW-breaking pattern: foreach with reference ---\n"; $beforeRef = getMemoryUsageMB(); foreach ($productCatalogue as &$product) { // & forces a write-separation for every element! // Even if we never mutate $product, this still breaks CoW on the iterated array // because PHP can't know in advance whether we will write. } unset($product); // ALWAYS unset the dangling reference after a reference-foreach! $afterRef = getMemoryUsageMB(); echo "Memory with reference foreach: {$afterRef} MB\n"; echo "Lesson: avoid foreach-by-reference unless you genuinely need to mutate in place.\n";
foreach ($array as &$item), the variable $item remains a reference to the LAST element of the array after the loop ends. If you then use $item for anything else in the same scope, you'll silently corrupt the last element of your array. Always add unset($item) immediately after any reference-foreach. This is one of the most common, hardest-to-spot bugs in PHP codebases.Cyclic Garbage Collection: When Reference Counting Isn't Enough
Reference counting has one fundamental blind spot: circular references. If object A holds a reference to object B, and object B holds a reference back to object A, both refcounts stay at 1 forever — even after all external references are gone. Neither object ever reaches zero, so neither is ever freed. Over a long-running process, this is a slow death by a thousand leaks.
PHP 5.3 introduced a cyclic garbage collector to plug this hole. It's inspired by the Bacon-Rajan algorithm and works in two phases. First, PHP maintains a root buffer — whenever a refcount decreases (but doesn't hit zero), the value is added as a potential cycle root. When the buffer fills up (default: 10,000 roots), or when you call , the collector runs.gc_collect_cycles()
The collection algorithm does a depth-first traversal, tentatively decrementing every refcount it can reach from each root. Any node that still has a refcount above zero after this traversal is reachable from outside the cycle and is safe — its counts are restored. Anything that reaches zero is garbage and gets freed. This is a stop-the-world pause, which matters in latency-sensitive code.
<?php declare(strict_types=1); /** * Demonstrates circular reference memory leaks and how PHP's cyclic * garbage collector rescues us — plus the WeakReference escape hatch. */ function getMemoryKB(): int { return (int)(memory_get_usage() / 1024); } // --- 1. THE LEAK: two objects referencing each other --- class OrderItem { public string $productName; public ?Order $parentOrder = null; // back-reference — the cycle-forming link public function __construct(string $productName) { $this->productName = $productName; } } class Order { public int $orderId; /** @var OrderItem[] */ public array $items = []; public function __construct(int $orderId) { $this->orderId = $orderId; } public function addItem(OrderItem $item): void { $item->parentOrder = $this; // CYCLE: $item -> $order -> $items[n] -> $item $this->items[] = $item; } } // Disable GC so we can see the raw leak in isolation gc_disable(); $memBefore = getMemoryKB(); for ($iteration = 0; $iteration < 5_000; $iteration++) { $order = new Order($iteration); $order->addItem(new OrderItem('Widget A')); $order->addItem(new OrderItem('Widget B')); // $order goes out of scope here — but because of the cycle, // refcounts never reach 0, so NOTHING is freed. unset($order); } $memAfterLeak = getMemoryKB(); echo "=== Cyclic Leak (GC disabled) ===\n"; echo "Memory before loop: {$memBefore} KB\n"; echo "Memory after 5000 leaked cycles: {$memAfterLeak} KB\n"; echo "Leaked: " . ($memAfterLeak - $memBefore) . " KB\n"; // --- 2. THE RESCUE: run the cyclic garbage collector manually --- $cyclesCollected = gc_collect_cycles(); // walks root buffer, frees unreachable cycles $memAfterGC = getMemoryKB(); echo "\nCycles collected: {$cyclesCollected}\n"; echo "Memory after gc_collect_cycles(): {$memAfterGC} KB\n"; echo "Freed by GC: " . ($memAfterLeak - $memAfterGC) . " KB\n"; gc_enable(); // restore normal operation // --- 3. THE BETTER FIX: WeakReference breaks the cycle structurally --- class OrderV2 { public int $orderId; /** @var OrderItemV2[] */ public array $items = []; public function __construct(int $orderId) { $this->orderId = $orderId; } public function addItem(OrderItemV2 $item): void { // WeakReference does NOT increment the refcount of the Order object // so the cycle is broken — when external refs drop, Order is freed immediately $item->parentOrderRef = WeakReference::create($this); $this->items[] = $item; } } class OrderItemV2 { public string $productName; public ?WeakReference $parentOrderRef = null; // does NOT hold a strong ref public function __construct(string $productName) { $this->productName = $productName; } public function getParentOrderId(): ?int { $parentOrder = $this->parentOrderRef?->get(); // returns null if Order was GC'd return $parentOrder?->orderId; } } $memBeforeV2 = getMemoryKB(); for ($iteration = 0; $iteration < 5_000; $iteration++) { $order = new OrderV2($iteration); $order->addItem(new OrderItemV2('Widget A')); $order->addItem(new OrderItemV2('Widget B')); unset($order); // Order's refcount hits 0 immediately — freed without GC! } $memAfterV2 = getMemoryKB(); echo "\n=== WeakReference fix ===\n"; echo "Memory before loop: {$memBeforeV2} KB\n"; echo "Memory after 5000 iterations (no leak): {$memAfterV2} KB\n"; echo "Net change: " . ($memAfterV2 - $memBeforeV2) . " KB (should be near 0)\n";
gc_collect_cycles() at the end of each job iteration rather than relying on the root buffer threshold. This gives you predictable, controlled GC pauses instead of random stop-the-world stutters. Pair it with gc_status() (PHP 8.0+) to log cycle collection stats and alert if freed > 0 unexpectedly — that's your early warning system for new circular reference bugs.Production Memory Profiling and Leak Detection Patterns
Knowing the theory is worthless if you can't apply it when your staging environment is eating 500MB per worker per hour. Production memory debugging requires a layered toolkit: coarse-grained checkpoints, mid-level memory_get_usage() telemetry, and fine-grained profiling with tools like Blackfire or Xdebug's memory profiler.gc_status()
The most practical first step is memory bracketing: record memory before and after each logical unit of work (one queue job, one import row batch, one API request in a long-lived Swoole context). If memory grows monotonically across iterations, you have a leak. If it spikes and returns, you just have a large temporary allocation — totally normal.
The second step is identifying what is leaking. tells you how many roots are pending, how many cycles were collected, and how much GC ran. If you see root buffer fills happening every few iterations, you have a circular reference problem. If memory grows but gc_status() shows zero cycles collected, you likely have a growing static/global collection, an event listener accumulating closures, or a cache that never evicts.gc_status()
For deep profiling, Blackfire's memory dimension shows allocation counts per function call. A function that allocates 10MB across 1 million tiny allocations is a very different problem to one that allocates one 10MB blob — and the fix for each is completely different.
<?php declare(strict_types=1); /** * A practical memory monitoring harness for long-running PHP workers. * Demonstrates: memory bracketing, gc_status() telemetry, and * static cache leak simulation with detection. * * Pattern: wrap every job iteration with MemoryMonitor to catch leaks early. */ final class MemoryMonitor {\n private int $baselineBytes;\n private int $iterationCount = 0;\n private int $totalBytesLeaked = 0;\n\n public function __construct(\n private readonly int $alertThresholdBytes = 1_048_576, // 1 MB per iteration\n private readonly bool $forceGcBeforeMeasure = true\n ) {\n $this->baselineBytes = memory_get_usage();\n } public function beginIteration(): void { $this->iterationCount++; // Ensure we're measuring steady-state, not leftover allocation noise if ($this->forceGcBeforeMeasure) { gc_collect_cycles(); } $this->baselineBytes = memory_get_usage(); } public function endIteration(string $jobDescription): void { if ($this->forceGcBeforeMeasure) { gc_collect_cycles(); } $currentBytes = memory_get_usage(); $deltaBytes = $currentBytes - $this->baselineBytes; $this->totalBytesLeaked += max(0, $deltaBytes); // only count growth, not shrink $gcStatus = gc_status(); $report = sprintf( '[Iter %04d] %-30s | Delta: %+d KB | Peak: %d MB | GC Roots: %d | Cycles Collected: %d', $this->iterationCount, $jobDescription, (int)($deltaBytes / 1024), (int)(memory_get_peak_usage() / 1024 / 1024), $gcStatus['roots'], // pending roots in the buffer $gcStatus['collected'], // total cycles freed since process start ); echo $report . "\n"; // Alert if this single iteration leaked more than the threshold if ($deltaBytes > $this->alertThresholdBytes) { echo " *** MEMORY ALERT: iteration leaked " . (int)($deltaBytes / 1024) . " KB — investigate immediately ***\n"; } } public function summary(): void { echo "\n=== Memory Monitor Summary ===\n"; echo "Total iterations: {$this->iterationCount}\n"; echo "Total net growth: " . (int)($this->totalBytesLeaked / 1024) . " KB\n"; echo "Per-iteration avg: " . (int)($this->totalBytesLeaked / $this->iterationCount / 1024) . " KB\n"; echo "Current usage: " . (int)(memory_get_usage() / 1024) . " KB\n"; echo "Peak usage: " . (int)(memory_get_peak_usage() / 1024 / 1024) . " MB\n"; } } // --- Simulate two job types: a clean one and a leaking one --- // LEAK PATTERN: a static cache that never evicts (common in poorly-designed singletons) class ProductRepository { /** @var array<int, array<string,mixed>> */ private static array $queryCache = []; // grows forever — classic long-running leak public static function findById(int $productId): array { if (!isset(self::$queryCache[$productId])) { // Simulates a DB row — in reality this would be a PDO fetch self::$queryCache[$productId] = [ 'id' => $productId, 'name' => 'Product ' . $productId, 'description' => str_repeat('x', 1024), // 1KB per entry ]; } return self::$queryCache[$productId]; } // The fix: add a cache size limit or a clear method called after each job public static function clearCache(): void { self::$queryCache = []; } } function processOrderJobClean(int $orderId): void { // Fetches product but clears the static cache after each job ProductRepository::findById($orderId); ProductRepository::findById($orderId + 1); ProductRepository::clearCache(); // releases static cache — memory returns to baseline } function processOrderJobLeaking(int $orderId): void { // Fetches product but NEVER clears the cache — static grows unbounded ProductRepository::findById($orderId); ProductRepository::findById($orderId + 1); // No clearCache() call — each iteration permanently adds ~2KB to the static array } $monitor = new MemoryMonitor(alertThresholdBytes: 4096); // alert on > 4KB growth per iter echo "--- Phase 1: Clean jobs (cache cleared each iteration) ---\n"; for ($jobId = 1; $jobId <= 5; $jobId++) { $monitor->beginIteration(); processOrderJobClean($jobId * 100); $monitor->endIteration("processOrderJob#" . $jobId); } echo "\n--- Phase 2: Leaking jobs (static cache grows unbounded) ---\n"; for ($jobId = 1; $jobId <= 5; $jobId++) { $monitor->beginIteration(); processOrderJobLeaking($jobId * 100 + 50); $monitor->endIteration("leakingJob#" . $jobId); } $monitor->summary();
static $var and every self::$property in code that runs inside a long-lived PHP process.gc_status() shows no cycles, yet memory grows.memory_get_usage() delta per iteration and alert on >5% growth.PHP Memory Configuration and Tuning: memory_limit, Real Usage, and Fragmentation
Beyond the algorithmic side, PHP's memory behaviour is shaped by configuration and internal allocation strategies. The memory_limit directive sets the hard cap for userland memory per request. But it's not the whole picture: memory_get_usage(true) reports the real memory (including internal heap overhead and fragmentation), while memory_get_usage(false) reports only user allocations. The difference between them can be surprisingly large — up to 30% on small scripts due to the Zend Memory Manager's chunk-based allocation.
When PHP allocates memory via the Zend MM, it requests large blocks (chunks, typically 256 KB) from the OS and then partitions them internally. This amortizes system calls but can lead to fragmentation over the lifetime of a long-running worker. The internal heap has its own free list and lazy coalescing — fragmentation is rarely a problem for short-lived FPM requests, but it becomes significant in CLI workers that run for hours.
Interned strings are another hidden memory consumer. When PHP encounters the same string literal multiple times, it stores it once in a shared interned string table. In CLI workers, this table persists for the process lifetime. While usually small (a few MB), if your code dynamically generates interned strings (e.g., via str_repeat in a loop), the table can grow unexpectedly.
Tuning: for CLI workers, consider increasing memory_limit to a safe upper bound, then implement a self-healing mechanism: monitor memory_get_usage(true) and restart the worker when utilisation exceeds a threshold (e.g., 80% of limit). For FPM, keep memory_limit low enough to catch leaks early but high enough to handle peak loads. Never set memory_limit to unlimited in production — you'll swap instead of crash, and swapping kills performance.
<?php declare(strict_types=1); /** * Demonstrates the difference between real and user memory usage, * and a pattern for self-healing worker restarts. */ function getMemoryStats(): array { return [ 'user' => memory_get_usage(false), 'real' => memory_get_usage(true), 'peak' => memory_get_peak_usage(true), 'limit' => ini_get('memory_limit'), ]; } // --- 1. Real vs User memory gap --- echo "=== Starting Gap Test ===\n"; $stats = getMemoryStats(); echo "User: " . round($stats['user']/1024, 1) . " KB\n"; echo "Real: " . round($stats['real']/1024, 1) . " KB\n"; echo "Gap: " . round(($stats['real'] - $stats['user'])/1024, 1) . " KB\n"; // --- 2. Simulate fragmentation: allocate and free many small blocks --- echo "\n=== Fragmentation Simulation ===\n"; $fragments = []; for ($i = 0; $i < 10000; $i++) { $fragments[] = str_repeat('x', 128); // 128-byte strings } // Free every other one to create holes foreach ($fragments as $idx => $val) { if ($idx % 2 === 0) { unset($fragments[$idx]); } } gc_collect_cycles(); $statsAfter = getMemoryStats(); echo "User after fragment free: " . round($statsAfter['user']/1024, 1) . " KB\n"; echo "Real after fragment free: " . round($statsAfter['real']/1024, 1) . " KB\n"; echo "Gap widened? " . (($statsAfter['real'] - $statsAfter['user']) > ($stats['real'] - $stats['user']) ? 'Yes' : 'No' ) . "\n"; // Note: real usage often does not drop because freed chunks are kept for reuse. // --- 3. Self-healing worker watchdog pattern --- function workerHealthCheck(): void { $limitBytes = 128 * 1024 * 1024; // 128 MB $threshold = 0.8 * $limitBytes; // 102.4 MB $realUsage = memory_get_usage(true); if ($realUsage > $threshold) { echo "[WATCHDOG] Memory usage " . round($realUsage / 1024 / 1024, 1) . " MB exceeds " . round($threshold / 1024 / 1024, 1) . " MB. Restarting...\n"; // In real worker: exit or throw to trigger supervisor restart exit(0); } } // Simulate a few iterations for ($iter = 1; $iter <= 5; $iter++) { // Do some work $tmp = str_repeat('data', 1000); unset($tmp); workerHealthCheck(); echo "Iteration $iter: OK\n"; }
memory_get_usage(true) in CLI workers — it includes the Zend MM heap overhead that accumulates over time due to fragmentation. The user-level false value can stay flat while real usage creeps up, giving you a false sense of safety until the OOM killer arrives.The Hidden Cost of Persistent Connections: Memory That Never Lets Go
You just fixed a ticket where a cron worker died after processing 10,000 batches. Memory kept climbing until the OOM killer stepped in. The root cause? Persistent database connections holding onto result buffers.
Here's the WHY: MySQL Native Driver (mysqlnd) reuses zval memory for result sets. When you call mysqli_query() in a persistent connection, the internal result buffer is allocated from PHP's memory manager. But persistent connections outlive individual requests. The zval reference counts never drop to zero because the connection object — and its internal result buffers — stay alive in the worker process.
Every query leaves ghost memory: result metadata, field definitions, and buffered row data. With default buffered mode, a 10MB result set consumes 10MB of PHP memory that never frees until the connection closes. Over 1,000 requests against a persistent connection, that's 10GB of leaked memory.
The fix? Explicitly close result sets with mysqli_free_result() or switch to unbuffered queries for large datasets. Also, never use persistent connections with long-running scripts that run many queries — each buffer accumulates.
Production pattern: Measure memory_get_usage() before and after each query batch. If the delta doesn't return to baseline, you're leaking through persistent buffers.
// io.thecodeforge // Demonstrates memory accumulation with persistent connections $conn = new mysqli('p:localhost', 'user', 'pass', 'prod_db'); $before = memory_get_usage(true); for ($i = 0; $i < 1000; $i++) { $result = $conn->query('SELECT * FROM large_table LIMIT 1000'); // Oops: never freed // $result->free(); } $after = memory_get_usage(true); echo 'Growth: ' . number_format($after - $before) . ' bytes\n';
free() or use unbuffered mode when processing many result sets in a long-running process.Defragmentation: Why Your Memory Limit Is a Lie
You saw it in production: a PHP worker with memory_limit=256MB crashed with 'Allowed memory size exhausted' after processing a 50MB JSON payload. But memory_get_usage() showed only 180MB. What gives?
The answer is fragmentation. PHP's memory manager (based on Doug Lea's allocator) uses a slab system: it carves memory into fixed-size blocks (8, 16, 32, 64 bytes, etc.). When you allocate and free objects of different sizes, you create holes — tiny gaps between used blocks. The allocator can't merge these holes. After enough allocations, the free space is scattered across hundreds of non-contiguous fragments. A request for a 64KB buffer fails because no single fragment is large enough, even though total free memory is 76MB.
Real-world trigger: ORM hydration loops. Each entity object differs slightly in field sizes, creating a fragmented heap. The fix? Pre-allocate buffers for known payload sizes, batch process objects to keep allocation patterns uniform, or use PHP 8.1+'s new garbage collector tunables to collect more aggressively.
But the real production pattern: monitor memory_get_usage(true) (real usage) vs memory_get_usage(false) (emalloc usage). When the difference exceeds 20-30%, you have fragmentation. Restart the worker or batch objects to defragment naturally.
The WHY: Allocators optimize for speed, not density. Fragmentation is the tax you pay for fast allocation.
// io.thecodeforge // Detects memory fragmentation in production function checkFragmentation(): void { $real = memory_get_usage(true); // from OS $internal = memory_get_usage(false); // emalloc overhead $ratio = ($real - $internal) / $real * 100; if ($ratio > 25) { error_log( sprintf( 'Fragmentation alert: OS=%sM, emalloc=%sM, wasted=%.1f%%', number_format($real / 1048576, 1), number_format($internal / 1048576, 1), $ratio ) ); } } checkFragmentation();
The Silent Worker OOM: A Static Cache That Never Evicted
gc_status() showed zero cycles collected — no circular refs.memory_get_usage() per job and alert on delta > 5%.- Static properties are process-level state — they live as long as the worker runs.
- In CLI workers (queue, long-running scripts), every static cache needs an eviction strategy.
- Memory growth without GC activity points to static accumulation, not circular references.
gc_collect_cycles() and check gc_status()['collected'] — if zero, suspect static/global accumulation.phpinfo(). Use memory_get_peak_usage(true) to find allocation spikes. Profile with Blackfire or Xdebug to see which function allocates the most.memory_get_peak_usage() - memory_get_usage() > 20MB for the same function. Look for large temporary arrays or string concatenation in loops.xdebug_debug_zval() to trace the cycle location. Consider WeakReference to break the loop structurally.memory_get_usage() / 1024 / 1024 . ' MB'gc_status() to see roots and collected cyclesmemory_get_peak_usage(true) and ini_get('memory_limit')Run Blackfire profile or xdebug_debug_zval() on the allocation-heavy function.gc_disable(); // then measure response timesgc_collect_cycles() at controlled batch boundaries instead.gc_collect_cycles() once per 1000 iterations, not per request.memory_get_usage(true) after 10, 100, 1000 iterations.gc_status()['roots'] — if > 0 but collected = 0, leak is static accumulation.| Aspect | Reference Counting | Cyclic GC (Mark & Sweep) |
|---|---|---|
| Trigger | Every refcount decrement | Root buffer full (10k roots) or gc_collect_cycles() |
| Speed | O(1) — immediate on decrement | O(N) — proportional to cycle graph size |
| Pause type | Incremental — spread across operations | Stop-the-world — full collection pass |
| Handles cycles | No — fundamental limitation | Yes — designed exactly for this case |
| Memory freed | Immediately when refcount hits 0 | Deferred until GC runs |
| Can be disabled | No — core engine mechanism | Yes — gc_disable() / gc_enable() |
| PHP version | All versions | PHP 5.3+ |
| Best suited for | Short-lived scalars, non-circular graphs | Object graphs with parent/child back-refs |
| Monitoring API | xdebug_debug_zval() | gc_status(), gc_collect_cycles() |
| WeakReference bypass | N/A | WeakReference prevents cycle formation entirely |
| File | Command / Code | Purpose |
|---|---|---|
| ZvalInspection.php | declare(strict_types=1); | How PHP Stores Every Value |
| CopyOnWrite.php | declare(strict_types=1); | Reference Counting and Copy-on-Write |
| CyclicGarbageCollection.php | declare(strict_types=1); | Cyclic Garbage Collection |
| MemoryLeakDetector.php | declare(strict_types=1); | Production Memory Profiling and Leak Detection Patterns |
| MemoryTuning.php | declare(strict_types=1); | PHP Memory Configuration and Tuning |
| connection_leak.php | $conn = new mysqli('p:localhost', 'user', 'pass', 'prod_db'); | The Hidden Cost of Persistent Connections |
| fragmentation_detector.php | function checkFragmentation(): void { | Defragmentation |
Key takeaways
& (by-reference) opts you out of CoW entirely and can increase memory pressure by forcing early separation of shared values.WeakReference::create() to break cycles structurally (the preferred fix) or call gc_collect_cycles() at controlled batch boundaries (the defensive fix).memory_get_usage(true) (real usage) not memory_get_usage(false) in long-running workersCommon mistakes to avoid
5 patternsForgetting `unset($item)` after `foreach ($array as &$item)`
$item is used anywhere in the same scope, causing data corruption that only manifests intermittently.unset($item) as the very next statement after any reference-based foreach loop, without exception.Calling `gc_collect_cycles()` inside every tight inner loop
gc_collect_cycles() at batch boundaries (every 1,000 rows or every job), not per-row. Use gc_status()['runs'] to measure how often the collector actually triggers and calibrate your call frequency accordingly.Assuming PHP-FPM workers reset static state between requests
Using `&` (by reference) in function parameters to avoid copying large arrays
& when you intend to mutate the original inside the function. Profile to confirm the impact.Relying on the implicit GC root buffer threshold to clean up cycles
gc_collect_cycles() at controlled boundaries (end of each job iteration) and use gc_status() to monitor collected cycles. This gives predictable, bounded GC pauses.Interview Questions on This Topic
PHP uses reference counting for memory management — but reference counting alone can't handle all cases. What scenario does it fail on, and how does PHP resolve it?
Explain Copy-on-Write in PHP. If I write `$b = $a` where `$a` is a 100MB array, how much new memory is allocated at that exact moment, and what triggers an actual memory copy?
$b[0] = 'new'), at which point PHP separates the value and performs a full copy of the 100MB array. This is the core of Copy-on-Write optimization — it delays memory duplication until mutation is required.You have a PHP queue worker that processes jobs in a loop. After 12 hours it's using 2GB of RAM and gets OOM-killed. Walk me through your investigation: what tools do you use, what patterns do you suspect, and how do you fix them without restarting the process more frequently?
memory_get_usage(true) at start and end of each job. If there's a positive delta every iteration, that's a leak. Then I'd check gc_status() for collected cycles. If collected is 0 but memory grows, the leak is in static/global state — not circular references. I'd use xdebug_debug_zval() or Blackfire to locate the accumulating data structure. Common patterns: unbounded static cache, event listeners that never detach, or singletons holding references. The fix is to clear static caches per job, use WeakReference for cross-object dependencies, and implement a watchdog that restarts the worker when real memory exceeds 80% of limit. Avoid relying solely on restart frequency.Frequently Asked Questions
Call ini_set('memory_limit', '512M') at the top of your script before any heavy allocations. This overrides php.ini for that process only. You can also pass it via CLI with php -d memory_limit=512M script.php. Note: you can only increase the limit this way — if memory_limit is managed by a hosting provider with open_basedir restrictions, ini_set may be blocked.
Yes — when a variable goes out of scope (function returns, loop iteration ends), its refcount is decremented. If the count reaches zero, PHP frees the memory immediately via the Zend Memory Manager. The exception is circular references: if two objects reference each other, their counts never reach zero and memory is only freed by the cyclic garbage collector, not automatically on scope exit.
memory_get_usage() returns the current live memory footprint at the moment of the call. memory_get_peak_usage() returns the highest memory watermark the process has ever reached. For catching leaks, track memory_get_usage() across iterations (monotonic growth = leak). For capacity planning and finding temporary allocation spikes, monitor memory_get_peak_usage() — a script that peaks at 400MB but settles at 50MB may OOM under concurrent load even though its 'normal' usage looks fine.
WordPress typically runs fine with 128MB per request (default in many shared hosts). A Laravel queue worker processing complex jobs may need 512MB to 2GB depending on data sizes. The key difference: FPM requests are short-lived, so even a temporary spike above limit causes a 500 error. CLI workers run indefinitely — a slow leak that adds 1MB per job will kill a 2GB worker after ~200,000 jobs. Set memory_limit high enough to handle peak per job, then implement a watchdog restart at 80% utilisation.
Compare memory_get_usage(true) - memory_get_usage(false) over time. If the gap grows while user allocations stay stable, fragmentation is increasing. You can also restart the worker periodically to reset the heap. The Zend MM does not defragment internally. A growing gap with no leak indicates fragmentation — restarting is the only practical remedy.
20+ years shipping production PHP systems at scale. Written from production experience, not tutorials.
That's Advanced PHP. Mark it forged?
7 min read · try the examples if you haven't