# Memory Management in PHP: Reference Counting and Garbage Collection

> PHP memory management made clear: reference counting, copy-on-write, cycles and the garbage collector, measuring functions and why PHP2Shell targets exactly this.

Source: https://phpsos.de/en/php/speicherverwaltung/  
As of: 25/10/2026  
Provider: PHP SOS, PHP emergency help and server security. Immediate help via WhatsApp: https://wa.me/491736494914?text=Hello%20PHP%20SOS%2C%20we%20need%20help%20with%20our%20PHP%20server.%20Company%3A%20%E2%80%A6%20Website%3A%20%E2%80%A6

**In short:** PHP frees memory mainly through reference counting: every value knows how many variables point to it and is deleted as soon as this counter reaches zero. For circular references, which would never be freed this way, there is a cycle garbage collector. At the end of every request, PHP additionally cleans up everything the request occupied. Both PHP2Shell vulnerabilities target memory states: an out-of-memory condition during request processing, and internal state that persists too long after being freed.

## How PHP manages memory: the overview

In languages such as C, the programmer has to request and release memory themselves. PHP takes this off their hands. Every variable, every array and every object occupies memory that PHP manages through its own memory manager. The limit is set by `memory_limit`, which according to the manual defaults to `128M`.

For decision-makers one point is central: in classic operation with PHP-FPM or mod_php, long-lived worker processes handle many requests one after another. What one request leaves behind must be reliably cleaned up before the next. This is exactly where one of the PHP2Shell vulnerabilities lies.

## Reference counting: counting who uses a value

PHP stores every value in a container called a zval. Besides type and content, according to the manual it holds a reference counter (`refcount`) and a flag indicating whether the value is part of a PHP reference with `&` (`is_ref`).

```php
<?php
$a = str_repeat('x', 1000);  // one value, refcount 1
$b = $a;                     // no copying, refcount 2
$b .= 'y';                   // only now is it copied (copy-on-write)
unset($a);                   // the original's refcount drops
// When a counter reaches 0, PHP frees the memory immediately.
```

The principle is called copy-on-write: PHP copies a value only when one of the variables modifies it. This saves a lot of memory. A variable's counter can be made visible with the Xdebug extension and the function `xdebug_debug_zval()`. The manual points out that the exact numbers can differ between PHP versions.

## Cycles: when reference counting is not enough

Reference counting has a well-known weakness. If a structure points to itself, its counter never drops to zero.

```php
<?php
$a = ['eins'];
$a[] = &$a;   // the array contains a reference to itself
unset($a);    // nobody can access it any more, but refcount stays 1
```

According to the manual, such circular references occur especially with objects, for example in parent-child structures or parsers. Individual leaks are harmless because PHP cleans up at the end of the request. In long-running processes such as queue workers or large test runs, however, millions of them can add up to a real memory problem.

## The cycle garbage collector

Since PHP 5.3.0, PHP has used an algorithm by Bacon and Rajan to find such cycles. The basic idea: a cycle can only arise when a counter drops to a value greater than zero. PHP remembers only those values as possible roots.

1. Possible roots land in a buffer. The manual gives 10,000 entries as the default size; the currently effective threshold is shown by `gc_status()` in the `threshold` field.
2. When the buffer is full, PHP traverses all reachable values and tentatively decrements their counters.
3. In a second pass, values with a counter of zero are garbage, and for all others the decrement is undone.
4. The roots are removed from the buffer and all values recognised as garbage are freed.

This is controlled via `zend.enable_gc` (on by default) or at runtime with `gc_enable()` and `gc_disable()`. `gc_collect_cycles()` forces a run and returns the number of cycles collected.

## Measuring and observing memory

| Function | Purpose |
|---|---|
| `memory_get_usage()` | memory currently occupied by the script |
| `memory_get_usage(true)` | memory reserved from the system by the memory manager; `memory_limit` is checked against this value |
| `memory_get_peak_usage()` | peak of the memory occupied |
| `gc_status()` | runs, cycles collected, threshold, roots; since PHP 8.3.0 also run times |
| `gc_collect_cycles()` | trigger cycle collection immediately |
| `WeakMap` | mapping to objects without preventing their release, since PHP 8.0 |

According to the manual, `memory_get_usage()` records only memory that PHP requests through its own memory manager. What extensions obtain directly from the operating system is missing from these figures. For the memory limit of the whole process, the operating system or container is therefore responsible.

## Memory management and PHP2Shell

The descriptions of both vulnerabilities lead directly to this topic. With CVE-2026-109618, an out-of-memory condition can interrupt PHP while request data is only partly initialised; during clean-up, invalid data can then be treated as valid. What happens internally during a memory overflow is explained on the page [memory_limit and out-of-memory](https://phpsos.de/en/php/out-of-memory/). With CVE-2026-109619, internal state of a request can persist after the memory is freed and be used by a later request in the same worker before it is reset. The sequence from start to clean-up is described on the page [The request lifecycle](https://phpsos.de/en/php/request-lebenszyklus/).

As an interim measure, php.net recommends setting `memory_limit=-1` in such a way that applications cannot override it, for example with `php_admin_value` in the FPM pool, and limiting memory through containers or cgroups instead, not through `RLIMIT_AS` or `RLIMIT_DATA`. How to do this with systemd is shown in the article [Setting memory limits with systemd and cgroups](https://phpsos.de/en/blog/speichergrenzen-systemd-cgroups/).

```ini
; FPM pool: interim measure according to php.net, then restart all workers
php_admin_value[memory_limit] = -1
```

> **Important:**
> Without a limit via container or cgroup, a single process can exhaust the server. And no setting replaces the update to PHP 8.5.12, 8.4.27, 8.3.36 or 8.2.35 including the restart of all workers, because only the restart removes old processes from memory.

## How does PHP SOS help?

- We check the effective memory settings per pool and whether applications can override them.
- We implement the interim measures with a memory limit at container or cgroup level and test the load.
- We apply the update, restart all workers and demonstrate that no old processes are still running, as part of our [PHP patch service](https://phpsos.de/en/leistungen/php-patch-service/).
- We evaluate memory errors and aborted workers in the logs from 20 October 2026 onwards.


## Frequently asked questions

**What is reference counting in PHP?**  
PHP counts, for every value, how many variables or elements refer to it. When the counter drops to zero, the memory is released immediately. This is how most memory is returned without a dedicated clean-up run.

**Why does PHP still need a garbage collector then?**  
For cycles. If an array refers to itself or two objects know each other, the counter never drops to zero, even though nobody can access them any more. The cycle garbage collector finds and removes such structures.

**Does PHP release memory at the end of a request?**  
Yes, PHP cleans up the memory a script occupies during a request at the end of that request. With PHP2Shell, however, CVE-2026-109619 concerns exactly the case where internal state can persist after being freed and be used by a later request in the same worker.

**Why does php.net recommend memory_limit=-1 as an interim measure?**  
The vulnerability CVE-2026-109618 is linked to an out-of-memory condition. php.net recommends setting memory_limit=-1 so that applications cannot override it, and limiting memory through containers or cgroups instead. This is not complete protection and does not replace the update.

## Sources

- [PHP Manual: Reference counting basics](https://www.php.net/manual/de/features.gc.refcounting-basics.php)
- [PHP Manual: Collecting cycles](https://www.php.net/manual/de/features.gc.collecting-cycles.php)
- [PHP Manual: gc_status](https://www.php.net/manual/de/function.gc-status.php)
- [PHP Manual: memory_get_usage](https://www.php.net/manual/en/function.memory-get-usage.php)
- [php.net: Security announcements](https://www.php.net/)
