Сборка мусора в PHP
Как PHP управляет памятью
PHP использует автоматическое управление памятью. Программисту не нужно вручную выделять и освобождать память, как в C. Но понимание механизма критически важно для long-running скриптов, воркеров очередей и async-приложений.
Создание переменной:
$a = "hello";
Внутри Zend Engine:
┌─────────────────────┐
│ zval │
├─────────────────────┤
│ type: IS_STRING │
│ value: → zend_string │
│ refcount: 1 │
│ is_ref: false │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ zend_string │
├─────────────────────┤
│ hash: 0x... │
│ len: 5 │
│ val: "hello" │
│ gc.refcount: 1 │
└─────────────────────┘
Reference Counting — основной механизм
Каждое значение в PHP имеет счётчик ссылок (refcount). Когда на значение ссылается новая переменная, refcount увеличивается. Когда переменная уничтожается — уменьшается. При refcount = 0 память освобождается немедленно.
<?php
declare(strict_types=1);
// refcount tracking with debug_zval_refcountval
// (Note: xdebug may alter refcount display)
$a = "hello"; // refcount = 1
$b = $a; // refcount = 2 (same zend_string, COW)
$c = $a; // refcount = 3
unset($b); // refcount = 2
unset($c); // refcount = 1
unset($a); // refcount = 0 → FREED
// With arrays
$arr = [1, 2, 3]; // Array: refcount = 1
$arr2 = $arr; // Array: refcount = 2 (COW — no copy yet)
$arr2[] = 4; // COW triggered: $arr2 gets its own copy
// $arr: refcount = 1, $arr2: refcount = 1
Визуализация refcount
<?php
declare(strict_types=1);
// debug_zval_refcountval shows internal refcount
// (adds +1 because the argument itself is a reference)
$val = 'test';
debug_zval_refcountval($val);
// string(4) "test" refcount(2) — 1 real + 1 for function arg
$val2 = $val;
debug_zval_refcountval($val);
// string(4) "test" refcount(3) — 2 real + 1 for function arg
unset($val2);
debug_zval_refcountval($val);
// string(4) "test" refcount(2) — 1 real + 1 for function arg
Copy-on-Write (COW)
Copy-on-Write — оптимизация, при которой копия создаётся только когда одна из переменных модифицируется. До этого момента все переменные разделяют одно значение.
<?php
declare(strict_types=1);
// COW in action
$original = str_repeat('x', 1_000_000); // 1MB string
$memBefore = memory_get_usage();
$copy1 = $original; // No copy! Just refcount++
$copy2 = $original; // Still no copy! refcount = 3
$copy3 = $original;
$memAfter = memory_get_usage();
echo "Memory for 3 'copies': " . ($memAfter - $memBefore) . " bytes\n";
// Almost 0 bytes! All share the same data
// Now modify one
$memBefore = memory_get_usage();
$copy1 .= 'modified'; // NOW a real copy is created (COW triggered)
$memAfter = memory_get_usage();
echo "Memory after modification: " . ($memAfter - $memBefore) . " bytes\n";
// ~1MB — copy1 now has its own 1MB string
COW с массивами
<?php
declare(strict_types=1);
// Arrays also use COW
$data = range(1, 100_000);
$memBefore = memory_get_usage();
$data2 = $data; // No copy (COW)
$data3 = $data; // No copy (COW)
echo "After assignment: " . (memory_get_usage() - $memBefore) . " bytes\n";
// Minimal memory
$memBefore = memory_get_usage();
$data2[0] = 999; // COW triggered — full array copy
echo "After modification: " . (memory_get_usage() - $memBefore) . " bytes\n";
// Significant memory increase
// WARNING: foreach with reference breaks COW!
$arr = range(1, 100_000);
$arr2 = $arr; // COW — shared data
// This does NOT trigger copy:
foreach ($arr as $val) {
// Read-only — COW preserved
}
// This DOES trigger copy:
foreach ($arr as &$val) {
// Reference forces separation!
// $arr is now marked as is_ref=true
break;
}
unset($val);
// After &$val: $arr and $arr2 no longer share data
Важно: Использование
&(ссылок) ломает COW. Избегайтеforeach ($arr as &$val)если не собираетесь модифицировать элементы. Всегда делайтеunset($val)после такого foreach.
Проблема циклических ссылок
Reference counting не может освободить память при циклических ссылках — когда объекты ссылаются друг на друга.
<?php
declare(strict_types=1);
// Circular reference problem
class Node
{
public ?Node $next = null;
public string $data;
public function __construct(string $data)
{
$this->data = $data;
}
}
$a = new Node('A');
$b = new Node('B');
// Create circular reference
$a->next = $b; // A → B
$b->next = $a; // B → A (CYCLE!)
// After unset:
unset($a);
// $a is destroyed, but object A still referenced by $b->next
// Object A refcount: 1 (from $b->next)
unset($b);
// $b is destroyed, but object B still referenced by A->next
// Object A refcount: 1 (from B->next)
// Object B refcount: 1 (from A->next)
//
// NEITHER can be freed! refcount never reaches 0
// This is a MEMORY LEAK
// Solution: PHP's Cycle Collector (GC)
Визуализация циклической ссылки
After unset($a) and unset($b):
No variables point to these objects, but:
┌──────────┐ next ┌──────────┐
│ Object A │─────────────►│ Object B │
│ refcount=1│◄────────────│ refcount=1│
└──────────┘ next └──────────┘
refcount never reaches 0 → Memory leak!
Without cycle collector, these would leak forever.
Cycle Collector
PHP включает сборщик циклических ссылок, который обнаруживает и освобождает циклические структуры. Он запускается автоматически, когда количество «подозрительных» значений достигает порога (по умолчанию 10000).
<?php
declare(strict_types=1);
// gc_enabled() — check if cycle collector is active
echo "GC enabled: " . (gc_enabled() ? 'yes' : 'no') . "\n"; // yes
// gc_collect_cycles() — manually trigger collection
// Returns number of collected cycles
$collected = gc_collect_cycles();
echo "Collected: {$collected} cycles\n";
// gc_disable() / gc_enable() — toggle collector
gc_disable(); // Turn off (for performance-critical sections)
// ... do work ...
gc_enable(); // Turn back on
gc_collect_cycles(); // Clean up accumulated cycles
// gc_status() — detailed GC statistics (PHP 8.0+)
$status = gc_status();
print_r($status);
// Array(
// [runs] => 0 — number of times GC ran
// [collected] => 0 — total values collected
// [threshold] => 10001 — root buffer threshold
// [roots] => 0 — current possible roots in buffer
// [running] => false — is GC currently running?
// [protected] => false — is GC protected from recursion?
// [full] => false — is root buffer full?
// [buffer_size] => 16384 — root buffer size (PHP 8.3+)
// )
Демонстрация цикла GC
<?php
declare(strict_types=1);
// Demonstrate cycle collection
gc_disable(); // Disable auto-collection
$memStart = memory_get_usage();
for ($i = 0; $i < 10_000; $i++) {
$a = new stdClass();
$b = new stdClass();
$a->ref = $b;
$b->ref = $a;
unset($a, $b);
// Circular refs leak without GC!
}
$memBeforeGc = memory_get_usage();
echo "Memory leaked: " . number_format($memBeforeGc - $memStart) . " bytes\n";
// Significant memory used by 10000 cycles
// Now collect cycles
$collected = gc_collect_cycles();
$memAfterGc = memory_get_usage();
echo "Collected: {$collected} cycles\n";
echo "Memory freed: " . number_format($memBeforeGc - $memAfterGc) . " bytes\n";
gc_enable(); // Re-enable
gc_mem_caches() — очистка кешей Zend (PHP 8.1+)
<?php
declare(strict_types=1);
// gc_mem_caches() clears Zend Engine internal caches
// Useful for long-running processes
$freed = gc_mem_caches();
echo "Freed from caches: {$freed} bytes\n";
// Best practice for workers:
function workerIteration(): void
{
// ... process message ...
// Periodic cleanup
static $iterations = 0;
if (++$iterations % 100 === 0) {
gc_collect_cycles();
gc_mem_caches();
}
}
WeakReference (PHP 7.4+)
WeakReference — ссылка, которая НЕ предотвращает сборку мусора. Если объект больше не имеет обычных ссылок, он будет уничтожен, а WeakReference вернёт null.
<?php
declare(strict_types=1);
class ExpensiveResource
{
public function __construct(
public readonly string $name,
) {
echo "Created: {$this->name}\n";
}
public function __destruct()
{
echo "Destroyed: {$this->name}\n";
}
}
// Create object and weak reference
$resource = new ExpensiveResource('database-connection');
$weakRef = WeakReference::create($resource);
// Weak reference does NOT prevent GC
echo "Object alive: " . ($weakRef->get() !== null ? 'yes' : 'no') . "\n";
// "yes"
echo "Name: {$weakRef->get()->name}\n";
// "database-connection"
// Destroy the only strong reference
unset($resource);
// Output: "Destroyed: database-connection"
// Weak reference now returns null
echo "Object alive: " . ($weakRef->get() !== null ? 'yes' : 'no') . "\n";
// "no"
$obj = $weakRef->get();
var_dump($obj); // null
Кеш с WeakReference
<?php
declare(strict_types=1);
/**
* Cache that doesn't prevent objects from being garbage collected.
* If an object is no longer used elsewhere, the cache entry expires automatically.
*
* @template T of object
*/
final class WeakCache
{
/** @var array<string, WeakReference<T>> */
private array $cache = [];
/**
* @param T $object
*/
public function set(string $key, object $object): void
{
$this->cache[$key] = WeakReference::create($object);
}
/**
* @return T|null
*/
public function get(string $key): ?object
{
if (!isset($this->cache[$key])) {
return null;
}
$object = $this->cache[$key]->get();
if ($object === null) {
// Object was garbage collected — clean up entry
unset($this->cache[$key]);
return null;
}
return $object;
}
public function cleanup(): void
{
foreach ($this->cache as $key => $ref) {
if ($ref->get() === null) {
unset($this->cache[$key]);
}
}
}
}
// Usage
$cache = new WeakCache();
$obj = new stdClass();
$obj->data = 'important';
$cache->set('item-1', $obj);
echo "Cached: " . ($cache->get('item-1') !== null ? 'yes' : 'no') . "\n";
// "yes"
unset($obj);
echo "After unset: " . ($cache->get('item-1') !== null ? 'yes' : 'no') . "\n";
// "no" — object was GC'd
WeakMap (PHP 8.0+)
WeakMap — структура данных, где ключами являются объекты, и эти ключи НЕ предотвращают сборку мусора. Когда объект-ключ уничтожается, соответствующая запись автоматически удаляется из WeakMap.
<?php
declare(strict_types=1);
// WeakMap: keys are objects, values are anything
// When the key object is destroyed, the entry is removed
$map = new WeakMap();
$obj1 = new stdClass();
$obj2 = new stdClass();
$map[$obj1] = ['metadata' => 'for obj1', 'created' => time()];
$map[$obj2] = ['metadata' => 'for obj2', 'created' => time()];
echo "Count: {$map->count()}\n"; // 2
// Access
echo $map[$obj1]['metadata'] . "\n"; // 'for obj1'
// Check existence
var_dump(isset($map[$obj1])); // true
// Destroy key object
unset($obj1);
echo "Count after unset: {$map->count()}\n"; // 1
// $obj1 entry automatically removed!
// No memory leak for the metadata
Практическое использование: кеширование метаданных
<?php
declare(strict_types=1);
/**
* Attach computed metadata to objects without preventing their GC.
* Classic use case: ORM entity metadata, serialization cache, etc.
*/
final class MetadataStore
{
private WeakMap $metadata;
public function __construct()
{
$this->metadata = new WeakMap();
}
public function getReflection(object $entity): ReflectionClass
{
if (!isset($this->metadata[$entity])) {
$this->metadata[$entity] = [
'reflection' => new ReflectionClass($entity),
'properties' => $this->extractProperties($entity),
'computed_at' => microtime(true),
];
}
return $this->metadata[$entity]['reflection'];
}
/**
* @return array<string, mixed>
*/
public function getProperties(object $entity): array
{
if (!isset($this->metadata[$entity])) {
$this->getReflection($entity); // Populates metadata
}
return $this->metadata[$entity]['properties'];
}
/**
* @return array<string, mixed>
*/
private function extractProperties(object $entity): array
{
$result = [];
$reflection = new ReflectionClass($entity);
foreach ($reflection->getProperties() as $prop) {
$prop->setAccessible(true);
$result[$prop->getName()] = $prop->getValue($entity);
}
return $result;
}
}
// Usage
$store = new MetadataStore();
$user = new class('John', 30) {
public function __construct(
public readonly string $name,
public readonly int $age,
) {}
};
$props = $store->getProperties($user);
print_r($props); // ['name' => 'John', 'age' => 30]
// When $user is unset, metadata is automatically cleaned up
unset($user);
// No manual cleanup needed — WeakMap handles it
WeakMap vs SplObjectStorage
<?php
declare(strict_types=1);
// SplObjectStorage — PREVENTS GC (strong references to keys)
$storage = new SplObjectStorage();
$obj = new stdClass();
$storage[$obj] = 'data';
unset($obj);
// Object still alive! SplObjectStorage holds a strong reference
echo "SplObjectStorage count: {$storage->count()}\n"; // 1
// WeakMap — allows GC (weak references to keys)
$map = new WeakMap();
$obj = new stdClass();
$map[$obj] = 'data';
unset($obj);
// Object destroyed! Entry automatically removed
echo "WeakMap count: {$map->count()}\n"; // 0
| Аспект | WeakMap | SplObjectStorage |
|---|---|---|
| Ключи | Только объекты (weak) | Только объекты (strong) |
| GC ключей | Разрешена | Предотвращена |
| Автоочистка | Да, при GC ключа | Нет |
| Утечки памяти | Нет | Возможны |
| Use case | Кеш метаданных, прикрепление данных к объектам | Множество объектов, проверка принадлежности |
Interned Strings
PHP оптимизирует хранение строк через интернирование — одинаковые строки хранятся в одном экземпляре.
<?php
declare(strict_types=1);
// Interned strings: identical strings share memory
$a = 'hello'; // Interned — stored once
$b = 'hello'; // Same interned string — no new allocation
// String literals, class names, function names, property names
// are always interned
// Dynamically created strings may or may not be interned
$c = 'hel' . 'lo'; // Optimized at compile time → interned
$d = str_repeat('hello', 1); // Runtime → NOT interned
// OPcache increases interning: all scripts share interned strings
// opcache.interned_strings_buffer = 16 (MB)
Мониторинг памяти
<?php
declare(strict_types=1);
// Current memory usage
$current = memory_get_usage(); // Allocated by emalloc
$real = memory_get_usage(true); // Allocated from OS
echo "Current: " . formatBytes($current) . "\n";
echo "Real: " . formatBytes($real) . "\n";
// Peak memory usage (highest point)
$peak = memory_get_peak_usage();
$peakReal = memory_get_peak_usage(true);
echo "Peak: " . formatBytes($peak) . "\n";
echo "Peak real: " . formatBytes($peakReal) . "\n";
// Reset peak (PHP 8.2+)
memory_reset_peak_usage();
// Memory limit
echo "Limit: " . ini_get('memory_limit') . "\n";
function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return number_format($bytes, 2) . ' ' . $units[$i];
}
Мониторинг в long-running скриптах
<?php
declare(strict_types=1);
/**
* Memory monitor for long-running scripts.
*/
final class MemoryMonitor
{
private int $warningThreshold;
private int $criticalThreshold;
private float $lastReport = 0;
private int $reportIntervalSeconds;
public function __construct(
string $warningPercent = '80%',
string $criticalPercent = '90%',
int $reportIntervalSeconds = 60,
) {
$limit = $this->parseMemoryLimit(ini_get('memory_limit') ?: '128M');
$this->warningThreshold = (int) ($limit * ((int) $warningPercent / 100));
$this->criticalThreshold = (int) ($limit * ((int) $criticalPercent / 100));
$this->reportIntervalSeconds = $reportIntervalSeconds;
}
public function check(): void
{
$usage = memory_get_usage(true);
$now = microtime(true);
if ($usage >= $this->criticalThreshold) {
fwrite(STDERR, sprintf(
"[CRITICAL] Memory: %s (limit threshold reached)\n",
$this->format($usage),
));
gc_collect_cycles();
gc_mem_caches();
} elseif ($usage >= $this->warningThreshold
&& ($now - $this->lastReport) >= $this->reportIntervalSeconds
) {
fwrite(STDERR, sprintf(
"[WARNING] Memory: %s\n",
$this->format($usage),
));
$this->lastReport = $now;
}
}
public function report(): string
{
return sprintf(
"Memory: current=%s, peak=%s, GC runs=%d, collected=%d",
$this->format(memory_get_usage()),
$this->format(memory_get_peak_usage()),
gc_status()['runs'],
gc_status()['collected'],
);
}
private function parseMemoryLimit(string $limit): int
{
$value = (int) $limit;
return match (strtoupper(substr($limit, -1))) {
'G' => $value * 1024 * 1024 * 1024,
'M' => $value * 1024 * 1024,
'K' => $value * 1024,
default => $value,
};
}
private function format(int $bytes): string
{
return number_format($bytes / 1024 / 1024, 2) . ' MB';
}
}
// Usage in a worker
$monitor = new MemoryMonitor(
warningPercent: '75%',
criticalPercent: '90%',
);
for ($i = 0; $i < 100_000; $i++) {
// ... process message ...
processMessage($i);
// Check memory every 100 iterations
if ($i % 100 === 0) {
$monitor->check();
}
}
echo $monitor->report() . "\n";
function processMessage(int $id): void
{
// Simulate work
$data = range(1, 100);
unset($data);
}
Утечки памяти: обнаружение и исправление
Типичные причины утечек
<?php
declare(strict_types=1);
// 1. CIRCULAR REFERENCES (solved by GC, but costly)
class Parent_
{
/** @var array<Child_> */
public array $children = [];
}
class Child_
{
public ?Parent_ $parent = null;
}
$parent = new Parent_();
$child = new Child_();
$parent->children[] = $child;
$child->parent = $parent; // CYCLE
// Fix: use WeakReference for back-references
class ChildFixed
{
private ?WeakReference $parentRef = null;
public function setParent(Parent_ $parent): void
{
$this->parentRef = WeakReference::create($parent);
}
public function getParent(): ?Parent_
{
return $this->parentRef?->get();
}
}
// 2. STATIC PROPERTIES accumulate data
class Cache_
{
/** @var array<string, mixed> */
private static array $data = [];
public static function set(string $key, mixed $value): void
{
self::$data[$key] = $value; // Grows forever!
}
}
// Fix: limit cache size
class BoundedCache
{
/** @var array<string, mixed> */
private static array $data = [];
private static int $maxSize = 1000;
public static function set(string $key, mixed $value): void
{
if (count(self::$data) >= self::$maxSize) {
array_shift(self::$data); // Remove oldest
}
self::$data[$key] = $value;
}
}
// 3. EVENT LISTENERS that hold references
class EventEmitter_
{
/** @var array<string, array<Closure>> */
private array $listeners = [];
public function on(string $event, Closure $callback): void
{
$this->listeners[$event][] = $callback;
// If callback captures $this → cycle!
}
}
// 4. CLOSURES capturing variables
function createLeak(): Closure
{
$largeData = str_repeat('x', 1_000_000);
return function () use ($largeData) {
// $largeData kept alive as long as closure exists
return strlen($largeData);
};
}
$closures = [];
for ($i = 0; $i < 100; $i++) {
$closures[] = createLeak(); // 100MB leaked!
}
// 5. UNSET FOREACH REFERENCES
$arr = range(1, 100_000);
foreach ($arr as &$val) {
$val *= 2;
}
// $val still references last element of $arr!
// Always: unset($val);
unset($val);
Инструмент для обнаружения утечек
<?php
declare(strict_types=1);
/**
* Simple memory leak detector for development.
*/
final class LeakDetector
{
private int $baseline;
private int $previousUsage;
private int $iteration = 0;
private float $growthRate = 0;
public function __construct()
{
gc_collect_cycles();
$this->baseline = memory_get_usage();
$this->previousUsage = $this->baseline;
}
/**
* Call this each iteration. Returns true if leak suspected.
*/
public function check(): bool
{
$this->iteration++;
if ($this->iteration % 100 !== 0) {
return false; // Check every 100 iterations
}
gc_collect_cycles();
$current = memory_get_usage();
$growth = $current - $this->previousUsage;
$totalGrowth = $current - $this->baseline;
$this->growthRate = $totalGrowth / $this->iteration;
$this->previousUsage = $current;
// Suspect leak if consistent growth > 100 bytes/iteration
if ($this->growthRate > 100 && $this->iteration > 1000) {
fwrite(STDERR, sprintf(
"[LEAK?] Iteration %d: +%s total (%s/iter)\n",
$this->iteration,
number_format($totalGrowth),
number_format($this->growthRate, 1),
));
return true;
}
return false;
}
public function summary(): string
{
gc_collect_cycles();
$finalUsage = memory_get_usage();
$totalGrowth = $finalUsage - $this->baseline;
return sprintf(
"Iterations: %d, Memory growth: %s (%s/iter), Peak: %s",
$this->iteration,
number_format($totalGrowth),
number_format($totalGrowth / max($this->iteration, 1), 1),
number_format(memory_get_peak_usage()),
);
}
}
// Usage
$detector = new LeakDetector();
for ($i = 0; $i < 50_000; $i++) {
// Your code here
doWork();
if ($detector->check()) {
echo "Potential memory leak detected!\n";
break;
}
}
echo $detector->summary() . "\n";
function doWork(): void
{
// Simulate work
$data = array_fill(0, 100, 'value');
unset($data);
}
Best Practices для Long-Running скриптов
<?php
declare(strict_types=1);
/**
* Best practices for queue workers, daemons, and long-running scripts.
*/
// 1. Periodic GC
function workerLoop(): void
{
$iteration = 0;
while (true) {
$message = getNextMessage();
processMessage($message);
$iteration++;
// Every 1000 iterations: collect cycles
if ($iteration % 1000 === 0) {
gc_collect_cycles();
gc_mem_caches();
}
// Every 10000 iterations: check memory
if ($iteration % 10_000 === 0) {
$usage = memory_get_usage(true);
$limit = 256 * 1024 * 1024; // 256MB
if ($usage > $limit * 0.9) {
// Approaching limit — restart worker
fwrite(STDERR, "Memory threshold reached, restarting\n");
exit(0); // Supervisor (systemd/supervisord) will restart
}
}
}
}
// 2. Unset large variables immediately
function processBatch(array $items): void
{
foreach ($items as $item) {
$result = heavyComputation($item);
saveToDB($result);
unset($result); // Free immediately
}
}
// 3. Use generators for large datasets
function readLargeFile(string $path): Generator
{
$handle = fopen($path, 'r');
while (($line = fgets($handle)) !== false) {
yield $line; // Only 1 line in memory at a time
}
fclose($handle);
}
// 4. Avoid global state accumulation
// BAD: static cache grows forever
// GOOD: bounded cache or WeakMap
function getNextMessage(): string { return 'msg'; }
function processMessage(string $msg): void {}
function heavyComputation(mixed $item): mixed { return $item; }
function saveToDB(mixed $result): void {}