深入剖析PHP7內核源碼(二)- PHP變數容器
- 2019 年 10 月 3 日
- 筆記
簡介
PHP的變數使用起來非常方便,其基本結構是底層實現的zval,PHP7採用了全新的zval,由此帶來了非常大的性能提升,本文重點分析PHP7的zval的改變。
PHP5時代的ZVAL
typedef struct _zval_struct { zvalue_value value; // (長度16位元組,具體看下面的分析) zend_uint refcount__gc; // unsigned int (長度4位元組) zend_uchar type; // unsigned char (長度1位元組) zend_uchar is_ref__gc; // unsigned char (長度1位元組) } zval typedef union _zvalue_value { long lval; // 用於 bool 類型、整型和資源類型(長度8位元組) double dval; // 用於浮點類型(長度8位元組) struct { // 用於字元串 char *val; // 字元串指針(長度8位元組) int len; //字元串長度(長度4位元組) } str; HashTable *ht; // 用於數組(長度8位元組) zend_object_value obj; // 用於對象(12位元組) zend_ast *ast; // 用於常量表達式(長度8位元組) } zvalue_value;
- zvalue_value 是聯合體,長度取最大的一個,為12位元組,記憶體對齊後是16位元組(需要對齊為8的倍數)。
- zval 是結構體,長度是各個變數的總和,為22位元組,記憶體對齊後是24位元組。
- php5.3後對zval進行了擴充,解決循環引用的問題,因此實際上申請一個變數分配了 24 + 8 = 32位元組的記憶體。
typedef struct _zval_gc_info { zval z; union { gc_root_buffer *buffered; struct _zval_gc_info *next; } u; // (長度8位元組) } zval_gc_info;
所以在PHP裡面,給一個變數賦值,實際上會轉換成這樣來運行
<?php $var = 123 => zval.value = 123 zval.type = IS_LONG zval.refcount__gc= 0 zval.is_ref__gc = 0 ...
PHP7 時代的ZVAL
struct _zval_struct { union { zend_long lval; // 整型(長度8位元組) double dval; // 浮點型(長度8位元組) zend_refcounted *counted; // 引用計數(長度8位元組) zend_string *str; // 字元串類型(長度8位元組) zend_array *arr; // 數組(長度8位元組) zend_object *obj; // 對象(長度8位元組) zend_resource *res; // 資源型(長度8位元組) zend_reference *ref; // 引用型(長度8位元組) zend_ast_ref *ast; //抽象語法樹(長度8位元組) zval *zv; // zval類型(長度8位元組) void *ptr; // 指針類型(長度8位元組) zend_class_entry *ce; // class類型(長度8位元組) zend_function *func; // function類型(長度8位元組) struct { uint32_t w1; // (長度4位元組) uint32_t w2; // (長度4位元組) } ww; // 長度8位元組 } value; // 因為是聯合體,所以實際上整個value只用了8位元組 union { struct { ZEND_ENDIAN_LOHI_4( zend_uchar type, // zval的類型(長度1位元組) zend_uchar type_flags, //對應變數類型特有的標記(長度1位元組) zend_uchar const_flags, // 常量類型標記(長度1位元組) zend_uchar reserved) // 保留欄位(長度1位元組) } v; // 總共長度是4位元組 uint32_t type_info; // 其實就是v的值位運算結果(長度4位元組) } u1; // u1也是聯合體,總共長度4位元組 union { uint32_t var_flags; uint32_t next; // 用來解決哈希衝突的(長度4位元組) uint32_t cache_slot; // 運行時快取(長度4位元組) uint32_t lineno; // zend_ast_zval行號(長度4位元組) uint32_t num_args; // Ex(This) 參數個數(長度4位元組) uint32_t fe_pos; // foreach 的位置(長度4位元組) uint32_t fe_iter_idx; // foreach 迭代器游標(長度4位元組) } u2; // u2也是聯合體,總共長度4位元組 };
- value (8) + u1(4) +u2(4) = 16,整個變數才用了16位元組,相比PHP5來說,節省了一半記憶體。
- value 保存具體是值,不同的類型的值,用的是聯合體的同一塊空間。
- u1 變數的類型就通過u1.v.type區分,另外一個值type_flags為類型掩碼,在變數的記憶體管理、gc機制中會用到
- u2 輔助值,假如zval只有:value、u1兩個值,整個zval的大小也會對齊到16byte,所以加了u2作為輔助,比如next在哈希表解決哈希衝突時會用到,還有fe_pos在foreach會用到
zvalue的類型
zvalue.u1.type
/* 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_AST 11 /* internal types (偽類型)*/ #define IS_INDIRECT 13 #define IS_PTR 14 #define _IS_ERROR 15 /* fake types used only for type hinting (Z_TYPE(zv) can not use them) 內部類型*/ #define _IS_BOOL 16 #define IS_CALLABLE 17 #define IS_ITERABLE 18 #define IS_VOID 19 #define _IS_NUMBER 20
- PHP是根據u1.v.type的類型取不同的值,比如u1.v.type == IS_LONG,則取值 value.lval
- IS_UNDEF 未定義,表示數據可以被刪除,可用於對數組unset的時候標記Bucket的位置為IS_UNDEF,等標記元素達到閾值的時候,進行rehash操作刪除數據
- IS_TRUE IS_FALSE 將PHP5時代的IS_BOOL分開為兩個,只需要一次操作即可取值。
- IS_REFERENCE 處理&變數
- IS_INDIRECT 解決全局符號表訪問CV變數表
- IS_PTR 指針類型,解釋 value.ptr,通常用在函數類型上,比如聲明一個函數
- _IS_ERROR 檢查zval的類型是否合法
字元串的實現
struct _zend_string { zend_refcounted_h gc; // 引用計數,變數引用資訊 zend_ulong h; // 哈希值,數組中計算索引時會用到 size_t len; // 字元串長度 char val[1]; // 字元串內容 };
- zend_ulong h 快取了字元串的hash值,避免了數組中的重複計算字元串hash,提升了5%的性能
- val值儲存字元串類型,用的是柔性數組類型
zval.value->gc.u.flags 這個標記代表了下面幾種不同類型的字元串
IS_STR_PERSISTENT(通過malloc分配的) IS_STR_INTERNED(php程式碼里寫的一些字面量,比如函數名、變數值) IS_STR_PERMANENT(永久值,生命周期大於request) IS_STR_CONSTANT(常量) IS_STR_CONSTANT_UNQUALIFIED
整數的實現
整數是標量,在容器中zval直接存儲
$a = 666; // $a = zval_1(u1.v.type=IS_LONG,value.lval=666) $b = $a; // $a = zval_1(u1.v.type=IS_LONG,value.lval=666) // $b = zval_2(u1.v.type=IS_LONG,value.lval=666) unset($a); // $a = zval_1(u1.v.type=IS_UNDEF,value.lval=666)
- PHP7相對於PHP5 的一個改變就是,對標量的值直接拷貝,而沒有做寫時拷貝,因為zval只有16位元組,寫時拷貝實際上節省不了記憶體還會增加操作的複雜度。
- unset的時候把 u1.v.type 標記為IS_UNDEF,記憶體不會釋放。
數組的全貌
數組的基本結構是基於key value的 HashTable,同時是一個雙向鏈表。熟悉數據結構的都知道,對一個字元串Hash的時候有可能產生哈希衝突,PHP是怎麼解決的?當發生衝突的時候,PHP在該映射後面會加上一條鏈表,哈希衝突後就會從鏈表中找值。使用了雙向鏈表的好處是,我們對數組最常用的操作就是遍曆數組,通過雙向鏈表,我們可以很方便進行遍歷。你可能會問,那如果僅僅是這樣,單向鏈表不也解決了嗎?還節省點空間。實際上,之所以用雙向鏈表的一個原因,是因為鏈表在刪除元素的時候,就必須找到上一個元素,把它的指針指向到下下個元素,雙向鏈表已經儲存了上一個元素的指針,而單向鏈表就必須遍歷整個HashTable,時間複雜度將會是很差的O(n)。
- HashTable刪除元素的時間複雜度是O(1),雙向鏈表刪除的時間複雜度也是O(1),所以整個刪除操作可以做到時間最優的O(1)。
這個是PHP數組的大概樣子,後面會專門寫一篇來概述是數組HashTable的實現。
資源類型
PHP中很多依賴外部的操作都是資源類型,比如文件資源 Socket連接資源,資源類型的定義如下
struct _zend_resource{ zend_refcounted_h gc; int handle; int type; void *ptr; //指針,根據使用場景轉換為任何類型 }
對象類型
struct _zend_object { zend_refcounted_h gc; uint32_t handle; zend_class_entry *ce; //對象對應的class類 const zend_object_handlers *handlers; HashTable *properties; //對象屬性哈希表 zval properties_table[1]; };
properties 是一個HashTable ,key 對象的屬性 ,value是對象在properties_table 數組中的偏移量,值真正的位置是在properties_table 數組中。
引用類型
PHP的引用類型是比較特殊的一種類型,可以通過 & 操作符可以產生一個引用變數,假如把 $b = &a; $b 的值改變的時候,$a 的值也跟著改變。
struct _zend_reference { zend_refcounted_h gc; zval val; };
- zend_refcounted_h 結構體用來儲存引用計數的資訊
- val 存儲的是實際的值
$a = "time:" . time(); //$a -> zend_string_1(refcount=1) $b = &$a; //$a,$b -> zend_reference_1(refcount=2) -> zend_string_1(refcount=1) $c = $b; //$a,$b -> zend_reference_1(refcount=2) -> zend_string_1(refcount=2) //$c -> zend_string_1(refcount=2)
- $a 賦值字元串,zend_string_1 的引用計數記為1。
- 把$a的引用賦值給$b,zend_string_1 結構的引用計數不變,產生了一個中間結構體zend_reference_1,該結構體的引用計數為2。
- $b 賦值給$c ,zend_reference_1引用計數不變,zend_string_1引用計數記為2。
中間結構體zend_reference_1存在的好處是,zend_string只需要存一份,減少空間的浪費以及申請空間帶來的額外開銷
附錄
什麼是記憶體對齊
比如數據匯流排有32位,它訪存只能4個位元組4個位元組地進行。 0-3,4-7,8-11,12-15,…… 即使我們需要的數據只佔一個位元組,也是一次讀取4個位元組。 一個位元組的數據不管地址是什麼,都能通過一次訪存讀取出來。 而如果要讀取的數據是一個位元組以上,比如兩個位元組, 如果該數據的記憶體地址是0x03,則需要兩次才能讀取該數據, 第一次讀0x00-0x03,第二次讀0x04-0x07。 這個數據就跨越了訪存邊界。而相對CPU的運算來說,訪存是非常慢的,所以要盡量減少訪存次數。 為了減少跨越訪存邊界的數據引起的訪存開銷, 所以編譯器會進行記憶體對齊,即把變數的地址做一些偏移, 目的是一次訪存就讀出數據,不然的話也要以儘可能少地訪存次數讀出數據。如上一個例子中那樣,整型成員i的地址做4個位元組的偏移, 而Sample對象的地址也會做4位元組邊界的對齊, 這樣i的地址始終是4的倍數,從而使得i不跨越訪存邊界, 能一次讀出它的值。
typedef struct{ char a; char b; int i; } Sample1;
Sample1佔多少空間呢?仍然是8個位元組。 a在第0個位元組,b在第1個位元組,i佔4-7位元組。 這是記憶體對齊的原則,佔用盡量少的記憶體。 如果在b之後,還有char類型的成員c和d,同樣是佔8個位元組。 a,b,c,d在0-3位元組。
引用
- 深入理解PHP7內核之zval http://www.laruence.com/2018/04/08/3170.html
- C語言的記憶體對齊 https://www.cnblogs.com/jiqingwu/p/4043338.html
- php7-internal https://github.com/pangudashu/php7-internal/blob/master/2/zval.md
- 《PHP7 底層設計與源碼實現》 陳雷等