PHP配列の隠された実態:Packed ArrayからHash Arrayへの内部遷移と極限のメモリ最適化
PHPの `array` 型ほど、その気安さゆえにエンジニアから侮られているデータ構造はない。リストとしても、連想配列としても、セットとしても振る舞うこの「何でも屋」のコンテナは、PHPアプリケーションのメモリフットプリントとCPUキャッシュヒット率を左右する最大のボトルネックであり、同時に最適化のフロンティアである。
表面的な構文の裏で、Zend Engineは猛烈な勢いでメモリレイアウトの動的最適化を行っている。とりわけ、インデックスの連続性によって発動する Packed Array と、任意のキーや順序不整合によってフォールバックする Hash Array の間の遷移メカニズムを理解していなければ、大規模トラフィックを捌くWebシステムのアーキテクチャ設計において致命的な見落としを生む。
今回は、Zend VMのC言語レベルのソースコード(Zend Engine)の挙動に踏り込み、配列が辿る運命の分岐点と、メモリ効率を極限まで高めるためのデータ構造設計の真髄を解き明かす。
—
1. Zend VMにおける配列の正体:`zend_array` と HashTable
PHPの配列は、スクリプト言語の動的な利便性を保つため、内部的には `HashTable` 構造体として実装されている。Cの観点から見れば、これは単なるハッシュマップではなく、「二重連結リスト」と「衝突解決のためのリンクリスト」、そして「連続したバッファ」が一体となった怪物である。
Zend Engine(PHP 7以降)における配列の実体である `zend_array`(エイリアスとして `Bucket` や `HashTable` を内包)のメモリ構造を俯瞰する。
typedef struct _zend_array HashTable;
struct _zend_array {
zend_refcounted_h gc;
union {
struct {
zend_uchar flags;
zend_uchar _unused;
zend_uchar nIteratorsCount;
zend_uchar _unused2;
} v;
uint32_t flags;
} u;
uint32_t nTableMask;
Bucket arData; // 実際のデータを格納する連続メモリ領域
uint32_t nNumUsed; //arData内で使用されている(または削除マークがついている)バケットの数
uint32_t nNumOfElements;//配列に実際に存在する要素の数
uint32_t nTableSize; //arDataの総サイズ(常に2のべき乗)
int32_t internal_pointer;
zend_long nNextFreeElement;
dtor_func_t pDestructor;
};
この構造体の中で最も注目すべきは `arData` である。配列に要素が追加されると、Zend Engineはこの `arData` に対してメモリを動的に割り当てる。ここでのアロケーション戦略こそが、メモリ効率の分水嶺となる。
—
2. Packed Array:連続した奇跡の最適化
配列を `$arr = [10, 20, 30, 40];` のように、キーを明示せず、0から始まる連続した整数インデックスで順次構築したとき、Zend VMはこれを特別なモードである Packed Array(Packed HashTable) として扱う。
Packed Arrayの内部挙動
1. ハッシュ計算のスキップ: キーのハッシュ値(DJB3A亜種)を計算する必要がない。インデックスがそのまま配列のオフセットとなる。
2. バケットの簡略化: 通常の `Bucket` 構造体はキーの文字列やハッシュ値を持つが、純粋なPacked Arrayでは、値(`zval`)の配列として連続したメモリ空間に配置される。
3. キャッシュ効率の最大化: CPUのL1/L2キャッシュラインにデータが連続して載るため、走査(`foreach` や `array_map` 等)の速度が劇的に向上する。C言語のネイティブな配列と同等のメモリ局所性を発揮する。
—
3. Hash Arrayへの暗黙の昇格(Promotion)とそのトリガー
しかし、この優美なPacked Arrayは、開発者が何気なく書く一つの操作によって、一瞬にして重厚長大でメモリを食う Hash Array(通常のHashTable) へと強制降格させられる。これを「Hash Arrayへの遷移」と呼ぶ。
Zend EngineがPacked ArrayからHash Arrayへ昇格させる条件は主に以下の通りである。
① 順序の不整合・穴あきインデックス(Sparse Array)
② キーの逆順・ランダム挿入
③ 文字列キーの混入
④ 要素の削除による穴あきと再構築
要素の削除(`unset($arr[1])`)が行われた場合、即座にメモリが縮小されるわけではない。削除されたスロットには「墓石(Tombstone / 削除マーク)」が残り、その後の追加やパージ処理の過程で構造の再編(Rehash)が発生する。
—
4. メモリ消費の境界線:コードによる検証
百聞は一見にしかず。PHPの低レイヤメモリ消費量を正確に計測するためには、`memory_get_usage()` を用いる。以下のコードは、Packed ArrayからHash Arrayへの遷移に伴うメモリフットプリントの膨張を暴くものである。
/
function report_memory(string $label, array $arr) {
// ガベージコレクションを強制実行し、純粋な配列のメモリを測定
gc_collect_cycles();
$mem = memory_get_usage();
echo sprintf(“[%s] Memory: %8d bytes | Elements: %d\n”, $label, $mem, count($arr));
}
// 1. ベースライン(空の配列)
$baseMem = memory_get_usage();
// 2. 綺麗なPacked Arrayの構築
$packed = [];
for ($i = 0; $i < 10000; $i++) {
$packed[] = $i; // 連続した整数インデックス
}
report_memory("Packed Array (Sequential) ", $packed);
// 3. 意図的に穴を開けてHash Arrayへ強制昇格させる
$sparse = [];
for ($i = 0; $i < 10000; $i++) {
$sparse[$i 10] = $i; // インデックスを分散させ、完全に非連続にする
}
report_memory("Hash Array (Sparse/Non-seq)", $sparse);
// 4. 文字列キーを混入させた場合
mixed_array = [];
for ($i = 0; $i < 10000; $i++) {
$mixed_array["key_" . $i] = $i;
}
report_memory("Hash Array (String Keys) ", $mixed_array);
実行結果からの洞察
このコードを実行すると、`Packed Array` と `Hash Array` では、同一要素数(10,000件)であっても消費メモリに数倍(環境やZendのバージョンによるが、バケットオーバーヘッドとポインタの分だけ大きく)差が出る。
Hash Arrayは、各要素が `Bucket` 構造体を持ち、さらにハッシュ 충돌(collision)を防ぐためのリスト構造やインデックス用のビットマスク領域を確保するため、圧倒的なメモリのオーバーヘッドを背負うことになる。
—
5. 極限のデータ構造設計:パフォーマンスチューニングの鉄則
大規模トラフィックを扱うWebシステムにおいて、この仕様を無視したコードは、PHP-FPMワーカーのメモリ制限(`memory_limit`)を圧迫し、OOM(Out of Memory) Killerの餌食になるか、あるいは頻繁なGCとメモリ再割り当てによるCPUサイクル(CPUグラインド)を引き起こす。
最高峰のアーキテクトが実践する、配列メモリ最適化の極意を提示する。
1. 大規模データの一括生成時は「必ず」0から連番で積む
APIレスポンスなどでDBから数万件のレコードを配列に格納して処理する場合、途中で `unset` を挟んだり、主キー(ID)をそのまま配列のインデックスにしたりしてはならない。
// ❌ 悪手:主キーをインデックスにすると、IDが連番であっても削除や順序の都合でHash Arrayに落ちやすい
$map = [];
foreach ($records as $record) {
$map[$record[‘id’]] = $record;
}
// ⭕ 盛装:純粋なシーケンシャルリストとして構築し、ルックアップが必要な場合は別アプローチを取る
$list = [];
foreach ($records as $record) {
$list[] = $record; // 完璧なPacked Array
}
2. 連想配列(マップ)が必要な場合は、SPLの固定長配列やロジックの分離を検討する
もしキーによるO(1)の高速検索が絶対条件であるなら、HashTableのオーバーヘッドを受け入れる覚悟が必要だが、もしメモリが逼迫しているなら、文字列キーではなく数値IDを適切に扱い、可能であればSplFixedArray(ZendのHashTable層をバイパスし、Cのネイティブ配列に近いメモリブロックを確保する)の採用を検討せよ。
6. まとめ:Zend VMと共鳴するコードを書くということ
PHPは「動的で優しい言語」という顔の裏に、Zend VMという極めて洗練されたC言語のエンジンを隠し持っている。初心者向けのチュートリアルでは語られないこの「Packed ArrayとHash Arrayの境界線」を意識できるか否かが、中流エンジニアと、数千万リクエストを余裕で捌く超一流のWebシステムアーキテクトを分ける境界線である。
メモリは無限ではない。CPUキャッシュは貴重である。
Zend VMがどのようにメモリを割り当て、どの瞬間に構造を昇格させるのか——そのメカニズムを脳内にトレースし、エンジンと対話するようなコードを紡ぎ出すことこそが、真のPHPマスタリーへの唯一の道なのである。