Как устроены массивы в PHP и как код влияет на скорость работы с ними

от автора

Массивы — это хлеб и масло PHP-разработчика. Мы используем их постоянно, но редко задумываемся, как они устроены внутри. А от этого устройства напрямую зависит скорость и память нашего приложения. Давайте разберемся.

Давайте развеем 3 мифа:

  1. Доступ к элементам массива всегда занимает одинаковое время.

  2. В PHP обычный и ассоциативный массивы – одно и то же.

  3. Использовать ссылку в foreach быстрее, чем просто итерироваться по элементам.

Представим ситуацию. Вы создали массив и одним и тем же оператором «$array[] = $value;» добавляете в него элементы. Ключи получаются последовательными: 0, 1, 2, 3, … 9999. Последний ключ в массиве – 10000. Вы добавляете новый элемент с ключом 100000000. Вопрос: будет ли скорость добавления этого последнего элемента такой же, как у всех предыдущих?
Согласитесь, большинство даже не задумается об этом. И кажется, что не должно оно отличаться. Но раз такой вопрос написан, может все-таки отличается?
Откуда и какие появляются отличия разберем дальше. (Спойлер: у меня время добавления увеличилось в 2872 раза).

Массив — один из самых часто используемых типов в PHP. Понимание его внутренностей помогает:

  • избегать лишних аллокаций и пересчетов хэшей;

  • проектировать структуры данных;

  • прогнозировать пиковое потребление памяти;

  • не наступать на «микро‑грабли» производительности.

Два «режима» массива: packed и hash

В PHP тип array может быть реализован по-разному. Под капотом у него два режима хранения — упакованный (packed) и хэш‑таблица (hash).

Packed — упакованный список. Это список значений с ключами 0,1,2,… без строковых ключей. Все ключи – последовательность чисел, начиная с 0. Хранится как один непрерывный блок памяти со значениями. У него нет хэш‑индекса и нет «корзин», благодаря чему он экономный и быстрый.

Hash  — хэш‑таблица. Данная структура используется во всех остальных случаях. Когда ключи произвольные: строки, отрицательные, большие, с «дырами», смешанные типы. Данные хранится в хэш‑таблице: элементы распределяются по «корзинам» (buckets) по значению хэша ключа.

Как выбирается режим хранения данных

В пустом массиве структура не определена. Реальная структура выделится при первой записи и сразу определит режим.

Общее правило простое: если ключи массива начинаются с 0 и идут последовательно – это packed. В других случаях – hash. В packed могут быть небольшие «дырки», например после unset. Массив может переключиться в режим hash в случаях:

  •  Появился строковый ключ: $a[‘x’] = 1;

  • Отрицательный ключ: $a[-1] = 1;

  • «Большой прыжок» по индексу: $a[1_000_000] = 1 (PHP не станет выделять миллион пустых ячеек);

  • Некоторые операции, нарушающие «плотный» список, могут триггерить переключение.

PHP может переключить из packed в hash, но не наоборот.

<?php// 1) Пустой массив: реальной структуры ещё нет$a = [];// 2) Две вставки подряд -> packed$a[] = 'A'; // ключ 0$a[] = 'B'; // ключ 1// 3) Любая строка как ключ => конвертация в hash$a['user'] = 'Ivan'; // теперь массив в hash-режиме// 4) Большой прыжок по индексу -> hash$b = [];$b[] = 1; $b[] = 2; // packed$b[1_000_000] = 3;  // станет hash (не будет «миллиона пустых ячеек»)// 5) Отрицательный ключ -> hash сразу$c = [];$c[-1] = 'cat '; // hash

Заметка. array_is_list($arr) в PHP 8.1+ скажет, выглядит ли массив как «список» (ключи 0..n−1). Это хороший индикатор для кода, хотя он не гарантирует внутренний режим на 100% в каждую версию.

Как работает хеш-таблица в PHP

Представьте шкаф с множеством пронумерованных ящиков (корзин). Ключ элемента массива превращается в число (хеш), и по этому числу выбирается ящик — туда кладём и там потом ищем.

Как добавляется элемент

  • Считаем хеш ключа и выбираем ящик.

  • Если ящик пуст — кладём элемент.

  • Если в ящике уже что-то есть (коллизия) — кладем рядом (добавляем в цепочку внутри этого ящика).

  • Если такой ключ уже был — просто обновляем значение, дубля не создаётся.

Как найти элемент

  • Снова считаем хеш, идём в нужный ящик.

  • Просматриваем элементы в этом ящике и находим тот, у которого ключ совпадает.

Это быстро. В большинстве случаев.

Получается, что время доступа к элементам массива зависит от способа хранения данных в массиве. И, более того, в какой-то момент массив может изменить принцип хранения данных (переключиться в hash), что может занять достаточно большое время. Давайте проверим и убедимся, что разница действительно есть.

Ниже скрипт, который:

  • заполняет массив 10 000 элементами подряд (packed-режим), меряет общее время и среднее время добавления одного элемента;

  • затем добавляет элемент с ключом 100000000 (огромный «скачок» по индексу), меряет время этой одиночной вставки — тут как раз произойдёт конвертация из packed в hash;

  • добавляет ещё один элемент после конверсии (обычная вставка уже в hash-режиме), чтобы было с чем сравнить;

  • считает во сколько раз вставка с конвертацией медленнее обычной вставки в packed и обычной вставки в hash;

  • запускает сценарий несколько раз и показывает усреднённые результаты.

<?phpdeclare(strict_types=1);gc_disable();function median(array $xs): float {    sort($xs);    $n = count($xs);      return $n ? ($n % 2 ? $xs[intdiv($n, 2)] : ($xs[$n/2 - 1] + $xs[$n/2]) / 2) : NAN;}function ns(callable $fn): int {    $t = hrtime(true);    $fn();      return hrtime(true) - $t;}$N    = 10000;       // размер массива для заполнения$BIG  = 100000000;   // большой индекс, чтобы триггернуть конвертацию packed->hash$RUNS = 9;           // число повторовprintf(    "PHP %s | SAPI=%s | JIT=%s | int=%d | N=%d | BIG=%d | RUNS=%d\n",    PHP_VERSION,    PHP_SAPI,    ini_get('opcache.jit') ?: 'off',    PHP_INT_SIZE,    $N,    $BIG,    $RUNS);// Построение packed-массива только через $a[]$build = function() use ($N): array {    $a = [];    for ($i = 0; $i < $N; $i++) {        $a[] = $i;    }      return $a;}; // Небольшой прогрев, чтобы не ловить помехиfor ($w = 0; $w < 2; $w++) {    $tmp = $build();    unset($tmp);}$fill = $avgAppend = $conv = $hashIns = [];for ($r = 0; $r < $RUNS; $r++) {    $a = [];    // Заполнение packed    $tFill = ns(function() use (&$a, $build) { $a = $build(); });    $avg    = $tFill / max(count($a), 1); // ns на вставку      // Конвертация packed->hash установкой большого индекса    $tConv = ns(function() use (&$a, $BIG) { $a[$BIG] = -1; });    // Вставка после конвертации (уже hash-структура)    $tHash = ns(function() use (&$a) { $a[] = -2; });    $fill[] = $tFill;    $avgAppend[] = $avg;    $conv[] = $tConv;    $hashIns[] = $tHash;}// Агрегация: медианы$mf = median($fill) / 1e9; // в секундах$ma = median($avgAppend); // ns$mc = median($conv);       // ns$mh = median($hashIns);   // nsecho "\nМедиана (после прогрева):\n";printf("- Заполнение N=%d: %.6f s; среднее на вставку: %.1f ns\n", $N, $mf, $ma);printf("- Вставка с большим индексом (конвертация packed->hash): %.1f ns\n", $mc);printf("- Обычная вставка после конвертации: %.1f ns\n", $mh);printf("- Конвертация vs avg packed-вставка: ×%.1f\n", $mc / max($ma, 1.0));printf("- Конвертация vs обычная hash-вставка: ×%.1f\n", $mc / max($mh, 1.0));

В начале статьи был вопрос про добавление нового элемента. Именно из-за конвертации время вставки нового элемента в том примере отличается во много раз.

Обратите внимание на достаточно большую разницу даже при обычной вставке элементов.

Что такое корзины и коллизии (hash‑режим)

Корзина (bucket) — это «ящик» в хэш‑таблице, куда попадают элементы, у которых совпал индекс (hash(key) mod размер_таблицы).

Коллизия — когда разные ключи дают один индекс и попадают в одну корзину. Тогда внутри корзины элементы связываются в цепочку, и при поиске PHP перебирает цепочку до совпадения ключа. Важный момент – поиск элемента идет не только по хэшу, а также перебором элементов с одинаковым хэшем. В среднем доступ к элементам остаётся быстрым (O(1)), потому что корзин много, а элементы распределяются равномерно.

Коллизии «в живую»: пример с целочисленными ключами.

Давайте попробуем искусственно создать коллизии (положить много элементов в одну корзину) и сравнить скорость доступа к ним. Для этого нам нужен ассоциативный массив.

Для строк PHP добавляет случайную «соль» к хэшу на каждый процесс/запрос — специально устроить серию коллизий для строк из пользовательского кода практически невозможно. 

Но для целых ключей можно «искусственно» завалить одну корзину и увидеть замедление доступа.

Напишем скрипт, который создает 2 массива, но с разными ключами. В одном из массивов подберем ключи таким образом, чтобы они максимально попадали в одну и ту же корзину. Затем попробуем обращаться к элементам массива в случайном порядке.

<?phpfunction colliding(int $n, int $shift): array {    $a = [];    for ($i = 0; $i < $n; $i++) {          $a[$i << $shift] = $i;    }    return $a;}function distributed(int $n): array {    $a = [];    for ($i = 0; $i < $n; $i++) {          $a[($i << 1) | 1] = $i;    }    return $a;}function bench(array $a, string $label): void {    $keys = array_keys($a);    shuffle($keys);    $t0 = hrtime(true);    $s = 0;    foreach ($keys as $k) {        $s += $a[$k];      }    $dt = (hrtime(true)-$t0)/1e9;    printf("%s: %d lookups in %.4f s (sum=%d)\n", $label, count($a), $dt, $s);}$n = 10000; $shift = (PHP_INT_SIZE===8) ? 20 : 12;$A = colliding($n, $shift);$B = distributed($n); bench($A,'colliding');bench($B,'well-distributed');

Сравните полученные результаты. Скорость доступа к элементам в массиве отличается в 260 (php 7.4) и 178 раз (php 8.2).

Как массивы работают с памятью и как растут

Packed (вектор). Значения хранятся подряд в одном блоке памяти. Есть ёмкость (capacity). Пока есть запас — вставки производятся в конец (сложность O(1)). Когда места не хватает — аллоцируется больший блок памяти, старые элементы копируются в него. Операция дорогая (O(n)), но редкая. Усредненно вставка остаётся O(1).

Hash (хэш‑таблица). Следит за коэффициентом загрузки (load factor). Когда элементов становится слишком много — увеличиваем число корзин (обычно ×2) и  перераспределяем элементы по новой таблице. Удаления оставляют «дыры» (слоты, помеченные как пустые). Их стараются переиспользовать, периодически таблица может «чиститься».
Коэффициент загрузки (load factor) — это отношение количества элементов к количеству корзин. Когда это значение превышает определенный порог, PHP увеличивает размер таблицы, чтобы уменьшить количество коллизий.

Принцип copy‑on‑write (копирование по записи)

Представьте, что у вас есть массив с миллионом записей. Вы передаете его в функцию. Для ускорения работы PHP не копирует массив, а создает ссылку на него. Но если вы попытаетесь изменить элемент массива – запустится процесс копирования, который может занять время.

То же самое с обычным копированием. $b = $a — не копирует элементы. Обе переменные указывают на один и тот же массив (но счётчик ссылок > 1). Первое изменение одной из копий вызывает фактическое копирование данных. Это может стоить O(n).

Циклы с count() и циклы с foreach()

count

Часто можно встретить код:
for ($i = 0; $i < count($a); $i++) {…}

Здесь count($a) вставлен в условие цикла.

Можно заставить код работать быстрее (на больших массивах), просто вынеся count($a) в отдельную переменную. Для массивов count() — это операция O(1). Число элементов хранится внутри хеш-таблицы/списка в поле (в Zend HashTable это nNumOfElements) и обновляется на вставках/удалениях. Функция просто читает готовое значение. Но сам вызов функции в каждой итерации добавляет накладные расходы.

foreach

Также можно встретить код:
foreach ($a as &$v) {…}

Здесь $v передается по ссылке. И иногда комментируют, что этот подход будет быстрее, т.к. используется ссылка. Но все не так однозначно.

foreach по значению не копирует данные элементов. Это дешевая операция: движок лишь увеличивает счётчик ссылок на zval; фактические байты (строки/подмассивы) не копируются, пока вы их не меняете.

foreach по ссылке превращает элементы в «ссылки». Даже если вы их не меняете, движок вынужден работать с другой семантикой, что создаёт накладные расходы и может ломать packed-режим для части операций. Поэтому не используйте & без необходимости.

Если нужно менять исходный массив по месту — по ссылке это хороший вариант. Но нужно понимать, что при наличии других ссылок на массив (после b=a) произойдёт реальное копирование всего массива при первом изменении (copy-on-write).

Давайте попробуем сравнить варианты циклов.

  • сравним for с count() внутри и с вынесенным count();

  • сравним foreach по значению и foreach по ссылке (когда ссылка не нужна).

<?phpini_set('memory_limit', '1024M');printf("PHP %s, int-size=%d, JIT=%s\n",    PHP_VERSION, PHP_INT_SIZE, ini_get('opcache.jit') ?: 'off');function bench(string $label, callable $fn): void {    $fn();    $t0 = hrtime(true);    $res = $fn();    $dt = (hrtime(true) - $t0) / 1e9;    printf("%-35s %.6f s (checksum=%s)\n", $label . ':', $dt, $res);}function makeList(int $n): array {    // Последовательные ключи 0..n-1 (packed-массив)    $a = [];    for ($i = 0; $i < $n; $i++) {        $a[$i] = $i;     }    return $a;} $n = 500_000;$a = makeList($n); // 1) for с count() в условииbench("for (count() в каждой итерации)", function() use ($a) {    $sum = 0;    for ($i = 0; $i < count($a); $i++) { // вызов функции на каждой итерации        $sum += $a[$i];    }      return $sum;});// 2) for с вынесенным count()bench("for (count() вынесен)", function() use ($a) {    $sum = 0;    $len = count($a); // один раз    for ($i = 0; $i < $len; $i++) {        $sum += $a[$i];    }    return $sum;});// 3) foreach по значениюbench("foreach по значению", function() use ($a) {    $sum = 0;    foreach ($a as $v) { // копируется только «контейнер» zval (refcount++), данные не копируется        $sum += $v;    }    return $sum;});// 4) foreach по ссылкеbench("foreach по ссылке (без изменений)", function() use ($a) {    $sum = 0;    foreach ($a as &$v) { // элементы становятся «ссылочными», теряются оптимизации        $sum += $v;    }    unset($v); // важно очищать ссылку после foreach    return $sum;});

По результату видно, что вынесение count() дало небольшой прирост скорости. Однако важно понимать, что измерение было на большом массиве.

А вот foreach по ссылке и по значению показал разницу в 4 раза. Достаточно много.

array_values

Небольшой комментарий. Иногда имеет смысл вручную вернуть массив из hash в packed. Для этого можно использовать функцию array_values().

Про память

Давайте сравним, какой тип массива сколько памяти требует. На примере. Без комментариев.

<?phpfunction usage() {    printf("mem=%.1f MB\n", memory_get_usage()/1024/1024);}$n = 200_000;$a = [];usage();for ($i = 0; $i < $n; $i++) {    $a[] = $i; // packed}echo " packed: ";usage();$a['x'] = 1; // конвертация в hashecho "Переключение в hash: ";usage();// Вернуться к packed$a = array_values($a);echo "После array_values (снова packe): ";usage();

Данный пример показывает отличие в занимаемой памяти. Обратит внимание, насколько PHP 8.2 экономичнее. Еще нюанс — память выделяется блоками. Если в примере выше поставить на 200К элементов, а 250К — результаты теста занимаемой памяти не изменятся.

Как PHP увеличивает память при росте массива

Давайте посмотрим, в какой момент и на сколько PHP увеличивает память и копирует массив при увеличении количества элементов в нем.

<?php$arr = [];$prev = memory_get_usage();echo "Элемент\tПамять\t\tДельта\n";echo "==========================================\n";for ($i = 0; $i < 100000; $i++) {    $arr[] = $i;    $curr = memory_get_usage();    $delta = $curr - $prev;        if ($delta > 0 && $i > 0) {        echo "$i\t\t" . number_format($curr) . " B\t" . number_format($delta) . " B\n";    }        $prev = $curr;}?>

Результат:

При достижении порогового значения выделяемая память увеличивается в 2 раза.

Итоги

  • Массив в PHP — это либо быстрый список (packed), либо универсальная хэш‑таблица (hash).

  • Выбирая ключи и способ вставки, вы влияете на внутреннюю структуру, а значит — на скорость и память.

  • Понимание, когда случается расширение или обновление хэшей, и как работает copy-on-write, помогает писать предсказуемый и экономичный код.

ссылка на оригинал статьи https://habr.com/ru/articles/947162/