在 PHP 中,我们越来越频繁地使用闭包(Closure)[1],例如依赖注入、中间件、集合回调,以及异步处理。
然而,闭包有一个可能出乎意料的行为:在实例方法中创建的任何闭包,都会自动携带对当前对象的引用,即使它根本没有使用$this。这种行为可能会对对象的生命周期产生意外影响,如果不注意,还可能导致内存泄漏。
要理解其中的原因,我们首先需要了解 PHP 是如何管理内存的。
与 Java 等依赖垃圾回收器(延迟释放内存)的语言不同,PHP 使用的是引用计数(PHP 也有垃圾回收器[2]来处理循环引用,但那是另一个话题)。
当你赋值给一个变量时,其内容会存储在内存中;当变量不再被使用时,内存就可以被释放。例如:
<?php$a = 'Hello';$b = $a;
PHP 不会为 $b 再分配一块新的内存空间,而是让它指向与 $a 相同的内存空间。如果你之后给 $a 赋新值(如 "Hi"),则会分配新的内存空间,$a 指向新空间,而 $b 仍指向旧空间。
如果再把 $b 赋值为 NULL,那么存放 "Hello" 的内存空间就不再被任何变量引用,可以被释放。PHP 通过维护引用计数来实现这一点,当计数降为 0 时,内存空间就被释放。
对象的生命周期
对于对象,当引用计数降为 0 时,在释放内存之前,如果类定义了 __destruct 方法,则会先调用它:
<?phpclass Foo{ publicfunction __construct() { echo "Construct\n"; } publicfunction __destruct() { echo "Destruct\n"; }}new Foo();echo "End\n";
输出:
ConstructDestructEnd
对象没有被赋值给任何变量,因此构造函数调用后引用计数立即变为 0,__destruct 紧接着被调用。
而如果对象被赋值给变量,销毁就会被推迟:
<?php$foo = new Foo();echo "End\n";
输出:
ConstructEndDestruct
只要 $foo 还指向该对象,计数就保持为 1。只有在脚本结束、所有变量都被释放时才会销毁。要强制提前销毁,只需显式释放变量(重新赋值或使用 unset()):
<?php$foo = new Foo();echo "Before release\n";$foo = null;echo "After release\n";
输出:
ConstructBefore releaseDestructAfter release
闭包会让对象保持存活
下面来看一个定义了 getCallback() 方法的类 Bar,该方法返回一个读取 $this->id 属性的闭包:
<?phpclass Bar{ publicfunction __construct(private string $id) { echo "Construct\n"; } publicfunction __destruct() { echo "Destruct\n"; } publicfunction getCallback(): Closure{ returnfunction(): string{ return $this->id; }; }}$bar = new Bar('foo');$getId = $bar->getCallback();echo "Before releasing the object\n";$bar = null;echo "After releasing the object\n";echo $getId() . "\n";echo "End\n";
输出:
ConstructBefore releasing the objectAfter releasing the objectfooEndDestruct
当我们把 $bar 赋值为 null 时,对象并没有被销毁,因为闭包访问了 $this->id,从而构成了对对象的引用。只要闭包存在(即脚本结束前),引用计数就不会降为 0。如果我们之后把 $getId 重新赋值,__destruct 就会更早被调用。
即使不使用 $this,对象依然存活
如果闭包中没有使用$this,会发生什么?
<?phpclass Bar{ publicfunction __construct() { echo "Construct\n"; } publicfunction __destruct() { echo "Destruct\n"; } publicfunction getCallback(): Closure{ returnfunction(): void{}; }}$bar = new Bar();$callback = $bar->getCallback();echo "Before releasing the object\n";$bar = null;echo "After releasing the object\n";$callback = null;echo "End\n";
输出:
ConstructBefore releasing the objectAfter releasing the objectDestructEnd
对象仍然被保持存活!即使我们没有在闭包中使用 $this,PHP 仍然会自动将 $this 绑定到在实例方法中创建的任何闭包上——无论是否使用、是否为空。
闭包因此总是隐式地携带对对象的引用,这在阅读代码时是看不出来的。
当然,如果闭包是在静态方法中创建的,就不会有对 $this 的引用,对象会在变量释放时立即被销毁:
<?phpclass Bar{ publicfunction __construct() { echo "Construct\n"; } publicfunction __destruct() { echo "Destruct\n"; } public staticfunction getCallback(): Closure{ returnfunction(): void{}; }}$bar = new Bar();$closure = $bar::getCallback();echo "Before releasing the object\n";$bar = null;echo "End\n";
输出:
ConstructBefore releasing the objectDestructEnd
静态闭包
在闭包前加上 static 关键字,可以显式禁止它绑定到 $this。此时 PHP 不再存储任何对对象的引用(即使是隐式的)。
// ...publicfunction getCallback(): Closure{ return staticfunction(): void{};}// ...
输出:
ConstructBefore releasing the objectDestructEnd
如果需要在闭包中使用属性的值,可以通过 use 来传递:
// ...publicfunction getCallback(): Closure{ $id = $this->id; return staticfunction() use ($id): string{ return $id; };}
这样,PHP 会在变量释放后立即销毁对象,因为闭包不再持有对它的引用。
如果你试图在静态闭包中使用 $this,PHP 会直接报错:
staticfunction(): string{ return $this->id; // Error: Using $this when not in object context};
PHP 引擎通过这种方式保护你,避免意外捕获对象。
短闭包
短闭包(fn() =>)提供了更简洁的语法,并且会自动捕获外部作用域的变量(无需 use)。
但它们在处理 $this 时与普通闭包行为一致:
publicfunction getCallback(): Closure{ returnfn(): string => $this->id;}
这里 $this 被隐式捕获,对象会一直存活到闭包被释放。
static 关键字同样适用于短闭包。外部变量仍会自动捕获,但 $this 不再被捕获:
publicfunction getCallback(): Closure{ return staticfn(): string => $this->id; // Error: Using $this when not in object context}
要想传递值而不捕获对象,只需提前提取出来:
publicfunction getCallback(): Closure{ $id = $this->id; return staticfn(): string => $id;}
此时 $id 是按值捕获的,$this 不再参与其中……
引用链接
[1] 闭包(Closure): https://www.php.net/closure[2] 垃圾回收器: https://www.php.net/manual/en/features.gc.php