PHPの配列は「魔法の箱」ではない:内部メモリ構造から紐解くPacked Arrayの真実
コードレビューをしていると、未だに「PHPの配列は何でも入れられる便利なハッシュマップだ」という認識でコードを書いているジュニアやミドルクラスのエンジニアを見かける。`$data = [];` と書き始め、数値添字を入れたり、途中で文字列キーを混ぜたり、果ては動的に要素を削除して穴あきにしたりする。
言っておくが、PHP 7以降のZendエンジンにおいて、配列(`zend_array`)は単なるハッシュテーブルではない。それは「連続したメモリ領域に値が並ぶ高密度な構造(Packed Array)」から、「ポインタの奔流によってキャッシュミスを誘発する従来のハッシュ構造(Hash Array)」まで、動的に姿を変える極めて高度なデータ構造である。
この変換メカニズムを理解していないということは、スポーツカーのエンジンルームに砂利を放り込むようなものだ。数百万件を扱うAPIや、高スループットを求められるWebアプリケーションにおいて、この内部仕様の無知は致命的なメモリ肥大化とCPUキャッシュの効率悪化を招く。
今回は、Zend VMの内部実装の深部まで潜り込み、Packed Arrayがどのような条件で破壊され、Hash Arrayへフォールバックするのか、そして実務でそれをどうコントロールすべきかを徹底的に解説する。
—
Zend VMにおける配列(`zend_array`)の二面性
PHPのすべての配列は、内部的にはC言語の構造体である `zend_array`(旧名 `HashTable`)として表現されている。この構造体の中身は、PHP 7での大幅なリファクタリングによって劇的に効率化された。
配列には大きく分けて2つのモードが存在する。
1. Packed Array(連続配列モード)
- キーが `0` から始まる連続した整数であり、かつ挿入順序が昇順である場合、Zendエンジンはこの配列を「純粋なCの配列」のように扱う。
- ハッシュ計算のオーバーヘッドがゼロであり、値(`zval`)がメモリ上で物理的に連続して配置される。
- CPUのキャッシュヒット率が極めて高く、メモリ消費量も最小限に抑えられる。
2. Hash Array(ハッシュ配列モード)
- 文字列キーが含まれる、整数キーが連続していない(飛び石になっている)、あるいは順序がバラバラに挿入された場合、配列はこのモードにフォールバックする。
- ハッシュ値計算のためのテーブルと、衝突解決のためのリンクポインタ(`zval`のリスト構造)が必要になり、メモリ消費量が跳ね上がる。
メモリ消費量の圧倒的な差
文字や数字を適当に詰め込んだ配列と、美しく整えられたPacked Arrayでは、1要素あたり数十バイト単位(64bit環境のZend VM上ではポインタとアライメントを考慮すると数倍の差)でメモリフットプリントが変わる。数万件のレコードを処理するバッチや、大規模なJSONレスポンスを組み立てるAPIサーバーにおいて、この差がOOM(Out of Memory)エラーの引き金になることは想像に難くないだろう。
—
Packed ArrayがHash Arrayへ堕ちる瞬間(フォールバック条件)
では、どのようなコードを書いたときに、Zendエンジンは悲鳴を上げながらPacked ArrayからHash Arrayへ内部構造を切り替えるのだろうか。その条件は主に以下の3つに集約される。
1. 非順序・非連続の整数キーの代入
// 最初から飛び石のインデックスを指定した場合
$arr = [];
$arr[10] = ‘a’;
$arr[0] = ‘b’; // 順序が逆転、かつ連続していない
Zendエンジンは内部の `ZARR_INIT` または要素追加時に「キーが現在の最大インデックスと一致しているか」「順序が昇順か」を厳しく監視している。ここが崩れた瞬間、インデックスの最適化が解除される。
2. 文字列キーの混入
$arr = [10, 20, 30]; // ここまではPacked Array
$arr[‘id’] = 40; // 文字列キーが混入した瞬間、Hash Arrayへ完全移行
一度文字列キーが混入すると、たとえその後に数千個の綺麗な整数キーを追加したとしても、元のPacked Arrayには戻らない(配列が破棄されて新しく作られない限り)。
3. 要素の削除と「穴(Holes)」の発生
$arr = [0 => ‘a’, 1 => ‘b’, 2 => ‘c’];
unset($arr[1]); // 中間を削除すると「穴」ができる
配列の中間要素を `unset` すると、メモリ上の連続性が断たれる。この状態の配列に対して末尾以外への追加や走査が発生すると、内部的な再構築やフラグの変更が生じる。
—
実務のための検証コード:内部構造の変化を暴く
百聞は一見にしかず。PHPのメモリ消費量を精密に測定し、どのような操作がメモリに牙をむくのかを証明する実用的なリファレンスコードを提示する。
/
declare(strict_types=1);
class ArrayMemoryProfiler
{
private int $initialMemory;
public function __construct()
{
// ガベージコレクションを強制し、ベースラインを安定させる
gc_collect_cycles();
$this->initialMemory = memory_get_usage(true);
}
public function measure(string $label, callable $callback): void
{
$before = memory_get_usage(true);
$timeBefore = microtime(true);
$result = $callback();
$after = memory_get_usage(true);
$timeAfter = microtime(true);
$memoryUsed = $after – $before;
$totalMemory = $after – $this->initialMemory;
$timeSpent = ($timeAfter – $timeBefore) 1000;
echo sprintf(
“[%s]\n” .
” -> 差分メモリ: %10s bytes\n” .
” -> 累計メモリ: %10s bytes\n” .
” -> 実行時間: %10.2f ms\n\n”,
$label,
number_format($memoryUsed),
number_format($totalMemory),
$timeSpent
);
}
}
$profiler = new ArrayMemoryProfiler();
$iterations = 100_000;
// パターンA: 完璧なPacked Array(0から始まる連続した整数キー)
$profiler->measure(“Pattern A: 理想的なPacked Array”, function() use ($iterations) {
$arr = [];
for ($i = 0; $i < $iterations; $i++) {
$arr[] = $i; // 自動インクリメントによる連続キー
}
return $arr;
});
// パターンB: 文字列キーの混入によるHash Arrayへのフォールバック
$profiler->measure(“Pattern B: 文字列キーが混入した配列”, function() use ($iterations) {
$arr = [];
for ($i = 0; $i < $iterations; $i++) {
$arr[$i] = $i;
}
// 途中で文字列キーを1つ混ぜる(これで内部構造がHashTableモードに強制変更される)
$arr['trigger_fallback'] = true;
return $arr;
});
// パターンC: 逆順・飛び石による非効率なインデックス
$profiler->measure(“Pattern C: 飛び石・非順序の整数キー”, function() use ($iterations) {
$arr = [];
// あえて逆順で代入
for ($i = $iterations – 1; $i >= 0; $i–) {
$arr[$i] = $i;
}
return $arr;
});
実行結果から読み解くエンジニアリングの要諦
このスクリプトをCLIで実行すると、Pattern A(Packed Array)が圧倒的にメモリを節約し、Pattern BやCでは余分なハッシュテーブルのオーバーヘッドによってメモリ消費量が跳ね上がる(環境やPHPのバージョンにもよるが、数十MB単位の差が出る)ことが確認できるはずだ。
—
現場で使える!堅牢な配列設計のルール
WebアプリケーションやAPIのパフォーマンスを極限まで高めるため、テクニカルリードとしてチームに徹底させるべき設計ルールを明文化する。
1. DTOやコレクションでは「型と順序」を厳守する
APIのレスポンスデータを組み立てる際、オブジェクトではなく配列( associative array )を多用するケースは多い。その際、連想配列のキーの順序がバラバラな状態でループを回すと、Zend VMのキャッシュ効率が落ちる。
- データを生成する際は、必ずキーの追加順序を統一する。
- 動的にキーを追加するのではなく、初期段階で必要なキーを網羅したスキーマ(テンプレート)を定義しておく。
2. 大規模データ処理では Generator を活用する
数万件のDBレコードを一度に配列にフェッチして処理するのは悪手である。
function fetchLargeDataset(PDO $pdo): Generator
{
$stmt = $pdo->query(“SELECT id, name, payload FROM huge_table”);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
// 1行ずつyieldすることで、メモリ上に巨大な配列が生成されるのを防ぐ
yield $row;
}
}
メモリ上に巨大な `zend_array` を展開するのではなく、ストリームとして処理を流すことで、Packed ArrayやHash Arrayのメモリ肥大化問題そのものを回避できる。
3. 配列の削除(`unset`)を安易に行わない
パフォーマンスクリティカルなループ内で `unset($arr[$key])` を多用すると、配列内部の「穴」の管理や再インデックス化が発生し、CPUサイクルが無駄に消費される。
- むしろ「必要な要素だけを新しい配列に集める(filtering)」アプローチをとる方が、Zendエンジンにとってもクリーンであり、結果的に高速に動作することが多い。
—
まとめ:底を知る者が、PHPを制す
PHPは「書けば動く言語」であるゆえに、内部の最適化機構に無頓着なコードでも動いてしまう。しかし、高負荷なプロダクション環境において、エンジニアの無知はそのままサーバーのコスト増大とレイテンシの悪化に直結する。
Zend VMがどのようにメモリを割り当て、どのような条件でPacked ArrayからHash Arrayへ堕ちるのか。その境界線を脳内に焼き付けておけば、コードレビューの精度は劇的に変わり、あなたの書くPHPコードは、他の誰よりも美しく、かつ圧倒的に速い最高峰のシステムへと昇華されるはずだ。