Массивы — это хлеб и масло 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;
запускает сценарий несколько раз и показывает усреднённые результаты.
<?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 — это либо быстрый список (packed), либо универсальная хэш‑таблица (hash).
Выбирая ключи и способ вставки, вы влияете на внутреннюю структуру, а значит — на скорость и память.
Понимание, когда случается расширение или обновление хэшей, и как работает copy-on-write, помогает писать предсказуемый и экономичный код.
Zippy
Как будто попал в старый добрый хабр.
Статья о програмированиии а не вайбкодировании и написании промптов