zval の内側:PHP の型システムとメモリモデル
前提知識
- ›第1回:アーキテクチャとライフサイクルの概要
- ›C言語のunion、ビットフィールド、ポインタ演算
- ›参照カウントとガベージコレクションの基本的な理解
zval の内側:PHP の型システムとメモリモデル
第1回では、php-src の4層アーキテクチャを整理し、リクエストのライフサイクルを追いました。今回はさらに深く潜り、Zend Engine の土台となるデータ構造を見ていきます。コンパイラ、VM、拡張モジュール、ガベージコレクタ、どのコードを読んでいても、zval・zend_string・HashTable・zend_object には必ず出会います。これらのメモリレイアウト、参照カウントのプロトコル、そしてそれらを管理するアロケータを理解することが、このシリーズの残りを読み解くための前提知識となります。
PHP の型システム設計を貫く核心的な制約は 「すべての PHP 値はちょうど 16 バイトに収まらなければならない」 という点です。このアライメント制約がその他のすべての設計を規定しています。
zval:PHP の汎用値コンテナ
_zval_struct は Zend/zend_types.h で定義されており、16 バイトに3つのコンポーネントが詰め込まれています。
flowchart LR
subgraph zval["zval (16 bytes)"]
direction TB
subgraph value["zend_value (8 bytes)"]
V["lval: zend_long<br/>dval: double<br/>str: *zend_string<br/>arr: *zend_array<br/>obj: *zend_object<br/>ref: *zend_reference<br/>..."]
end
subgraph u1["u1 (4 bytes)"]
U1["type_info:<br/> type (uint8)<br/> type_flags (uint8)<br/> extra (uint16)"]
end
subgraph u2["u2 (4 bytes)"]
U2["next: uint32 (hash chain)<br/>cache_slot: uint32<br/>lineno: uint32<br/>num_args: uint32<br/>..."]
end
end
zend_value union(8バイト) は実際の値を保持します。IS_LONG や IS_DOUBLE のような単純な型では、値は zval にインラインで格納されます。整数値や浮動小数点数はヒープを一切使わず zval の中に直接収まります。IS_STRING・IS_ARRAY・IS_OBJECT などの複合型では、union はヒープ上に確保された構造体へのポインタを保持します。
u1 union(4バイト) には type_info が入っています。型タグ、型フラグ、追加フィールドがここにまとめて格納されます。type バイトは IS_* 定数のいずれかです。type_flags バイトには、参照カウント対象かどうか(IS_TYPE_REFCOUNTED)と、ガベージコレクションの循環に関与し得るか(IS_TYPE_COLLECTABLE)が符号化されています。IS_LONG・IS_DOUBLE・IS_NULL・IS_TRUE・IS_FALSE などの単純な型はフラグを持ちません。値がインラインで格納されるため、参照カウントの対象外です。
u2 union(4バイト) は巧妙なアイデアです。この領域はアライメントのためにどのみちパディングになる部分ですが、エンジンはここをコンテキストに応じた「おまけ」ストレージとして再利用しています。zval が HashTable のバケットに存在する場合は u2.next がコリジョンチェーンのポインタを持ちます。op_array のリテラルとして存在する場合は u2.cache_slot がランタイムキャッシュのオフセットを保持します。引数情報として使われる場合は u2.num_args に引数の個数が入ります。
ポイント:
u2の再利用は php-src 全体を通じて繰り返し登場するパターンです。アライメントのパディングを無駄にするのではなく、その空きバイトにコンテキスト依存のメタデータを格納する——この発想はzend_string・Bucket・zend_objectでも同様に見られます。
PHP の型タグと型システム
PHP の型システムは Zend/zend_types.h で定義された整数定数の集合で表現されています。
| 型定数 | 値 | 参照カウント | コレクタ対象 | 値の格納場所 |
|---|---|---|---|---|
IS_UNDEF |
0 | No | No | なし(未初期化) |
IS_NULL |
1 | No | No | なし(値不要) |
IS_FALSE |
2 | No | No | なし(型が値を表す) |
IS_TRUE |
3 | No | No | なし(型が値を表す) |
IS_LONG |
4 | No | No | zend_value.lval にインライン |
IS_DOUBLE |
5 | No | No | zend_value.dval にインライン |
IS_STRING |
6 | Yes | No | zend_string へのポインタ |
IS_ARRAY |
7 | Yes | Yes | zend_array へのポインタ |
IS_OBJECT |
8 | Yes | Yes | zend_object へのポインタ |
IS_RESOURCE |
9 | Yes | No | zend_resource へのポインタ |
IS_REFERENCE |
10 | Yes | Yes | zend_reference へのポインタ |
IS_FALSE と IS_TRUE が単一の boolean 型ではなく別々の型として定義されている点に注目してください。これによって条件分岐が1つ減ります。if ($x) の評価は型チェックだけで済み、型チェック+値チェックの2段階にはなりません。
型付きプロパティや関数パラメータに使われる zend_type 構造体は別の話です。こちらは union 型・intersection 型・nullable 型を、型タグのビットマスクとクラスエントリへのポインタの組み合わせで表現しています。第3回のコンパイラ解説で改めて登場します。
参照カウントとコピーオンライト
ヒープに確保されるすべての型は共通のヘッダ zend_refcounted_h を持っています。ここには refcount(32ビット)と、型および GC フラグを格納する type_info フィールドが含まれます。
PHP で変数を代入する($b = $a)と、エンジンは内部のデータをコピーしません。代わりに、参照先の構造体の refcount をインクリメントします。$a と $b の両方が同じ zend_string・zend_array・zend_object を指した状態になります。
flowchart TD
A["$a = 'hello'"] --> |"refcount=1"| STR["zend_string<br/>'hello'"]
B["$b = $a"] --> |"refcount=2"| STR
C["$b .= ' world'"] --> |"refcount > 1?<br/>Yes → separate!"| SEP{{"Copy on Write"}}
SEP --> STR2["zend_string<br/>'hello world'<br/>refcount=1"]
SEP --> STR3["zend_string<br/>'hello'<br/>refcount=1"]
コピーオンライト(COW)のパスはパフォーマンスの要です。文字列への追記や配列への要素追加など、値を変更しようとするとき、エンジンはまず refcount を確認します。refcount > 1 であれば 分離(separate) が発生します。コピーを作成し、元の refcount をデクリメントし、コピーを変更します。refcount == 1 であれば、その場で変更します。
PHP の参照($b = &$a)は別の仕組みを使います。zend_reference ラッパーです。zend_reference 構造体は独自の refcount ヘッダと内部に zval を1つ持ちます。$a と $b はともに IS_REFERENCE zval となり、同じ zend_reference を指します。その zend_reference が実際の値を保持しています。間接参照が1段階増えますが、参照でない値の COW セマンティクスは保たれます。
ポイント: PHP の参照(
&$var)がパフォーマンスを 下げる ことが多いのはこのためです。zend_referenceによる間接参照が発生し、コピーオンライトの最適化が効かなくなります。現代の PHP では、参照を使う必要はほぼありません。
zend_string:インターン文字列と永続文字列
Zend/zend_types.h で定義されている zend_string は、単なる参照カウント付き文字バッファではありません。
flowchart LR
subgraph zend_string
direction TB
RC["zend_refcounted_h (8 bytes)<br/>refcount + type_info + GC flags"]
HASH["h: zend_ulong (8 bytes)<br/>Cached hash value"]
LEN["len: size_t (8 bytes)<br/>String length"]
VAL["val[1]: char (flexible array)<br/>Actual string data, NUL-terminated"]
end
ハッシュ値(h)は一度だけ計算されてキャッシュされます。変数名・関数名・配列キーなど、文字列はハッシュキーとして頻繁に使われるため、ルックアップのたびにハッシュを再計算しなくて済むのは大きな利点です。Zend/zend_string.h の ZSTR_H()・ZSTR_VAL()・ZSTR_LEN() マクロを通じてアクセスします。
インターン文字列は特別なカテゴリです。関数名・クラス名・ソースコード中の文字列リテラルなど、頻繁に使われる文字列は「インターン」されます。グローバルテーブルに一度だけ格納され、リクエスト中は解放されません。refcount には特殊な値が設定され、参照カウントマクロがインクリメント・デクリメントを完全にスキップするようになっています。OPcache はさらに踏み込んで、インターン文字列を共有メモリに配置し、すべての FPM ワーカープロセス間で共有します。
永続文字列は、リクエストごとのアロケータ(emalloc)ではなくシステムアロケータ(malloc)で確保されます。リクエストをまたいで生存し、モジュールレベルのデータ——拡張モジュール名・INI エントリ名・MINIT 時に登録されるクラス名など——に使われます。
HashTable:2つのモードを持つ配列
PHP の array 型は、あらゆるプログラミング言語の中でも特に汎用性の高いデータ構造の一つです。リスト、辞書、順序付きマップ、スタック、キュー、セットの役割を一手に担います。そのすべてのユースケースで高速に動作する実装が求められます。
Zend/zend_types.h で定義されている zend_array(HashTable の別名)は2つの動作モードを持ちます。
パック配列モードは、キーが 0 から始まる連続した整数の場合($arr[] = value という典型的なパターン)に使われます。データは arPacked を通じてフラットな zval 配列として格納されます。ハッシュ計算もバケットもコリジョンチェーンも不要で、C の配列と同等の速度が出ます。
ハッシュ配列モードは、キーが文字列または連続していない整数の場合に使われます。データは Bucket 構造体(キー・ハッシュ・zval を含む)の配列として格納され、O(1) のルックアップを実現するハッシュインデックステーブルを持ちます。
flowchart TD
subgraph packed["Packed Array Mode"]
direction LR
P_HT["HashTable header<br/>nTableSize, nNumUsed, nNumOfElements"]
P_DATA["arPacked: zval[]<br/>[0]: zval<br/>[1]: zval<br/>[2]: zval<br/>..."]
P_HT --> P_DATA
end
subgraph hash["Hash Array Mode"]
direction LR
H_HT["HashTable header<br/>nTableSize, nNumUsed, nNumOfElements"]
H_IDX["Hash Index Table<br/>(grows BACKWARDS from arData)<br/>[-1]: idx<br/>[-2]: idx<br/>..."]
H_DATA["arData: Bucket[]<br/>[0]: {h, key, val}<br/>[1]: {h, key, val}<br/>[2]: {h, key, val}<br/>..."]
H_HT --> H_DATA
H_IDX -.-> H_DATA
end
ハッシュ配列のメモリレイアウトは独創的です。ハッシュインデックステーブルとバケット配列が1つのメモリ確保を共有しています。バケット配列(arData)はベースポインタから前方向に伸び、ハッシュインデックステーブルは同じポインタから後ろ方向に伸びます。つまり arData[-1]・arData[-2]・... がハッシュインデックスのスロットで、arData[0]・arData[1]・... がバケットです。emalloc の呼び出し1回で両方が確保され、efree の呼び出し1回で両方が解放されます。
コリジョンの解決には、zval の「空き」u2 フィールドである Bucket.val.u2.next を使ったチェーン方式が使われます。各バケットの u2.next は、チェーン内の次のバケットのインデックスを指し、チェーンの末端は -1(HT_INVALID_IDX)になります。
Zend/zend_hash.h で定義されている HashTable フラグは配列の状態を管理します。パックモードを示す HASH_FLAG_PACKED、遅延確保されたテーブルを示す HASH_FLAG_UNINITIALIZED、すべてのキーがインターン文字列のときの HASH_FLAG_STATIC_KEYS などがあります。
zend_object とクラスインスタンス
PHP で new Foo() と書くと、エンジンはクラス宣言をもとにコンパイル時に決定されたレイアウトの zend_object を確保します。
classDiagram
class zend_object {
+zend_refcounted_h gc
+uint32_t handle
+zend_class_entry *ce
+zend_object_handlers *handlers
+HashTable *properties_table
+zval properties_table[]
}
class zend_class_entry {
+char type
+zend_string *name
+HashTable function_table
+HashTable properties_info
+int default_properties_count
+zval *default_properties_table
}
class zend_object_handlers {
+read_property()
+write_property()
+get_method()
+call_method()
+clone_obj()
+compare()
+cast_object()
+...26 more handlers
}
zend_object --> zend_class_entry : ce
zend_object --> zend_object_handlers : handlers
properties_table は構造体の末尾にある柔軟配列メンバ(flexible array member)です。スロット数は ce->default_properties_count で決まります。宣言済みプロパティには固定スロットが割り当てられ(数値オフセットにコンパイルされます)、$obj->name はハッシュルックアップではなく直接のインデックスアクセスになります。動的プロパティ(実行時に追加されるもの)は別の HashTable にあふれ出します。
handlers vtable(Zend/zend_object_handlers.h の zend_object_handlers)は、演算子オーバーロード、プロパティアクセスの横断、カスタム比較といった PHP のオブジェクトシステムの機能を支える仕組みです。拡張モジュールは個々のハンドラを差し替えてオブジェクトの振る舞いをカスタマイズできます。ArrayObject・SplFixedArray・PDO のステートメントオブジェクトはこの仕組みで実現されています。
メモリアロケータ:emalloc とその仲間たち
Zend/zend_alloc.h で定義され Zend/zend_alloc.c で実装された PHP のリクエストごとのメモリアロケータは、PHP がリクエストベースのワークロードを効率よく処理できる重要な理由の一つです。
アロケータは3段階の戦略を使います。
flowchart TD
REQ["emalloc(size)"] --> CHECK{"size category?"}
CHECK -->|"≤ 3072 bytes"| SMALL["Small allocation<br/>30 size-class bins<br/>Pre-allocated pages<br/>Free-list per bin"]
CHECK -->|"3073 – page size"| LARGE["Large allocation<br/>Page-aligned chunks<br/>Best-fit search"]
CHECK -->|"> page size"| HUGE["Huge allocation<br/>Direct mmap()<br/>Tracked separately"]
SHUTDOWN["Request Shutdown"] --> BULK["Bulk deallocation<br/>Free all chunks at once<br/>No per-object free needed"]
スモールアロケーション(≤ 3072 バイト、大部分のアロケーションがここに該当)は、30のサイズクラスビンを持つプールアロケータを使います。各ビンには固定サイズのスロットに分割された事前確保済みのページがあります。確保はフリーリストからのポップで O(1)、解放はフリーリストへのプッシュで O(1) です。
ラージアロケーションは事前確保されたチャンク内のページベースアロケータを使います。ヒュージアロケーションは直接 mmap() に渡されます。
最大の工夫はリクエストシャットダウン時にあります。個々のアロケーションを一つずつ解放する代わりに、エンジンはメモリチャンクをまとめて一括解放できます。クリーンアップのコストが均一化されるため、PHP コードがメモリを「リーク」させても(明示的に unset() しなくても)、リクエストごとのアロケータがすべてを回収します。
API は標準 C アロケータに準じています:emalloc()・efree()・erealloc()・ecalloc()・estrdup()。pemalloc(size, persistent) バリアントは第2引数の値に応じて emalloc(リクエストごと)と malloc(永続・リクエスト横断)を切り替えます。
ガベージコレクタ:循環参照の回収
PHP のメモリ管理の大部分は参照カウントで賄われています。しかし参照カウントには古典的な弱点があります。循環参照です。オブジェクト A がオブジェクト B を参照し、B が A を参照している場合、両方が到達不能であっても refcount は決してゼロになりません。
Zend/zend_gc.c の循環コレクタ(型は Zend/zend_gc.h で定義)は Bacon-Rajan アルゴリズムの変種を使っています。
- ルート収集: 参照カウント対象の値の refcount がデクリメントされてもゼロにならなかった場合、それは潜在的な循環ルートです。エンジンはそれをルートバッファに追加します。
- トリガー: ルートバッファが一杯になると(デフォルト:10,000 エントリ)GC が実行されます。
- グレーマーク: 各ルートから再帰的にすべての子の refcount をデクリメントします。この「疑似収集」でルートの refcount がゼロになれば、ガベージの可能性があります。
- ホワイトマーク: 再度スキャンします。疑似 refcount がゼロの値はホワイト(ガベージ)です。循環の外から参照されている値はブラックに戻されます。
- ホワイト回収: ホワイトの値をすべて解放します。
IS_TYPE_COLLECTABLE フラグを持つ型(配列とオブジェクト)だけが潜在的な循環ルートとして考慮されます。文字列やリソースは参照カウントされますが、循環を形成することはありません。
ポイント: GC の動作は
gc_status()で監視できます。大量のルートが頻繁に回収されているようであれば、ループ内で循環参照が生まれている可能性が高いです。WeakReferenceを使うか、明示的なunset()で循環を断ち切ることを検討しましょう。
次回に向けて
これで土台が整いました。PHP がメモリ上で値をどう表現し、確保し、回収するかを理解できました。第3回では、PHP のソースファイルがコンパイルパイプラインを通過する過程を追います。生の文字列からトークンストリームへ、AST へ、そして VM が実行するオペコードへと変換される流れを見ていきます。今回学んだ zval の知識は不可欠です。リテラルは op_array の中で IS_CONST zval になり、コンパイルされたすべての変数スロットは zval を保持しています。