PHP陣列到底佔用多少記憶體空間

來,見證奇蹟發表於2018-02-02

本文翻譯自 nikic 的一篇博文。

PHP中的陣列到底佔用多大的空間?

簡要:這篇文章我並不是按照原文逐字逐句的都翻譯過來,其中略去了一些與本文知識點無關的內容,加入了一些個人理解,不過版權還是歸原作者所有。文章主要討論的是 PHP5.x 中的記憶體使用,當然在新版本PHP7.x 中記憶體的佔用這裡也有一些提及,記憶體佔用情況大約是本文所提PHP5的1/3。更多的資訊可以參考我翻譯的另一篇文章 -- PHP7中hashtable的實現

本文中所有的知識介紹和內容總結都基於下面的實際案例。

構造一個含有100000個不重複的整型元素的陣列並且測量其佔用的記憶體數量,實現程式碼如下:

// 記錄開始記憶體狀態
$startMemory = memory_get_usage();
// 生成包含1-100000為資料元素的陣列
$array = range(1, 100000);
// 獲取陣列佔用的記憶體空間大小(單位位元組:bytes)
echo memory_get_usage() - $startMemory, ' bytes';

那麼現在你能計算出上面的陣列佔用記憶體空間大小嗎?如果你有c語言基礎,那麼你該知道在c語言中,一個整型資料(在64位機器上使用長整型來表示)大小是 8 bytes,那麼上面的陣列中包含 100000 個整型元素,就意味著實際佔用記憶體是 800000 bytes,大約是 0.76 MBs, 但實際的結果是這樣嗎?

現在儲存並執行一下上面的程式碼,執行結果是 14649024 bytes ,大約是 13.97 MBs ! 沒錯,這個大約是我們上面計算結果的 18.3 倍!那麼這超出的17.3倍多的空間從哪兒來的呢?

分析及總結

下面是陣列構成元素中一部分主要元素所佔記憶體大小的表格:

元素名                        |  64 bit   | 32 bit
---------------------------------------------------
zval                         |  24 bytes | 16 bytes
+ cyclic GC info             |   8 bytes |  4 bytes
+ allocation header          |  16 bytes |  8 bytes
===================================================
zval (value) total           |  48 bytes | 28 bytes
===================================================
bucket                       |  72 bytes | 36 bytes
+ allocation header          |  16 bytes |  8 bytes
+ pointer                    |   8 bytes |  4 bytes
===================================================
bucket (array element) total |  96 bytes | 48 bytes
===================================================
total total                  | 144 bytes | 76 bytes

上面的相關數字依賴於你所使用的作業系統,你的編譯器甚至你的編譯配置,不同的環境可能會有不同的結果。例如,如果你編譯PHP原始碼時開啟了除錯(debug)模式或者執行緒安全(thread-safety),都會得到不同的結果。

我的環境 : 普通的64位機器,linux系統,PHP5.3!

上面得到在最終結果是陣列的每個元素佔用記憶體的大小是 144 bytes 。如果我們用陣列元素的個數 100000 乘以這裡的 144 bytes 得到的結果是 14400000 bytes ,大約是 13.73 MBs 。這個結果就與我們的真實測試結果非常接近了!當然並不完全一致!還有些空間大多數是陣列中的 雜湊表中用到與 buckets 相關的指標域空間, 這個我們馬上就會講!

OK,我們們繼續分析 :)

zvalue_value 共用體(union)

首先讓我們們看看 PHP 是如何儲存值的!如你所知,PHP是一種弱型別的語言,但弱型別不代表沒型別,況且PHP是由C構建的,一種強型別的語言構建了一種弱型別的語言,所以 PHP 內部肯定有自動快速定位資料型別的相關方法!

在 PHP 語言檔案 zend.h 的大約 307 行 定義瞭如下的 共用體(numion)型別變數:

typedef union _zvalue_value {
    long lval;                // 整型 和 布林型別
    double dval;              // 浮點型別 (doubles)
    struct {                  // 字串
        char *val;            //     儲存字串值
        int len;              //     字串長度
    } str;
    HashTable *ht;            // 陣列 (hash tables)
    zend_object_value obj;    // 物件型別
} zvalue_value;

如果你沒有C語言基礎,也沒關係,共用體概念很容易理解:一個共用體意味著一種將各種資料型別組合在一起的一種方案!你可以理解為PHP中的類!
共用體的使用和類也很相似!在這裡,如果你使用了 zvalue_value->lval ,實際上你就是用了一個整型的資料!入果你使用 zvalue_value->ht 那就相當於使用了一個指向某個 hashtable (實際上是array) 的指標.

當然,實在搞不懂也沒事兒!在這裡你需要記住一件事兒就行了!就是共用體(nuion)中的所有成員共同使用一塊記憶體,所以一個共用體的大小等於其元素中最大的那個元素的大小!!!

上面的共用體中最大的元素是 字串結構體(也理解為PHP中的類吧 >_<)

struct {                  // 字串
    char *val;            //     儲存字串值
    int len;              //     字串長度
} str;

和物件型別

zend_object_value obj;    // 物件型別

它們兩個一樣大,不過我們主要關注其中一個就好了,比如 位置更靠上的字串結構體!

在這裡字串結構體儲存了一個指標( 8 bytes )和一個整型資料( 4 bytes ), 一共是 12 bytes 大小!但是分配該變數的記憶體大小一定是其中最大的元素的整數倍,所以實際上這個結構體佔用了 16 bytes 的記憶體大小。而又由於該結構體是所在共用體中最大的,所以這個共用體的大小也是 16 bytes 。

現在我們知道了PHP中每個元素儲存值要用 16 bytes , 那麼我們一共有100000個元素,所以應該是一共是 1600000 bytes,大約是 1.53 MBs 。 但是實際大小是 13.97 MBs, 所以應該還有額外的我們沒研究到的!

所以,繼續研究!!!>_

zval 結構體(struct)

上面我們談到的共用體 zvalue_value, 只是用來儲存變數的值的,但是不要忘了,PHP也必須知道一個變數的其它相關資訊,比如變數的型別、變數的垃圾回收資訊等等!PHP要做到這些是通過一個叫做 zval 的結構體來完成的!當然有些人或許早就聽過這個東東!^_^。

zval定義如下:

struct _zval_struct {
    zvalue_value value;     // 儲存值
    zend_uint refcount__gc; // 變數的引用計數器 (for GC 垃圾銷燬用到)
    zend_uchar type;        // 變數型別
    zend_uchar is_ref__gc;  // 變數是否是被引用 (&)
};

一個結構體struct的大小等於它含有的元素大小之和

zvalue_value : 16 bytes
zend_uint : 4 bytes
zend_uchars : 1 byte
is_ref__gc : 1 byte

一共是 22 bytes。但是會分配8的倍數也就是 24 bytes 給這個結構體!

所以現在我們有100000個元素,大小一共就是 100000乘以24 等於 2400000 bytes!大約是2.29 MB。差距變小了,但是距離真實的值仍然有很大空間!

垃圾回收器 : cycles collector (as of PHP 5.3)

PHP 5.3 提供了一個新的迴圈垃圾回收器。為了完成這件事兒,PHP必須存出一些額外的資料。大家可以點選連線到官方手冊瞭解更多資訊,這裡不是我們的重點,俺就不多講啦!在這裡我們要知道的是 PHP 會記錄每一個 zbal 到 一個新的 zval_gc_info 結構體中作為記錄。

zval_gc_info 程式碼定義如下:

typedef struct _zval_gc_info {
    zval z;
    union {
        gc_root_buffer       *buffered;
        struct _zval_gc_info *next;
    } u;
} zval_gc_info;

對比 zval 的實現,PHP實現了的 zval_gc_info 僅僅是增加了 一個共用體(union), 共用體包又含了兩個指標!而對於共用體而言,它的大小等於包含元素中最大的那個:在這裡兩個元素都是指標,所以兩個元素大小相同都是 8

那麼現在我們再加上 zval 的大小 24 bytes,每個元素的大小 就已經變成了 32 bytes。 再次乘以 100000 個元素, 得到的記憶體大小是 3200000 bytes, 大約是 3.05 MB。不過還是差得遠!

繼續努力!ヾ(◍°∇°◍)ノ゙

Zend 控制記憶體分配器 : Zend MM allocator

C 語言中我們要自己完成記憶體的申請和釋放,但是 PHP 可以自動幫你控制記憶體分配和回收操作。為了完成這項工作,PHP使用了在傳統記憶體控制器的基礎上經過了優化的一個記憶體控制器 : Zend記憶體控制器(Zend Memory Manager), 簡稱為 Zend MM。

Zend MM 是在一個名為 Doug Lea's 記憶體分配器的基礎上新增了一些 PHP 的優化和特點(例如記憶體限制,請求完成後及時回收等等) 來構建的。

在這裡我們要關心的是 MM 會在每一次分配完成之後會為每一個 分配空間 新增一個 分配頭資訊(allocation header)。

具體的程式碼定義如下:

typedef struct _zend_mm_block {
    zend_mm_block_info info;
#if ZEND_DEBUG                      // 開啟除錯
    unsigned int magic;
# ifdef ZTS                         // 開啟多執行緒
    THREAD_T thread_id;
# endif
    zend_mm_debug_info debug;
#elif ZEND_MM_HEAP_PROTECTION       // 開啟堆保護
    zend_mm_debug_info debug;
#endif
} zend_mm_block;


typedef struct _zend_mm_block_info {
#if ZEND_MM_COOKIES     // 開啟 MM cookies     
    size_t _cookie;
#endif
    size_t _size;       // 分配空間的大小
    size_t _prev;       // 上一塊空間
} zend_mm_block_info;

上面的程式碼中你會發現有一堆編譯引數的檢查!如果你開啟了堆保護(heap protection)、多執行緒(multi-threading)、除錯(debug) 或者 MM cookies,那麼在生成分配頭資訊是就要大得多(有可能很大)!

本例中我們假設所有的配置項都是關閉的。那麼剩下的就只有兩個 size_ts 型別的變數 : _size 和 _prev。 一個 size_t 的大小是 8 bytes (64 位機器上), 所以 分配頭大小一共是 16 bytes , 並且每一塊兒分配空間上都有一個分配頭資訊。

那麼現在我們又要重新更新我們的 zval 大小了。加上了分配頭資訊之後的 zval 的大小就是 48 bytes 了。再次乘以 100000 個元素結果是 4.58 MB, 真實的情況是 13.97 MB,我們計算的值已經是真實值的 1/3 了。

ok, 繼續 ヾ(◍°∇°◍)ノ゙

Buckets

目前位置我們只考慮了值的儲存。但是PHP中的陣列資料結構也要佔用很多的空間。所實話,PHP中的所謂“陣列”實際上並不是純粹的陣列!而是“雜湊表”或者說是“字典”。那麼PHP中的雜湊表是如何工作的呢?其實PHP底層是使用C語言實現的,所以這裡的雜湊表採用了和C中雜湊表資料結構相似的做法!每一個元素被建立都會對應著一個雜湊表儲存在C構建的陣列中,並且如果發生了“衝突”(具有相同雜湊值的元素指向同一塊陣列地址)就會使用雙向連結串列來解決! 當要訪問一個元素時,PHP首先計算雜湊值找到對應的 bucket,然後遍歷連結串列,一個一個元素的比較關鍵值。

bucket 的程式碼定義如下(可以檢視 zend_hash.h#54):

typedef struct bucket {
    ulong h;                  // 雜湊表
    uint nKeyLength;          // 字串key的長度 
    void *pData;              // 實際的資料
    void *pDataPtr;           
    struct bucket *pListNext; // PHP 陣列是有序的. 找到序列中的下一個
    struct bucket *pListLast; // 陣列序列中的上一個
    struct bucket *pNext;     // 雙向連結串列中元素的下一個元素
    struct bucket *pLast;     // 雙向連結串列元素的上一個元素
    const char *arKey;        // 字串key 
} Bucket;

分析完上面的程式碼你會發現 PHP 中的“陣列”實際上使用了一種經過抽象的類似陣列的資料結構來儲存資料(PHP陣列既是陣列,又是字典還是連結串列,當然要用很多資訊啦>_<)。

現在讓我們統計一下一個 bucket 的大小。無符號長整型(ulong)佔用8 bytes,無符號整型(uint) 佔用 4 bytes,再加上7個指標,每個指標佔用 8 bytes, 一共是 68 bytes,要對齊為 8的倍數, 所以一個 bucket 佔用 72 bytes。

Buckets 就像 zvals 一樣分配空間也要加上分配頭資訊, 所以一個 bucket 需要額外的 16 bytes, 就變成了 88 bytes. 再加上在C陣列中要儲存指向這些 buckets 的指標(Bucket **arBuckets;) , 每個元素還要再加上 8 bytes。所以每一個 bucket 一共需要 96 bytes 空間來儲存。

所以,如果每個值都要一個bucket, 那麼一個bucket要96 bytes,一個zval要 48 bytes, 一共是 144 bytes!100000個元素就是 14400000 bytes,即 13.37 MB。

謎題解開了!

哎,等等,還少 0.24 MB 呢!!!

剩餘的 0.24 MB 是未初始化的 buckets 的空間 : C語言的陣列中儲存buckets的空間在理想狀態下與儲存的陣列元素的數量大致相同。這種方法可以儘量減少資料的“碰撞”(除非你想浪費更多空間)。但是 PHP 並不會每次新增元素都要重新分配記憶體,如果每次都重新分配就效率太低了!PHP 採用的方案是當元素達到陣列大小的邊界時就將陣列大小擴充套件一倍!所以陣列的容量總是2的n次方。

按照上面的分析,那麼我們需要100000個空間,但實際上陣列擁有的容量是 2^17 = 131072。那麼這些buckets並不會完全初始化(所以我們沒有必要完全花費掉 96 bytes),但是bucket指標的記憶體空間(bucket內的)仍然會被初始化。所以剩餘的31072個沒被使用的陣列空間每個元素仍然佔用了 8 bytes。一共是248576 bytes,大約是 0.23 MB。正好是多出來的 0.23 MB 空間!(不過還是少一些空間的,例如雜湊表本身也要花一部分,陣列背身也要佔一部分空間,等等)。

這次,所有謎題全部解開了!

我們從中有什麼收穫呢?

PHP 不是 C 語言,所以你就別期望一門動態型別的語言例如 PHP 能夠像 C 語言那樣可以高效的使用記憶體了!

但是如果在PHP中你想更加高效的使用記憶體,有一種更好的方案推薦 : 使用 SplFixedArray 構建大的,靜態的陣列!

下面有一個關於 SplFixedArray 的測試樣例:

$startMemory = memory_get_usage();
$array = new SplFixedArray(100000);
for ($i = 0; $i < 100000; ++$i) {
    $array[$i] = $i;
}
echo memory_get_usage() - $startMemory, ' bytes';

上面的程式碼實際上和我們之前做了一樣的事 : 建立了一個包含100000個不重複元素的陣列。但是我們執行這段程式碼,你會發現這個陣列僅僅佔用了 5600640 bytes!這是因為對於上面我們建立的是靜態陣列,靜態陣列不需要 bucket 結構!所以這個陣列僅僅消耗掉的空間有 : 每個元素佔用了一個 zval(48 bytes)和一個指標(8 bytes),一共 56 bytes!

所以如果你明確知道陣列的長度或者需要一個很大的陣列空間,使用 SplFixedArray 是個不錯的選擇!

相關文章