Массивы — это хлеб и масло 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, помогает писать предсказуемый и экономичный код.

Комментарии (0)


  1. Zippy
    15.09.2025 18:37

    Как будто попал в старый добрый хабр.

    Статья о програмированиии а не вайбкодировании и написании промптов