In a long-running PHP script, when does the cycle collector run on its own, and when is calling gc_collect_cycles() worth it?
answer
- refcounting frees most values at once
- cycles need the separate collector
- a root buffer, threshold 10 000
- zend.enable_gc defaults to 1
- gc_status() timings since 8.3
basics
~20 sRefcounting frees most values at once; the cycle collector handles arrays and objects that reference each other. It runs when its possible-root buffer reaches a threshold (10,000 by default, adapted after each run); call gc_collect_cycles() at batch boundaries to free cycles sooner.
solid answer
~50 sRefcounting frees a value the moment its last reference goes, so most PHP memory never involves the collector. What refcounting cannot free are **cycles**: objects or arrays that point at each other with nothing outside referring to them. When a refcounted array or object is decremented but not to zero, PHP records it in a **root buffer** as a possible cycle. At the **threshold** — 10 000 roots by default, raised after unproductive runs — the collector frees unreachable cycles and runs their destructors. In a short web request this rarely matters, since everything is freed at the end. In a long CLI job or worker, I call `gc_collect_cycles()` after each batch that builds cyclic graphs, and read `gc_status()` — `runs`, `collected`, `threshold`, `roots`, and since 8.3 `collector_time` — to see whether collection is happening and what it costs.
code
php · 13 lines<?php
declare(strict_types=1);
foreach (array_chunk($documentIds, 500) as $batch) {
foreach ($batch as $id) {
$tree = loadDocumentTree($id); // nodes point to parent and children
indexTree($tree);
}
$freed = gc_collect_cycles();
$gc = gc_status();
error_log(sprintf('freed %d, runs %d, gc %.3fs, %.1f MB',
$freed, $gc['runs'], $gc['collector_time'], memory_get_usage() / 1048576));
}go deeper
Know that PHP frees most values by refcounting and that a separate collector cleans up objects that reference each other.
Explain the root buffer and its 10,000 default threshold, how it adapts, and what gc_collect_cycles() and gc_status() return.
Decide where a long job should collect explicitly, read gc_status() timings to price it, and separate cyclic garbage from retained data when memory grows.
Set design rules for long-running PHP — break back-references or collect per batch — so memory behaviour stays predictable across teams.
## Two mechanisms, one engine PHP reclaims memory in two ways: 1. **Reference counting** frees a string, array or object as soon as nothing refers to it any more. This handles the overwhelming majority of values, immediately and deterministically. 2. The **cycle collector** handles what refcounting cannot: arrays and objects that refer to each other — a parent with a list of children that each point back at it — after the program has dropped every outside reference. Their counts never reach zero on their own. The collector only considers **arrays and objects**; strings cannot form cycles. ## When it runs on its own PHP tracks candidates cheaply. Whenever the refcount of an array or object is decremented to a value above zero, the value might now be part of an unreachable cycle, so it is recorded in the **root buffer**. The collector runs when the buffer reaches the **threshold**: - The default threshold is **10 000** possible roots. - After each run, PHP adapts it: if the run freed fewer than **100** items, or the buffer was still full, the threshold grows by 10 000 (up to a large maximum), so an application that creates many harmless roots is not scanned constantly. When runs are productive, the threshold shrinks back towards the default. - `zend.enable_gc` (default `1`) switches automatic collection on or off; `gc_enable()`, `gc_disable()` and `gc_enabled()` do the same at runtime. With automatic collection disabled, cycles are no longer freed on their own; they stay until an explicit `gc_collect_cycles()` call, which still works, or the end of the process. ## What a collection does The collector walks from the recorded roots, subtracts references that come from inside the candidate set, and treats anything whose count drops to zero as garbage. Before freeing it, it runs the **destructors** of the objects involved, then frees the memory. Destructors therefore run during collection, which may be well after the program dropped the last outside reference. ## gc_collect_cycles() and gc_status() `gc_collect_cycles(): int` forces a collection now and returns the number of freed items. `gc_status(): array` reports: | Key | Meaning | |---|---| | `runs` | collections so far | | `collected` | items freed so far | | `threshold` | roots that trigger the next automatic run | | `roots` | possible roots currently buffered | | `running`, `protected`, `full`, `buffer_size` | collector state (PHP 8.3+) | | `application_time`, `collector_time`, `destructor_time`, `free_time` | seconds spent, by phase (PHP 8.3+) | ## When a manual call is worth it - **Short web requests**: rarely. The process frees all request memory at the end, cycles included. - **Long CLI jobs and workers that build object graphs per batch** — parsed documents, entity graphs with back-references, event objects holding their dispatcher — can accumulate cycles faster than the adaptive threshold collects them. A `gc_collect_cycles()` after each batch keeps memory flat and makes destructor timing predictable. - **Before measuring memory**: calling it first ensures `memory_get_usage()` reflects live data rather than uncollected cycles. It is not worth it inside tight loops: each call scans the buffered roots, and `gc_status()['collector_time']` will show the cost. ## Designing cycles out Collecting more often treats the symptom; long-running code is easier to reason about when it creates fewer cycles: - **Weak back-pointers.** A child that must reach its parent can hold a `WeakReference` to it, so the tree is freed by refcounting alone. - **Explicit teardown.** A `close()` or `reset()` method that clears child lists and parent links breaks the cycle deterministically. - **Closures that capture `$this`.** A closure created inside an object keeps a reference to that object; storing it in one of the object's own properties forms a cycle. Use a `static fn` when the closure does not need the object. ## When it will not help The collector frees only **unreachable** cycles. Data still referenced — an array that keeps every processed row, a static cache, a registry of listeners — is live, and no collection will free it. If memory keeps growing after `gc_collect_cycles()`, the cause is retained data, not uncollected cycles.
- Why can memory keep growing after gc_collect_cycles() returns a large number?The collector frees only cycles nothing else can reach. If the script also keeps live references — an array of processed items, a static cache, a logger buffer — that memory is reachable and stays. A large return value shows cycles were freed; continued growth points at retained data.
- When do destructors of objects in a cycle run?During the cycle collection that finds them unreachable, not when the last outside reference was dropped. That may be much later, or at process end if the collector never runs, so destructors in cyclic graphs are a poor place for time-sensitive cleanup.
saying these in an interview costs you the question
- PHP needs the cycle collector to free ordinary unreferenced arrays
- gc_collect_cycles() frees data that is still referenced
- The collector runs after every function call
- Calling gc_collect_cycles() in every loop iteration is free
- Strings can form reference cycles