内存溢出
[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变量容器,除了包含变量的类型和值,还包括两个字节的额外信息。第一个是"is_ref",是个bool值,用来标识这个变量是否是属于引用集合(reference set)。通过这个字节,php引擎才能把普通变量和引用变量区分开来,由于php允许用户通过使用&来使用自定义引用,zval变量容器中还有一个内部引用计数机制,来优化内存使用。第二个额外字节是"refcount",用以表示指向这个zval变量容器的变量(也称符号即symbol)个数。所有的符号存在一个符号表中,其中每个符号都有作用域(scope),那些主脚本(比如:通过浏览器请求的的脚本)和每个函数或者方法也都有作用域。
<?php
$a = "new string";
xdebug_debug_zval('a');
?>
//以上例程会输出:
a: (refcount=1, is_ref=0)='new string'
php在5.3之前,只根据引用计数回收内存,即当引用次数等于0时,回收内存。
<?php
$a = "new string";
$c = $b = $a;
xdebug_debug_zval( 'a' );
unset( $b, $c );
xdebug_debug_zval( 'a' );
unset($a);
xdebug_debug_zval( 'a' );
?>
//输出
a: (refcount=3, is_ref=0)='new string'
a: (refcount=1, is_ref=0)='new string'
a: no such symbol
复合类型
除了上面讲到的基础的类型,还有一些复合类型,比如array和object.
当考虑像 array和object这样的复合类型时,事情就稍微有点复杂. 与 标量(scalar)类型的值不同,array和 object类型的变量把它们的成员或属性存在自己的符号表中。这意味着下面的例子将生成三个zval变量容器。
<?php
$a = array( 'meaning' => '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
)
循环引用
上面的两个例子用引用计数都能正常回收,但还有一些例子,单纯的引用计数没办法正确回收.比如,循环引用.
<?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)=...
)
这是官方文档给的例子,
能看到数组变量 (a) 同时也是这个数组的第二个元素(1) 指向的变量容器中“refcount”为 2。上面的输出结果中的"…“说明发生了递归操作, 显然在这种情况下意味着”…"指向原始数组。
跟刚刚一样,对一个变量调用unset,将删除这个符号,且它指向的变量容器中的引用次数也减1。所以,如果我们在执行完上面的代码后,对变量$a调用unset, 那么变量 $a 和数组元素 “1” 所指向的变量容器的引用次数减1, 从"2"变成"1".
尽管不再有某个作用域中的任何符号指向这个结构(就是变量容器),由于数组元素“1”仍然指向数组本身,所以这个容器不能被清除 。因为没有另外的符号指向它,用户没有办法清除这个结构,结果就会导致内存泄漏。庆幸的是,php将在脚本执行结束时清除这个数据结构,但是在php清除之前,将耗费不少内存。
如果上面的情况发生仅仅一两次倒没什么,但是如果出现几千次,甚至几十万次的内存泄漏,这显然是个大问题。这样的问题往往发生在长时间运行的脚本中,比如请求基本上不会结束的守护进程
回收周期
引用计数内存机制,无法处理循环的引用内存泄漏。然而 5.3.0 PHP 使用引用计数系统中的同步周期回收(Concurrent Cycle Collection in Reference Counted Systems)中的同步算法,来处理这个内存泄漏问题。
引用php官方文档中来说.
首先,我们先要建立一些基本规则,如果一个引用计数增加,它将继续被使用,当然就不再在垃圾中。如果引用计数减少到零,所在变量容器将被清除(free)。就是说,仅仅在引用计数减少到非零值时,才会产生垃圾周期(garbage cycle)。其次,在一个垃圾周期中,通过检查引用计数是否减1,并且检查哪些变量容器的引用次数是零,来发现哪部分是垃圾。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xY332Vkj-1631010414531)(https://www.php.net/manual/zh/images/12f37b1c6963c1c5c18f30495416a197-gc-algorithm.png)]
为避免不得不检查所有引用计数可能减少的垃圾周期,这个算法把所有可能根(possible roots 都是zval变量容器),放在根缓冲区(root buffer)中(用紫色来标记,称为疑似垃圾),这样可以同时确保每个可能的垃圾根(possible garbage root)在缓冲区中只出现一次。仅仅在根缓冲区满了时,才对缓冲区内部所有不同的变量容器执行垃圾回收操作。看上图的步骤 A。
在步骤 B 中,模拟删除每个紫色变量。模拟删除时可能将不是紫色的普通变量引用数减"1",如果某个普通变量引用计数变成0了,就对这个普通变量再做一次模拟删除。每个变量只能被模拟删除一次,模拟删除后标记为灰(原文说确保不会对同一个变量容器减两次"1",不对的吧)。
在步骤 C 中,模拟恢复每个紫色变量。恢复是有条件的,当变量的引用计数大于0时才对其做模拟恢复。同样每个变量只能恢复一次,恢复后标记为黑,基本就是步骤 B 的逆运算。这样剩下的一堆没能恢复的就是该删除的蓝色节点了,在步骤 D 中遍历出来真的删除掉。
算法中都是模拟删除、模拟恢复、真的删除,都使用简单的遍历即可(最典型的深搜遍历)。复杂度为执行模拟操作的节点数正相关,不只是紫色的那些疑似垃圾变量。
PHP的垃圾回收机制是打开的,然后有个 php.ini 设置允许你修改它:zend.enable_gc。
静态变量和全局变量
有一种额外的情况,就是静态变量和全局变量这样的静态资源,是全局生效. 并且,PHP的静态变量在所在对应的结构体的生命周期中永久存在,且值保持一致,不论这个结构体被调用或者实例化了多少次.
意味着不会静态变量不会被回收,如果一些常驻守护进程任务会不断的往某个全局变量或者静态变量中写入数据,又由于静态变量会伴随着进程一直存在,内存不会被回收,也就导致进程的使用内存越来越大,最终出现内存不足问题.
单例模式
单例模式中,类的实例化就是存储在类中一个静态变量里的,这种情况下,如果存在某个方法不断的往这个类的成员变量中写入数据,即使这个成员变量不是静态变量,也会导致内存溢出.
结论
说完内存回收过后,其实上面的问题也就解决了. 但是为啥还在最后来这么一段,主要是想,我们能说一下,我们了解这些东西不仅仅是为了解决这个已经出现的bug,更多的是为了在以后写代码的时候就注意这么一些问题, 让我们的代码更加的健壮, 减少这样类似的问题再出现次数.
php内存溢出的问题一般很少出现,或者说很难发现,因为大多数的php代码都是运行在php-fpm模式下,单次请求结束后,进程关闭,资源全部回收,即使存在内存溢出的情况,我们也没办法发现.
但是在cli模式下,正如上面说过的,发生仅仅一两次倒没什么,但是如果出现几千次,甚至几十万次的内存泄漏,这显然是个大问题。并且这样内存泄漏的问题要去定位会非常困难, 所以,我们需要在编写之前就了解相关的情况,从一开始就把问题扼杀在摇篮.
|