内存泄漏
[2021-09-03 21:03:10] local.ERROR: Symfony\Component\Debug\Exception\FatalErrorException: Allowed memory size of 268435456 bytes exhausted (tried to allocate 12288 bytes) in D:\penghao\project\api\vendor\guzzlehttp\psr7\src\Stream.php:95
Stack trace:
#0 D:\penghao\project\api\vendor\laravel\lumen-framework\src\Concerns\RegistersExceptionHandlers.php(54): Laravel\Lumen\Application->handleShutdown()
#1 [internal function]: Laravel\Lumen\Application->Laravel\Lumen\Concerns\{closure}()
#2 {main} {"exception":"[object] (Symfony\\Component\\Debug\\Exception\\FatalErrorException(code: 1): Allowed memory size of 268435456 bytes exhausted (tried to allocate 12288 bytes) at D:\\penghao\\project\\api\\vendor\\guzzlehttp\\psr7\\src\\Stream.php:95)
[stacktrace]
#0 D:\\penghao\\project\\api\\vendor\\laravel\\lumen-framework\\src\\Concerns\\RegistersExceptionHandlers.php(54): Laravel\\Lumen\\Application->handleShutdown()
#1 [internal function]: Laravel\\Lumen\\Application->Laravel\\Lumen\\Concerns\\{closure}()
#2 {main}
"}
前几天在运行一个批量执行脚本的时候,运行了几万条数据之后脚本就停了,查看日志,发现了上面的错误,相信写过一段时间代码的人都应该能看出来是什么问题,没错,就是内存不足。
内存不足的问题可能贯穿了整个开发生涯,刚开始启动PHP的时候还不明白这个错误是什么意思,查了资料才知道是内存不足,进一步检查后可以用函数给个别任务增加内存,也可以在php.ini里调整。
ini_set(‘memory_limit’,’64M’);
后来出现这个问题我就会下意识地把内存加大。
几年后,有一天我在写批处理任务的时候,又出现了内存不足的问题。这回我开始思考,为什么会不足呢?我是循环执行的,并不是一下子就加载到内存中了,为什么执行了一段时间之后就出现内存不足的情况了?
我们先把问题放在这里,要解决这个问题,首先我们要了解PHP的内存管理机制。
PHP垃圾回收机制引用计数
每个 PHP 变量都存储在一个名为“zval”的变量容器中。zval 变量容器除了变量的类型和值之外,还包括两个字节的附加信息。第一个是“”,它是一个 bool 值,用于标识变量是否属于引用集。通过这个字节,PHP 引擎可以区分普通变量和引用变量。由于 PHP 允许用户通过使用 & 来使用自定义引用,因此 zval 变量容器中还有一个内部引用计数机制来优化内存使用。第二个附加字节是“”,用于指示指向这个 zval 变量容器的变量(也称为符号)的数量。所有符号都存储在一个符号表中,每个符号都有一个作用域。主脚本(例如:浏览器请求的脚本)和每个函数或方法也都有一个作用域。
//以上例程会输出:
a: (refcount=1, is_ref=0)='new string'
在PHP 5.3之前,内存回收都是基于引用计数的,也就是当引用数等于0的时候,才进行内存回收。
//输出
a: (refcount=3, is_ref=0)='new string'
a: (refcount=1, is_ref=0)='new string'
a: no such symbol
复合类型
除了上面提到的基本类型外,还有一些复合类型,例如数组等。
当考虑像数组和这样的复合类型时,事情会变得稍微复杂一些。与标量()值不同,数组和类型的变量将其成员或属性存储在自己的符号表中。这意味着以下示例将生成三个 zval 变量容器。
'life', 'number' => 42 );
xdebug_debug_zval( 'a' );
?>
a: (refcount=1, is_ref=0)=array (
'meaning' => (refcount=1, is_ref=0)='life',
'number' => (refcount=1, is_ref=0)=42
)
循环引用
上面两个例子使用引用计数是可以正常回收的,但也有一些例子单纯使用引用计数是无法正确回收的。比如循环引用。
a: (refcount=2, is_ref=1)=array (
0 => (refcount=1, is_ref=0)='one',
1 => (refcount=2, is_ref=1)=...
)
这是官方文档中给出的例子。
我们可以看到数组变量(a)也是这个数组的第二个元素(1),而变量容器“...”是2。上面输出中的“...”表示发生了递归操作,显然意味着本例中的“...”指向的是原始数组。
和之前一样,对一个变量调用 unset 会删除符号,同时它指向的变量容器的引用数也会减少 1。所以,如果我们在执行完上述代码后对变量 $a 调用 unset ,变量 $a 和数组元素“1”指向的变量容器的引用数就会减少 1,从“2”变成“1”。
虽然在某个作用域内不再有任何符号指向这个结构(也就是变量容器),但是由于数组元素“1”仍然指向数组本身,因此无法清除该容器。由于没有其他符号指向它,用户没有办法清除这个结构,从而导致内存泄漏。幸好 PHP 会在脚本执行结束时清除这个数据结构,但是在 PHP 清除它之前,会消耗大量的内存。
如果上述情况只发生一两次,还不算什么大问题,但如果有数千甚至数十万次内存泄漏,那显然就是大问题了。此类问题经常发生在长时间运行的脚本中,例如请求基本上永不结束的守护进程。
回收周期
引用计数内存机制无法处理循环引用内存泄漏,不过PHP 5.3.0在引用计数体系中采用了同步循环回收(Cycle in)中的同步算法来处理此内存泄漏问题。
引用自 PHP 官方文档。
首先,我们需要建立一些基本规则。如果引用计数增加,它将继续被使用,并且不再处于垃圾中。如果引用计数减少到零,变量容器将被清除(释放)。换句话说,只有当引用计数减少到非零值时,才会发生垃圾循环。其次,在垃圾循环期间,我们可以通过检查引用计数是否减少 1 以及检查哪些变量容器的引用计数为零来找出哪些部分是垃圾。
[外链图片传输失败,源站可能有防盗链机制,建议保存图片后直接上传(img--31)()]
为了避免检查所有引用计数可能减少的垃圾循环,算法将所有可能的根(根是 zval 变量容器)放在根缓冲区(标记为紫色,称为疑似垃圾),这也可以确保每个可能的垃圾根在缓冲区中只出现一次。只有当根缓冲区已满时,才会对缓冲区内所有不同的变量容器进行垃圾收集。参见上图中的步骤 A。
步骤B中,模拟删除每一个紫色变量。模拟删除时,非紫色的普通变量的引用计数可能会减“1”,如果某个普通变量的引用计数变为0,则再次模拟删除这个普通变量。每个变量只能模拟删除一次,模拟删除后标记为灰色(原文说的是保证同一个变量容器不会减“1”两次,是错的)。
步骤 C 中,模拟恢复每个紫色变量。恢复是有条件的。当变量的引用计数大于 0 时,模拟恢复。同样,每个变量只能恢复一次。恢复后,将其标记为黑色。它基本上是步骤 B 的逆操作。这样,剩下的一堆未恢复的就是应该删除的蓝色节点。它们被遍历并在步骤 D 中实际删除。
算法模拟删除、模拟恢复、实际删除,全部使用简单遍历(最典型的是深度搜索遍历)。复杂度与执行模拟操作的节点数正相关,而不仅仅是紫色疑似垃圾变量。
PHP 的垃圾回收机制是开启的,有一个 php.ini 设置可以让你改变它:zend。
静态变量和全局变量
还有一种附加情况,就是静态变量、全局变量等静态资源都是全局有效的。另外,PHP 静态变量在对应结构体的生命周期内是永久存在的,值是保持一致的,无论该结构体被调用或实例化多少次。
也就是说静态变量是不会被回收的,如果某些常驻守护任务不断往某个全局变量或者静态变量中写入数据,而由于静态变量会一直伴随进程而存在,内存就不会被回收,这样就会导致进程占用的内存越来越多,最终出现内存不足的问题。
单例模式
单例模式中,类的实例化是保存在类中的一个静态变量中的,这种情况下,如果有一个方法不断的向这个类的成员变量中写入数据,那么即使这个成员变量不是静态变量,也会引起内存溢出。
综上所述
说完内存回收,上面的问题其实就解决了。但是为什么最后还要有这么一段话呢?主要原因可以说,我们了解这些东西不只是为了解决现有的bug,更是为了以后写代码的时候能够注意此类问题,让我们的代码更加健壮,减少类似问题的出现。
PHP 内存溢出问题很少发生,或者说很难被发现,因为大部分 PHP 代码都是以 PHP-FPM 模式运行的,单次请求完成后,进程就关闭了,资源全部回收,即使出现内存溢出,我们也无法发现。
但在 cli 模式下,正如上文所说,如果只发生一两次还不算什么,但如果发生几千甚至几十万次内存泄漏,那显然就是大问题了。而且这种内存泄漏的定位难度非常大。因此,我们需要在编写之前了解相关情况,从一开始就将问题消灭在萌芽状态。
扫一扫在手机端查看
我们凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求,请立即点击咨询我们或拨打咨询热线: 13761152229,我们会详细为你一一解答你心中的疑难。