在介绍自动垃圾回收之前,先简单的介绍一下什么是内存泄漏。
当我们定义一个变量时,系统会为其开辟一个内存空间,当该变量使用完毕后由于一些原因使得该变量指向的内存空间没有被释放(例如一个对象使用完之后,其他地方还持有该对象的引用,这个对象就不会被释放),导致这块内存空间一直被占用的情况就是内存泄漏。
长时间的内存泄漏会导致一个进程占用的内存一直疯长导致程序崩溃。
这里需要和内存溢出做区分,内存溢出是指程序向系统申请一块特定大小的内存,但由于系统内存不足导致系统无法给程序分配其指定大小的内存。
而内存泄漏是程序已经申请成功了一块内存,但是由于某些原因长时间不归还这块内存给系统,导致系统无法将这块内存再分配给其他程序使用。
下面介绍一下垃圾回收的常见机制(以php和python为例)
Python的垃圾回收机制:
引用计数器为主、分代码回收和标记清除为辅
大管家refchain
在Python的C源码中有一个名为refchain的环状双向链表,Python程序中一旦创建对象都会把这个对象添加到refchain这个链表中。也就是说他保存着所有的对象。
refchain中的所有对象内部都有一个ob_refcnt(对象引用计数器)用来保存当前对象被引用的次数。当值被多次引用(一个变量就是一次引用)时候,不会在内存中重复开辟空间创建数据,而是引用计数器+1 。 当对象被销毁时候同时会让引用计数器-1,如果引用计数器为0,则将对象从refchain链表中摘除,同时在内存中进行销毁(暂不考虑缓存等特殊情况)
age = 18
number = age # 对象18的引用计数器 + 1
del age # 对象18的引用计数器 – 1
def run(arg):
print(arg)
run(number) # 刚开始执行函数时,对象18引用计数器 + 1,当函数执行完毕之后,对象18引用计数器 - 1 。
num_list = [11,22,number] # 对象18的引用计数器 + 1
当把number传到run的arg的时候,其实相当于一个 arg = number 的复制操作,所以number代表的值的引用多了一份。
基于引用计数器进行垃圾回收非常方便和简单,但他还是存在循环引用的问题,导致无法正常的回收一些数据。
v1 = [11,22,33] # refchain中创建一个列表对象,由于v1=对象,所以列表引对象用计数器为1.
v2 = [44,55,66] # refchain中再创建一个列表对象,因v2=对象,所以列表对象引用计数器为1.
v1.append(v2) # 把v2追加到v1中,则v2对应的[44,55,66]对象的引用计数器加1,最终为2.
v2.append(v1) # 把v1追加到v1中,则v1对应的[11,22,33]对象的引用计数器加1,最终为2.
del v1 # 引用计数器-1
del v2 # 引用计数器-1
· 对于上述代码会发现,执行del操作之后,没有变量再使用那两个列表对象,但由于循环引用的问题,他们的引用计数器不为0(假如python没有引入标记清除和分代回收技术的话),所以他们的状态:永远不会被使用、也不会被销毁。项目中如果这种代码太多,就会导致内存一直被消耗,直到内存被耗尽,程序崩溃。 所以循环引用会引发内存泄漏的问题,因为引用计数没有清0所以内存空间并没有被释放。
· 为了解决循环引用的问题,引入了标记清除和分代回收技术,专门针对那些可能存在循环引用的对象进行特殊处理,可能存在循环引用的类型有:列表、元组、字典、集合、自定义类等那些能进行数据嵌套的类型。
至于具体的标记清除和分代回收可以参见下面这篇文章:
https://juejin.im/post/6888640567345938440
从上文大家可以了解到当对象的引用计数器为0时,就会被销毁并释放内存。而实际上他不是这么的简单粗暴,因为反复的创建和销毁内存空间会使程序的执行效率变低。Python中引入了“缓存机制”机制。
例如:引用计数器为0时,不会真正销毁对象,而是将他放到一个名为 free_list 的链表中,之后会再创建对象时不会在重新开辟内存,而是在free_list中将之前的对象取出并重置内部的值来使用(就是说在原先那个内存空间中将存的数据改为新的数据,复用了原先的内存空间,无需开辟新空间)。
例如:
v1 = 3.14 # 开辟内存来存储float对象,并将对象添加到refchain链表。
print( id(v1) ) # 内存地址:4436033488
del v1 # 引用计数器-1,如果为0则在rechain链表中移除,不销毁对象,而是将对象添加到float的free_list.
v2 = 9.999 # 优先去free_list中获取对象,并重置为9.999,如果free_list为空才重新开辟内存。
print( id(v2) ) # 内存地址:4436033488
# 注意:当一个对象的引用计数器变为0时,会先判断free_list中缓存个数是否满了,未满则将对象缓存,已满则直接将对象销毁。
PHP的垃圾回收机制:
其实PHP底层也是使用了引用计数来做垃圾回收的。在理解PHP垃圾回收机制(GC)之前,先了解一下变量的存储。
php中变量存在于一个zval的变量容器(zval是一个结构体)中,zval内容如下:
struct _zval_struct {
union {
long lval;
double dval;
struct {
char *val;
int len;
} str;
HashTable *ht;
zend_object_value obj;
} value; //变量value值
zend_uint refcount__gc; //引用计数内存中使用次数,为0删除该变量
zend_uchar type; //变量类型
zend_uchar is_ref__gc; //区分是否是引用变量
};
Zval这个数据结构存储变量的类型和值和其他信息,我们重点关注is_ref字段和refcount字段。
is_ref:是个bool值,用来区分变量是否属于引用集合(是否使用的 &取地址符强制引用 )
refcount:计数器,表示指向这个zval变量容器的变量个数。
下面是在php 5.6的版本下进行的测试:
$a=1;
xdebug_debug_zval( 'a' );
// 当给变量a赋值为1的时候,系统会在内存开辟一块空间(就是zval容器)存储1这个值,zval会记录这个内存空间的引用次数refcount(一个变量就是一个引用),这个内存空间存储的数据值和类型,以及is_ref引用这块内存空间的变量是否是引用变量(使用了&引用赋值的变量就是引用变量)。
运行结果:a:
(refcount=1, is_ref=0),int 1
这个结果表示,这块内存空间有一个引用指向它(这个引用就是$a)。
// 承接上面的代码
$b = $a; # 传值赋值
xdebug_debug_zval( 'a' );
xdebug_debug_zval( 'b' );
这里对$b进行传值赋值,此时不会马上为$b开辟新的内存空间,而是让$b也引用$a所引用的内存空间,这块空间有$a和$b两个引用。
运行结果:
a:
(refcount=2, is_ref=0),int 1
b:
(refcount=2, is_ref=0),int 1
Zval容器的refcount变为2,即这块内存的引用个数为2
Zval容器的refcount变为2,即这块内存的引用个数为2.
PS:如果此时对$a进行重新赋值 $a = 2;此时会新开辟一块空间用来存2(这就是cow写时复制),并且新创建一个zval。
$a引用新的内存空间的数据(val=2),$b还是引用旧的内存空间的数据(val=1)
再如果我们unset $a 那么Zval的refcount记录的引用次数会-1
// 承接上面的代码
$c = &$a; # 引用赋值
xdebug_debug_zval( 'a' );
xdebug_debug_zval( 'b' );
xdebug_debug_zval( 'c' );
这里对$c进行了引用赋值,此时php将内存空间(称之为X)的地址赋值给了$c。$a和$c存储的地址相同,都指向(val = 1)的内存空间。并且系统会重新开辟一块内存空间(称之为Y)并将X的值(val=1)写入到Y中,并且让$b从之前引用X变为引用Y。
所以,此时$a和$c引用X空间,$b引用Y空间。
运行结果:
a:
(refcount=2, is_ref=1),int 1
b:
(refcount=1, is_ref=0),int 1
c:
(refcount=2, is_ref=1),int 1
PS:如果此时unset $a 那么记录X空间的Zval的refcount会 -1 ,is_ref会变为false。但是$c还存在,所以空间X的引用次数为1,空间X不会被释放。
所以传值赋值($a = $b)和引用赋值($a = &b)的相同点是一开始$a和$b都指向同一块空间,区别是当对$b的值进行修改的时候,传值赋值下的$b会发生cow开辟新空间,$a和$b会指向两块不同的空间;而引用赋值下的$b和$a还是指向同一块空间,只不过这块空间的值改变了而已。
在PHP中,标量的赋值都是传值赋值,而对象的赋值都是引用赋值。也就是说$a如果是一个对象,那么 $b = $a 等价于 $b = &$a;
我们看看如果是定义一个数组会怎样:
$a = array('meaning' => 'life', 'number' => 42);
xdebug_debug_zval("a");
结果:
a:
(refcount=1, is_ref=0),
array (size=2)
'meaning' => (refcount=1, is_ref=0),string 'life' (length=4)
'number' => (refcount=1, is_ref=0),int 42
$a数组(在底层体现为一张哈希表)中有3个元素。
此时底层开辟了3块空间,如下图:$a指向空间0,$a[‘meaning’]指向空间1,$a[‘number’]指向空间2。
此时会生成3个Zval,记录3块内存空间的数据值和引用信息。
下面在$a中添加一个元素,并将现有的一个元素的值赋给新的元素:
$a = array('meaning' => 'life', 'number' => 42);
$a['name'] = $a['meaning'];
xdebug_debug_zval("a");
结果为:
a:
(refcount=1, is_ref=0),
array (size=3)
'meaning' => (refcount=2, is_ref=0),string 'life' (length=4)
'number' => (refcount=1, is_ref=0),int 42
'name' => (refcount=2, is_ref=0),string 'life' (length=4)
这是因为下标meaning和name都引用了同一块空间(val=life);
<?php
$a = array('meaning' => 'life', 'number' => 42);
$a['name'] = 'life';
xdebug_debug_zval("a");
?>
结果为:
a:
(refcount=1, is_ref=0),
array (size=3)
'meaning' => (refcount=1, is_ref=0),string 'life' (length=4)
'number' => (refcount=1, is_ref=0),int 42
'name' => (refcount=1, is_ref=0),string 'life' (length=4)
下面我们看看PHP中循环引用的问题:
<?php
$a = array('meaning' => 'life', 'number' => 42);
$a[] = $a;
xdebug_debug_zval("a");
?>
运行结果:
a:
(refcount=1, is_ref=0),
array (size=3)
'meaning' => (refcount=2, is_ref=0),string 'life' (length=4)
'number' => (refcount=2, is_ref=0),int 42
0 => (refcount=1, is_ref=0),
array (size=2)
'meaning' => (refcount=2, is_ref=0),string 'life' (length=4)
'number' => (refcount=2, is_ref=0),int 42
如果我们使用引用赋值:
<?php
$a = array('meaning' => 'life', 'number' => 42);
$a[] = &$a;
xdebug_debug_zval("a");
?>
结果就会变成这样。
a:
(refcount=2, is_ref=1),
array (size=3)
'meaning' => (refcount=1, is_ref=0),string 'life' (length=4)
'number' => (refcount=1, is_ref=0),int 42
0 => (refcount=2, is_ref=1),
&array<
我们看传值赋值 $a[] = $a和 引用赋值 $a[] = &$a 的区别在于:一个发生了cow($a和$a[0]指向不同空间),一个没有($a和$a[0]指向同一空间)
如果对$a的一个元素做出改变:
$a[] = $a
$a[‘meaning’] = ‘life2’;
传值赋值的情况下就会发生cow(对life进行cow)
$a[] = &$a
$a[‘meaning’] = ‘life2’;
引用赋值的形况就不会发生cow
对于 $a[] = $a 这种情况并不算真正的循环引用,因为$a和$a[0]并不指向同一块空间,$a的refcount是1而不是2,所以unset $a后空间0的refcount会变为0,$a引用的空间(空间0)会被释放。
对于$a[] = &$a 这种情况就是一种真正意义的循环引用,$a和$a[0]指向同一块空间。此时unset $a之后,refcount – 1 = 1。如果是在php 5.2及以前的版本,就不会释放内存导致脚本运行期间发生内存泄漏。
如果只是一种短期脚本的运行,这种泄露无关紧要,因为最终会在脚本结束的时候释放所有该脚本占用的内存。但如果这个php脚本是作为一个守护进程长期运行,那么这种内存泄漏累积(比如内存泄漏的代码是写在一个函数中,然后守护进程多次调用这个函数,泄漏的内存就会不断积累)所消耗的内存不容忽视,严重的可能导致系统崩溃。
Php 5.3版本以后,php使用了一种同步循环回收算法,这个算法有点复杂而且大神们的文字我看的有点玄乎,所以这里不做介绍。这个算法不能完全杜绝循环引用引起的内存泄漏问题,但是可以将内存泄漏保持在一个阈值以下。