<? phpukraine СПІВБЕСІДИ
Пошук по платформі

PHP для Senior: питання на співбесіду

4 питання рівня Senior з теми Core PHP з розгорнутими відповідями, порадами та перевіркою.

Тема
Рівень
4 питання
PHP
Core PHP·Senior ·WeakMap ·WeakReference ·памʼять

WeakReference (PHP 7.4+) і WeakMap (PHP 8.0+) тримають обʼєкт, не збільшуючи його refcount: як тільки обʼєкт знищується, посилання стає null, а запис у WeakMap зникає сам — це штатний спосіб робити привʼязані до обʼєктів кеші й реєстри в довгоживучих процесах.

Ви кешуєте розраховані права в масиві за spl_object_id — що з цим не так у воркері Octane?
Чим WeakMap відрізняється від SplObjectStorage?
Як привʼязати дані до обʼєкта, не подовживши йому життя?
Чому запис у WeakMap не зник, хоча я зробив unset() ключа?

Основа памʼяті в PHP — підрахунок посилань: кожен обʼєкт має refcount, і щойно він падає до нуля, обʼєкт знищується негайно, з викликом __destruct(). Будь-яка звичайна змінна, властивість чи елемент масиву, що вказує на обʼєкт, цей лічильник збільшує — тобто утримує обʼєкт живим. Слабке посилання — це виняток із правила: WeakReference::create($obj) (PHP 7.4+) запамʼятовує обʼєкт, не чіпаючи refcount. Поки на обʼєкт є хоч одне сильне посилання, $weak->get() повертає його; коли останнє зникає, обʼєкт знищується за звичайним сценарієм, а get() починає повертати null. До 7.4 це вміла лише PECL-розширення weakref, тож на старих проєктах ви цього API не побачите.

WeakMap (PHP 8.0+) — надбудова над тією ж ідеєю: мапа, де ключ — обʼєкт (тільки обʼєкт, скаляр дасть TypeError), значення — будь-що, і слабким є саме ключ. Клас реалізує ArrayAccess, Countable та IteratorAggregate, тому працює як звичайний масив: $map[$obj] = $data, isset($map[$obj]), count($map), foreach ($map as $obj => $data). Головна властивість — запис видаляється автоматично разом зі знищенням ключа, без жодного коду очищення з вашого боку. Це те, чого не дають альтернативи: SplObjectStorage теж мапить обʼєкти на дані, але тримає ключі сильно, а масив, індексований spl_object_id(), обʼєкт не тримає, зате накопичує мертві записи й ризикує колізією — ідентифікатор знищеного обʼєкта PHP може видати новому, і кеш віддасть чужі дані.

Практичний сенс зʼявляється там, де процес живе довго: Octane, RoadRunner, Swoole, queue:work у режимі демона, довгі CLI-команди, ReactPHP/Amp. У класичному FPM-запиті все одно все звільняється наприкінці, тому витік у пер-обʼєктному кеші там непомітний; у демоні той самий статичний масив росте від першого запиту до перезапуску воркера. Канонічний сценарій для WeakMap — метадані, привʼязані до обʼєкта і не потрібні довше за нього: обчислені права користувача, memoized-результат дорогого методу для конкретної сутності, стан гідратації або ознака «цей обʼєкт уже провалідовано» у межах одного job. У прикладі PermissionResolver кеш живе рівно стільки, скільки живе $user: count() сам падає до нуля, коли воркер відпускає сутність.

Слабкість односпрямована, і на цьому найчастіше спотикаються. WeakMap тримає значення сильно, тому будь-який шлях від значення назад до ключа перетворює слабкий запис на вічний: $map[$order] = ['owner' => $order] з останнього блоку коду означає ланцюжок WeakMap → масив → $order, і unset($order) уже нічого не змінює. Лікується це зберіганням даних без зворотного посилання або загорнутим WeakReference усередині значення. Друга межа — цикли: якщо обʼєкт-ключ входить у циклічний граф (батько тримає дитину, дитина тримає батька), refcount ніколи не досягне нуля сам, і запис зникне лише після проходу збирача циклів, тож у тестах перед перевіркою count() варто викликати gc_collect_cycles(). Третя — обидва класи не серіалізуються: serialize() на них кине виняток, тому в сесію, кеш чи payload черги вони не потрапляють.

Нарешті, варто чесно назвати, чим WeakMap не є. Це не кеш із витісненням: у нього немає ані TTL, ані ліміту розміру, ані LRU — єдиний критерій видалення це смерть ключа. Якщо обʼєкт живе весь процес (синглтон із контейнера, case enum, зареєстрований слухач), WeakMap не дасть нічого, крім зайвої непрямості, і звичайна властивість буде чеснішою. І це не заміна аналізу витоків: якщо памʼять воркера росте, спершу треба знайти, хто тримає посилання (php-memprof, підрахунок живих обʼєктів у контрольних точках, memory_get_usage(true) і gc_status() у метриках), і лише потім вирішувати, чи справді проблемне місце — це реєстр, якому слабких ключів вистачить.

final class PermissionResolver
{
    /** @var WeakMap<User, list<string>> ключ тримається слабко */
    private WeakMap $cache;

    public function __construct(private readonly PermissionStorage $storage)
    {
        $this->cache = new WeakMap;          // PHP 8.0+
    }

    /** @return list<string> */
    public function for(User $user): array
    {
        // ??= працює через ArrayAccess: offsetExists → offsetGet → offsetSet
        return $this->cache[$user] ??= $this->storage->load($user->id);
    }

    public function cachedCount(): int
    {
        return count($this->cache);          // WeakMap реалізує Countable
    }
}

$resolver = new PermissionResolver($storage);
$user = new User(42);
$resolver->for($user);
echo $resolver->cachedCount();               // 1

// WeakReference (PHP 7.4+) не інкрементує refcount; new заборонено
$weak = WeakReference::create($user);
unset($user);                                // зникло останнє сильне посилання

var_dump($weak->get());                      // NULL — обʼєкт уже знищено
echo $resolver->cachedCount();               // 0 — запис пішов разом із ключем

// Пастка: значення тримає ключ, слабкість працює лише в один бік
$map = new WeakMap;
$order = new Order;
$map[$order] = ['owner' => $order];          // WeakMap → значення → ключ
unset($order);
echo count($map);                            // 1 — памʼять не звільниться
Що слабке посилання не інкрементує refcount: обʼєкт звільняється в момент, коли зникає останнє *сильне* посилання, а слабке після цього просто перестає працювати.
Що WeakMap — це мапа «обʼєкт → будь-яке значення» зі слабким ключем: запис видаляється автоматично разом із ключем, тому `count($map)` зменшується сам, без вашого коду очищення.
Що SplObjectStorage і звичайний масив, індексований `spl_object_id()`, цю задачу не розвʼязують: перший тримає обʼєкти сильно, другий накопичує мертві записи, а ідентифікатори ще й перевикористовуються після знищення обʼєкта.
Що проблема стає видимою саме в довгих процесах — Octane, RoadRunner, Swoole, `queue:work` у режимі демона, ReactPHP/Amp, — бо між запитами процес не помирає і статичні реєстри ростуть вічно.
Що значення у WeakMap тримається сильно: якщо воно (прямо чи через ланцюжок) посилається на ключ, запис не звільниться ніколи — слабкість односпрямована.
Що WeakMap не скасовує збирач циклів: якщо ключ у циклічному графі, запис зникне лише після проходу gc_collect_cycles, а не миттєво.
Казати «WeakMap — це кеш, який сам чиститься за LRU/TTL»: він нічого не витісняє за розміром чи часом, єдиний критерій — смерть ключа.
Класти у WeakMap рядок або int як ключ: `$map['user-42'] = ...` дає TypeError, ключем може бути тільки обʼєкт.
Робити `new WeakReference($obj)` — пряма інстанціація заборонена, є лише `WeakReference::create($obj)`.
Зберігати у значенні WeakMap сам обʼєкт-ключ (`$map[$o] = ['owner' => $o]`) і дивуватись, що памʼять не звільняється: значення тримає ключ живим.
Використовувати WeakMap для обʼєктів, які й так живуть весь процес — синглтонів із контейнера, case-ів enum, зареєстрованих слухачів: слабкість там не дає нічого, крім накладних витрат.
Вважати `unset($obj)` гарантією звільнення: якщо на обʼєкт лишилось інше сильне посилання (у логері, у події, у замиканні зі звʼязаним `$this`), `WeakReference::get()` і далі поверне обʼєкт.
ПОРАДА

Формулюйте через refcount: «сильне посилання каже обʼєкту жити, слабке — лише питає, чи він ще живий». Далі одне речення практики: у довгому воркері метадані, привʼязані до обʼєкта, тримають у WeakMap, бо масив за `spl_object_id` там перетворюється на витік із перевикористаними ключами.

Сторінка питання →
PHP
Core PHP·Senior ·PHP 8.5 ·pipe operator ·clone with

PHP 8.5 (реліз 20 листопада 2025) додав оператор конвеєра `|>`, синтаксис `clone $obj with {...}`, атрибут `#[\NoDiscard]` з приведенням `(void)`, вбудоване розширення URI з класами `Uri\Rfc3986\Uri` та `Uri\WhatWg\Url`, а також дрібніші зручності — `array_first()`/`array_last()`, атрибути на константах, замикання в константних виразах і бектрейси для фатальних помилок.

Ви вже дивилися на 8.5? Що з нового реально взяли б у продакшн, а що ні?
Що робить оператор `|>` і чим він кращий за вкладені виклики функцій?
Навіщо `clone with`, якщо вже є `__clone()` і звичайні `with*()`-методи?
Чому нам більше не радять `parse_url()` і що прийшло на заміну?

PHP 8.5 вийшов 20 листопада 2025 року, і за структурою це типовий мінорний реліз: три зміни, які видно прямо в синтаксисі (|>, clone with, #[\NoDiscard]), одне велике доповнення стандартної бібліотеки (розширення URI) і десяток дрібниць — array_first()/array_last(), атрибути на константах, замикання в константних виразах, final для просунутих у конструкторі властивостей, get_error_handler()/get_exception_handler(), бектрейси для фатальних помилок, персистентні share-хендли cURL. Сильна відповідь на це питання — не перелік, а сортування: що змінює те, як ви пишете код щодня, що закриває конкретний клас багів, а що є нішевим і чекатиме свого випадку.

Оператор конвеєра |> розвертає ланцюжок викликів зліва направо: замість htmlspecialchars(trim($raw)) пишеться $raw |> trim(...) |> htmlspecialchars(...). Механіка проста і має рівно одну пастку: права частина — це вираз, значення якого має бути callable, і викликається він з одним аргументом. Тому там стоїть синтаксис callable першого класу trim(...) (PHP 8.1), замикання або змінна з callable, але не trim() — такий запис викличе функцію без аргументів. Функції з параметром за посиланням у конвеєр не передаються взагалі, а для функцій із кількома обовʼязковими аргументами доводиться загортати виклик у fn, бо часткового застосування (str_replace('a', 'b', ?)) у PHP немає — відповідний RFC відхилили. Пріоритет у |> нижчий за арифметику й конкатенацію, тож 'x' . $y |> f(...) спершу склеїть рядки, а вже потім передасть їх далі; у сумнівних місцях (особливо поруч із ?? і тернарником) простіше поставити дужки. І окремо: жодного «пропускати null», як у ?->, тут немає — null спокійно піде в наступну ланку і впаде вже на її типі.

clone with закриває найнудніший бойлерплейт незмінних обʼєктів. До 8.5 wither писався як new static($this->a, $this->b, $new, $this->d) — і кожна нова властивість вимагала правки всіх таких методів. Тепер return clone $this with {url: $url}; копіює обʼєкт і присвоює лише перелічені властивості. Важливі деталі: конструктор не викликається, тому валідація з нього не спрацює; копіювання лишається поверхневим, тож вкладені мутабельні обʼєкти й далі спільні і глибоку копію робить __clone(), який викликається вже після присвоєння нових значень; readonly-властивість змінити так можна, але лише з коду, який має право її записувати, тобто зсередини класу-власника — ззовні буде Error: Cannot modify readonly property. Це саме той випадок, коли фічу варто брати одразу: вона не змінює семантику, лише прибирає рядки, які й так були помилконебезпечними.

Атрибут #[\NoDiscard] позначає функцію чи метод, чий результат не можна мовчки викидати; разом із ним у мову додали приведення (void), яким ігнорування оформлюють свідомо. Ефект — E_WARNING, а не виняток, тож це підказка, а не гарантія, і користь від неї є там, де попередження реально видно в логах і CI. Найкращі кандидати — withers незмінних обʼєктів і чисті перетворення, у яких виклик без присвоєння не робить нічого. Розширення URI — інша за вагою зміна: воно приносить Uri\Rfc3986\Uri (сувора граматика RFC 3986, resolve(), equals(), toString()/toRawString()) і Uri\WhatWg\Url (алгоритм браузерів, нормалізація хоста й IDN через getAsciiHost()/getUnicodeHost()). Обидва незмінні, з with*()-методами й без __toString(), а невдалий розбір — це Uri\InvalidUriException / Uri\WhatWg\InvalidUrlException у конструкторі або null зі статичного parse(). Практичний сенс — вибір стандарту стає явним: parse_url() не відповідає жодному з них, лише розрізає рядок і мовчки ковтає значну частину сміття, а розбіжність між вашою перевіркою і тим, куди насправді піде браузер, — класичне джерело SSRF-обходів.

Межі варто назвати чесно. Новий синтаксис — це підвищення мінімальної версії: для застосунку, який ви деплоїте самі, це питання CI й версії на серверах, а для бібліотеки з зовнішніми користувачами |> або clone with у коді означає відсікти всіх, хто ще на 8.3–8.4. Перед першим рядком нового синтаксису переконайтесь, що всі інструменти, які парсять код — статичний аналізатор, Pint/PHP-CS-Fixer, генератори документації, — уже вміють 8.5, бо інакше вони не розберуть файл цілком. І тримайте пропорцію: clone with і розширення URI дають користь одразу і майже без ризику, #[\NoDiscard] — точково на кількох ключових API, а |> варто вводити лише там, де ланцюжок справді складається з унарних функцій; конвеєр із трьох fn поспіль читається гірше, ніж три звичайні змінні з іменами.

use Uri\InvalidUriException;
use Uri\Rfc3986\Uri;

final class Link
{
    public function __construct(
        public readonly string $url,
        public readonly string $host,
    ) {}

    /** clone with: решту властивостей не перелічуємо, readonly пишемо зі scope класу */
    public function withUrl(string $url): static
    {
        return clone $this with {url: $url};
    }
}

// Виклик, чий результат відкинули, дасть E_WARNING саме з цим поясненням
#[\NoDiscard('поверніть нормалізований рядок: сам виклик нічого не змінює')]
function normalize(string $raw): string
{
    // праворуч від |> має стояти ВИРАЗ-callable: trim(...) або замикання
    return $raw
        |> trim(...)
        |> fn (string $u): string => rtrim($u, '/');
}

$raw = '  https://Example.COM/docs/?utm_source=x#top  ';

try {
    // сувора граматика RFC 3986; «як у браузері» — Uri\WhatWg\Url
    $uri = new Uri(normalize($raw));
} catch (InvalidUriException $e) {
    throw new InvalidArgumentException('Некоректне посилання', previous: $e);
}

// null прибирає компонент; __toString() у класу немає — лише явний toString()
$canonical = $uri->withQuery(null)->withFragment(null)->toString();
$link = (new Link($uri->toString(), $uri->getHost() ?? ''))->withUrl($canonical);

normalize($raw);        // Warning: return value should either be used...
(void) normalize($raw); // свідоме ігнорування — попередження зникає
Що праворуч від `|>` стоїть вираз, який має дати callable, тому пишуть `trim(...)`, а не `trim()`; часткового застосування (partial application) у PHP немає, і для функцій із кількома аргументами доводиться писати `fn`.
Що `clone with` — це не глибоке копіювання, а звичайний `clone` із присвоєнням перелічених властивостей, і саме він прибирає бойлерплейт `with*()`-методів у readonly-DTO.
Що `#[\NoDiscard]` дає `E_WARNING`, а не помилку, і що свідоме ігнорування результату пишеться приведенням `(void)`.
Що розширення URI дає два різні набори правил — сувору RFC 3986 і браузерну WHATWG — і що `parse_url()` не відповідає жодному стандарту, тому для перевірки користувацького вводу він поганий вибір.
Що senior-відповідь містить план апгрейду: мінімальна версія в `composer.json`, матриця CI, статичний аналіз і форматер, які вміють парсити новий синтаксис, і вікно підтримки версії.
Що кандидат не вигадує фіч: наприклад, у PHP немає ані оператора `|>` з автоматичним пропуском `null`, ані вбудованого часткового застосування.
Писати `$x |> strtoupper()` замість `$x |> strtoupper(...)`: перший варіант викличе `strtoupper()` без аргументів і спробує використати результат як callable.
Вважати, що конвеєр «зупиняється на null», як `?->`: жодного короткого замикання немає — `null` просто піде в наступну ланку і там впаде на типі.
Пробувати `$array |> sort(...)`: функції з параметром за посиланням у конвеєр не передаються.
Розраховувати, що `clone $order with {status: ...}` спрацює ззовні класу для `readonly`-властивості — доступ на запис перевіряється так само, як при звичайному присвоєнні, і буде `Cannot modify readonly property`.
Думати, що `#[\NoDiscard]` кидає виняток або що ним уже позначені всі «небезпечні» функції стандартної бібліотеки — це opt-in-атрибут насамперед для вашого коду.
Інтерполювати обʼєкт URI в рядок: у нових класів немає `__toString()`, є явні `toString()`/`toRawString()` (і `toAsciiString()`/`toUnicodeString()` у WHATWG-версії).
Називати «новинками 8.5» речі з попередніх релізів — property hooks і асиметричну видимість (8.4), readonly-класи (8.2), `never`/enum (8.1).
ПОРАДА

Побудуйте відповідь як рішення, а не як список: «синтаксично найпомітніші три — `|>`, `clone with`, `#[\NoDiscard]`; найкорисніше для продакшну — розширення URI, бо воно прибирає самописну валідацію посилань поверх `parse_url()`; `clone with` беру одразу у withers, `|>` — точково, бо без часткового застосування він швидко перетворюється на ланцюжок `fn`».

Сторінка питання →
PHP
Core PHP·Senior ·OPcache ·JIT ·preload

OPcache тримає в спільній памʼяті вже скомпільовані опкоди, прибираючи парсинг і компіляцію з кожного запиту — це і є основний виграш; preload (PHP 7.4+) додатково тримає звʼязані класи фреймворку, а JIT (PHP 8.0+, вимкнений за замовчуванням через `opcache.jit_buffer_size=0`) компілює гарячі опкоди в машинний код і майже нічого не дає типовому веб-застосунку, бо той упирається в I/O, а не в CPU.

У нас увімкнули JIT на проді, а сторінки не стали швидшими — чому?
Що саме кешує OPcache: результат виконання скрипта чи щось інше?
Ми задеплоїли новий код, а сервер віддає старий — що не так із налаштуваннями?
Що таке preload і чим він відрізняється від звичайного кешу опкодів?

PHP лишається мовою «кожен запит з нуля»: інтерпретатор читає файл, проганяє його через лексер, парсер і компілятор, отримує масив опкодів і лише тоді починає їх виконувати. Для фреймворку це сотні файлів на кожен запит — і вся ця компіляція викидається одразу після відповіді. Zend OPcache (у ядрі з PHP 5.5) прибирає саме цей крок: скомпільовані опкоди складаються у спільну памʼять (shared memory), доступну всім воркерам PHP-FPM того ж майстра, і наступний запит бере готове. Важливо правильно назвати межу: OPcache кешує опкоди, а не результат — код усе одно виконується щоразу, запити до бази робляться щоразу, шаблони рендеряться щоразу. Саме тому це «безкоштовні» десятки відсотків, а не чарівна кнопка.

Другий, менш очевидний виграш — файлові перевірки. За замовчуванням opcache.validate_timestamps=1 і opcache.revalidate_freq=2: раз на дві секунди OPcache робить stat() на кожен включений файл, щоб помітити зміни. На локальній машині це необхідно, у продакшені — чистий податок на syscalls, тим більший, чим повільніша файлова система (класично боляче на NFS і на монтованих томах у Docker). У продакшені ставлять opcache.validate_timestamps=0, і разом із цим приймають зобовʼязання: код у скомпільованому вигляді відтепер змінює тільки перезапуск. Деплой має закінчуватись graceful reload PHP-FPM (SIGUSR2) або підняттям нового контейнера; opcache_reset() теж працює, але скидає кеш різко для всіх воркерів одночасно, даючи сплеск CPU на перекомпіляції. Пропущений reload — це та сама класична історія «задеплоїли, а на сайті старий код»; жодних помилок у логах при цьому не буде.

Preload (opcache.preload, PHP 7.4+) іде на крок далі. Указаний файл виконується один раз під час старту майстер-процесу — до появи будь-якого запиту, від користувача opcache.preload_user, якщо PHP стартує від root. Усе, що цей файл завантажив через require або opcache_compile_file(), лишається в памʼяті звʼязаним: ієрархія класів побудована, інтерфейси й трейти підключені, тож при запиті такі класи доступні без автозавантажувача взагалі. Обмеження варто озвучувати самому, не чекаючи уточнення: класи, чиї батьки або інтерфейси не резолвляться на момент preload, тихо пропускаються (Can't preload unlinked class); preload-скрипт не повинен робити нічого, що залежить від конкретного запиту; на Windows механізм не підтримується; і головне — оновити preload-код можна лише рестартом, opcache_reset() його не чіпає. Через це preload у dev-оточенні — вірний спосіб згаяти день на «мої правки не застосовуються». Реалістична вигода — одиниці відсотків на середньому фреймворку; Symfony генерує preload.php із prod-контейнера з коробки, Laravel — ні.

JIT (PHP 8.0+) — теж частина OPcache, і в цьому вже половина відповіді. Він працює не з вихідним кодом, а з опкодами: гарячі ділянки транслюються в машинний код у буфер opcache.jit_buffer_size. Найчастіша плутанина на співбесіді — opcache.jit=tracing виглядає як «увімкнено», але за замовчуванням opcache.jit_buffer_size=0, і JIT просто вимкнений; вмикає його саме буфер. Чому веб від нього майже нічого не отримує: у типовому запиті більшість часу — це очікування бази й мережі, а гарячі внутрішні операції (функції масивів, рядків, PCRE, серіалізація) і так виконуються в C, куди JIT не дотягується; додайте до цього короткий час життя запиту, за який трасувальний JIT ледве встигає прогрітись. Виграш у рази реальний на іншому профілі навантаження: чисельні розрахунки, попіксельна обробка зображень, парсери, довгі CLI-процеси. У PHP 8.4 бекенд JIT переписали на нову проміжну репрезентацію (IR framework) — код підтримувати стало легше, порядок величини для веба не змінився.

Практичний висновок для продакшену виглядає нудно і саме тому правильно: увімкнений OPcache із memory_consumption під розмір кодової бази, max_accelerated_files із запасом над реальною кількістю .php-файлів (значення округлюється вгору до наступного числа з ряду 3907/7963/16229/32531/65407/130987), validate_timestamps=0 плюс reload на деплої, save_comments=1 — бо докблоки читає і рефлексія, і низка бібліотек. Далі — метрики з opcache_get_status() у моніторингу: hit rate нижче 95%, ненульові oom_restarts або num_cached_keys під стелею означають, що кеш регулярно скидається і застосунок половину часу живе на холодному. Preload — наступний крок, коли базове вже налаштоване й ви готові платити рестартом. JIT — останній і найчастіше зайвий: якщо профіль не показує, що понад половина часу запиту йде в самому PHP-коді, вмикати його — це додати ризик без прискорення. І, як завжди, найбільші виграші лежать поза цим списком — у запитах до бази, кількості HTTP-викликів і кешуванні відповідей.

<?php
// preload.php — виконується один раз при старті php-fpm (opcache.preload=/app/preload.php)
// php.ini прод:
//   opcache.enable=1
//   opcache.memory_consumption=256          ; SHM під опкоди
//   opcache.interned_strings_buffer=16      ; буфер під інтерновані рядки, МБ
//   opcache.max_accelerated_files=32531     ; більше за реальну кількість .php у релізі
//   opcache.validate_timestamps=0           ; без stat() — деплой зобовʼязаний робити reload FPM
//   opcache.save_comments=1                 ; докблоки потрібні рефлексії; не вимикати
//   opcache.preload=/app/preload.php
//   opcache.preload_user=www-data
//   opcache.jit_buffer_size=0               ; JIT вимкнено: веб-запит упирається в I/O, не в CPU

require __DIR__.'/vendor/autoload.php';     // автозавантажувач потрібен, щоб звʼязати батьків

$classmap = require __DIR__.'/vendor/composer/autoload_classmap.php';

foreach ($classmap as $class => $file) {
    // виключаємо те, що не має бути звʼязаним назавжди або тягне побічні ефекти
    if (str_contains($file, '/tests/') || str_contains($file, '/Database/Migrations/')) {
        continue;
    }

    // compile_file кладе опкоди в SHM; клас, чий батько не резолвиться,
    // буде пропущено з попередженням "Can't preload unlinked class"
    @opcache_compile_file($file);
}

// Перевірка на проді: hit rate має бути 99%+, а рестарти — нульові.
// php -r '$s = opcache_get_status(false); var_dump(
//     $s["opcache_statistics"]["opcache_hit_rate"],
//     $s["opcache_statistics"]["oom_restarts"],   // > 0 => замало memory_consumption
//     $s["opcache_statistics"]["num_cached_keys"], // близько до max => підняти max_accelerated_files
//     $s["preload_statistics"]["memory_consumption"] ?? null
// );'
Що PHP компілює файл при кожному запиті, і OPcache прибирає саме крок «читання файлу → лексер → парсер → компілятор», а не виконання опкодів.
Що `opcache.validate_timestamps=0` у продакшені знімає `stat()` з кожного включеного файлу, але тоді деплой зобовʼязаний завершуватись перезавантаженням FPM (або `opcache_reset()`), інакше сервер віддаватиме старий код.
Що `opcache.preload` (PHP 7.4+) виконується один раз при старті master-процесу, зберігає класи вже звʼязаними і робить їх доступними без автозавантажувача, а оновити їх можна лише рестартом — `opcache_reset()` preload не чіпає.
Що JIT — це частина OPcache, вмикається `opcache.jit_buffer_size`, і дає відчутний ефект тільки на CPU-bound коді (математика, обробка зображень, довгі CLI-процеси), а не на типовому запиті з базою й шаблонами.
Що діагностика ведеться числами з `opcache_get_status()`: hit rate, `num_cached_keys` проти `max_cached_keys`, `wasted_memory`, `oom_restarts` — а не відчуттям «наче швидше».
Казати, що OPcache «кешує результат виконання скрипта» або «кешує сторінки» — він кешує опкоди, і код усе одно виконується при кожному запиті.
Вмикати `opcache.jit=tracing` і вважати, що JIT працює: без ненульового `opcache.jit_buffer_size` він лишається вимкненим.
Ставити `opcache.validate_timestamps=0` і продовжувати деплоїти простим `git pull` без перезавантаження FPM — далі йде година пошуку «чому не застосувались зміни».
Вмикати preload на дев-машині й потім не розуміти, чому правки в класі не видно до рестарту php-fpm.
Лишати `opcache.max_accelerated_files` за замовчуванням у великому проєкті: файлів більше за ліміт, частина класів компілюється щоразу заново, а в статусі росте `misses`.
Вимикати `opcache.save_comments`, щоб «зекономити памʼять»: ламаються всі бібліотеки, які читають докблоки (Doctrine annotations, частина рефлексії в PHPUnit).
Обіцяти бізнесу «PHP 8 з JIT — це вдвічі швидше», спираючись на синтетичні бенчмарки Mandelbrot.
ПОРАДА

Одна фраза, яка закриває питання: «Реальні гроші лежать в OPcache — прибрати компіляцію з кожного запиту й прибрати `stat()` через `validate_timestamps=0`; preload — ще кілька відсотків для фреймворку ціною рестарту на деплої; JIT для веба — статистична похибка, він для CPU-bound коду». Далі одразу додайте, чим замінюєте валідацію таймстемпів: graceful reload FPM у кінці деплою.

Сторінка питання →
PHP
Core PHP·Senior ·памʼять ·GC ·воркери

PHP звільняє памʼять через підрахунок посилань, а окремий збирач циклів періодично прибирає обʼєкти, які посилаються одне на одного.

Чому воркер черги росте в памʼяті, хоча кожен job невеликий?
Що таке refcount і чому його недостатньо?
Коли в PHP взагалі запускається збирач сміття?

Основний механізм керування памʼяттю в PHP — підрахунок посилань. Кожен zval знає, скільки змінних чи властивостей на нього вказують; щойно лічильник падає до нуля, памʼять звільняється негайно, без участі збирача. Саме тому у звичайному запиті про GC можна не думати.

Refcount не справляється з циклами. Якщо два обʼєкти тримають один одного, після unset зовнішніх змінних у кожного лишається одне посилання від сусіда, і нуль недосяжний. Для таких випадків існує збирач циклів: можливі корені накопичуються в root buffer, і коли він заповнюється, алгоритм обходить граф і звільняє недосяжні підграфи. Запуск не привʼязаний до часу, а з PHP 7.3 поріг адаптивний.

Проблемою GC стає в довгих процесах: воркери черг, Octane, RoadRunner, демони. Процес живе години, тому все, що не звільнилось, накопичується: цикли між обʼєктами, статичні кеші, реєстри слухачів, identity map ORM. Виглядає це як повільний ріст RSS воркера до OOM.

Лікування починається з діагностики, а не з gc_collect_cycles. Метрики memory_get_usage у логах воркера, ліміт памʼяті й кількості завдань для контрольованого перезапуску, WeakMap для кешів, привʼязаних до обʼєктів, і gc_status для розуміння, чи взагалі збирач знаходить сміття. Якщо не знаходить, витік у ваших статичних структурах, а не в циклах.

final class Node
{
    public ?Node $peer = null;
}

$a = new Node;
$b = new Node;
$a->peer = $b;
$b->peer = $a;      // цикл: refcount кожного = 2

unset($a, $b);      // refcount кожного = 1, памʼять не звільнена
gc_collect_cycles(); // лише збирач циклів прибере обидва

// Типова пастка у довгому воркері: статичний кеш росте вічно
final class Registry
{
    private static array $seen = [];
    public static function remember(object $o): void { self::$seen[] = $o; }
}

// Безпечна альтернатива: WeakMap не тримає обʼєкт живим
$cache = new WeakMap;
$cache[$order] = computeTotals($order); // зникне разом з $order

// Контроль у воркері
if (memory_get_usage(true) > 256 * 1024 * 1024) {
    exit(12); // супервізор перезапустить процес
}
Що основний механізм це refcount у zval: обʼєкт звільняється миттєво, щойно лічильник посилань падає до нуля.
Що refcount не бачить циклів: два обʼєкти, які тримають одне одного, ніколи не досягнуть нуля, тому існує окремий збирач циклів на root buffer.
Що збирач запускається не за таймером, а коли root buffer заповнюється (10 000 можливих коренів), і в PHP 7.3+ поріг адаптивний.
Практику для довгих процесів: gc_collect_cycles у воркері, memory_get_usage у метриках, обмеження max-jobs або memory для перезапуску воркера, обережність зі статичними кешами.
Розуміння, що витік у воркері частіше спричинений не GC, а вашим кодом: статичні масиви, реєстри подій, накопичені слухачі, identity map ORM.
Казати, що PHP звільняє памʼять лише наприкінці запиту: більшість обʼєктів звільняється одразу при refcount = 0.
Вважати, що unset($obj) гарантовано звільняє обʼєкт: якщо на нього є інші посилання або цикл, памʼять лишається.
Плутати збирач циклів з opcache або з памʼяттю Zend MM: memory_get_usage показує аллокатор PHP, а не RSS процесу.
Лікувати витік через gc_collect_cycles на кожен job, не знайшовши, хто саме тримає посилання.
ПОРАДА

Сильна відповідь містить практику: gc_collect_cycles у довгих воркерах, memory_get_usage у метриках і перезапуск воркера після N запитів. Ще краще розповісти про реальний витік, який ви знайшли.

Сторінка питання →
Прогрес карток і тестів зберігається у профілі. Створити профіль·Увійти