變數的內部引用和計數
在引擎內部,一個PHP的變數是儲存在“zval”結構中,此結構包含了變數的類型和值資訊,這個在之前的文章 變數的內部儲存:值和類型 中已經介紹了,此結構還有另外兩個欄位資訊,一個是"is_ref"(此欄位在5.3.2版本中是is_ref__gc),此欄位是一個布爾值,用來標識變數是否是一個引用,通過這個欄位,PHP引擎能夠區分一般的變數和引用變數。PHP代碼中可以通過 & 操作符號來建立一個引用變數,建立的引用變數內部的zval的is_ref欄位就為1。zval中還有另外一個欄位refcount(此欄位在5.3.2版本中是refcount__gc),這個欄位是一個計數器,表示有多少個變數名指向這個zval容器,當此欄位為0時,表示沒有任何變數指向這個zval,那麼zval就可以被釋放,這是引擎內部對記憶體的一種最佳化。考慮如下代碼:
代碼中有兩個變變數$a和$b,通過普通賦值方式將$a賦給$b,這樣$b的值和$a相等,對$b的修改不會對$a造成任何影響,那麼在這段代碼中,如果$a和$b對應兩個不同的zval,那麼顯然是對記憶體的一種浪費,PHP的開發人員也不會讓這樣的事情發生。所以實際上$a和$b是指向同一個zval。這個zval的類型是STRING,值是"Hello world",有$a和$b兩個變數指向它,所以它的refcount=2, 由於是一個普通賦值,所以is_ref欄位為0。 這樣就節省了記憶體開銷。
當執行$a = "Hello world"之後,$a對應的zval的資訊為:a: (refcount=1, is_ref=0)="Hello world"
但執行$b=$a之後,$a對應的zval的資訊為:a: (refcount=2, is_ref=0)="Hello world"
下面將之前的代碼修改一下:
這樣就通過引用賦值方式將$a賦給$b。
當執行$a = "Hello world"之後,$a對應的zval的資訊為:a: (refcount=1, is_ref=0)="Hello world"
但執行$b=&$a之後,$a對應的zval的資訊為:a: (refcount=2, is_ref=1)="Hello world"
可以發現is_ref欄位被設定成1了,這樣$a和$b對應的zval就是一個引用。這樣我們基本對引擎中變數的引用和計數有了一個基本的瞭解,下面將介紹變數的分離。
變數的分離 copy on write
考慮前面第一段代碼,用普通方式將$a賦給$b,在內部兩個變數還是指向同一個zval的,這個時候如果我們將$b的值修改為"new string",$a變數的值依然是"Hello world":
$a和$b明明是指向同一個zval,為什麼修改了$b,$a還能保持不變呢,這就是copy on write(寫時複製)技術,簡單的說,當重新給$b賦值的時候,會將$b從之前的zval中分離出來。分離之後,$a和$b分別是指向不同的zval了。
寫時複製技術的一個比較有名的應用是在unix類作業系統核心中,當一個進程調用fork函數產生一個子進程的時候,父子進程擁有相同的地址空間內容,在老版本的系統中,子進程是在fork的時候就將父進程的地址空間中的內容都拷貝一份,對於規模較大的程式這個過程可能會有著很大的開銷,更崩潰的是,很多進程在fork之後,直接在子進程中調用exec執行另外一個程式,這樣原來花了大量時間從父進程複製的地址空間都還沒來得及碰一下就被新的進程地址空間代替,這顯然是對資源的極大浪費,所以在後來的系統中,就使用了寫時複製技術,fork之後,子進程的地址空間還是簡單的指向父進程的地址空間,只有當子進程需要寫地址空間中的內容的時候,才會單獨分離一份(一般以記憶體頁為單位)給子進程,這樣就運算元進程馬上調用exec函數也沒關係,因為根本就不需要從父進程的地址空間中拷貝內容,這樣節約了記憶體同時又提高了速度。
當$b從$a指向的zval分離出來之後,zval的refcount就要減1,這樣由之前的2變成了1,表示這個zval還有一個變數指向它,就是$a。$b變數指向了一個新的zval,新的zval的refcount為1,值為字串"new string",大概過程如下:
$a = "Hello world" //a: (refcount=1, is_ref=0)="Hello world"$b = $a //a,b: (refcount=2, is_ref=0)="Hello world"$b = "new string" //a: (refcount=1, is_ref=0)="Hello world" b: (refcount=1, is_ref=0)="new string"(發生分離操作)
這個分離邏輯可以表敘為:對一個一般變數a(isref=0)進行一般賦值操作,如果a所指向的zval的計數refcount大於1,那麼需要為a重新分配一個新的zval,並且把之前的zval的計數refcount減少1。
以上為普通賦值的情況,如果是引用賦值,我們看看這個變化過程:
$a = "Hello world" //a: (refcount=1, is_ref=0)="Hello world"$b = &$a //a,b: (refcount=2, is_ref=1)="Hello world"$b = "new string" //a,b: (refcount=2, is_ref=1)="new string"
可以看出來,對一個參考型別的zval進行賦值是不會進行分離操作的,實際上我們再產生一個引用變數的時候是可能出現一個分離操作的,只是時機有些不同:
在普通賦值的情況下,分離操作發生在$b="new string"這一步,也就是在對變數賦新的值的時候,才會進行zval分離操作
在引用賦值的情況下,分離操作有可能發生在$b = &$a這一步,也就是在產生引用變數的時候
情況1就不多解釋了,情況2中強調是有可能發生分離,以前面的這代碼為例子,是否進行分離與$a當前指向的zval的refcount有關係,代碼中$b = &$a 的時候, $a指向的zval的refcount=1,這個時候不需要進行分離操作,但是如果refcount=2,那麼就需要分離一個zval出來。比如如下代碼:
在執行引用賦值的時候,$a指向的zval的refcount=2,因為$a和$c同時指向了這個zval,所以在$b=&$a的時候,就需要進行一個分離操作,這個分離操作產生了一個ref=1的zval,並且計數為2,因為$a,$b兩個變數指向分離出來的zval,原來的zval的refcount減少1,所以最終只有$c指向一個值為"Hello world",ref=0的zval1, $a和$b指向一個值為"Hello world",ref=1的zval2。 這樣我們對$c的修改時在操作zval1,對$a和$b的修改都是在操作zval2,這樣就符合引用的特性了。
此過程大致如下:
$a = "Hello world";//a: (refcount=1, is_ref=0)="Hello world"$c = $a; // a,c: (refcount=2, is_ref=0)="Hello world"$b = &$a; // c: (refcount=1, is_ref=0)="Hello world" a,b: (refcount=2, is_ref=1)="Hello world" (發生分離操作)$b = "new string"; // c: (refcount=1, is_ref=0)="Hello world" a,b: (refcount=2, is_ref=1)="new string"
試想一下如果不進行這個分離會有什麼後果?如果不進行分離,$a,$b,$c都指向了同一個zval,對$b的修改也會影響到$c,這顯然是不符合PHP語言特性的。
這個分離邏輯可以表述為:將一個一般變數a(isref=0)的引用賦給另外一個變數b的時候,如果a的refcount大於1,那麼需要對a進行一次分離操作,分離之後的zval的isref等於1,refcount等於2
通過以上的一些知識和分離邏輯讀者應該可以很容易分析其它的一些情況。比如將一個引用變數a(isref=1)的引用賦給一般變數b的時候,需要將b之前指向的zval的refcount減少1,然後將b指向a的zval,a的zval的refcount加1,沒有任何分離操作
這些理論結合實際代碼會讓你更容易理解這個過程。
unset的作用
unset()並非一個函數,而是一種語言結構,這個可以通過查看編譯產生的opcode看到區別,unset對應的不是一個函數調用的opcode。那麼unset到底做了什嗎? 在unset對應的opcode的handler中可以看到相關內容,主要的操作時從當前符號表中刪除參數中的符號,比如在全域代碼中執行unset($a),那麼將會在全域符號表中刪除a這個符號。全域符號表是一張雜湊表,建立這張表的時候會提供一個表中的項的解構函式,當我們從符號表中刪除a的時候,會對符號a指向的項(這裡是zval的指標)調用這個解構函式,這個解構函式的主要功能是將a對應的zval的refcount減1,如果refcount變成了0,那麼釋放這個zval。所以當我們調用unset的時候,不一定能釋放變數所佔的記憶體空間,只有當這個變數對應的zval沒有別的變數指向它的時候,才會釋放掉zval,否則只是對refcount進行減1操作。