引用導(dǎo)致內(nèi)存泄漏?一文講清排查與解決)
1. 前言做 PHP 開發(fā)的朋友多多少少都遇到過內(nèi)存占用居高不下的情況。尤其是跑長任務(wù)、寫常駐腳本或者處理大數(shù)組的時(shí)候內(nèi)存蹭蹭往上漲看著就讓人頭疼。其實(shí)很多時(shí)候問題并不在業(yè)務(wù)邏輯本身而是出在 PHP 的垃圾回收機(jī)制上——說得再具體一點(diǎn)是循環(huán)引用把本該釋放的內(nèi)存給“鎖”住了。這篇文章不打算堆砌太多理論而是想從實(shí)際排查的角度出發(fā)把 PHP 垃圾回收的原理、循環(huán)引用為什么會(huì)導(dǎo)致內(nèi)存泄漏以及我們平時(shí)該怎么定位和解決這類問題一次講清楚。2. 先搞清楚 PHP 的引用計(jì)數(shù)PHP 的內(nèi)存管理核心就是“引用計(jì)數(shù)”。簡單來說每個(gè)變量背后都有一個(gè)計(jì)數(shù)器記錄著這個(gè)變量被引用了多少次。當(dāng)計(jì)數(shù)器歸零的時(shí)候這個(gè)變量占用的內(nèi)存就會(huì)被回收。舉個(gè)例子下面這段代碼就很直觀?php $a new stdClass(); // 此時(shí) $a 的引用計(jì)數(shù)為 1 $b $a; // 賦值給 $b 后引用計(jì)數(shù)變?yōu)?2 unset($a); // 銷毀 $a引用計(jì)數(shù)減為 1 unset($b); // 銷毀 $b引用計(jì)數(shù)歸零內(nèi)存被回收 ?看起來挺簡單的對(duì)吧但問題恰恰出在“循環(huán)引用”上——當(dāng)兩個(gè)對(duì)象互相引用對(duì)方的時(shí)候它們的引用計(jì)數(shù)永遠(yuǎn)都不會(huì)歸零。3. 循環(huán)引用是怎么把內(nèi)存“鎖死”的循環(huán)引用說白了就是 A 引用了 BB 又引用了 A形成一個(gè)閉環(huán)。這時(shí)候就算外部已經(jīng)沒有變量指向它們了這兩個(gè)對(duì)象的引用計(jì)數(shù)也始終是 1永遠(yuǎn)不會(huì)歸零內(nèi)存也就一直占著不放??磦€(gè)典型的例子?php class Node { public $next; public $data; public function __construct($data) { $this-data $data; } } $node1 new Node(第一個(gè)節(jié)點(diǎn)); $node2 new Node(第二個(gè)節(jié)點(diǎn)); // 互相引用形成循環(huán) $node1-next $node2; $node2-next $node1; // 外部引用全部銷毀 unset($node1); unset($node2); // 但內(nèi)存并沒有被釋放 // 因?yàn)?$node1 和 $node2 還在互相引用著對(duì)方 ?這段代碼跑完之后兩個(gè)對(duì)象依然存在于內(nèi)存中誰也釋放不了誰。如果是在循環(huán)里反復(fù)創(chuàng)建這樣的對(duì)象內(nèi)存就會(huì)像滾雪球一樣越積越多最終導(dǎo)致內(nèi)存泄漏。4. PHP 的垃圾回收器是怎么工作的PHP 當(dāng)然也意識(shí)到了這個(gè)問題所以在 5.3 版本之后引入了專門的垃圾回收器GC。它的思路是定期掃描那些“可能形成循環(huán)引用”的變量通過一種叫“根緩沖區(qū)”的機(jī)制把真正沒有外部引用的循環(huán)對(duì)象找出來并回收掉。簡單理解就是當(dāng)一個(gè)變量的引用計(jì)數(shù)減少但沒歸零時(shí)它會(huì)被放進(jìn)根緩沖區(qū)。當(dāng)根緩沖區(qū)滿了默認(rèn) 10000 個(gè)節(jié)點(diǎn)GC 就會(huì)啟動(dòng)一次掃描。GC 會(huì)模擬“如果把這個(gè)對(duì)象的外部引用全部去掉它的引用計(jì)數(shù)會(huì)不會(huì)歸零”如果會(huì)就說明它是垃圾可以回收。這個(gè)機(jī)制本身是有效的但問題在于——GC 不是實(shí)時(shí)觸發(fā)的它需要等根緩沖區(qū)攢夠一定數(shù)量才會(huì)跑一次。所以如果你的腳本創(chuàng)建了大量循環(huán)引用但還沒觸發(fā) GC內(nèi)存就會(huì)一直漲。5. 實(shí)戰(zhàn)排查怎么定位循環(huán)引用排查內(nèi)存泄漏最直接的辦法就是看內(nèi)存占用曲線。如果內(nèi)存只漲不降那基本可以斷定有泄漏。接下來就是定位具體是哪里出了問題。這里分享幾個(gè)我常用的排查手段?php // 方法一在關(guān)鍵節(jié)點(diǎn)打印內(nèi)存占用 echo 當(dāng)前內(nèi)存 . memory_get_usage() . 字節(jié) . PHP_EOL; // 方法二手動(dòng)觸發(fā)一次垃圾回收看內(nèi)存有沒有降下來 gc_collect_cycles(); echo GC 后內(nèi)存 . memory_get_usage() . 字節(jié) . PHP_EOL; // 方法三查看根緩沖區(qū)里有多少待處理節(jié)點(diǎn) echo 根緩沖區(qū)節(jié)點(diǎn)數(shù) . gc_status()[runs] . PHP_EOL; ?如果手動(dòng)調(diào)用gc_collect_cycles()之后內(nèi)存明顯下降那就說明循環(huán)引用確實(shí)存在而且 GC 還沒來得及處理它們。6. 怎么避免和解決循環(huán)引用定位到問題之后解決思路其實(shí)就幾條打破循環(huán)在不需要的時(shí)候手動(dòng)把對(duì)象的引用置為null比如$node1-next null;這樣引用計(jì)數(shù)就能正常歸零。用弱引用PHP 7.4 之后支持WeakReference它不會(huì)增加引用計(jì)數(shù)適合做緩存、觀察者這類場(chǎng)景。及時(shí)觸發(fā) GC在長任務(wù)里可以定期調(diào)用gc_collect_cycles()避免垃圾堆積。避免深層嵌套的對(duì)象圖設(shè)計(jì)上盡量讓對(duì)象之間的依賴關(guān)系簡單一些減少形成閉環(huán)的可能。拿前面的鏈表例子來說最簡單的修復(fù)就是?php // 銷毀前先打破循環(huán) $node1-next null; $node2-next null; unset($node1); unset($node2); // 這次內(nèi)存就能正常釋放了 ?7. 寫在最后PHP 的垃圾回收機(jī)制其實(shí)并不復(fù)雜但循環(huán)引用這個(gè)坑確實(shí)容易踩。尤其是寫隊(duì)列消費(fèi)、定時(shí)任務(wù)這類長生命周期腳本的時(shí)候一個(gè)不起眼的循環(huán)引用可能跑幾天就把內(nèi)存吃光了。建議大家在寫代碼的時(shí)候心里多一根弦對(duì)象之間互相引用的時(shí)候想想它的生命周期是不是可控的。真出了問題也別慌用memory_get_usage()配合gc_collect_cycles()一步步排查很快就能找到元兇。希望這篇文章能幫到你。如果你在實(shí)戰(zhàn)中也遇到過類似的內(nèi)存泄漏問題歡迎在評(píng)論區(qū)聊聊你的排查思路。