【テクニカル・上級編】PHPの内部配列(Packed Array vs Hash Array)の切り替え条件とメモリ消費 – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

PHP配列の正体を暴く:Packed ArrayとHash Arrayのメモリ空間とフォールバックメカニズムの深層

PHPの `array` 型ほど、その気安さと引き換えに内部実装の複雑さを隠蔽しているデータ構造はない。多くのエンジニアは、配列を「何でも入る便利なリスト/マップ」として扱い、メモリの物理配置やZend EngineのC言語レベルでの挙動を意識することはない。

しかし、数百万リクエストを捌く高負荷システムや、メモリ制限がシビアなマイクロサービスアーキテクチャにおいて、PHPの配列がどのようにメモリを消費し、どのような条件でパフォーマンスの崖(Cliff)を転がり落ちるのかを知ることは、シニアアーキテクトにとって必須の素養である。

PHP 7で導入された配列の内部構造改革、特に Packed Array(連続配列) と Hash Array(ハッシュ配列) の動的な切り替わりとフォールバックのメカニズムを、Zend VMの低レイヤ視点から解き明かす。

—

1. Zend Engineにおける配列の基本構造:`Bucket` と `HashTable`

PHPの配列は、C言語のレベルでは `zval`(Zend Value)のコンテナであり、実体は `HashTable` 構造体として実装されている。

従来(PHP 5以前)の配列は、すべての要素が双方向リストで結ばれた純粋なハッシュテーブルであった。これにより、整数インデックスであっても文字列キーであっても、内部的にはハッシュ関数を通し、衝突解決のためのポインタを辿るオーバヘッドが存在した。さらに、要素一つ一つ(`Bucket`構造体)がヒープ上のバラバラなメモリ領域に散らばり、CPUキャッシュヒット率の観点から非常に非効率な構造をしていた。

PHP 7以降、この設計は根本から覆された。配列が「純粋な連番のリスト」である場合、Zend Engineはハッシュテーブルとしての機能を捨て、Cの配列のようにメモリ上に連続して要素を配置する Packed Array モードに身を落とす。

物理メモリ上の構造差

  • Packed Array: キーのハッシュ計算をバイパスし、`zval` の配列(`Bucket` の値部分、あるいは直接の `zval`)を連続したメモリ領域に確保する。インデックスアクセスは `O(1)` かつポインタ追従(Pointer Chasing)を最小限に抑え、CPUのL1/L2キャッシュを極限まで効率化する。
  • Hash Array: キーが順序不同である、文字列キーが含まれる、あるいは一度Packed Arrayの条件から外れた場合にフォールバックする。ハッシュ衝突チェーン、グローバルなハッシュ用インデックス(`arData`)、衝突解決のためのポインタを持つ `Bucket` 構造体の配列へと変貌する。

—

2. Packed ArrayからHash Arrayへフォールバックする瞬間

Zend VMは、配列の初期化時や要素の追加時に、その配列が「純粋なシーケンシャル配列」であるかを常に監視している。以下の条件のいずれか一つでも満たされた瞬間、配列はPacked ArrayからHash Arrayへ降格(フォールバック)する。

1. キーの順序の逆転・不連続:
`$arr[1] = ‘a’; $arr[0] = ‘b’;` のように、追加順序が内部の物理インデックスと一致しない場合。
2. 歯抜けのインデックス(穴あき配列):
`$arr[0] = ‘a’; $arr[2] = ‘b’;` のようにインデックスが連続していない場合(一定の閾値を超えるスキップ)。
3. 文字列キーの混入:
`$arr[‘key’] = ‘val’;` が追加された瞬間、ハッシュ計算が不可避となるため。
4. 負のインデックスや巨大なインデックスの指定。

一度Hash Arrayにフォールバックした配列は、要素をすべて削除して空にしない限り、Packed Arrayには二度と戻らない。この挙動は、OPcacheの最適化コンテキストや、大規模なデータ処理ループにおいて致命的なメモリ肥大化を引き起こす原因となる。

—

3. コード検証:メモリ消費量の劇的な変化

以下のPHPコードを実行し、配列の構築方法の違いがメモリ消費と内部構造にどのような影響を与えるかを実測・検証する。

  • PHPコア・内部配列のメモリ消費検証スクリプト
  • 実行環境: PHP 8.2+
  • /

    // ガベージコレクションを一旦無効化し、純粋なメモリ割り当てを計測
    gc_disable();

    function print_memory_usage(string $label, array $arr): void {
    //memory_get_usage(true) はヒープ全体のサイズを返す
    $mem = memory_get_usage(true);
    // 実際に配列が保持されている概算サイズ(zend_allocのオーバーヘッド含む)を視覚化
    echo “[$label] メモリ使用量: ” . number_format($mem) . ” bytes\n”;
    }

    echo “=== 1. Packed Arrayの生成 (連続した整数キー) ===\n”;
    $baseMem = memory_get_usage(true);
    $packed = [];
    for ($i = 0; $i < 100000; $i++) { $packed[] = $i; // 連続追加 -> Packed Arrayとして最適化される
    }
    print_memory_usage(“Packed Array”, $packed);

    echo “\n=== 2. Hash Arrayへのフォールバック (順序の逆転) ===\n”;
    $hashReversed = [];
    // あえて逆順で代入し、Packedの条件を破壊する
    for ($i = 100000; $i > 0; $i–) {
    $hashReversed[$i] = $i;
    }
    print_memory_usage(“Hash Array (Reversed)”, $hashReversed);

    echo “\n=== 3. Hash Arrayの生成 (文字列キーの混入) ===\n”;
    $hashString = [];
    for ($i = 0; $i < 100000; $i++) { $hashString["key_" . $i] = $i; } print_memory_usage("Hash Array (String Keys)", $hashString);

    実行結果からの洞察

    上記のコードをCLIで実行すると、`Packed Array` と `Hash Array` の間で明確なメモリ消費量の差(おおよそ1.5倍から2倍以上の差、およびCPUキャッシュミスによる実行速度の劣化)が観測される。

    Hash Arrayにフォールバックすると、各 `Bucket` 構造体(通常、64ビット環境では32バイト〜40バイト以上)に加え、ハッシュ値(`zend_ulong h`)、キーのポインタ、次の要素へのポインタなどが付随するため、データペイロード以外のメタデータ領域が肥大化する。

    —

    4. OPcacheとJITのコンテキストにおける配列最適化

    PHP 8で導入されたJIT(Just-In-Time)コンパイラおよびOPcacheの最適化フェーズにおいて、配列の型と構造の予測可能性は極めて重要な意味を持つ。

    OPcacheのプリローディング(Preloading)段階でスクリプトがメモリにロードされる際、グローバル定数や設定配列などが初期化される。ここで意図せずHash Arrayとして評価される配列構造を定義してしまうと、プロセス全体のベースラインメモリ(Shared Memory / SHM)が不必要に圧迫される。

    JITが有効な環境では、Zend VMのオペコード(例: `INIT_ARRAY`, `ADD_ARRAY_ELEMENT`)は、対象の配列がPacked Arrayであることが静的あるいは動的に保証されている場合、ネイティブな機械語レベルでのインライン展開や、ハッシュ関数の呼び出し(`zend_string_hash_val`など)を完全にバイパスする最適化コードへコンパイルされる。

    [OPcode: ADD_ARRAY_ELEMENT]
    ↓ (Packed Array判定: True)
    [メモリへの直接高速ストア (Direct Offset Assignment)]
    ↓ (Packed Array判定: False)
    [Hash関数計算 -> 衝突解決 -> Bucketポインタ割当 (重い処理)]

    この分岐の有無が、秒間数万リクエストを処理するAPIサーバのレイテンシ(p99)を決定づけるファクターとなる。

    —

    5. セキュリティとパフォーマンスの交差点:配列インジェクションとメモリ枯渇(DoS)

    この配列の内部実装特性は、パフォーマンスだけでなくセキュリティの文脈においても極めて危険な脆弱性ベクターになり得る。

    攻撃者が外部からの入力(JSONリクエストやクエリパラメータ)を巧みに操作し、PHPの配列に対して意図的に不規則なキー(例:極端に大きな整数キーや、散発的なインデックス)を大量に投入した場合、Zend Engineは無限にHash Arrayへのフォールバックとハッシュテーブルの動的拡張(Re-hashing)を強いられる。

    これが悪化すると、以下の問題を引き起こす:

    • CPUバウンドなDoS攻撃: ハッシュ衝突を誘発する入力によるCPU使用率の100%張り付き。
    • メモリ枯渇(Memory Exhaustion): Packed Arrayで済むはずのデータ構造がすべてHash Arrayのオーバーヘッドを背負うことによる、想定外のヒープメモリ消費。

    防御的アーキテクチャの鉄則

    1. 入力データのサニタイジングと型厳格化:
    外部からの配列データを受け取る際は、必ずキーが連続した期待通りの数値または安全な文字列であるかをバリデーションし、生データをそのままドメインロジックの深部へ持ち込ませない。
    2. SplFixedArrayの活用:
    サイズが事前に確定している、あるいは純粋な高速シーケンシャル処理が必要な場合は、通常のPHP配列(`array`)ではなく、標準ライブラリの `SplFixedArray` を強制する。`SplFixedArray` は最初からCのネイティブ配列に近いメモリレイアウトを持ち、ZendのHash Arrayのオーバヘッドを完全に排除できる。

    結言

    PHPの配列は「魔法の箱」ではない。それはC言語で書かれた緻密なメモリ管理機構の表れであり、Zend VMの挙動を無視したコードは、いかにモダンなPHP 8.x環境であっても、そのポテンシャルの半分も引き出すことはできない。

    アーキテクトたる者、コードの表面的な美しさだけでなく、その一行がZend VMのメモリ空間上でどのようなオペコードを生み出し、どのように `HashTable` を揺さぶるのかを常に脳内でトレースできなければならない。ハードウェアとエンジンの境界線を意識した者だけが、真にスケーラブルなWebシステムを構築できる。

    タイトルとURLをコピーしました