<?php $array = range(0, 1000000); $ref =& $array; var_dump(count($array)); // <-- 這裡會進行分離
由於大量的細節描述,本文將會分成兩個部分:第一部分主要描述 zval(zend value) 的實現在 PHP5 和 PHP7 中有何不同以及引用的實現。第二部分將會分析單獨類型(strings、objects)的細節。
PHP5 中的 zval
PHP5 中 zval 結構體定義如下:
typedef struct _zval_struct { zvalue_value value; zend_uint refcount__gc; zend_uchar type; zend_uchar is_ref__gc; } zval;
如上,zval 包含一個 value、一個 type 以及兩個 __gc 後綴的字段。value 是個聯合體,用於存儲不同類型的值:
typedef union _zvalue_value { long lval; // 用於 bool 類型、整型和資源類型 double dval; // 用於浮點類型 struct { // 用於字符串 char *val; int len; } str; HashTable *ht; // 用於數組 zend_object_value obj; // 用於對象 zend_ast *ast; // 用於常量表達式(PHP5.6 才有) } zvalue_value;
C 語言聯合體的特征是一次只有一個成員是有效的並且分配的內存與需要內存最多的成員匹配(也要考慮內存對齊)。所有成員都存儲在內存的同一個位置,根據需要存儲不同的值。當你需要 lval 的時候,它存儲的是有符號整形,需要 dval 時,會存儲雙精度浮點數。
需要指出的是是聯合體中當前存儲的數據類型會記錄到 type 字段,用一個整型來標記:
#define IS_NULL 0 /* Doesn't use value */
#define IS_LONG 1 /* Uses lval */
#define IS_DOUBLE 2 /* Uses dval */
#define IS_BOOL 3 /* Uses lval with values 0 and 1 */
#define IS_ARRAY 4 /* Uses ht */
#define IS_OBJECT 5 /* Uses obj */
#define IS_STRING 6 /* Uses str */
#define IS_RESOURCE 7 /* Uses lval, which is the resource ID */
/* Special types used for late-binding of constants */
#define IS_CONSTANT 8
#define IS_CONSTANT_AST 9
PHP5 中的引用計數
在PHP5中,zval 的內存是單獨從堆(heap)中分配的(有少數例外情況),PHP 需要知道哪些 zval 是正在使用的,哪些是需要釋放的。所以這就需要用到引用計數:zval 中 refcount__gc 的值用於保存 zval 本身被引用的次數,比如 $a = $b = 42 語句中,42 被兩個變量引用,所以它的引用計數就是 2。如果引用計數變成 0,就意味著這個變量已經沒有用了,內存也就可以釋放了。
注意這裡提及到的引用計數指的不是 PHP 代碼中的引用(使用 &),而是變量的使用次數。後面兩者需要同時出現時會使用『PHP 引用』和『引用』來區分兩個概念,這裡先忽略掉 PHP 的部分。
一個和引用計數緊密相關的概念是『寫時復制』:對於多個引用來說,zaval 只有在沒有變化的情況下才是共享的,一旦其中一個引用改變 zval 的值,就需要復制("separated")一份 zval,然後修改復制後的 zval。
下面是一個關於『寫時復制』和 zval 的銷毀的例子:
<?php $a = 42; // $a -> zval_1(type=IS_LONG, value=42, refcount=1) $b = $a; // $a, $b -> zval_1(type=IS_LONG, value=42, refcount=2) $c = $b; // $a, $b, $c -> zval_1(type=IS_LONG, value=42, refcount=3) // 下面幾行是關於 zval 分離的 $a += 1; // $b, $c -> zval_1(type=IS_LONG, value=42, refcount=2) // $a -> zval_2(type=IS_LONG, value=43, refcount=1) unset($b); // $c -> zval_1(type=IS_LONG, value=42, refcount=1) // $a -> zval_2(type=IS_LONG, value=43, refcount=1) unset($c); // zval_1 is destroyed, because refcount=0 // $a -> zval_2(type=IS_LONG, value=43, refcount=1)
引用計數有個致命的問題:無法檢查並釋放循環引用(使用的內存)。為了解決這問題,PHP 使用了循環回收的方法。當一個 zval 的計數減一時,就有可能屬於循環的一部分,這時將 zval 寫入到『根緩沖區』中。當緩沖區滿時,潛在的循環會被打上標記並進行回收。
因為要支持循環回收,實際使用的 zval 的結構實際上如下:
typedef struct _zval_gc_info { zval z; union { gc_root_buffer *buffered; struct _zval_gc_info *next; } u; } zval_gc_info;
zval_gc_info 結構體中嵌入了一個正常的 zval 結構,同時也增加了兩個指針參數,但是共屬於同一個聯合體 u,所以實際使用中只有一個指針是有用的。buffered 指針用於存儲 zval 在根緩沖區的引用地址,所以如果在循環回收執行之前 zval 已經被銷毀了,這個字段就可能被移除了。next 在回收銷毀值的時候使用,這裡不會深入。
修改動機
下面說說關於內存使用上的情況,這裡說的都是指在 64 位的系統上。首先,由於 str 和 obj 占用的大小一樣, zvalue_value 這個聯合體占用 16 個字節(bytes)的內存。整個 zval 結構體占用的內存是 24 個字節(考慮到內存對齊),zval_gc_info 的大小是 32 個字節。綜上,在堆(相對於棧)分配給 zval 的內存需要額外的 16 個字節,所以每個 zval 在不同的地方一共需要用到 48 個字節(要理解上面的計算方式需要注意每個指針在 64 位的系統上也需要占用 8 個字節)。
在這點上不管從什麼方面去考慮都可以認為 zval 的這種設計效率是很低的。比如 zval 在存儲整型的時候本身只需要 8 個字節,即使考慮到需要存一些附加信息以及內存對齊,額外 8 個字節應該也是足夠的。
在存儲整型時本來確實需要 16 個字節,但是實際上還有 16 個字節用於引用計數、16 個字節用於循環回收。所以說 zval 的內存分配和釋放都是消耗很大的操作,我們有必要對其進行優化。
從這個角度思考:一個整型數據真的需要存儲引用計數、循環回收的信息並且單獨在堆上分配內存嗎?答案是當然不,這種處理方式一點都不好。
這裡總結一下 PHP5 中 zval 實現方式存在的主要問題:
zval 總是單獨從堆中分配內存;
zval 總是存儲引用計數和循環回收的信息,即使是整型這種可能並不需要此類信息的數據;
在使用對象或者資源時,直接引用會導致兩次計數(原因會在下一部分講);
某些間接訪問需要一個更好的處理方式。比如現在訪問存儲在變量中的對象間接使用了四個指針(指針鏈的長度為四)。這個問題也放到下一部分討論;
直接計數也就意味著數值只能在 zval 之間共享。如果想在 zval 和 hashtable key 之間共享一個字符串就不行(除非 hashtable key 也是 zval)。
PHP7 中的 zval
在 PHP7 中 zval 有了新的實現方式。最基礎的變化就是 zval 需要的內存不再是單獨從堆上分配,不再自己存儲引用計數。復雜數據類型(比如字符串、數組和對象)的引用計數由其自身來存儲。這種實現方式有以下好處:
簡單數據類型不需要單獨分配內存,也不需要計數;
不會再有兩次計數的情況。在對象中,只有對象自身存儲的計數是有效的;
由於現在計數由數值自身存儲,所以也就可以和非 zval 結構的數據共享,比如 zval 和 hashtable key 之間;
間接訪問需要的指針數減少了。
我們看看現在 zval 結構體的定義(現在在 zend_types.h 文件中):
struct _zval_struct { zend_value value; /* value */ union { struct { ZEND_ENDIAN_LOHI_4( zend_uchar type, /* active type */ zend_uchar type_flags, zend_uchar const_flags, zend_uchar reserved) /* call info for EX(This) */ } v; uint32_t type_info; } u1; union { uint32_t var_flags; uint32_t next; /* hash collision chain */ uint32_t cache_slot; /* literal cache slot */ uint32_t lineno; /* line number (for ast nodes) */ uint32_t num_args; /* arguments number for EX(This) */ uint32_t fe_pos; /* foreach position */ uint32_t fe_iter_idx; /* foreach iterator index */ } u2; };
結構體的第一個元素沒太大變化,仍然是一個 value 聯合體。第二個成員是由一個表示類型信息的整型和一個包含四個字符變量的結構體組成的聯合體(可以忽略 ZEND_ENDIAN_LOHI_4 宏,它只是用來解決跨平台大小端問題的)。這個子結構中比較重要的部分是 type(和以前類似)和 type_flags,這個接下來會解釋。
上面這個地方也有一點小問題:value 本來應該占 8 個字節,但是由於內存對齊,哪怕只增加一個字節,實際上也是占用 16 個字節(使用一個字節就意味著需要額外的 8 個字節)。但是顯然我們並不需要 8 個字節來存儲一個 type 字段,所以我們在 u1 的後面增加了了一個名為 u2 的聯合體。默認情況下是用不到的,需要使用的時候可以用來存儲 4 個字節的數據。這個聯合體可以滿足不同場景下的需求。
PHP7 中 value 的結構定義如下:
typedef union _zend_value { zend_long lval; /* long value */ double dval; /* double value */ zend_refcounted *counted; zend_string *str; zend_array *arr; zend_object *obj; zend_resource *res; zend_reference *ref; zend_ast_ref *ast; zval *zv; void *ptr; zend_class_entry *ce; zend_function *func; struct { uint32_t w1; uint32_t w2; } ww; } zend_value;
首先需要注意的是現在 value 聯合體需要的內存是 8 個字節而不是 16。它只會直接存儲整型(lval)或者浮點型(dval)數據,其他情況下都是指針(上面提到過,指針占用 8 個字節,最下面的結構體由兩個 4 字節的無符號整型組成)。上面所有的指針類型(除了特殊標記的)都有一個同樣的頭(zend_refcounted)用來存儲引用計數:
typedef struct _zend_refcounted_h { uint32_t refcount; /* reference counter 32-bit */ union { struct { ZEND_ENDIAN_LOHI_3( zend_uchar type, zend_uchar flags, /* used for strings & objects */ uint16_t gc_info) /* keeps GC root number (or 0) and color */ } v; uint32_t type_info; } u; } zend_refcounted_h;
現在,這個結構體肯定會包含一個存儲引用計數的字段。除此之外還有 type、flags 和 gc_info。type 存儲的和 zval 中的 type 相同的內容,這樣 GC 在不存儲 zval 的情況下單獨使用引用計數。flags 在不同的數據類型中有不同的用途,這個放到下一部分講。
gc_info 和 PHP5 中的 buffered 作用相同,不過不再是位於根緩沖區的指針,而是一個索引數字。因為以前根緩沖區的大小是固定的(10000 個元素),所以使用一個 16 位(2 字節)的數字代替 64 位(8 字節)的指針足夠了。gc_info 中同樣包含一個『顏色』位用於回收時標記結點。
zval 內存管理
上文提到過 zval 需要的內存不再單獨從堆上分配。但是顯然總要有地方來存儲它,所以會存在哪裡呢?實際上大多時候它還是位於堆中(所以前文中提到的地方重點不是堆,而是單獨分配),只不過是嵌入到其他的數據結構中的,比如 hashtable 和 bucket 現在就會直接有一個 zval 字段而不是指針。所以函數表編譯變量和對象屬性在存儲時會是一個 zval 數組並得到一整塊內存而不是散落在各處的 zval 指針。之前的 zval * 現在都變成了 zval。
之前當 zval 在一個新的地方使用時會復制一份 zval * 並增加一次引用計數。現在就直接復制 zval 的值(忽略 u2),某些情況下可能會增加其結構指針指向的引用計數(如果在進行計數)。
那麼 PHP 怎麼知道 zval 是否正在計數呢?不是所有的數據類型都能知道,因為有些類型(比如字符串或數組)並不是總需要進行引用計數。所以 type_info 字段就是用來記錄 zval 是否在進行計數的,這個字段的值有以下幾種情況:
#define IS_TYPE_CONSTANT (1<<0) /* special */ #define IS_TYPE_IMMUTABLE (1<<1) /* special */ #define IS_TYPE_REFCOUNTED (1<<2) #define IS_TYPE_COLLECTABLE (1<<3) #define IS_TYPE_COPYABLE (1<<4) #define IS_TYPE_SYMBOLTABLE (1<<5) /* special */
注:在 7.0.0 的正式版本中,上面這一段宏定義的注釋這幾個宏是供 zval.u1.v.type_flags 使用的。這應該是注釋的錯誤,因為這個上述字段是 zend_uchar 類型。
type_info 的三個主要的屬性就是『可計數』(refcounted)、『可回收』(collectable)和『可復制』(copyable)。計數的問題上面已經提過了。『可回收』用於標記 zval 是否參與循環,不如字符串通常是可計數的,但是你卻沒辦法給字符串制造一個循環引用的情況。
是否可復制用於表示在復制時是否需要在復制時制造(原文用的 "duplication" 來表述,用中文表達出來可能不是很好理解)一份一模一樣的實體。"duplication" 屬於深度復制,比如在復制數組時,不僅僅是簡單增加數組的引用計數,而是制造一份全新值一樣的數組。但是某些類型(比如對象和資源)即使 "duplication" 也只能是增加引用計數,這種就屬於不可復制的類型。這也和對象和資源現有的語義匹配(現有,PHP7 也是這樣,不單是 PHP5)。
下面的表格上標明了不同的類型會使用哪些標記(x 標記的都是有的特性)。『簡單類型』(simple types)指的是整型或布爾類型這些不使用指針指向一個結構體的類型。下表中也有『不可變』(immutable)的標記,它用來標記不可變數組的,這個在下一部分再詳述。
interned string(保留字符)在這之前沒有提過,其實就是函數名、變量名等無需計數、不可重復的字符串。
| refcounted | collectable | copyable | immutable
----------------+------------+-------------+----------+----------
simple types | | | |
string | x | | x |
interned string | | | |
array | x | x | x |
immutable array | | | | x
object | x | x | |
resource | x | | |
reference | x | | |
要理解這一點,我們可以來看幾個例子,這樣可以更好的認識 zval 內存管理是怎麼工作的。
下面是整數行為模式,在上文中 PHP5 的例子的基礎上進行了一些簡化 :
<?php $a = 42; // $a = zval_1(type=IS_LONG, value=42) $b = $a; // $a = zval_1(type=IS_LONG, value=42) // $b = zval_2(type=IS_LONG, value=42) $a += 1; // $a = zval_1(type=IS_LONG, value=43) // $b = zval_2(type=IS_LONG, value=42) unset($a); // $a = zval_1(type=IS_UNDEF) // $b = zval_2(type=IS_LONG, value=42)
這個過程其實挺簡單的。現在整數不再是共享的,變量直接就會分離成兩個單獨的 zval,由於現在 zval 是內嵌的所以也不需要單獨分配內存,所以這裡的注釋中使用 = 來表示的而不是指針符號 ->,unset 時變量會被標記為 IS_UNDEF。下面看一下更復雜的情況:
<?php $a = []; // $a = zval_1(type=IS_ARRAY) -> zend_array_1(refcount=1, value=[]) $b = $a; // $a = zval_1(type=IS_ARRAY) -> zend_array_1(refcount=2, value=[]) // $b = zval_2(type=IS_ARRAY) ---^ // zval 分離在這裡進行 $a[] = 1 // $a = zval_1(type=IS_ARRAY) -> zend_array_2(refcount=1, value=[1]) // $b = zval_2(type=IS_ARRAY) -> zend_array_1(refcount=1, value=[]) unset($a); // $a = zval_1(type=IS_UNDEF), zend_array_2 被銷毀 // $b = zval_2(type=IS_ARRAY) -> zend_array_1(refcount=1, value=[])
這種情況下每個變量變量有一個單獨的 zval,但是是指向同一個(有引用計數) zend_array 的結構體。修改其中一個數組的值時才會進行復制。這點和 PHP5 的情況類似。
類型(Types)
我們大概看一下 PHP7 支持哪些類型(zval 使用的類型標記):
/* regular data types */ #define IS_UNDEF 0 #define IS_NULL 1 #define IS_FALSE 2 #define IS_TRUE 3 #define IS_LONG 4 #define IS_DOUBLE 5 #define IS_STRING 6 #define IS_ARRAY 7 #define IS_OBJECT 8 #define IS_RESOURCE 9 #define IS_REFERENCE 10 /* constant expressions */ #define IS_CONSTANT 11 #define IS_CONSTANT_AST 12 /* internal types */ #define IS_INDIRECT 15 #define IS_PTR 17
這個列表和 PHP5 使用的類似,不過增加了幾項:
IS_UNDEF 用來標記之前為 NULL 的 zval 指針(和 IS_NULL 並不沖突)。比如在上面的例子中使用 unset 注銷變量;
IS_BOOL 現在分割成了 IS_FALSE 和 IS_TRUE 兩項。現在布爾類型的標記是直接記錄到 type 中,這麼做可以優化類型檢查。不過這個變化對用戶是透明的,還是只有一個『布爾』類型的數據(PHP 腳本中)。
PHP 引用不再使用 is_ref 來標記,而是使用 IS_REFERENCE 類型。這個也要放到下一部分講;
IS_INDIRECT 和 IS_PTR 是特殊的內部標記。
實際上上面的列表中應該還存在兩個 fake types,這裡忽略了。
IS_LONG 類型表示的是一個 zend_long 的值,而不是原生的 C 語言的 long 類型。原因是 Windows 的 64 位系統(LLP64)上的 long 類型只有 32 位的位深度。所以 PHP5 在 Windows 上只能使用 32 位的數字。PHP7 允許你在 64 位的操作系統上使用 64 位的數字,即使是在 Windows 上面也可以。
zend_refcounted 的內容會在下一部分講。下面看看 PHP 引用的實現。
引用
PHP7 使用了和 PHP5 中完全不同的方法來處理 PHP & 符號引用的問題(這個改動也是 PHP7 開發過程中大量 bug 的根源)。我們先從 PHP5 中 PHP 引用的實現方式說起。
通常情況下, 寫時復制原則意味著當你修改一個 zval 之前需要對其進行分離來保證始終修改的只是某一個 PHP 變量的值。這就是傳值調用的含義。
但是使用 PHP 引用時這條規則就不適用了。如果一個 PHP 變量是 PHP 引用,就意味著你想要在將多個 PHP 變量指向同一個值。PHP5 中的 is_ref 標記就是用來注明一個 PHP 變量是不是 PHP 引用,在修改時需不需要進行分離的。比如:
<?php $a = []; // $a -> zval_1(type=IS_ARRAY, refcount=1, is_ref=0) -> HashTable_1(value=[]) $b =& $a; // $a, $b -> zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_1(value=[]) $b[] = 1; // $a = $b = zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_1(value=[1]) // 因為 is_ref 的值是 1, 所以 PHP 不會對 zval 進行分離
但是這個設計的一個很大的問題在於它無法在一個 PHP 引用變量和 PHP 非引用變量之間共享同一個值。比如下面這種情況:
<?php $a = []; // $a -> zval_1(type=IS_ARRAY, refcount=1, is_ref=0) -> HashTable_1(value=[]) $b = $a; // $a, $b -> zval_1(type=IS_ARRAY, refcount=2, is_ref=0) -> HashTable_1(value=[]) $c = $b // $a, $b, $c -> zval_1(type=IS_ARRAY, refcount=3, is_ref=0) -> HashTable_1(value=[]) $d =& $c; // $a, $b -> zval_1(type=IS_ARRAY, refcount=2, is_ref=0) -> HashTable_1(value=[]) // $c, $d -> zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_2(value=[]) // $d 是 $c 的引用, 但卻不是 $a 的 $b, 所以這裡 zval 還是需要進行復制 // 這樣我們就有了兩個 zval, 一個 is_ref 的值是 0, 一個 is_ref 的值是 1. $d[] = 1; // $a, $b -> zval_1(type=IS_ARRAY, refcount=2, is_ref=0) -> HashTable_1(value=[]) // $c, $d -> zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_2(value=[1]) // 因為有兩個分離了的 zval, $d[] = 1 的語句就不會修改 $a 和 $b 的值.
這種行為方式也導致在 PHP 中使用引用比普通的值要慢。比如下面這個例子:
<?php $array = range(0, 1000000); $ref =& $array; var_dump(count($array)); // <-- 這裡會進行分離
因為 count() 只接受傳值調用,但是 $array 是一個 PHP 引用,所以 count() 在執行之前實際上會有一個對數組進行完整的復制的過程。如果 $array 不是引用,這種情況就不會發生了。
現在我們來看看 PHP7 中 PHP 引用的實現。因為 zval 不再單獨分配內存,也就沒辦法再使用和 PHP5 中相同的實現了。所以增加了一個 IS_REFERENCE 類型,並且專門使用 zend_reference 來存儲引用值:
struct _zend_reference { zend_refcounted gc; zval val; };
本質上 zend_reference 只是增加了引用計數的 zval。所有引用變量都會存儲一個 zval 指針並且被標記為 IS_REFERENCE。val 和其他的 zval 的行為一樣,尤其是它也可以在共享其所存儲的復雜變量的指針,比如數組可以在引用變量和值變量之間共享。
我們還是看例子,這次是 PHP7 中的語義。為了簡潔明了這裡不再單獨寫出 zval,只展示它們指向的結構體:
<?php $a = []; // $a -> zend_array_1(refcount=1, value=[]) $b =& $a; // $a, $b -> zend_reference_1(refcount=2) -> zend_array_1(refcount=1, value=[]) $b[] = 1; // $a, $b -> zend_reference_1(refcount=2) -> zend_array_1(refcount=1, value=[1])
上面的例子中進行引用傳遞時會創建一個 zend_reference,注意它的引用計數是 2(因為有兩個變量在使用這個 PHP 引用)。但是值本身的引用計數是 1(因為 zend_reference 只是有一個指針指向它)。下面看看引用和非引用混合的情況:
<?php $a = []; // $a -> zend_array_1(refcount=1, value=[]) $b = $a; // $a, $b, -> zend_array_1(refcount=2, value=[]) $c = $b // $a, $b, $c -> zend_array_1(refcount=3, value=[]) $d =& $c; // $a, $b -> zend_array_1(refcount=3, value=[]) // $c, $d -> zend_reference_1(refcount=2) ---^ // 注意所有變量共享同一個 zend_array, 即使有的是 PHP 引用有的不是 $d[] = 1; // $a, $b -> zend_array_1(refcount=2, value=[]) // $c, $d -> zend_reference_1(refcount=2) -> zend_array_2(refcount=1, value=[1]) // 只有在這時進行賦值的時候才會對 zend_array 進行賦值
這裡和 PHP5 最大的不同就是所有的變量都可以共享同一個數組,即使有的是 PHP 引用有的不是。只有當其中某一部分被修改的時候才會對數組進行分離。這也意味著使用 count() 時即使給其傳遞一個很大的引用數組也是安全的,不會再進行復制。不過引用仍然會比普通的數值慢,因為存在需要為 zend_reference 結構體分配內存(間接)並且引擎本身處理這一塊兒也不快的的原因。
結語
總結一下 PHP7 中最重要的改變就是 zval 不再單獨從堆上分配內存並且不自己存儲引用計數。需要使用 zval 指針的復雜類型(比如字符串、數組和對象)會自己存儲引用計數。這樣就可以有更少的內存分配操作、更少的間接指針使用以及更少的內存分配。
在下篇文章給大家介紹變量在 PHP7 內部的實現(二),感興趣的朋友繼續關注。