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.
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.
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.
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.
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. gc_status() 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.
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.
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.
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.
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.
PHP 8.4 Memory Improvements: JIT, GC Optimizations
PHP 8.4 introduces significant memory management enhancements, particularly in the JIT compiler and garbage collector. The JIT compiler now supports more aggressive inlining and escape analysis, reducing memory allocations for temporary variables. The GC has been optimized to run incrementally, avoiding long pauses and reducing peak memory usage. Additionally, PHP 8.4 improves the handling of immutable arrays and strings, reducing copy-on-write overhead.
Example: The following code demonstrates how JIT can reduce memory usage in a tight loop:
```php
$data = range(1, 1000000); $result = processData($data); echo memory_get_peak_usage(true) / 1024 / 1024 . " MB "; ?> ```
In PHP 8.4 with JIT enabled, this code uses ~20% less memory compared to PHP 8.3 due to better temporary variable elimination.
To enable JIT, add to php.ini: ``ini opcache.jit=1255 opcache.jit_buffer_size=100M ``
The GC optimizations in PHP 8.4 include deferred collection of cyclic references, which reduces memory fragmentation. The function now provides more detailed statistics, helping developers tune collection intervals.gc_status()
gc_status() to ensure GC runs efficiently.Memory Leak Detection with Xdebug and Valgrind
Detecting memory leaks in PHP requires specialized tools. Xdebug provides tracing and profiling capabilities, while Valgrind offers low-level memory analysis. For PHP, Valgrind can be used with the --tool=memcheck option to detect unreleased memory.
Example: Tracing memory allocation with Xdebug: ```php
$cache = []; for ($i = 0; $i < 1000; $i++) { $cache[] = str_repeat('x', 1024); }
xdebug_stop_trace(); ?> ```
Analyze the trace file to identify functions that allocate memory without freeing.
For Valgrind, run PHP script as: ``bash valgrind --tool=memcheck --leak-check=full --show-leak-kinds=all php script.php ``
Valgrind reports memory leaks at the C level, useful for extensions. Example output: `` ==12345== 100 bytes in 1 blocks are definitely lost in loss record 1 of 5 ==12345== at 0x4C2B0E0: malloc (vg_replace_malloc.c:299) ==12345== by 0x5E6A2F: zend_string_alloc (zend_string.c:123) ``
This indicates a string allocation that was never freed. Common causes: circular references, static variables, or persistent resources.
To detect PHP-level leaks, use before and after suspect code blocks.memory_get_usage()
Memory-Limited Environments: Testing and CI Optimization
In CI/CD pipelines, memory limits can cause false negatives. Optimize tests to run within constraints using techniques like memory profiling, incremental GC, and resource cleanup.
Example: PHPUnit test with memory limit: ```php <?php use PHPUnit\Framework\TestCase;
class MemoryTest extends TestCase { public function testLargeDataSet() { $this->expectNotToPerformAssertions(); $data = range(1, 100000); // Process data without exceeding memory $result = array_map(function($v) { return $v * 2; }, $data); unset($data, $result); $this->assertTrue(true); } } ?> ```
Set memory limit in phpunit.xml: ``xml <php> <ini name="memory_limit" value="128M" /> </php> ``
For CI, use tools like to assert memory usage: ``memory_get_peak_usage()php $this->assertLessThan(64 1024 1024, memory_get_peak_usage(true)); ``
To avoid OOM in CI, split large test suites into parallel jobs. Use --processes option in PHPUnit to run tests in separate processes, each with its own memory limit.
Example: Running tests in parallel: ``bash phpunit --processes=4 tests/ ``
Also, disable Xdebug in CI to reduce memory overhead: ``bash php -n -d xdebug.mode=off vendor/bin/phpunit ``
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 cycles| 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 |
| php84-memory-example.php | function processData(array $data): array { | PHP 8.4 Memory Improvements |
| leak-detection.php | xdebug_start_trace('/tmp/trace', XDEBUG_TRACE_MEMORY); | Memory Leak Detection with Xdebug and Valgrind |
| ci-memory-test.php | use PHPUnit\Framework\TestCase; | Memory-Limited Environments |
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 workersInterview 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?
Frequently Asked Questions
20+ years shipping production PHP systems at scale. Written from production experience, not tutorials.
That's Advanced PHP. Mark it forged?
9 min read · try the examples if you haven't