
纯大白话最后一阶段也不破例。第9阶段85-99就讲一件事PHP 的内存是怎么领的、怎么还的、怎么打扫卫生的。先给总图再逐条拆。---总概念PHP 有两套钱库PHP 根本不用系统的 malloc 凭空领钱它自己先开了一个大金库Zend 内存管理器然后每次要内存都从这个金库里取。系统内存只是进货渠道PHP 家的金库才是日常领钱的地方。 所以整章就三件大事1.批发PHP 一次性从系统进一大堆货大块内存放金库。2.零售你的变量要内存从金库的存货里随手拿一块。3.打扫没人用的内存要还回金库、整理干净破了循环的垃圾要清掉。---85.内存管理全景——两层钱库 PHP 的内存是两极结构-Zend 内存管理器ZMM一个自己维护的大内存池。绝大多数 PHP 内部请求数组、字符串、对象都从这领因为申请/释放超快不碰系统、还能统一跟踪、还能脚本结束一锅端还掉。-系统 malloc少数大而特殊的东西比如大内存块、mmap 映射、某些第三方库直接找系统领。 大内存分配策略ZMM 里分小块和大块两档。小块的从金库存货里切超过一定阈值默认2MB 左右就直接向系统 malloc不占金库存货——反正它大而少管起来不划算。 一句话PHP 自建金库管内存常用的小块从库里切超大块直接找系统要省得占金库存货。---86.zend_mm 内存分配器——金库的结构 ZMM 的底层设计核心三样 内存池预先从系统申请一大块连续的囤货区切成一块块小格子。 空闲链表free list金库里维护着一张哪些格子空着的清单。你要内存它优先从这张空清单里找合适的而不是每次都去系 统进货。就像仓库里第一个拿库存不急着进货。 内存块合并当你还回来两块紧挨着的空格时它把这两块拼成一大块合并这样下次有人要一大块能正好用上不会碎成小块浪 费。 一句话金库囤货区一张哪里空着的清单空块挨着就拼大。核心是尽量用存货、还回来就合并省空间。---87.小对象分配——sizclassslab 你的字符串、小数组这种小内存用一个高效的快递柜方案-size class规格分类把内存按固定的尺寸档分类比如一档装8字节、一档装16字节、一档装32字节……你要20字节就给你32 字节档。宁可浪费一小点换取出柜O(1)超快。-slab一排柜格每档规格是一个slab——一大块被切成很多同样大小的小格子的区域。-binned_free_list格子化空清单每个规格一个这档里有几个空的的链表。取走一个、登记一个空位还回来就塞回链表。 精髓因为每个格子大小固定取用和归还都不用搜、不用算直接往链表头插/摘时间恒定O(1)超级快。代价是规格向上取整浪费一点空间。 一句话小内存按尺寸分档切成固定格子slab每档一个空位链表取还都是O(1)秒取秒还代价是向上取整略浪费。---88.大对象分配——直接mallocmmap对齐 超过金库格子档的东西-直接 malloc太大不占金库存货直接找系统领一整块。-mmap某些超大块用内存映射直接连到文件或匿名内存能按需、便宜。-内存对齐分配时保证起始地址按特定字节对齐比如16字节这样 CPU 读起来快还能安全收存一些底层指针。 一句话超大内存不走金库、直接找系统 malloc 或 mmap并保证按字节对齐图它又大又快。---89.内存碎片整理——把杂乱的空位归拢 金库用久了会出现碎片空块七零八落中间夹着已用的块。就像仓库里东西堆得乱七八糟空车进不来。 整理方式zend_mm_collect_cycles 只是其中一环-合并相邻空块前面86说过把挨着的空块拼大。-回收整页整块整块没用的页直接还给系统/超始池。-核心思路是顺手不是定期大扫除而是每次动内存时尽量把相邻的空拼起来、把完全没用的整页放走让空区尽量连成片。 一句话碎片整理动内存时顺手把相邻空块拼接、把完全空的整页放走让空闲连成片不搞大扫除式停机。---90.引用计数——内存的保质期管理 PHP 用引用计数判断一块内存啥时候能回收。核心是 GC_REFCOUNT 这个计数盒-每块被 PHP 管的内存字符串、数组、对象头上都挂一个计数器。-引用增你 $b$a;让两个变量指向同一块数据 →计数器1。-引用减某变量重新赋值、或变量销毁 →计数器-1。-归零回收计数器减到0没人用了 →立即把这内存收进空链表供复用不是还系统是回金库。 循环引用问题这就是有名的死结——两个对象互相指着对方$a-$b;$b-a$a;计数器永远不为0互相撑也不外部没人用但就是减不到零 →内存泄漏。这就是为什么需要专门的 GC91。 一句话每块内存挂个在用人数计数器增引用1、减引用-1归零就回收可对象互相指着对方会永远减不到零这就是垃圾回收 器要解决的事。---91.垃圾回收器 GC——专门治循环引用为了解决90的死结PHP 有独立的一套垃圾回收机制GC核心叫标记-清除mark-sweep颜色标记 思想不给循环引用的东西瞎回收因为没参考而是用三色标记判断它到底是指着别人的死环还是被人活用的活东西。-白色还没被检查疑似垃圾。-灰色正在被检查可能有问题。-黑色已确认还活着外面还有其他路指着他。 过程1.先把疑似垃圾的候选那些计数怪怪的循环引用先涂成灰色/白色。2.从**确定还活着的人根**出发遍历——碰到能到的东西就涂黑还活着。3.最后还是白色的真没人要的循环孤岛回收。 一句话GC 用从活人出发能到达的算活黑到达不了还白的算死清除的办法专治互相指着的循环引用。---92.GC 的触发条件——垃圾多到一定量才扫 GC 不是总跑它有触发开关核心是几个阈值-gc_runs已经跑过多少次 GC。-gc_threshold根缓冲区满到多少条就开始跑 GC默认10001或动态调整。-gc_buffer_size根缓冲区容量。 动态调阈GC 跑完会把下次的阈值根据收集到多少垃圾自动上调——如果这次没扫出几个垃圾说明存量不脏下次阈值提高点 少 白跑几趟如果扫出很多下次调低点勤扫。这个叫自适应。 一句话垃圾候选堆到阈值默认约1万个才触发 GC跑完根据扫到多少垃圾自动调高或调低下次阈值——垃圾少就少扫、垃圾多就勤扫。---93.根缓冲区——哪些东东会进疑似垃圾名单GC 第一步把有可能形成循环引用的东西放进根缓冲区一份候审名单。 什么会进引用计数减为零却仍存在内部循环连接的复杂结构主要是-对象尤其对象间互相引用-数组数组里互相引用或有对象 但注意整数、字符串、纯布尔这种根本没法互相指的不进根缓冲区——它们不可能形成循环一个字符串没法指向另一个字符串 。放进去了纯浪费。 一句话只有能互相指着的复杂结构对象、数组才值得进疑似垃圾名单字符串/数字这种没法互相指的根本不送审。---94.GC 标记阶段——从灰到黑的遍历 这是 GC 最核心的两大动作之一标记1.起点是根缓冲区里的疑似垃圾——它们先被涂成灰色标记查着你们呢。2.开始逐个往下钻递归遍历问每个候选你还指着谁-指到还确定活着的东西 →这个候选涂黑它还被活人引用是活的救回来。-顺着往下钻把能钻到的都遍历到。3.遍历完剩下的灰色/白色没被任何活着的东西引用确定为垃圾。 一句话从疑似名单往下钻能通到活人的就涂黑救活遍历完还黑不了的灰色白色就是真垃圾。---95.GC 清除阶段——什么时候、按什么顺序回收 标记出谁是真垃圾后进行清除-时机不是马上而是在 GC 主循环收尾时把确认的垃圾结构统一减少引用计数并释放把循环里的引用一个个解除计数器归零自然回收。-顺序有个讲究——先解除最外层、再往里一层层解除而且要小心别重进防止已经回收的又被引一次又报错。实际上GC 会先钻进结构内部把循环断开从内部开始再往外。 简单说按先内部后外部 / 先解除引用再归零回收的顺序,确保循环解开、内存正确还掉不重复释放。 一句话确认的真垃圾先解开内部互相引用、再逐层归零释放按从内到外的顺序小心回收避免重复释放。---96.内存泄漏检测——工具怎么抓漏 三种常用手段-valgrind一个放大镜录音机。它模拟执行你的程序记下每块内存是哪行申请的。结束时不释放的内存它列出来哪行代码 申请了没还——漏点当场暴露。用valgrind--toolmemcheck php test.php。-ASANAddressSanitizer编译 PHP 时开-fsanitizeaddress它在每块内存周围布置哨兵越界写/非法读/泄漏直接报警崩溃定位。比 valgrind 快适合持续跑。-自定义追踪自己用 zend_mm_set_heap/钩子统计 malloc 和 free 差看增长趋势或用startup()前后memory_get_usage()对比。 一句话valgrind 记账本记每行申请没还的ASAN 布哨抓越界泄漏自定义就统计申请的减释放的看涨不涨。---97.OPcache 的内存管理——共享内存 OPcache 把编译好的 opcode 缓存到一个跨进程共享的内存区mmap这样多个进程/Worker 共用一份编译结果不用各自编译。 关键点-mmap 映射用一个映射文件/共享段所有进程看着同一块物理内存。-内存布局这块区域里存的是打包过的持久化 op_array把指针转成偏移量重启进程也还在如果开持久化。-持久化分配从共享区里捞内存不能随便 free因为别人还在用所以它有个独立的只能写、不回收直到重启/失效的分配器。 一句话OPcache 用 mmap 共享内存把编译结果存成所有 Worker 共享的持久拷贝从共享区里的内存基本只分配、不主动回收。---98.Swoole 的内存管理——高性能三件套 Swoole就是你在用的那个框架为了下性能几个专门的内存结构-FixedPool固定大小内存池预先把一大块切成同样大小的一格格用的时候取一个、用完还一个。适合固定结构的请求数据O(1)取还。-RingBuffer环形缓冲区一块内存首尾相接围成环写指针和读指针绕着转。适合流水式的大块数据比如网络包、队列消息 先入先出不碎片。-MemoryGlobal全局内存一个跨协程/跨进程共享的全局区里面存各协程都能访问的公共数据带上锁排队访问。 一句话Swoole 的 FixedPool 管固定小对象O(1)取还、RingBuffer 管流水大数据环形先进先出不碎片、MemoryGlobal 管跨协程共享全局数据加锁排队。都是为了省掉来回 malloc 的开销。---99.自己动手写一个 PHP 内存泄漏检测工具实战 用纯 PHP一点底层钩子给你一条完整可行的路线大白话不建文件 核心思路不重现 valgrind而是用 PHP 原生能看到的两把尺子抓泄漏趋势。 方案 A基线对比法最简单最实用 $beforememory_get_usage(true);// 拿到程序开始的内存do_your_thing();// 跑你的业务$aftermemory_get_usage(true);if($after-$beforeTHRESHOLD)echo疑似泄漏: 涨了 .($after-$before). 字节\n;关键在循环里反复跑同一个操作 N 次若内存随次数稳定上涨 →有东西没释放就是漏点所在循环体。 方案 B探针图法定位到具体函数register_shutdown_function(function(){echo峰值内存: .memory_get_peak_usage(true).\n;});// 或每 100 次迭代打点配合把每次变化存起来画出内存曲线看哪个阶段在爬。 方案 C引用计数自检抓循环引用 垃圾回收可以用gc_collect_cycles() $beforememory_get_usage();for($i0;$i10000;$i){$arr[];$arr[self]$arr;}gc_collect_cycles();// 手动触发 GC$aftermemory_get_usage();// 若 after 还明显大于 before说明有 GC 都清不掉的漏了/持活方案 D给 PHP 打补丁的真工具进阶 用 C 写一个 ZEND_EXT/扩展挂 zend_post_startup 钩子和 zend_atend统计 zend_alloc_globals 里 malloc 计数-重载分发用 zend_mm_set_alloc_alocation_handlerPHP 自定义堆钩子——每次分配/释放你都增/减一个全局计数-结束脚本打印净未归还数量。 这就是 valgrind 的轻量版配计数结束时报缺口。 完整流程按顺序走必成1.用方案 A 的循环 前后差值快速判断有没有漏、漏在哪个循环。2.用方案 Cdebug_zval_dump 或gc_status()看是不是循环引用垃圾。3.定位到具体函数后用方案 B 打点画趋势锁定那一块。4.还不够 →上 valgrind 精确定位到行valgrind--toolmemcheck--leak-checkfull php t.php。5.长期嵌 CI →ASAN 编译跑冒烟。 关键技巧别只测一次一定要循环跑 N 次看趋势——一次性跑看不出泄漏每次起点高只有循环才现形。 一句话先循环跑 N 次看前后内存趋势判断漏没漏、在哪循环再用 gc_status/debug_zval_dump 查是不是循环引用精确定位就上 valgrind/ASAN核心法则是只看单次没用要看循环的增长趋势。---85-99总账 地基层85PHP自建大金库(ZMM)管常用内存超大块直接找系统malloc/mmap86金库囤货区空位链表空块就拼大尽量用存货87小内存按尺寸定档切固定格子(slab)每档空链表O(1)秒取秒还88大内存直接malloc/mmap字节对齐89碎片整理顺手合并相邻空块放走整空页 垃圾清理层90每块内存挂在用人数计数器归零回收对象互指会成死结91GC用三色标记(黑白灰)能从活人到达的算活到不了白的清掉专治循环92垃圾候选堆到阈值(~1万)才跑GC扫完自动调高低下次阈值93只有能互相指的(对象/数组)进疑似名单字符串数字不进94从疑似名单往下钻通到活人的涂黑救活剩白的才是垃圾95确认的垃圾解开互相引用、从内到外逐层归零释放 检测层96valgrind记账本记每行没还的ASAN布哨抓越界泄漏自定义统计申请-释放97OPcache用mmap共享内存存所有Worker共用的持久编译结果只分配基本不回收98Swoole:FixedPool管固定小件RingBuffer管流水大数据MemoryGlobal管跨协程共享99漏测循环跑N次看趋势(别只看单次)→gc查循环→valgrind定位行→ASAN进CI---一条开发中最实用的提醒99的循环跑 N 次看趋势是最容易忽略但最有效的。单跑一次根本看不出泄漏因为脚本结束 PHP 本来就会大扫除只有不结束、反复跑同一个操作、看着内存稳定上涨才是真泄漏。Swoole 常驻进程尤其容易踩——因为它不主动结束攒着不回的金库垃圾迟早爆。