Почему мы вообще задумываемся о памяти в PHP?
Большинство разработчиков на PHP привыкли, что память — это бесконечный ресурс. Запросил массив на миллион элементов? Пожалуйста. Создал объект, потом забыл про него? Ну и ладно, PHP сам подчистит. Но когда ваш проект начинает падать с ошибкой Allowed memory size exhausted или неожиданно тормозить на ровном месте, приходит время разобраться, что же на самом деле происходит под капотом.
Сегодня мы заглянем в чёрный ящик под названием «Память и сборщик мусора в PHP». Вы узнаете, как PHP считает ссылки, почему циклические ссылки — это зло, и как заставить сборщик мусора работать на вас, а не против вас. Поехали!
Как PHP управляет памятью: от запроса до ответа
В отличие от C или C++, где вы сами выделяете и освобождаете память, PHP работает через Zend Engine. У каждого процесса PHP есть свой пул памяти. Когда вы пишете:
$data = [1, 2, 3];
PHP выделяет память под массив, сохраняет значения и увеличивает так называемый refcount (счётчик ссылок). Пока на переменную $data кто-то ссылается, память не освобождается.
Но как только вы делаете:
unset($data);
счётчик ссылок падает до нуля, и Zend Engine мгновенно возвращает память обратно в пул. Всё просто и быстро. Однако есть нюансы…
Ссылочная арифметика: refcount и is_ref
Каждая переменная в PHP — это структура zval, которая хранит значение, тип, флаг is_ref (является ли ссылкой) и refcount. Давайте посмотрим на примере:
$a = "Hello";
$b = $a; // refcount увеличивается до 2 (копирование при записи)
$b = "World"; // теперь $a и $b — разные zval-ы, refcount для "Hello" падает до 1
Это называется copy-on-write (копирование при записи). Пока вы не меняете значение, PHP экономит память, используя одну и ту же область. Но как только вы изменяете переменную, создаётся копия. Хитро, правда?
Сборщик мусора (GC): когда refcount не спасает
Обычный механизм подсчёта ссылок работает отлично для 99% случаев. Но есть одна проблема — циклические ссылки. Представьте, что у вас есть два объекта, которые ссылаются друг на друга:
class Node {
public $next;
}
$a = new Node();
$b = new Node();
$a->next = $b;
$b->next = $a;
unset($a);
unset($b);
После unset оба объекта всё ещё имеют refcount = 1 (каждый ссылается на другой). Формально они недоступны из кода, но память не освобождается. Это и есть циклическая ссылка.
Если таких объектов много, память будет утекать. Для борьбы с этим в PHP начиная с версии 5.3 появился полноценный сборщик мусора (GC).
Как работает GC в PHP?
Сборщик мусора не запускается на каждую строчку кода. Он активируется, когда корневой буфер (массив потенциальных циклических ссылок) заполняется. По умолчанию его размер — 10 000 элементов. Когда буфер полон, GC запускает алгоритм, который:
- Сканирует корни — находит все zval-ы с refcount > 0, которые могли бы быть частью цикла.
- Уменьшает refcount для всех дочерних элементов (как будто ссылок нет).
- Проверяет — если после уменьшения refcount стал равен нулю, значит это «мусор» и его можно удалить.
- Восстанавливает refcount для тех, кто не оказался мусором, и освобождает память.
Этот процесс называется garbage collection. Вы можете принудительно запустить его:
gc_collect_cycles();
Но не делайте этого на каждый чих — это дорогая операция.
Как отслеживать утечки памяти в PHP?
Если ваш скрипт работает долго (например, воркер очереди или демон), утечки становятся критичными. Вот несколько инструментов и приёмов:
1. Функции memory_get_usage и memory_get_peak_usage
Самый простой способ — замерить потребление до и после:
echo "Начало: " . memory_get_usage() . " байт\n";
// ваш код
echo "Конец: " . memory_get_usage() . " байт\n";
echo "Пик: " . memory_get_peak_usage() . " байт\n";
2. Используйте xdebug и XHProf
Xdebug может показывать профилирование памяти, а XHProf — строить граф вызовов. На продакшене лучше не включать, но на стейджинге — самое то.
3. Проверяйте циклические ссылки вручную
Если подозреваете, что где-то зациклились объекты, можно использовать:
gc_enabled(); // проверяет, включён ли GC
gc_status(); // возвращает статистику (в PHP 7.3+)
Оптимизация памяти: практические советы
Ниже — проверенные способы не допустить «поедания» памяти вашим кодом:
Используйте unset для временных данных
Особенно в циклах. Если вы обрабатываете CSV-файл на 100 000 строк, не держите все строки в одном массиве:
$handle = fopen('big.csv', 'r');
while (($row = fgetcsv($handle)) !== false) {
processRow($row);
unset($row); // явно освобождаем память
}
fclose($handle);
Избегайте глобальных переменных-монстров
Глобальные переменные живут до конца скрипта. Если вы складываете в $GLOBALS['data'] тонны данных, GC не сможет их убрать раньше времени. Лучше используйте локальные переменные или замыкания.
Работайте с генераторами
Генераторы (yield) не хранят всю коллекцию в памяти. Это спасение для больших наборов данных:
function getRows($file) {
$handle = fopen($file, 'r');
while (($row = fgetcsv($handle)) !== false) {
yield $row;
}
fclose($handle);
}
foreach (getRows('big.csv') as $row) {
// обрабатываем по одной строке
}
Следите за статическими свойствами
Статические свойства классов живут в памяти, пока загружен класс. Если вы храните в них результаты запросов или большие конфиги, будьте осторожны. Пример плохого кода: