Данная статья появилась вследствие неожиданных результатов экспериментов с переменными PHP. Некоторое время я не мог объяснить причины обнаруженных феноменов, но масса тестов,
обсуждение на форуме Дмитрия Котерова и собственное серое вещество сделали своё дело. Итак, как же выделяется память под переменные PHP.
Для начала я вкратце перескажу соответствующий раздел из книги Джорджа Шлосснейгла "Профессиональное программирование на PHP". Рассмотрим внутреннюю структуру переменной. Помимо, собственно, значения переменной и поля текущего типа, структура переменной содержит целочисленный счётчик ссылок "refcount" и булевый флаг "is_ref". Счётчик ссылок при инициализации переменной устанавливается в единицу, флаг ссылки в ноль. Если мы присвоим новой переменной значение старой, копирования структуры не произойдёт:
$var = str_repeat('A', 10000);
$copy = $var;
// Дополнительная память под переменную $copy не выделяется.
Здесь срабатывает механизм отложенного ("ленивого") копирования. Суть его в том, что пока исходная переменная или её копия не будут изменены, память под копию выделена не будет (таким образом можно считывать данные из копии, как из оригинала, не захламляя память одинаковыми данными). По факту у переменной всего лишь инкрементируется счётчик ссылок (флаг ссылки по-прежнему не установлен). Здесь важно понять одну вещь - нет никакой разницы, какая переменная была объявлена ранее, и кто кому был присвоен. Структура переменной в результате всё равно одна. И количество копий в данном случае на затраты памяти не влияет.
При изменении же оригинальной переменной или её копии инициализируется новая структура переменной, присваивается оригиналу или копии, а в старой структуре счётчик ссылок декрементируется. В итоге мы получаем две независимых структуры с количеством ссылок на каждую, равном единице. Когда счётчик ссылок какой-либо переменной обнуляется, сборщик мусора удаляет её из памяти. (Здесь ещё стоит добавить, что удаление происходит по завершении обработки единичной инструкции PHP, а не моментально. То есть в процессе выполнения инструкции вполне могут существовать и использоваться переменные с нулевым "refcount". Снаружи, разумеется, это уже не видно.)
Что касается переменных-ссылок, то с ними, казалось бы, и вовсе никаких вопросов нет.
$var = str_repeat('A', 10000);
$ref =& $var;
// Дополнительная память под ссылку $ref также не выделяется.
Мы можем создавать сколько угодно ссылок на одну переменную, и изменять любую из них, при этом изменяя непосредственно оригинальную структуру переменной. Ссылки внутри отличаются от не-ссылок только установленным флагом "is_ref", счётчик ссылок так же увеличивается и уменьшается. Здесь никаких утечек памяти также быть не должно.
Странности возникают, когда мы совмещаем оба метода для одной переменной. Рассмотрим следующий код:
$var = str_repeat('A', 10000);
$copy = $var;
$ref =& $var;
// Определения ссылки и копии можно поменять местами в данном случае - результат будет один.
Анализ затраченной памяти показывает, что у нас теперь есть две копии переменной. PHP зачем-то выделил память ещё раз. Более того, если мы добавим ещё копии и ссылки, то увидим, что выделение памяти зависит от порядка их определения. В частности, такой код потратит один лишний блок памяти размером с исходную переменную:
$var = str_repeat('A', 10000);
$copy1 = $var;
$copy2 = $var;
$copy3 = $var;
$ref =& $var;
А вот этот код выделит память уже под три таких переменных:
$var = str_repeat('A', 10000);
$ref =& $var;
$copy1 = $var;
$copy2 = $var;
$copy3 = $var;
Что происходит? Мы ведь всего лишь поменяли порядок команд, но PHP обработал переменные по разным алгоритмам.
Экспериментируя с количеством и положением копий и ссылок, я вывел следующую формулу:
Количество лишних блоков памяти определяется по формуле:
Одна копия до объявления первой ссылки (если эта копия есть) плюс количество копий после объявления первой ссылки.
Не буду мучить читателей многочисленными примерами кода с подписью "А вот тут вот так", скажу сразу, что я вывел три правила, по которым работает присваивание в PHP. Эти три правила описывают все основные ситуации, влияющие на внутреннее состояние переменных и выделение памяти, а также объясняют выведенную эмпирически формулу выше. Итак:
- Если происходит копирование, и флаг ссылки в исходной структуре не установлен, производится простой инкремент счётчика ссылок; аналогично счётчик увеличивается при создании ссылки на другую ссылку (эти ситуации мы уже разобрали).
- Если происходит копирование, и флаг ссылки в исходной структуре установлен, выделяется память под новую структуру, флаг ссылки у неё не устанавливается, счётчик ссылок как обычно равен единице; эта структура присваивается копии; исходная переменная не меняется. Суммарно, мы получаем один лишний блок памяти под новую переменную-копию.
- Если создаётся ссылка на обычную переменную, то:
- в исходной структуре декрементируется счётчик ссылок;
- если счётчик остался ненулевой, выделяется память под новую структуру, эта структура присваивается исходной переменной;
- в исходной переменной устанавливается флаг ссылки, и счётчик ссылок устанавливается в единицу;
- создаётся ссылка на исходную переменную (производится инкремент счётчика ссылок - до двух).
Если у исходной переменной были копии, мы имеем лишний блок памяти. Если же копий не было, после декремента исходная структура восстанавливается без дублирования, и в итоге утечек памяти нет.
Ещё одним важным свойством ссылок является автоматический сброс флага ссылки в структуре, если на переменную больше ссылок нет. Таким образом, после удаления всех ссылок переменная обретает свой первоначальный вид.
Первоначально эти правила были сформулированы не совсем верно, поэтому пришлось читать исходники Zend Engine, чтобы устранить несовпадения с практикой.
Теперь рассмотрим наши удивительные примеры.
В первом примере сначала создаются легковесные копии (правило 1), после чего создаётся ссылка, и поскольку ещё есть копии, имеем один лишний блок памяти (правило 3).
Во втором примере сначала создаётся легковесная ссылка (правило 3), после чего создаются копии, каждая из которых выделяет память заново (правило 2).
Напоследок, для закрепления материала рассмотрим две простейшие ситуации. Первая:
$var = $var;
И вторая:
$var =& $var;
В обеих ситуациях, при условии отсутствия других копий или ссылок, эти операции не потребуют дополнительной памяти, ни в процессе выполнения, ни по их завершении. Но во втором случае переменная получит флаг ссылки (несмотря на "refcount", равный единице), что при дальнейшем копировании может снова повлечь потребление дополнительной памяти.
Разумеется, использовать подобный код в работе я не планировал и не планирую (хотя нельзя забывать о ситуациях, когда ссылки создаются неявно, например, при использовании конструкции global). Для меня главным было понять, почему PHP ведёт себя так, а не иначе. Возможно, я не совсем верно сформулировал итоговые правила, поскольку не сильно разбираюсь в C/C++, так что любой желающий может самостоятельно изучить код функций zend_assign_to_variable и zend_assign_to_variable_reference из исходников PHP - буду рад любым поправкам.
P.S. Нашёл в дебрях официальной документации ссылку на замечательную
статью Дэрика Ретанса, где данная тема раскрыта гораздо полнее и с большим числом примеров. Также рекомендую обратить внимание на обновлённый
раздел документации, посвящённый сборщику мусора. В нём рассмотрена проблема утечки памяти в PHP версий 5.2 и ниже, и описано решение этой проблемы в PHP 5.3.