Массивы — это хлеб и масло PHP-разработчика. Мы используем их постоянно, но редко задумываемся, как они устроены внутри. А от этого устройства напрямую зависит скорость и память нашего приложения. Давайте разберемся.
Давайте развеем 3 мифа:
Доступ к элементам массива всегда занимает одинаковое время.
В PHP обычный и ассоциативный массивы – одно и то же.
Использовать ссылку в 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;
запускает сценарий несколько раз и показывает усреднённые результаты.
<?php declare(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); // ns echo "\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 массива, но с разными ключами. В одном из массивов подберем ключи таким образом, чтобы они максимально попадали в одну и ту же корзину. Затем попробуем обращаться к элементам массива в случайном порядке.
<?php function 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 по ссылке (когда ссылка не нужна).
<?php ini_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().
Про память
Давайте сравним, какой тип массива сколько памяти требует. На примере. Без комментариев.
<?php function 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; // конвертация в hash echo "Переключение в 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, помогает писать предсказуемый и экономичный код.
