【入門編】HashTableの衝突とメモリ消費:PHP 8のPacked Array最適化の内部構造 – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

こんにちは。普段から大規模なWebシステムの裏側を支えていると、「PHPの配列って、いったい中でどうなっているんだろう?」という素朴かつ深遠な疑問にぶつかることありませんか?

他の言語、例えばJavaやGo、あるいはTypeScriptなどを深く触ってきた優秀なエンジニアほど、「PHPの `array` は連想配列でありながら順序付きマップであり、リストとしても振る舞う。なのにメモリ効率が悪くないのはなぜだ?」と違和感を覚えるはずです。

実はPHP 8の時代において、私たちが何気なく書いている `$arr = []` というコードは、Zendエンジン内部で驚くべき省メモリ化と高速化の恩恵を受けています。今回は、PHPの心臓部である 「HashTable」と「Packed Array(密な配列)」 の関係を、低レイヤのメモリ配置の視点から紐解いていきましょう。

ここを理解すると、あなたの書くPHPコードのパフォーマンスは一段上のステージに到達しますよ。

—

1. PHPの配列の正体:すべては「HashTable」から始まる

まず大前提として、PHPの配列(Zend Array)の本質は、言語仕様上の「配列」ではなく、順序付きハッシュテーブル(Ordered HashTable) です。

C言語レベルの構造体で見ると、PHPの配列は大きく分けて以下の2つの要素で構成されています。

1. データ本体(Bucket配列): 実際の値(zval)やキーのハッシュ値を格納するメモリ領域。
2. インデックス(マッピング用配列): ハッシュ衝突(Collision)を解決しつつ、要素の追加順序を保持するための索引領域。

もしあなたが、ただ順番にデータを追加していくだけのリストを次のように書いたとします。

$list = [];
$list[] = ‘apple’;
$list[] = ‘banana’;
$list[] = ‘cherry’;

一見すると、ただの順番が保証されたシンプルな配列に見えますよね。しかし、素朴なHashTableとしてこれを処理しようとすると、Zendエンジンは「キーの文字列ハッシュ計算」「ハッシュ値からインデックスへのマッピング」「Bucket構造体ごとのポインタ追跡」という、無駄なオーバーヘッドを支払うことになります。

メモリ上でも、キー文字列を格納する構造やハッシュ衝突を防ぐためのポインタが散らばり、キャッシュヒット率(CPUキャッシュ)が低下してしまいます。

これを劇的に救うのが、PHP 8が標準で行っている 「Packed Array(密な配列)への最適化」 なのです。

—

2. Packed Arrayへ「昇格」する条件とは?

Zendエンジンは、配列が生成された瞬間からハッシュテーブルとしてフルスペックで動くわけではありません。メモリを極限まで節約し、C言語のネイティブな配列と同等の速度を出すために、一定の条件を満たした配列を Packed Array(別名:IMMUTABLE / PACKED 状態) へと昇格させます。

では、どのような条件の時にPacked Arrayになるのでしょうか? 内部ソースコード(Zend VM)の挙動を覗いてみましょう。

  • 条件1:キーが「純粋な整数(Integer)」であり、かつ「0から始まる連番」であること。
  • 条件2:キーが途中で抜けていないこと(穴あき配列ではないこと)。
  • 条件3:キーが逆順やランダムではなく、自然順(0, 1, 2, 3…)で追加されていること。

もしあなたが以下のようなコードを書いた場合、これは完全にPacked Arrayの条件を満たします。

// この配列は内部でPacked Arrayとして最適化されます!
$users = [];
$users[] = ‘Alice’; // index 0
$users[] = ‘Bob’; // index 1
$users[] = ‘Charlie’; // index 2

この状態のとき、Zendエンジンはハッシュの計算や衝突解決のための索引領域を完全にバイパスします。メモリ上には、純粋な `zval`(値)が連続したメモリブロックとして一直線に並ぶことになります。

逆転の発想:なぜ「穴あき」や「文字列キー」で陥落するのか?

では逆に、次のようなコードを実行するとどうなるでしょうか?

$badArray = [];
$badArray[0] = ‘Alice’;
$badArray[5] = ‘Bob’; // いきなりインデックスが飛んだ!

ここでインデックスに「穴(Gap)」が空きました。Zendエンジンは、「あ、これは純粋な連続したリストではないな」と判断し、この瞬間に配列を通常の HashTable(ハッシュテーブル)構造へ「降格(フォールバック)」 させます。

一度HashTableに落ちると、たとえ後から綺麗にデータを詰めたとしても、自動的にPacked Arrayには戻りません(リクエストライフサイクル内での話です)。この「密から疎への変化」を知っているだけでも、大量データを扱う際のメモリ消費量をコントロールできるようになりますよね。

—

3. メモリ消費の差を脳内シミュレーションする

実際に、10万件のデータを扱うときのメモリの使われ方をイメージしてみましょう。

ケースA:完全にPacked Arrayとして維持された場合

  • データ構造: 単なる `zval` の連続配列(C言語の `array` に近い)。
  • オーバーヘッド: 最小限。ハッシュ計算なし、衝突解決のポインタなし。
  • CPUキャッシュ: 非常に効率的(メモリ上の連続領域にあるため、CPUのプリフェッチが絶妙に効く)。

ケースB:文字列キーが混ざった、または穴あきHashTableの場合

  • データ構造: `Bucket` 構造体の配列 + ハッシュインデックス(高速検索のためのテーブル)。
  • オーバーヘッド: 各要素にキーのハッシュ値、キー文字列へのポインタ、次の要素を指すポインタなどが付随。
  • メモリ消費: ケースAに比べて、数倍から場合によっては10倍近いメモリフットプリントを消費することもある。

Webアプリケーションにおいて、1リクエストあたりのメモリ消費量(`memory_get_usage()`)が膨らむ原因の多くは、フレームワークの初期化やORMのhydration(水和)の過程で、意図せず配列がHashTableへフォールバックし、不要なメタデータを抱え込んでしまうことにあります。

—

4. 実務で活かす:高速でエコなコード設計の極意

ここまでの話をベースに、現場で私たちが意識すべき実践的なプラクティスをいくつか共有しますね。

① 配列の初期化時は「空」から順番に積み上げる

外部から取得したデータをループで回して配列に詰める際、キーを明示的に指定せず `$array[] = $value;` の形式でプッシュしていくのが最も安全かつ、Packed Arrayの恩恵を受けやすい書き方です。

// 良い例:0から綺麗に連番でインデックスが構築されるためPacked Arrayになりやすい
$collection = [];
foreach ($rawRows as $row) {
$collection[] = $row[‘name’];
}

② 大規模な連想配列とリストを混同しない

もし「O(1)の高速なキー検索」が必要な場合は、連想配列(文字列キーやランダムな整数キー)を使うのは当然の選択です。しかし、単なる「データのコンテナ(一覧)」として使うのであれば、キーを途中で飛ばしたり、文字列キーを混ぜたりしないよう設計をクリーンに保ちましょう。

// 注意が必要な例:キーを明示的に文字列やバラバラの数値にするとHashTable化する
$map = [];
$map[‘user_105’] = ‘Alice’;
$map[‘user_12’] = ‘Bob’;

—

まとめ:PHPの「裏側の息遣い」を感じる

いかがでしたでしょうか?
PHPの配列という、最も身近なデータ構造の裏側では、Zendエンジンがメモリ効率と実行速度のバランスを取るために、このような巧妙な「昇格・降格」の仕組みを隠蔽して動かしてくれています。

「動くからいいや」ではなく、「このコードを書いたとき、メモリ上でZend VMはどう振る舞っているか?」を想像できるようになると、あなたの書くPHPコードは、美しく、無駄がなく、そして何よりスケールする強靭なシステムへと生まれ変わります。

PHPは、私たちが思うよりもずっと深く、そしてモダンに進化を続けています。ぜひ、次のコードレビューや設計の現場で、この「Packed Arrayの知見」を思い出してみてください。きっと新しい視界が開けるはずです。

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