HardТеория12 min

Сборка мусора

Reference counting, cycle collector, WeakReference, WeakMap, copy-on-write, утечки памяти

Сборка мусора в 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 {}

Проверь себя

5 из 10

Как лучше всего кешировать метаданные объектов, не мешая их сборке мусора?

Чем WeakMap отличается от SplObjectStorage?

Что такое interned strings?

Какая разница между memory_get_usage() и memory_get_usage(true)?

Какую проблему решает cycle collector (gc_collect_cycles)?