Метка: оптимизация памяти

  • ZVAL в PHP: углублённый анализ работы с переменными. Часть 2 — копирование, ссылки и оптимизация

    Содержание

    Введение

    В предыдущей части мы рассмотрели, что такое ZVAL в PHP. В это части более подробно рассмотрим, копирование, ссылки и возможные оптимизации.

    Все примеры приведены для php 8.3. Для отладки примеров будем использовать метод xdebug_debug_zval. Для этого должно быть установлено расширение xdebug.

    $var = 1;
    xdebug_debug_zval('var');
    

    Почему мы используем xdebug_debug_zval() вместо debug_zval_dump()?

    Начиная с PHP 8.0, функция debug_zval_dump() перестала отображать критически важную информацию:

    • Не показывает refcount (счетчик ссылок)
    • Не отображает is_ref (флаг ссылочности)

    В то время как xdebug_debug_zval() продолжает предоставлять полную информацию о внутренней структуре ZVAL:

    $a = [1, 2, 3];
    xdebug_debug_zval('a');
    // Вывод: (refcount=2, is_ref=0)=array(...)
    
    debug_zval_dump($a);
    // Вывод PHP 8+: array(3) { ... } - без ключевых метаданных
    

    Примечание: Функция xdebug_debug_zval() всегда показывает значение refcount на 1 больше реального. Это особенность реализации Xdebug — при выводе информации он временно увеличивает счетчик ссылок. В следующих примерах я буду указывать корректные значения refcount (уменьшенные на 1), чтобы отражать реальное состояние переменных.

    Пример вывода:

    $a = [1,2,3];
    xdebug_debug_zval('a'); 
    // Выведет: (refcount=2, is_ref=0)=...
    // Реально: refcount = 1
    

    Реализациия метода на xdebug_debug_zval С.

    void xdebug_debug_zval(char *varname) {
        zval *zv;
        // вот здесь +1 временная ссылка
        zv = xdebug_get_zval(varname); 
        php_printf("%s: (refcount=%d, is_ref=%d)=...", 
            varname, 
            Z_REFCOUNT_P(zv) + 2,
            Z_ISREF_P(zv));
    }
    

    1. Разбор Copy-on-Write

    Механизм Copy-on-Write (COW) — фундаментальная оптимизация в PHP, которая минимизирует использование памяти:

    // Исходный массив (refcount=1 в реальности, xdebug покажет +1)
    $original = [1];
    xdebug_debug_zval('original');
    // Вывод Xdebug: (refcount=2, is_ref=0)=array (0 => (refcount=0, is_ref=0)=1)
    // Реальный refcount = 1
    
    // Присваивание (refcount увеличивается до 2)
    $copy = $original;
    xdebug_debug_zval('original');
    // Вывод: (refcount=3, is_ref=0)=...
    // Реальный refcount = 2
    xdebug_debug_zval('copy');
    // Вывод: (refcount=3, is_ref=0)=...
    // Реальный refcount = 2 (общая ссылка)
    
    // Модификация (срабатывает COW). Для $copy создается новый zval.
    $copy[0] = 999;
    xdebug_debug_zval('original');
    // Вывод: (refcount=2, is_ref=0)=array (0 => (refcount=0, is_ref=0)=1)
    // Реальный refcount = 1 (после разделения)
    
    xdebug_debug_zval('copy');
    // Вывод: (refcount=1, is_ref=0)=array (0 => (refcount=0, is_ref=0)=999)
    // Реальный refcount = 0 (новая копия)
    

    Обратите внимание на последний вывод

    xdebug_debug_zval('copy'); 
    // Реальный refcount = 0 (новая копия)
    

    Почему refcount = 0? Подробнее читайте в статье.

    Ключевые аспекты COW:

    • Глубокое копирование происходит только при модификации
    • Исключения: Объекты (PHP 5+) всегда передаются по ссылке
    • Особенность PHP 8.1: Оптимизация для массивов с одним элементом

    2. Руководство по ссылкам

    Ссылки в PHP — это не указатели, а особый флаг is_ref в ZVAL:

    $a = 10;
    xdebug_debug_zval('a');
    // a: (refcount=0, is_ref=0)=10
    
    // Устанавливается is_ref=1
    $b = &$a;
    xdebug_debug_zval('a');
    xdebug_debug_zval('b');
    // a: (refcount=2, is_ref=1)=10
    // b: (refcount=2, is_ref=1)=10
    
    // При is_ref=1 создаётся НОВЫЙ zval
    $c = $a;
    xdebug_debug_zval('a');
    xdebug_debug_zval('b');
    xdebug_debug_zval('c');
    // a: (refcount=2, is_ref=1)=10
    // b: (refcount=2, is_ref=1)=10
    // c: (refcount=0, is_ref=0)=10
    

    PHP следует этим правилам при работе с ссылками:

    1. Если есть хотя бы одна жёсткая ссылка (is_ref=1):
      • Все новые присваивания по значению (=) создают копии
      • Это предотвращает неожиданные изменения связанных переменных
    2. Скалярные значения (числа, строки):
      • При копировании используют оптимизацию interned-хранилища
      • Поэтому $c получает refcount=0

    Важные нюансы:

    • Цена ссылок: Увеличивают потребление памяти на 30-40%
    • Неожиданное поведение: При изменении через ссылку меняются все связанные переменные
    • Оптимизация PHP 8: Сокращение накладных расходов на ссылки

    3. Детали изменения типов

    Пример работы с zval и преобразованием типов

    $var1 = "42";      // type=IS_STRING
    xdebug_debug_zval('var1');
    // var1: (interned, is_ref=0)='42'
    
    $var1 += 0;        // Неявное преобразование в IS_LONG
    xdebug_debug_zval('var1');
    // var1: (refcount=0, is_ref=0)=42
    
    settype($var1, 'float'); // Явное изменение типа
    xdebug_debug_zval('var1');
    // var1: (refcount=1, is_ref=1)=42
    
    // Особый случай. Пустая строка преобразуется в false
    $var2 = "0";       
    xdebug_debug_zval('var2');
    // var2: (interned, is_ref=0)='0'
    

    Разбор примера:

    1. Изначально $var1 содержит строку (IS_STRING) с флагом interned
    2. При арифметической операции происходит неявное преобразование в целое число (IS_LONG)
    3. settype() выполняет явное преобразование в тип float
    4. Особый случай: строка «0» не считается пустой, но в булевом контексте преобразуется в false

    Внутренние механизмы:

    • Хэширование: Для быстрого сравнения смешанных типов
    • Кэширование: Сохранение преобразованных значений
    • Опасности: Потеря точности при больших числах

    4. Оптимизация памяти

    Профессиональные техники работы с памятью:

    МетодЭффектПример
    unset()Немедленное освобождениеunset($largeArray)
    = nullОтложенное освобождение$var = null
    Ссылки+30% к памяти$ref = &$original

    5. Практические кейсы

    Кейс 1: Оптимизация обработки больших данных

    // Проблемный код:
    function processData() {
        $data = loadHugeDataset(); // 500MB
        modifyData($data);
        return $data;
    }
    
    // Решение:
    function processDataOptimized() {
        $data = loadHugeDataset();
        modifyData($data);
        unset($data); // Явное освобождение
        return $result;
    }
    
    

    Больше примеров вынес в отдельную статью.

    6. Интернирование строк

    PHP автоматически оптимизирует хранение одинаковых строк:

    $a = 'hello';
    $b = 'hello';
    debug_zval_dump($a); // string(5) "hello" interned
    debug_zval_dump($b); // string(5) "hello" interned
    

    Это означает, что в памяти хранится только одна копия строки, а все переменные ссылаются на один zval.

    7. Измерение потребления памяти

    <?php
    
    function test1(): void
    {
    	$data = range(1, 100000);
    
    	$memBefore = memory_get_usage();
    	$copy = $data;
    	$memAfter = memory_get_usage();
    
    	showMemory($memBefore, $memAfter);
    }
    
    function test2(): void
    {
    	$data = range(1, 100000);
    
    	$memBefore = memory_get_usage();
    	$copy = $data;
    	$copy[0] = 1;
    	$memAfter = memory_get_usage();
    
    	showMemory($memBefore, $memAfter);
    }
    
    function test3(): void
    {
    	$data = range(1, 100000);
    
    	$memBefore = memory_get_usage();
    	$copy = &$data;
    	$memAfter = memory_get_usage();
    
    	showMemory($memBefore, $memAfter);
    }
    
    function test4(): void
    {
    	$data = range(1, 100000);
    
    	$memBefore = memory_get_usage();
    	$copy = &$data;
    	$copy[0] = 1;
    	$memAfter = memory_get_usage();
    
    	showMemory($memBefore, $memAfter);
    }
    
    function showMemory($memBefore, $memAfter): void
    {
    	echo "Память до копирования: {$memBefore} байт\n";
    	echo "Память после копирования: {$memAfter} байт\n";
    	echo "Разница: " . ($memAfter - $memBefore) . " байт\n";
    }
    
    // 1. Присвоение одного массива другому. Без изменения данных.
    test1();
    // Память не увеличилась.
    // Память до копирования: 2514944 байт
    // Память после копирования: 2514944 байт
    // Разница: 0 байт
    
    // 2. Присвоение одного массива другому. С изменением данных.
    test2();
    // В результате память увеличилась в двое, т.к. массив был скопирован.
    // Память до копирования: 2514976 байт
    // Память после копирования: 4628592 байт
    // Разница: 2113616 байт
    
    // 3. Присвовение массива по ссылке, без изменения данных
    test3();
    // Выделяется память только на ссылку.
    // Память до копирования: 2514976 байт
    // Память после копирования: 2515008 байт
    // Разница: 32 байт
    
    // 4. Присвовение массива по ссылке, с изменением данных
    test4();
    // Выделяется память только на ссылку.
    // Память до копирования: 2514976 байт
    // Память после копирования: 2515008 байт
    // Разница: 32 байт
    

  • Практическое применение ZVAL: 8 реальных кейсов оптимизации памяти в PHP

    Содержание статьи

    1. Утечка памяти в циклах обработки данных

    Проблемный код:

    while ($row = getBigDataRow()) {
      $processed[] = processRow($row);
    }
    

    Оптимизированное решение:

    $batch = [];
    while ($row = getBigDataRow()) {
      $batch[] = processRow($row);
      if (count($batch) >= 1000) {
        saveBatch($batch);
        $batch = [];
      }
    }
    

    Почему это работает:

    В оригинальном коде каждый новый элемент добавляется в массив $processed, что приводит к:

    • Постоянному увеличению refcount для zval массива
    • Многократному перевыделению памяти при расширении массива
    • Накоплению всех zval-значений до конца выполнения цикла

    Оптимизированный вариант сбрасывает ссылки каждые 1000 итераций, позволяя сборщику мусора своевременно освобождать память.

    2. Неожиданное поведение ссылок

    Проблемный код:

    $data = ['key' => 'original'];
    $ref = &amp;$data['key'];
    $copy = $data;
    $copy['key'] = 'modified';
    
    echo $data['key']; // Выведет 'modified'!
    

    Правильный подход:

    $data = ['key' => 'original'];
    $copy = $data; // Копируем ДО создания ссылки
    $ref = &amp;$data['key'];
    

    Механизм работы:

    Когда создаётся ссылка (&), PHP помечает zval флагом is_ref=1. Последующие операции:

    • При копировании массива с is_ref=1 создаётся не независимая копия
    • Все переменные продолжают ссылаться на один zval
    • Изменения через любую переменную влияют на все «копии»

    Решение создаёт настоящую копию до установки ссылки.

    3. Конкатенация больших строк

    Проблемный код:

    $report = '';
    foreach ($records as $record) {
      $report .= generateReportRow($record);
    }
    

    Оптимизированный вариант:

    $rows = [];
    foreach ($records as $record) {
      $rows[] = generateReportRow($record);
    }
    $report = implode('', $rows);
    

    Принцип работы:

    Конкатенация через .= в цикле вызывает:

    • Создание нового zval-строка на каждой итерации
    • Копирование всего предыдущего содержимого
    • Сложность O(n²) по памяти

    Вариант с implode:

    • Хранит части в отдельных zval
    • Выделяет память для результата один раз
    • Сложность O(n)

    4. Циклические ссылки в объектах

    Проблемный код:

    class Node {
      public $next;
    }
    
    $a = new Node();
    $b = new Node();
    $a->next = $b;
    $b->next = $a; // Создана циклическая ссылка
    

    Решение:

    // Перед удалением:
    unset($a->next, $b->next);
    
    // Теперь GC сможет очистить память
    unset($a, $b);
    

    Как работает:

    Циклические ссылки создают ситуацию, где:

    • Каждый объект имеет refcount=2 (оригинал + ссылка из другого объекта)
    • Сборщик мусора видит refcount=1 после unset() переменных
    • Память не освобождается, так как объекты всё ещё ссылаются друг на друга

    Явный разрыв ссылок перед удалением позволяет GC корректно очистить память.

    5. Эффективная работа с большими массивами в функциях

    Проблемный код:

    function processArray($data) {
      // Создаётся копия массива
      foreach ($data as $k => $v) {
        $data[$k] = $v * 2;
      }
      return $data;
    }
    
    $bigData = range(1, 100000);
    processArray($bigData); // Двойное потребление памяти
    

    Оптимизация:

    function processArray(&amp;$data) {
      foreach ($data as $k => $v) {
        $data[$k] = $v * 2;
      }
    }
    
    $bigData = range(1, 100000);
    processArray($bigData); // Работаем с оригиналом
    

    Механизм:

    При передаче массива в функцию:

    • Без & создаётся новый zval с refcount=1
    • С & используется оригинальный zval с увеличением refcount
    • Изменения применяются к исходному массиву

    Важно: после работы со ссылками нужно unset() временные переменные.

    6. Оптимизация временных переменных

    Проблемный код:

    function calculate($x) {
      $temp = $x * 2; // Лишний zval
      return $temp + 1;
    }
    

    Улучшенный вариант:

    function calculate($x) {
      return ($x * 2) + 1; // Нет временных zval
    }
    

    Принцип работы:

    Каждая переменная в PHP:

    • Создаёт отдельный zval в текущей scope
    • Увеличивает refcount для значений
    • Требует времени на аллокацию и освобождение

    Инлайн-вычисления позволяют:

    • Избежать создания промежуточных zval
    • Снизить нагрузку на сборщик мусора
    • Уменьшить пиковое потребление памяти

    7. Оптимизация ORM-запросов

    Проблемный код:

    $users = User::all(); // Загружает все объекты
    foreach ($users as $user) {
      $user->updateStats(); // Все zval в памяти
    }
    

    Пакетная обработка:

    User::chunk(1000, function($users) {
      foreach ($users as $user) {
        $user->updateStats();
      }
    }); // Освобождает память после каждой порции
    

    Как это работает:

    При загрузке объектов ORM:

    • Каждый объект — отдельный zval
    • Свойства объекта хранятся во внутреннем массиве (HashTable)
    • Потребление памяти растёт линейно

    Метод chunk():

    • Загружает данные порциями
    • После обработки порции zval освобождаются
    • Снижает пиковое потребление памяти на 90%+

    8. Эффективная сериализация

    Проблемный код:

    $bigData = getHugeDataset();
    $serialized = serialize($bigData); // 2GB в памяти
    

    Стриминг-решение:

    $handle = fopen('cache.dat', 'w');
    foreach (chunkData(getHugeDataset()) as $chunk) {
      fwrite($handle, serialize($chunk));
    }
    fclose($handle);
    

    Механизм работы:

    При сериализации:

    • PHP создаёт полную копию данных в памяти
    • Формирует строковое представление
    • Хранит всё в одном zval-строке

    Пакетная сериализация:

    • Обрабатывает данные частями
    • Не превышает лимит memory_limit
    • Позволяет работать с данными > доступной памяти