目录
3、隐式内存泄漏:无用数据未释放+配置不合理(fpm+常驻进程)
1、php函数获取当前程序内存使用情况:memory_get_usage()与memory_get_peak_usage()
3、PHP5.3及以后版本:垃圾回收机制GC下的同步周期回收算法
2、回收步骤(4步):可能根放入根缓冲区+模拟删除+模拟恢复+清空根缓冲区
3、回收特性(3个):缓冲区满后回收+解决循环引用的内存泄漏+内存泄漏保持在一定阈值
一、定义
1、内存溢出(out of memory):不够用
指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory;比如申请了一个integer,但给它存了long才能存下的数,那就是内存溢出。
2、内存泄露(memory leak):已申请的无法释放
指你向系统申请分配内存进行使用(new),可是使用完了以后却不归还(delete),结果你申请到的那块内存你自己也不能再访问(也许你把它的地址给弄丢了),而系统也不能再次将它分配给需要的程序。一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。memory leak会最终会导致out of memory!
二、出现场景
1、内存溢出1类:大数据创建或拷贝
大数据创建或拷贝 | 例1: 例2: |
2、内存泄漏4类: 常发性+偶发性+一次性+隐式
常发性内存泄漏 | 发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏。 |
偶发性内存泄漏 | 发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要。 |
一次性内存泄漏 | 发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。 比如,在类的构造函数中分配内存,在析构函数中却没有释放该内存,所以内存泄漏只会发生一次。 |
隐式内存泄漏 | 程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。 |
3、隐式内存泄漏:无用数据未释放+配置不合理(fpm+常驻进程)
无用的数据未及时释放 | 启用数据库监听后,每当有 SQL 执行时会 new 一个 QueryExecuted 对象并传入匿名函数以便后续操作,对于执行完毕就结束进程释放资源的 php 程序来说没有什么问题,而如果是一个常驻进程的程序,程序每执行一条 SQL 内存中就会增加一个 QueryExecuted 对象,程序不结束内存就会始终增长。 |
配置不合理 | 2G 内存机器上设置最大可以启动 100 个 php-fpm 子进程,但实际启动了 50 个 php-fpm 子进程后无法再启动更多进程 php-fpm内存泄露:php 在每次请求结束后自动释放内存,有效避免了常见场景下内存泄露的问题,然而实际环境中因某些扩展的内存管理没有做好或者 php 代码中出现循环引用导致未能正常释放不用的资源。 常驻进程:用 php 开发一个通过 Socket 与服务端建立长连接后持续实时上报数据的常驻进程程序,在程序业务功能开发联调完毕后实际运行发送大 量数据后发现内存增长非常迅速,在很短的时间内达到了 php 默认可用内存上限 128M |
三、PHP内存管理:引用计数+同步周期
1、php函数获取当前程序内存使用情况:memory_get_usage()与memory_get_peak_usage()
memory_get_usage(),这个函数的作用是获取目前PHP脚本所用的内存大小。
memory_get_peak_usage(),这个函数的作用返回当前脚本到目前位置所占用的内存峰值,这样就可能获取到目前的脚本的内存需求情况。
2、PHP5.2及以前版本:引用计数
引用计数基本知识(PHP: 引用计数基本知识 - Manual)
在PHP 5.2以前, PHP使用引用计数(Reference count)来做资源管理, 当一个zval的引用计数为0的时候, 它就会被释放.
虽然存在循环引用(Cycle reference), 但这样的设计对于开发 Web 脚本来说, 没什么问题, 因为 Web 脚本的特点和它追求的目标就是执行时间短, 不会长期运行。
对于循环引用造成的资源泄露, 会在请求结束时释放掉. 也就是说, 请求结束时释放资源, 是一种部补救措施( backup ).
【引用计数法(reference count)】:每个对象都一个数字用来标示被引用的次数。引用次数为0的可以回收。当对一个对象的引用创建时他的引用计数就会增加,引用销毁时计数减少。引用计数法可以保证对象一旦不被引用时第一时间销毁。但是引用计数有一些缺陷:1.循环引用,2.引用计数需要申请更多内存,3.对速度有影响,4.需要保证原子性,5.不是实时的
3、PHP5.3及以后版本:垃圾回收机制GC下的同步周期回收算法
然而, 随着 PHP 被越来越多的人使用, 就有很多人在一些后台脚本使用 PHP , 这些脚本的特点是长期运行, 如果存在循环引用, 导致引用计数无法及时释放不用的资源, 则这个脚本最终会内存耗尽退出.所以在 PHP 5.3 以后, 我们引入了GC.
【垃圾回收(Garbage Collection,简称GC)】:是一种自动内存管理的形式,GC程序检查并处理程序中那些已经分配出去但却不再被对象使用的内存。最早的GC是1959年前后John McCarthy发明的,用来简化在Lisp中手动控制内存管理。PHP的内核中已自带内存管理的功能,一般应用场景下,不易出现内存泄露。
PHP的垃圾回收机制是默认打开的,php.ini 可以设置 zend.enable_gc=0 来关闭。也能通过分别调用 gc_enable() 和 gc_disable() 函数来打开和关闭垃圾回收机制。
【同步周期回收算法(Concurrent Cycle Collection)】来处理内存泄露:回收周期(Collecting Cycles)(PHP: 回收周期(Collecting Cycles) - Manual )
四、同步周期回收算法
1、回收原理:可能根放入根缓冲区,满了后垃圾回收
A.首先PHP会分配一个固定大小的“根缓冲区”,这个缓冲区用于存放固定数量的 zval(默认是10,000),如果需要修改则需要修改源代码 Zend/zend_gc.c 中的常量 GC_ROOT_BUFFER_MAX_ENTRIES 然后重新编译。
B.这个根缓冲区中存放的是“可能根(possible roots)”(称为疑似垃圾),就是可能发生内存泄露的 zval。当根缓冲区满了的时候(或者调用 gc_collect_cycle() 函数时),PHP 就会执行垃圾回收。
2、回收步骤(4步):可能根放入根缓冲区+模拟删除+模拟恢复+清空根缓冲区
A.所有可能根放入根缓冲区
把所有可能根(possible roots 都是 zval 变量容器),放在根缓冲区(root buffer)中(称为疑似垃圾),并确保每个可能的垃圾根(possible garbage root)在缓冲区中只出现一次。只有在根缓冲区满了的时候,才对缓冲区内部所有不同的变量容器执行垃圾回收操作;
B.模拟删除
对每个根缓冲区中的根 zval 按照深度优先遍历算法遍历所有能遍历到的 zval,并将对应的 refcount 减 1,同时为了避免对同一 zval 多次减 1(因为可能不同的根能遍历到同一个 zval),每次对某个 zval 减 1 后就对其标记为“已减”。需要强调的是,这个步骤中,起初节点 zval 本身不做减 1 操作,但是如果节点 zval 中包含的符号表中有节点又指向了初始的 zval(环形引用),那么这个时候需要对节点 zval 进行减 1 操作;
C.模拟恢复
基本就是步骤 B 的逆运算,但恢复是有条件的。再次对每个缓冲区中的 zval 做深度优先遍历,如果某个 zval 的 refcount 不为 0,则对其加 1,否则保持其为 0。同样每个变量只能恢复一次;
D.清空根缓冲区中的所有根
清空根缓冲区中的所有根(注意是把所有 zval 从缓冲区中清除而不是销毁它们),然后销毁所有 refcount 为 0 的 zval,并收回其内存,是真实删除的过程。
3、回收特性(3个):缓冲区满后回收+解决循环引用的内存泄漏+内存泄漏保持在一定阈值
A.并不是每次 refcount 减少时都进入回收周期,只有根缓冲区满额后在开始垃圾回收;
B.解决了循环引用导致的内存泄露问题;
C.整体上可以总将内存泄露保持在一个阈值以下(与缓冲区的大小有关)。
4、举例:数组的循环引用
<?php $a = array( 'one' ); $a[] =& $a; xdebug_debug_zval( 'a' ); /* 结果 a: (refcount=2, is_ref=1)=array ( 0 => (refcount=1, is_ref=0)='one', 1 => (refcount=2, is_ref=1)=... ) | <?php $a = array( 'one' ); $a[] =& $a; unset($a); xdebug_debug_zval( 'a' ); /* 结果 (refcount=1, is_ref=1)=array ( 0 => (refcount=1, is_ref=0)='one', 1 => (refcount=1, is_ref=1)=... ) */ |
输出结果中的”…”说明发生了递归操作, 显然在这种情况下”…”表示指向原始数组。 |
(1)对一个变量调用 unset,将从符号表中删除这个引用,且它指向的 zval 的引用次数也减 1。所以,如果我们在执行完上面的代码后,对变量 $a 调用 unset, 那么变量 $a 和数组元素 1 所指向的变量容器的引用次数减 1,变成 1。 (2)尽管不再有任何作用域中的任何符号表中的引用指向这个 zval,由于数组元素 1 仍然指向数组本身,所以这个 zval 不能被清除 。 |
这就是循环引用,虽然 PHP 将在脚本执行结束时会清除这个数据结构,但是在 PHP 清除之前,将耗费不少内存。这中问题如果出现很难被发现。仅仅一两次倒没什么,但是如果出现几千次,甚至几十万次的内存泄漏,这显然是个大问题。尤其是当PHP被作为守护进程,长时间运行时。
同步周期的回收方案如下(详见上面的4个步骤):
通过一个双向链表来存储可能存在内存泄漏的zval引用。当可能存在内存泄漏的引用被释放时(比如 unset 一个 Object),就把该引用添加到可能根的链表中。同时判断可能根链表是否已满,如果已满(也就是GC_ROOT_BUFFER_MAX_ENTRIES 不够时),则开启垃圾回收周期。深度遍历所有可能根,然后清除。
$a = ['one']; --- zval_a(将$a对应的zval,命名为zval_a)
$a[] = &$a; --- step1
unset($a); --- step2
说明:未进行unset之前(step1),进行算法计算,对这个数组中的所有元素(索引0和索引1)的zval的refcount进行减1操作,由于索引1对应的就是zval_a,所以这个时候zval_a的refcount应该变成了1,这样说明zval_a不是一个垃圾不进行回收。当执行unset的时候(step2),进行算法计算,由于环形引用,上文得出会有垃圾的结构体,zval_a的refcount是1(zval_a中的索引1指向zval_a),用算法对数组中的所有元素(索引0和索引1)的zval的refcount进行减1操作,这样zval_a的refcount就会变成0,于是就认为zval_a是一个需要回收的垃圾。对于一个包含环形引用的数组,对数组中包含的每个元素的zval进行减1操作,之后如果发现数组自身的zval的refcount变成了0,那么可以判断这个数组是一个垃圾。
简言之,假设数组 a 的 refcount 等于 m,a 中有 n 个元素又指向 a,如果 m == n,那么判断 m - n = 0,那么 a 就是垃圾,如果 m > n,那么算法的结果 m - n > 0,所以 a 就不是垃圾了。m = n 代表什么? 代表 a 的 refcount 都来自数组 a 自身包含的 zval 元素,说明 a 之外没有任何变量指向它,说明 a 被 unset 掉了,用户代码空间中无法再访问到 a 所对应的 zval,也就是代表 a 是泄漏的内存,因此 GC 应该回收 a 所对应的 zval。
转载自:
掘金 引用计数和GC
PHP二十一问:PHP的垃圾回收机制 - Powered by MinDoc PHP二十一问:PHP的垃圾回收机制,详细说了同步周期回收算法
PHP 的垃圾回收机制-理解PHP如何解决循环引用导致的内存泄漏问题 | 百作坊 理解PHP如何解决循环引用导致的内存泄漏问题