使用弱引用提高效率

随着 PHP 的不断发展和成熟,越来越多的开发人员开始使用 PHP 框架来促进应用程序的快速开发。然而,这种做法的一个必然副产品就是占用内存的对象越来越大、越来越复杂。包含许多属性、其他对象或大小数组的大型对象通常被称为昂贵对象。

使这一趋势造成的潜在内存问题更加复杂的是,所有 PHP 对象的赋值都是通过引用自动完成的。如果没有引用,使用第三方框架就会变得非常麻烦。但是,当通过引用赋值对象时,该对象必须完整地保留在内存中,直到所有引用被销毁。只有在取消设置或覆盖对象后,它才会被完全销毁。

在 PHP 7.4 中,以弱引用支持的形式引入了这一问题的潜在解决方案。PHP 8 通过添加弱映射类扩展了这一新功能。在本节中,您将了解这一新技术的工作原理,以及它如何证明对开发有利。首先来看看弱引用。

利用弱引用

弱引用在 PHP 7.4 中首次引入,并在 PHP 8 中得到改进。该类作为对象创建的包装器,允许开发人员以这样一种方式使用对象引用,即范围外(例如,unset())对象不受垃圾回收的保护。

目前,pecl.php.net 上有许多 PHP 扩展程序都支持弱引用。大多数实现都入侵了 PHP 语言核心的 C 语言结构,要么重载对象处理程序,要么操作堆栈和各种 C 指针。在大多数情况下,这样做的最终结果是丧失了可移植性并产生大量分段错误。PHP 8 的实现避免了这些问题。

如果您的程序代码涉及大型对象,而且程序代码可能会运行很长时间,那么掌握 PHP 8 弱引用的使用就非常重要。在了解使用细节之前,让我们先看看类的定义。

检查 WeakReference 类定义

WeakReference 类的正式定义如下:

WeakReference {
    public __construct() : void
    public static create (object $object) : WeakReference
    public get() : object|null
}

如您所见,类的定义非常简单。该类可用于对任何对象进行封装。有了这个包装器,就可以更容易地完全销毁一个对象,而不必担心可能会有一个残留的引用导致该对象仍然存在于内存中。

有关弱引用的背景和性质的更多信息,请访问: https://wiki.php.net/rfc/weakrefs 。文档参考在这里: https://www.php.net/manual/en/class.weakreference.php

现在让我们来看一个简单的例子,以帮助您理解。

使用弱引用

本例演示了如何使用弱引用。在这个示例中,你将看到当进行普通的对象引用赋值时,即使原始对象被取消设置,它仍然会加载到内存中。另一方面,如果使用弱引用进行对象引用赋值,一旦原始对象被取消设置,它就会从内存中完全删除:

  1. 首先,我们定义四个对象。请注意,$obj2 是对 $obj1 的正常引用,而 $obj4 是对 $obj3 的弱引用:

    // /repo/ch010/php8_weak_reference.php
    $obj1 = new class () { public $name = 'Fred'; };
    $obj2 = $obj1; // normal reference
    $obj3 = new class () { public $name = 'Fred'; };
    $obj4 = WeakReference::create($obj3); // weak ref
  2. 然后,我们将显示 $obj1 取消设置前后 $obj2 的内容。由于 $obj1$obj2 之间的连接是普通的 PHP 引用,因此由于创建了强引用,$obj1 仍保留在内存中:

    var_dump($obj2);
    unset($obj1);
    var_dump($obj2); // $obj1 still loaded in memory
  3. 然后,我们对 $obj3$obj4 执行同样的操作。请注意,我们需要使用 WeakReference::get() 来获取相关对象。一旦取消设置 $obj3,与 $obj3$obj4 相关的所有信息都将从内存中删除:

    var_dump($obj4->get());
    unset($obj3);
    var_dump($obj4->get()); // both $obj3 and $obj4 are gone

以下是在 PHP 8 中运行的代码示例的输出:

root@php8_tips_php8 [ /repo/ch10 ]#
php php8_weak_reference.php
object(class@anonymous)#1 (1) {
  ["name"]=> string(4) "Fred"
}
object(class@anonymous)#1 (1) {
  ["name"]=> string(4) "Fred"
}
object(class@anonymous)#2 (1) {
  ["name"]=> string(4) "Fred"
}
NULL

输出结果告诉我们一个有趣的故事!第二次 var_dump() 操作显示,尽管 $obj1 已被取消设置,但由于 $obj2 创建了强引用,它仍像僵尸一样继续存在。如果你正在处理昂贵的对象和复杂的应用程序代码,为了释放内存,你需要在释放内存之前首先找到并销毁所有引用!

另一方面,如果真的需要内存,与其直接进行对象赋值(在 PHP 中是自动通过引用进行的),不如使用 WeakReference::create() 方法创建引用。弱引用具有普通引用的所有功能。唯一的区别是,如果它引用的对象被销毁或退出作用域,弱引用也会自动销毁。

从输出中可以看到,最后一次 var_dump() 操作的结果是 NULL。这说明对象确实已被销毁。当主对象被取消设置时,它的所有弱引用都会自动消失。现在你已经了解了如何使用弱引用,以及弱引用可能解决的问题,是时候来看看一个新类 WeakMap 了。

使用 WeakMap

PHP 8 中添加了一个新类 WeakMap,该类可利用弱引用支持。这个新类的功能类似于 SplObjectStorage。下面是该类的官方定义:

final WeakMap implements Countable, ArrayAccess, IteratorAggregate {
    public __construct ( )
    public count ( ) : int
    abstract public getIterator ( ) : Traversable
    public offsetExists ( object $object ) : bool
    public offsetGet ( object $object ) : mixed
    public offsetSet ( object $object , mixed $value ) : void
    public offsetUnset ( object $object ) : void
}

就像 SplObjectStorage 一样,这个新类也是一个对象数组。由于它实现了 IteratorAggregate,因此可以使用 getIterator() 方法访问内部迭代器。因此,新类不仅提供了传统的数组访问,还提供了 OOP 迭代器访问,可谓两全其美!在详细介绍如何使用 WeakMap 之前,了解 SplObjectStorage 的典型用法非常重要。

使用 SplObjectStorage 实现容器类

SplObjectStorage 类的一个潜在用途是将其作为依赖注入(DI)容器(也称为 服务定位器反转控制容器)的基础。依赖注入容器类旨在创建和保存对象实例,以便于检索。

在这个示例中,我们用从 Laminas\Filter\* 类中提取的昂贵对象数组加载了一个容器类。然后,我们使用容器对样本数据进行消毒,之后取消设置过滤器数组:

  1. 首先,我们定义了一个基于 SplObjectStorage 的容器类。(稍后,在下一节中,我们将开发另一个基于 WeakMap 的容器类,做同样的事情)。下面是 UsesSplObjectStorage 类。在 __construct() 方法中,我们将配置好的过滤器附加到 SplObjectStorage 实例上:

    // /repo/src/Php7/Container/UsesSplObjectStorage.php
    namespace Php7\Container;
    use SplObjectStorage;
    class UsesSplObjectStorage {
        public $container;
        public $default;
        public function __construct(array $config = []) {
            $this->container = new SplObjectStorage();
            if ($config) foreach ($config as $obj)
                $this->container->attach(
                    $obj, get_class($obj));
            $this->default = new class () {
                public function filter($value) {
                    return $value; }};
        }
  2. 然后,我们定义了一个 get() 方法,该方法会遍历 SplObjectStorage 容器,并在找到时返回过滤器。如果没有找到,则返回一个默认类,该类只需直接传递数据即可:

        public function get(string $key) {
            foreach ($this->container as $idx => $obj)
                if ($obj instanceof $key) return $obj;
            return $this->default;
        }
    }

    请注意,在使用 foreach() 循环遍历 SplObjectStorage 实例时,我们返回的是值 ($obj),而不是键。另一方面,如果我们使用的是 WeakMap 实例,则需要返回键而不是值!

然后,我们定义一个调用程序,使用新创建的 UsesSplObjectStorage 类来包含过滤器集:

  1. 首先,我们定义自动加载并使用相应的类:

    // /repo/ch010/php7_weak_map_problem.php
    require __DIR__ .
        '/../src/Server/Autoload/Loader.php';
    loader = new \Server\Autoload\Loader();
    use Laminas\Filter\ {StringTrim, StripNewlines,
        StripTags, ToInt, Whitelist, UriNormalize};
    use Php7\Container\UsesSplObjectStorage;
  2. 接下来,我们定义一个样本数据数组:

    $data = [
        'name' => '<script>bad JavaScript</script>name',
        'status' => 'should only contain digits 9999',
        'gender' => 'FMZ only allowed M, F or X',
        'space' => " leading/trailing whitespace or\n",
        'url' => 'unlikelysource.com/about',
    ];
  3. 然后,我们为所有字段分配必要的筛选器($required),并为某些字段分配特定的筛选器($added):

    $required = [StringTrim::class,
        StripNewlines::class, StripTags::class];
    $added = ['status' => ToInt::class,
        'gender' => Whitelist::class,
        'url' => UriNormalize::class ];
  4. 然后,我们创建一个过滤器实例数组,用于填充我们的服务容器 UseSplObjectStorage。请注意,每个过滤器类都会产生大量开销,可以被视为一个昂贵的对象:

    $filters = [
        new StringTrim(),
        new StripNewlines(),
        new StripTags(),
        new ToInt(),
        new Whitelist(['list' => ['M','F','X']]),
        new UriNormalize(['enforcedScheme' => 'https']),
    ];
    $container = new UsesSplObjectStorage($filters);
  5. 现在,我们使用容器类在数据文件中循环检索过滤器实例。filter() 方法会产生一个特定于该过滤器的净化值:

    foreach ($data as $key => &$value) {
        foreach ($required as $class) {
            $value = $container->get($class)->filter($value);
        }
        if (isset($added[$key])) {
            $value = $container->get($added[$key])
                ->filter($value);
        }
    }
    var_dump($data);
  6. 最后,我们抓取了内存统计数据,作为比较 SplObjectStorageWeakMap 使用情况的基础。我们还取消了 $filters,理论上这将释放大量内存。我们运行 gc_collect_cycles() 强制执行 PHP 垃圾回收过程,将释放的内存重新释放到内存池中:

    $mem = memory_get_usage();
    unset($filters);
    gc_collect_cycles();
    $end = memory_get_usage();
    echo "\nMemory Before Unset: $mem\n";
    echo "Memory After Unset: $end\n";
    echo 'Difference : ' . ($end - $mem) . "\n";
    echo 'Peak Memory Usage : ' . memory_get_peak_usage();

以下是刚刚显示的调用程序在 PHP 8 中运行的结果:

root@php8_tips_php8 [ /repo/ch10 ]#
php php7_weak_map_problem.php
array(5) {
["name"]=> string(18) "bad JavaScriptname"
["status"]=> int(0)
["gender"]=> NULL
["space"]=> string(30) "leading/trailing whitespace or"
["url"]=> &string(32) "https://unlikelysource.com/about"
}
Memory Before Unset: 518936
Memory After Unset: 518672
Difference : 264
Peak Memory Usage : 780168

从输出结果可以看出,我们的容器类运行完美,可以访问任何存储的过滤器类。值得注意的是,在执行 unset($filters) 命令后释放的内存只有 264 字节:并不多!

现在你已经了解了 SplObjectStorage 类的典型用法。现在让我们看看 SplObjectStorage 类的一个潜在问题,以及 WeakMap 如何解决这个问题。

了解 WeakMap 相对于 SplObjectStorage 的优势

SplObjectStorage 的主要问题是,当被分配的对象被取消设置或退出作用域时,它仍然保留在内存中。原因是当对象附加到 SplObjectStorage 实例时,它是通过引用完成的。

如果只处理少量对象,可能不会出现严重问题。如果使用 SplObjectStorage 并分配大量昂贵的对象进行存储,最终可能会导致长期运行的程序出现内存泄漏。另一方面,如果使用 WeakMap 实例进行存储,垃圾回收就可以移除对象,从而释放内存。当你开始将 WeakMap 实例融入常规编程实践时,你最终会得到更高效的代码,占用更少的内存。

有关 WeakMap 的更多信息,请点击此处查看原始 RFC: https://wiki.php.net/rfc/weak_maps 。还可以查看文档: https://www.php.net/weakMap

现在让我们重写上一节的示例 ( /repo/ch010/php7_weak_map_problem.php ),但这次要使用 WeakMap

  1. 如前面的代码示例所述,我们定义了一个名为 UsesWeakMap 的容器类,用于存放昂贵的过滤器类。该类与上一节所示类的主要区别在于 UsesWeakMap 使用 WeakMap 而不是 SplObjectStorage 进行存储。下面是类的设置和 __construct() 方法:

    // /repo/src/Php7/Container/UsesWeakMap.php
    namespace Php8\Container;
    use WeakMap;
    class UsesWeakMap {
        public $container;
        public $default;
        public function __construct(array $config = []) {
            $this->container = new WeakMap();
            if ($config)
                foreach ($config as $obj)
                    $this->container->offsetSet(
                        $obj, get_class($obj));
            $this->default = new class () {
                public function filter($value) {
                    return $value; }};
        }
  2. 这两个类的另一个区别是 WeakMap 实现了 IteratorAggregate。不过,这仍然允许我们在 get() 方法中使用简单的 foreach() 循环:

        public function get(string $key) {
            foreach ($this->container as $idx => $obj)
                if ($idx instanceof $key) return $idx;
            return $this->default;
        }
    }

    请注意,在使用 foreach() 循环遍历 WeakMap 实例时,我们返回的是 key($idx),而不是值!

  3. 然后,我们定义一个调用程序,调用自动加载器并使用相应的过滤器类。这个调用程序与上一节程序的最大区别在于,我们使用了基于 WeakMap 的新容器类:

    // /repo/ch010/php8_weak_map_problem.php
    require __DIR__ .
        '/../src/Server/Autoload/Loader.php';
    $loader = new \Server\Autoload\Loader();
    use Laminas\Filter\ {StringTrim, StripNewlines,
        StripTags, ToInt, Whitelist, UriNormalize};
    use Php8\Container\UsesWeakMap;
  4. 与上一个示例一样,我们定义了一个样本数据数组并分配了过滤器。此代码与上一示例的第 2 和第 3 步相同,故未显示。

  5. 然后,我们在数组中创建过滤器实例,作为新容器类的参数。我们使用过滤器数组作为参数来创建容器类实例:

    $filters = [
        new StringTrim(),
        new StripNewlines(),
        new StripTags(),
        new ToInt(),
        new Whitelist(['list' => ['M','F','X']]),
        new UriNormalize(['enforcedScheme' => 'https']),
    ];
    $container = new UsesWeakMap($filters);
  6. 最后,与上一个示例中的步骤 6 完全相同,我们循环浏览数据并应用容器类中的筛选器。我们还收集并显示内存统计数据。

以下是在 PHP 8 中运行的使用 WeakMap 的修订程序的输出结果:

root@php8_tips_php8 [ /repo/ch10 ]#
php php8_weak_map_problem.php
array(5) {
    ["name"]=> string(18) "bad JavaScriptname"
    ["status"]=> int(0)
    ["gender"]=> NULL
    ["space"]=> string(30) "leading/trailing whitespace or"
    ["url"]=> &string(32) "https://unlikelysource.com/about"
}
Memory Before Unset: 518712
Memory After Unset: 517912
Difference : 800
Peak Memory Usage : 779944

如你所料,总体内存使用量略有降低。不过,最大的区别在于取消设置 $filters 后的内存差异。在上一个示例中,差异为 264 字节。而在本例中,使用 WeakMap 产生的差异为 800 字节。这意味着,与使用 SplObjectStorage 相比,使用 WeakMap 有可能释放三倍以上的内存!

关于弱引用和弱映射的讨论到此结束。现在你可以编写更高效、使用更少内存的代码了。存储的对象越大,节省内存的潜力就越大。

总结

在本章中,你不仅学习了新的 JIT 编译器的工作原理,还了解了传统的 PHP 解释-编译-执行循环。使用 PHP 8 并启用 JIT 编译器有可能将 PHP 应用程序的速度提高三倍以上。

在下一节中,您将了解什么是稳定排序,以及 PHP 8 是如何实现这一重要技术的。掌握了稳定排序,你的代码就能以合理的方式生成数据,从而提高客户满意度。

接下来的章节将向你介绍一种利用 SplFixedArray 类大大提高性能和减少内存消耗的技术。之后,您将了解 PHP 8 对弱引用的支持以及新的 WeakMap 类。使用本章所涉及的技术将使你的应用程序执行得更快、运行得更高效、内存消耗得更少。

在下一章中,您将学习如何成功迁移到 PHP 8。