HashTableの衝突とメモリ消費:PHP 8のPacked Array最適化の内部構造
コードレビューの場で、次のようなコードに出くわしたことはないだろうか。
$data = [];
$data[‘id’] = 1;
$data[‘name’] = ‘Katsuragi’;
$data[‘metrics’] = [100, 200, 300];
一見して「何の問題もない、普通の連想配列の初期化」に見えるかもしれない。しかし、Zend VMのメモリ空間とハッシュテーブル(HashTable)の挙動を熟知したアーキテクトの目には、これが「メモリ帯域とCPUキャッシュの無駄遣い、そして将来の衝突(Collision)の爆弾を抱えた不毛なコード」に映る。
PHPの配列(`array`)は、言語仕様上「順序付きマップであり、かつインデックス付き配列であり、かつスタックであり、キューである」という、全知全能のデータ構造だ。これを実現するためにPHPの心臓部であるZend Engineは、内部で `HashTable` という巨大な汎用構造体を使用している。
今回は、PHP 8系における `HashTable` の内部構造、特に「Packed Array(密な配列)」への昇格条件と、衝突(Collision)が発生した際にメモリ空間で何が起きているのかを低レイヤの視点から解き明かし、実務で絶対に守るべき設計ルールを伝授する。
—
1. 内部構造の真実:なぜPHPの配列は重いのか
Zend Engine(C言語で書かれたPHPの仮想マシン)において、すべての配列は `_zval_struct` を経由して `HashTable`(`Bucket` 構造体の配列)へと紐づく。
typedef struct _bucket {
zend_ulong h; / ハッシュ値(整数インデックス、または文字列のハッシュ) /
zend_string key; / 文字列キー(数値キーの場合は NULL) /
zval val; / 格納される値(zval構造体) /
} Bucket;
通常のハッシュテーブル(Mixed Array)では、キーの文字列からハッシュ値を算出し、それを元にインデックスを引く。しかし、このアプローチには致命的な弱点がある。
1. メモリの非連続性: ポインタを辿るため、CPUキャッシュヒット率が低下する(キャッシュミスの多発)。
2. メモリオーバヘッド: `Bucket` 自体がサイズを持ち、さらにハッシュ衝突を解決するためのインデックス配列(Data Space)を必要とする。
ここでPHP 8の真骨頂である Packed Array の出番となる。
—
2. Packed Arrayへの昇格条件とメモリの最適化
PHP 8では、配列が「純粋な連番の数値インデックスのみで構成され、かつ追加順序が維持されている」場合、Zend Engineは自動的にこれを Packed Array(フラットな `Bucket` の連続領域)へと昇格させる。
Mixed Array から Packed Array への昇格要件
- キーがすべて数値(整数)であること。
- キーが `0` から始まり、欠損なく(あるいは意図しない順序の逆転なく)昇順で追加されていること。
- 文字列キーが一度も混入していないこと。
Packed Arrayになると、ハッシュ計算(DJBX33A等のハッシュアルゴリズム)のオーバーヘッドが完全にバイパスされ、メモリ上には `Bucket` がC言語のネイティブ配列のように連続して配置される。これにより、O(1)の高速アクセスはもちろん、ポインタ追跡のコストが劇的に削減される。
—
3. コードレビュー:なぜその書き方は「メモリを殺す」のか
以下の2つのコード例を見てほしい。どちらがPHPの内部エンジンにとって優しく、高速に動作するだろうか。
❌ 危険なアンチパターン:暗黙のMixed化とハッシュ衝突のリスク
/
- アンチパターン:
- 最初から文字列キーを混ぜたり、インデックスを飛ばして代入する
/
function createBadPayload(): array {
$payload = [];
// ここで文字列キーが混入した瞬間、HashTableは Mixed Array に固定される
$payload[‘status’] = ‘success’;
// 意図的にインデックスを飛ばす
$payload[100] = ‘foo’;
$payload[10] = ‘bar’; // 順序が逆転し、Packed化の要件を完全に破壊
return $payload;
}
何が起きているか:
このコードを実行した瞬間、Zend Engineは配列を `Mixed Array` としてメモリ上に構築する。文字列キー `’status’` のハッシュ値計算が行われ、ハッシュ衝突が発生した場合はリンクリスト(Collision Chain)の走査が発生する。高負荷なAPIエンドポイントでこの配列生成が何万回もループすると、CPUサイクルはハッシュの解決とメモリの断片化(Memory Fragmentation)に浪費される。
洗練されたベストプラクティス:Packed Arrayを維持し、型を厳格に固定する
declare(strict_types=1);
namespace App\Core;
/
- 高速かつメモリ効率を極限まで高めたデータ転送オブジェクト(DTO的アプローチ)
/
final class OptimizedMetricsCollector
{
/
- @var array
/
private array $samples = [];
/
- データを追加する。常に末尾に追加することで、
- Packed Arrayとしての特性(連続したメモリ領域)を維持する。
/
public function record(float $value): void
{
// [] を使った末尾追加($this->samples[] = …)は、
// Zend Engineに対して「連続したインデックスの追加」であることを強く伝える。
$this->samples[] = $value;
}
/
- まとめてシリアライズ、あるいは高速に返却する
- @return array{total: int, average: float, raw: array
}
/
public function compile(): array
{
// 最終的な出力構造体を構築する際も、
// 内部で無駄なハッシュ計算をさせないためにキーの順序と型を一致させる。
$count = \count($this->samples);
if ($count === 0) {
return [
‘total’ => 0,
‘average’ => 0.0,
‘raw’ => [],
];
}
$sum = array_sum($this->samples);
return [
‘total’ => $count,
‘average’ => $sum / $count,
// $this->samples は完全に Packed Array としてメモリ上に保持されているため、
// コピーや転送時のオーバーヘッドが最小限に抑えられる。
‘raw’ => $this->samples,
];
}
}
—
4. アーキテクトからの実践的な設計ルール
実務において、メモリリークやCPU使用率のスパイクを防ぐために、以下の鉄則をチーム全体で共有してほしい。
1. 配列の「ハイブリッド利用」を絶対に避ける
1つの配列の中に、意味論の異なるデータ(例: メタデータとしての文字列キーと、ループ処理用の数値インデックス)を混在させてはならない。データ構造が複雑になる場合は、配列ではなく `readonly class`(PHP 8.2+)や専用のDTOクラスに切り出すこと。オブジェクトのプロパティアクセスは、ハッシュテーブルのルックアップよりも最適化されている場合が多い。
2. 大規模ループ内での動的キー生成の禁止
数千件以上のレコードを処理するループの内部で、 `$result[$stringKey] = …` を乱用しないこと。もし連想配列が必要な場合は、事前にキーの構造を予測し、メモリの再割り Allocate(Rehash)が頻発しないように `array_pad` や適切な初期サイズを意識した構築を行うか、SplFixedArrayの利用を検討せよ。
3. PHP 8のJITとのシナジーを意識する
JITコンパイラ(Tracing JIT)が真価を発揮するのは、型が安定し、Zend VMのオペコードがネイティブ機械語にコンパイルされる瞬間である。配列が `Packed Array` から `Mixed Array` へ降格・昇格を繰り返すような動的すぎるコードは、JITの型推論(Type Inference)を阻害し、メガオプティマイズの恩恵を受けられなくする。
結びにかえて
PHPは「誰でも動かせる言語」であるからこそ、内部構造への無知がそのままプロダクトのスケール限界となって跳ね返ってくる。
1リクエストあたり数キロバイトのメモリ無駄遣いは、月間数億PV規模のシステムにおいてはサーバー台数直結のコストであり、レイテンシーの悪化という形でユーザー体験を静かに蝕む。
コードを書くときは、目の前のシンタックスだけでなく、「今、Zend Engineのメモリ空間で何がアロケートされているか」を脳内でトレースせよ。その静かなる執念こそが、真に高可用なWebシステムを支える唯一の武器となる。