Skip to content

Garbage collector not cleaning roots / leaking memory #9239

@olsavmic

Description

@olsavmic

Description

The following code:

<?php declare(strict_types = 1);

gc_enable();

class Bar
{
    public ?Foo $foo = null;
}

class Foo
{
    public Bar $bar;
}

function ff()
{
    $arr = [];
    for ($i = 0; $i < 10000000; $i++) {
        $b = new Bar();
        $a = new Foo();
        $a->bar = $b;
        $b->foo = $a;

        $arr[] = $a;
    }
}

ff();

function memory_usage_mb(): float
{
    return memory_get_usage() / 1024 / 1024;
}

echo "GC Status after first run:\n";
var_dump(gc_status());
echo 'Memory usage: ' . memory_usage_mb() . "MB\n";

ff();

echo "GC Status after second run:\n";
var_dump(gc_status());
echo 'Memory usage: ' . memory_usage_mb() . "MB\n";

gc_collect_cycles();
echo "GC Status after gc_collect_cycles:\n";
var_dump(gc_status());
echo 'Memory usage: ' . memory_usage_mb() . "MB\n";

ff();
echo "GC Status after third run:\n";
var_dump(gc_status());
echo 'Memory usage: ' . memory_usage_mb() . "MB\n";

Resulted in this output:

GC Status after first run:
array(4) {
  ["runs"]=>
  int(81)
  ["collected"]=>
  int(19580004)
  ["threshold"]=>
  int(440001)
  ["roots"]=>
  int(209999)
}
Memory usage: 278.76216888428MB
GC Status after second run:
array(4) {
  ["runs"]=>
  int(129)
  ["collected"]=>
  int(38860006)
  ["threshold"]=>
  int(620001)
  ["roots"]=>
  int(569998)
}
Memory usage: 317.21421051025MB
GC Status after gc_collect_cycles:
array(4) {
  ["runs"]=>
  int(130)
  ["collected"]=>
  int(40000000)
  ["threshold"]=>
  int(620001)
  ["roots"]=>
  int(0)
}
Memory usage: 256.33196258545MB
GC Status after third run:
array(4) {
  ["runs"]=>
  int(168)
  ["collected"]=>
  int(58540004)
  ["threshold"]=>
  int(760001)
  ["roots"]=>
  int(729999)
}
Memory usage: 334.30416107178MB

As you can see, escaping from the first ff() call or calling the ff() function for the second time did not free the roots of GC even though the GC was supposedly called (the number of runs increased) although there were more than 209999 roots in the buffer.

Calling manually gc_collect_cycles seems to clear the root buffer but it actually does not do anything - calling ff() for the third time actually shows total of 729999 roots in the buffer.

Perhaps this is somehow related to the issue mentioned in https://www.php.net/manual/en/features.gc.collecting-cycles.php

If the root buffer becomes full with possible roots while the garbage collection mechanism is turned off, further possible roots will simply not be recorded. Those possible roots that are not recorded will never be analyzed by the algorithm. If they were part of a circular reference cycle, they would never be cleaned up and would create a memory leak.

The presented case here though has GC enabled all the time, the number of roots seems to be counted and it still leaks memory.


After removing the assignment $arr[] = $a; from the ff function, the garbage collector is triggered every 10 000 roots (as documented) and the roots are gone as expected:

GC Status after first run:
array(4) {
  ["runs"]=>
  int(1999)
  ["collected"]=>
  int(19990000)
  ["threshold"]=>
  int(10001)
  ["roots"]=>
  int(10000)
}
Memory usage: 0.99087524414062MB
GC Status after second run:
array(4) {
  ["runs"]=>
  int(3999)
  ["collected"]=>
  int(39990000)
  ["threshold"]=>
  int(10001)
  ["roots"]=>
  int(10000)
}
Memory usage: 0.99087524414062MB
GC Status after gc_collect_cycles:
array(4) {
  ["runs"]=>
  int(4000)
  ["collected"]=>
  int(40000000)
  ["threshold"]=>
  int(10001)
  ["roots"]=>
  int(0)
}
Memory usage: 0.45681762695312MB

I got the same output for PHP 7.4, PHP 8.0, and PHP 8.1.

PHP Version

PHP 8.1.7

Operating System

Debian 10

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions