PHP中的GC垃圾回收机制

什么是GC

Gc,全称Garbage collection,即垃圾回收机制。

主要用于自动回收不再使用的内存,特别是用来管理引用计数机制下的内存回收。当 PHP 脚本执行时,垃圾回收会自动清理不再使用的对象和变量,以避免内存泄漏。

PHP 使用了 **引用计数 ** 和 **回收周期 ** 的结合来管理内存。

引用计数

为了理解这个引用计数的实现 我们需要了解一下zval 变量容器的工作原理

zval 容器

zval 容器是 PHP 内部用来存储变量信息的数据结构,除了存储变量的值外,它还包含了与该变量相关的额外信息,例如变量的类型、引用计数等。

在 PHP 中,所有的变量都会被封装在一个名为 zval 的容器中,zval 是一个结构体,包含以下几个关键部分:

类型:变量的类型(例如整数、字符串、数组、对象等)。:变量存储的具体值。

引用计数 (refcount):指向该 zval 容器的引用数量,表示有多少个变量或引用指向此 zval。这是个bool值 用来标识这个变量是否属于引用集合

引用标志 (is_ref):用于标识该变量是否为引用。PHP 引擎通过这个标志来区分普通变量和引用变量。

大概长这样

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct _zval_struct {
union {
long lval; // 长整型值
double dval; // 双精度浮点型值
struct {
char *val; // 字符串的值
int len; // 字符串长度
} str;
zval *obj; // 对象指针
} value;
uint refcount; // 引用计数
uint is_ref:1; // 是否为引用
} zval;

比较重要的是is_refrefcount 字段

is_ref(引用标志)

is_ref 是一个布尔值,用于标识当前 zval 是否为引用。如果 is_ref1,则说明该 zval 是通过引用传递的;如果 is_ref0,则说明该 zval 是一个普通变量。

在 PHP 中,引用是通过 & 操作符实现的。例如,$b = &$a;,此时 $b$a 的引用,因此 bzval 会将 is_ref 标志设置为 1

refcount(引用计数)

refcount 是一个无符号整数,表示有多少个引用指向当前 zval(指向zval变量容器的变量个数)。也就是说,refcount 记录着有多少变量或引用使用了该 zval

每当有一个新的引用指向该 zval 时,refcount 会递增。当 refcount 减为零时,PHP 会释放这个 zval 所占用的内存。

1
2
3
4
<?php
$a = "new string";
xdebug_debug_zval('a'); //用于查看变量a的zval变量容器的内容
?>

来看这个例子

这里定义了一个变量$a,生成了类型为String和值为new string的变量容器,而对于两个额外的字节,is_refrefcount,我们这里可以看到是不存在引用的,所以is_ref的值应该是false,而refcount是表示变量个数的,那么这里就应该是1,接下来我们验证一下

(这里要看你的php有没有装Xdebug Xdebug:支持 — 量身定制的安装说明 这个非常好用 把你的phpinfo内容粘贴进去就有教程了)

image-20250927141738577

下面我们添加 一个引用

1
2
3
4
5
<?php
$a = "new string";
$b=&$a;
xdebug_debug_zval('a'); //用于查看变量a的zval变量容器的内容
?>

在我们的思考中 每生成一个变量就有一个zval记录其类型和值以及两个额外字节,那我们这里的话a的refcount应该是1,is_ref应该是true

image-20250927142024040

结果这里是2 因为同一变量容器被变量a和变量b关联,当没必要时,php不会去复制已生成的变量容器。
所以这一个zval容器存储了ab两个变量,就使得refcount的值为2.

知道这个容器是怎么生成的 那么他的销毁

变量容器在refcount变成0时就被销毁。它这个值是如何减少的呢,当函数执行结束或者对变量调用了unset()函数,refcount就会减1。

1
2
3
4
5
6
7
8
9
10
11
<?php
$a = "new string"; // 创建一个字符串变量 $a
$b =& $a; // $b 通过引用赋值给 $a,即 $b 和 $a 引用相同的内存位置
$c =& $b; // $c 通过引用赋值给 $b,即 $c 和 $b 引用相同的内存位置

xdebug_debug_zval('a'); // 输出 $a 的引用计数和引用信息

unset($b, $c); // 删除变量 $b 和 $c,解除引用关系

xdebug_debug_zval('a'); // 再次输出 $a 的引用计数和引用信息
?>

image-20250927143128552

我这里和文章上出现的结果是不一样的

具体原因可能是版本差异

不过这个is_ref他反应的应该是变量曾经是否被引用过

所以这里是1应该也没问题

总之我们看到refcount数量变成了1才是比较关键的

GC在PHP 反序列化中的利用

GC如果在PHP反序列化中生效,那它就会直接触发_destruct方法

可以看一下这个demo示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
highlight_file(__FILE__);
error_reporting(0);
class test{
public $num;
public function __construct($num) {
$this->num = $num; echo $this->num."__construct"."</br>";
}
public function __destruct(){
echo $this->num."__destruct()"."</br>";
}
}
$a = new test(1);
unset($a);
$b = new test(2);
$c = new test(3);

当使用 new 关键字创建一个类的实例时,构造函数 __construct 会被自动调用。

在代码中,$a = new test(1); 会调用 test 类的构造函数,输出 1__construct

image-20250927151118619

析构函数 __destruct 在对象被销毁时自动调用。对象的销毁时机通常有两种情况:

脚本执行结束时,会自动销毁所有创建的对象,按对象创建的逆序调用析构函数。

当使用 unset 函数销毁对象时,会立即调用该对象的析构函数。

unset($a); 销毁了 $a 这个对象,所以会立即调用 $a 对应的 test 类实例的析构函数,输出 1__destruct()

另一种方法

核心是让对象在反序列化过程中提前变为 “无引用状态”(类似被赋值为 NULL),从而触发__destruct析构函数

当对象的引用计数归零时(没有任何变量引用它),PHP 会立即回收该对象并调用__destruct。“赋值为 NULL” 只是让引用计数归 0 的一种方式。

我们反序列化一个数组 第一个索引为对象 第二个元素使用与第一个元素相同的索引

比如

1
2
3
4
5
// 序列化前的数组(逻辑上)
$arr = [
0 => new B(), // 第一个元素:索引0对应B对象
0 => 0 // 第二个元素:索引0对应整数0(覆盖第一个元素)
];

当反序列化这个数组时,PHP 会按顺序解析:

先解析第一个元素:创建B对象,存入索引0(此时B对象的引用计数 = 1)

再解析第二个元素:发现索引也是0,于是用整数0覆盖了原来的B对象(数组中相同索引会被后出现的元素覆盖)

覆盖后,B对象不再被任何变量引用(引用计数 = 0),触发垃圾回收,进而调用__destruct

原本B对象被数组的索引0引用(有一个引用)

当相同索引的元素被覆盖后,B对象的所有引用都消失了(引用计数从 1 变为 0)

PHP 的垃圾回收器会实时检测到 “引用计数为 0” 的对象,将其标记为垃圾并立即回收

回收时必然会调用对象的__destruct方法,无论后续是否有异常抛出

下面尝试一下下

1
2
3
4
5
6
7
8
9
10
11
<?php
show_source(__FILE__);
$flag = "flag";
class B {
function __destruct() {
global $flag;
echo $flag;
}
}
$a = unserialize($_GET['1']);
throw new Exception('你想干什么');

image-20250927160952265

我们发现在这里反序列化之后就触发异常了

所以按照正常的方法我们没法触发_destruct

我们再反序列化一个数组

1
2
3
4
5
6
7
8
9
10
11
12
<?php
show_source(__FILE__);

class B {
function __destruct() {
global $flag;
echo $flag;
}
}
$a=array(new B,0);

echo serialize($a);

image-20250927162538540

image-20250927162824622

把第二个索引改为0

B 类对象被整数 0 覆盖后,原本指向 B 类对象的引用就不存在了,B 类对象的引用计数变为 0。PHP 的垃圾回收机制会检测到引用计数为 0 的对象,将其视为垃圾进行回收。

成功看到flag

GC在phar反序列化中的利用

其实差不多都是一样的

不过在这里

phar文件中的i:0是不能随便修改的

改了会因为签名错误而导致文件出错 但是签名可以进行伪造

利用下面的脚本使签名正确

1
2
3
4
5
6
7
8
import gzip
from hashlib import sha1
with open('D:\\phpStudy\\PHPTutorial\\WWW\html\\1.phar', 'rb') as file:
f = file.read()
s = f[:-28] # 获取要签名的数据
h = f[-8:] # 获取签名类型以及GBMB标识
newf = s + sha1(s).digest() + h # 数据 + 签名 + (类型 + GBMB)
open("2.phar","wb").write(newf)

路径记得修改一下

直接运行生成一个新的可用的phar文件

然后伪协议读取就可以了

基本的知识点就学完了

newstar2023第四周有道题目就是这个