Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 2

Pinning виртуального потока: Причины и ReentrantLock

Recommended resources

Здравствуйте!

В прошлом уроке мы увидели, как виртуальные потоки позволяют писать простой и масштабируемый I/O-интенсивный код. Ключевой механизм, который это обеспечивает, — способность виртуального потока "демонтироваться" с потока-носителя (carrier thread) во время блокирующей операции, освобождая его для других задач. Сегодня мы разберем ситуации, когда этот механизм дает сбой. Это явление называется "прикреплением" или "pinning".

Наша цель — научиться определять, что вызывает "pinning" (в первую очередь, блоки synchronized и вызовы нативного кода), и понять, как обойти эту проблему. Вы уже хорошо знакомы с java.util.concurrent, и сегодня мы увидим, как ReentrantLock из этого пакета становится критически важным инструментом для эффективной работы с виртуальными потоками в JDK 21.

Что такое "pinning" и почему это опасно?

Pinning (прикрепление) — это ситуация, когда виртуальный поток не может быть демонтирован со своего потока-носителя во время блокировки. В результате поток-носитель также блокируется, не имея возможности выполнять другие виртуальные потоки. Это полностью сводит на нет главное преимущество виртуальных потоков и возвращает нас к неэффективной модели "один системный поток на одну блокирующую операцию".

В худшем случае это может привести к полному "зависанию" приложения. Если у вас, например, 8 потоков-носителей (по числу ядер CPU) и одновременно 8 виртуальных потоков оказываются "прикреплены" и заблокированы, то в пуле не останется свободных носителей. Новые задачи не смогут быть выполнены, даже те, которые могли бы разблокировать "прикрепленные" потоки. Это называется carrier exhaustion deadlock.

Чтобы понять всю серьезность этой проблемы, давайте начнем с определения "pinning" и разбора реального инцидента, который произошел в Netflix.

Virtual Thread Pinning: The Silent Performance Killer in Your Codebase - Java Code Geeks

Прочтите введение в статью, чтобы понять, что такое pinning, почему это важно и как это проявилось в реальных продакшен-системах.

Пожалуйста, прочитайте раздел What pinning is. Обратите внимание на аналогию с классическим deadlock, но на уровне пула потоков-носителей.


Главная причина pinning в JDK 21: synchronized

До Java 24 основной причиной "прикрепления" при работе с виртуальными потоками были synchronized блоки и методы. Проблема кроется в исторически сложившейся реализации механизма мониторов в JVM.

Чтобы разобраться в механике, нужно вспомнить, как работает synchronized. Он использует внутренний механизм блокировки JVM — монитор. В версиях Java до 24-й, информация о том, какой поток владеет монитором, была привязана к идентификатору системного потока (нашего потока-носителя), а не к виртуальному потоку.

Теперь представьте последовательность:

  1. Виртуальный поток (VT-A) выполняется на потоке-носителе (CT-1).
  2. VT-A входит в блок synchronized(obj). JVM записывает: "Монитором obj владеет CT-1".
  3. Внутри synchronized-блока VT-A выполняет блокирующую I/O-операцию (например, запрос к базе данных).
  4. Планировщик виртуальных потоков пытается демонтировать VT-A, чтобы освободить CT-1.
  5. Проблема: JVM не может этого сделать. Если она демонтирует VT-A, а на CT-1 начнет выполняться другой виртуальный поток (VT-B), то возникнет парадокс: VT-B будет работать на потоке-носителе, который формально все еще владеет монитором obj. Это может привести к нарушению логики блокировок.

Чтобы гарантировать корректность, JVM до версии 24 принимала простое решение: если виртуальный поток вошел в synchronized-блок, он "прикрепляется" к своему носителю до тех пор, пока не выйдет из блока.

Следующая статья блестяще объясняет этот механизм.

Java Virtual Threads: The Pinning Problem, the Deadlock, and the Fix in Java 24 | Shubham Raizada’s Blog

Эта часть блога детально, шаг за шагом, разбирает, почему именно реализация мониторов в JVM приводит к pinning.

Прочтите раздел Pinning до подраздела ReentrantLock and LockSupport.park(). Сосредоточьтесь на объяснении и псевдокоде, который иллюстрирует проблему.

Стоит отметить, что synchronized вокруг быстрых, неблокирующих операций в памяти (например, доступ к HashMap) не является проблемой. "Прикрепление" длится наносекунды и незаметно. Опасность представляет именно комбинация synchronized и длительной блокирующей операции (I/O, Thread.sleep()).


Другие причины pinning

Хотя synchronized — самая частая причина, существуют и другие:

  1. Вызовы нативного кода (JNI): Когда виртуальный поток вызывает нативный метод, он прикрепляется к носителю. JVM не может управлять стеком вызовов за пределами Java-кода.
  2. Некоторые операции с файлами и другие внутренние механизмы JVM.

С выходом Java 24 и JEP 491, реализация мониторов была изменена. Теперь они отслеживают владение на уровне виртуального потока, и synchronized больше не вызывает pinning. Это огромное улучшение, но на широко используемом JDK 21 LTS проблема остается актуальной. Также pinning из-за нативного кода сохраняется во всех версиях.

Сравнение поведения при блокировке в synchronized-блоке до и после JEP 491. Слева (до JEP 491) поток-носитель остается заблокированным. Справа (с JEP 491) он освобождается.

Практика: как обнаружить "pinning"

Теория важна, но как найти проблему в реальном коде? Для этого есть несколько инструментов.

1. Системное свойство для разработки (JDK 21-23)

Самый простой способ во время разработки — запустить ваше приложение с флагом -Djdk.tracePinnedThreads=full. Каждый раз, когда виртуальный поток будет блокироваться в "прикрепленном" состоянии, в консоль будет выводиться полный стектрейс, указывающий на причину.

2. Java Flight Recorder (JFR) для продакшена

Для обнаружения в продакшене без деградации производительности используется JFR. Событие jdk.VirtualThreadPinned включено по умолчанию и срабатывает, если "прикрепление" длится дольше 20 мс.

Вы можете сделать короткую запись JFR на работающем приложении и проанализировать ее в JDK Mission Control (JMC).

Virtual Thread Pinning: The Silent Performance Killer in Your Codebase - Java Code Geeks

В этом разделе подробно описаны практические шаги по обнаружению pinning с помощью JFR и системных свойств.

Прочтите раздел Detecting pinning with JFR. Обратите внимание на команды jcmd и примеры вывода. Это готовые рецепты для диагностики.


Решение: ReentrantLock как альтернатива

Итак, мы нашли synchronized-блок, который оборачивает I/O операцию и вызывает "pinning". Что делать? На JDK 21-23 решением является замена synchronized на java.util.concurrent.locks.ReentrantLock.

Почему это работает? В отличие от synchronized, ReentrantLock для блокировки потоков использует LockSupport.park(), который "осведомлен" о виртуальных потоках. Когда виртуальный поток пытается захватить ReentrantLock и блокируется, он может быть безопасно демонтирован со своего носителя.

Переход от synchronized к ReentrantLock механический:

// БЫЛО: вызывает pinning в JDK 21-23, если fetchFromDb() блокирующий
private final Object lock = new Object();

public Data getOrLoad(String key) {
    synchronized (lock) {
        if (!cache.containsKey(key)) {
            // Блокирующая I/O операция внутри synchronized-блока
            cache.put(key, fetchFromDb(key)); 
        }
        return cache.get(key);
    }
}
// СТАЛО: не вызывает pinning
private final ReentrantLock lock = new ReentrantLock();

public Data getOrLoad(String key) {
    lock.lock();
    try {
        if (!cache.containsKey(key)) {
            // Виртуальный поток может быть демонтирован во время этой операции
            cache.put(key, fetchFromDb(key));
        }
        return cache.get(key);
    } finally {
        // Крайне важно освобождать lock в блоке finally!
        lock.unlock();
    }
}

Поскольку вы хорошо знакомы с java.util.concurrent, ReentrantLock для вас не новый инструмент. Здесь он показывает свою современную релевантность: его внутренняя реализация гармонично вписывается в модель виртуальных потоков, в отличие от унаследованного synchronized.

Краткое сравнение `synchronized` и `ReentrantLock`. Обратите внимание на явное управление блокировкой (`lock`/`unlock`) и дополнительные возможности `ReentrantLock`.

Давайте посмотрим, как José Paumard, один из Java-чемпионов, объясняет эту проблему и рекомендует ReentrantLock.

Virtual Threads and Structured Concurrency in Java 21 With Loom

В этом фрагменте дается точный ответ на вопрос о synchronized с виртуальными потоками.

Посмотрите ответ на вопрос, начиная с этого момента. Ведущий объясняет, почему synchronized вызывает pinning, и когда стоит использовать ReentrantLock в качестве замены.


Демонстрация проблемы и решения

Чтобы окончательно закрепить материал, давайте посмотрим на код, который наглядно демонстрирует истощение потоков-носителей из-за "pinning" и показывает, как ReentrantLock решает эту проблему.

В следующей статье есть полный, готовый к запуску пример.

Java Virtual Threads: The Pinning Problem, the Deadlock, and the Fix in Java 24 | Shubham Raizada’s Blog

Эта часть содержит полный код для демонстрации и объясняет, как его запустить и интерпретировать результаты.

Внимательно изучите раздел Reproducing the Deadlock. Скопируйте класс VirtualThreadPinningDemo.java себе в IDE. Запустите его с указанными флагами, особенно с -Djdk.virtualThreadScheduler.parallelism=2 и -Djdk.tracePinnedThreads=full, чтобы эффект был хорошо виден. Проанализируйте вывод и сравните время выполнения для synchronized и ReentrantLock. Разница будет очень показательной.

Этот практический эксперимент — лучшее доказательство того, почему понимание "pinning" так важно. Вы увидите, что при использовании synchronized, 4 задачи, каждая из которых спит 2 секунды, на 2 потоках-носителях выполнятся не за 4, а примерно за 6 секунд, потому что планировщик не может эффективно распределить работу. В то время как с ReentrantLock все 4 задачи завершатся за ~2 секунды.


Заключение

Сегодня мы погрузились в один из самых важных аспектов использования виртуальных потоков. Непонимание "pinning" может свести на нет все преимущества этой технологии.

Ключевые выводы:

  • Pinning — это прикрепление виртуального потока к носителю, что приводит к блокировке последнего и потере масштабируемости.
  • В JDK 21 основной причиной "pinning" является использование synchronized-блоков вокруг блокирующих операций.
  • Проблема synchronized решена в JDK 24 (JEP 491), но остается актуальной для JDK 21 LTS.
  • Надежным решением в коде, который вы контролируете, является замена synchronized на ReentrantLock.
  • Для диагностики используйте -Djdk.tracePinnedThreads на этапе разработки и JFR (jdk.VirtualThreadPinned) в продакшене.

В следующем уроке мы рассмотрим еще один аспект классической модели Java, который требует переосмысления в мире виртуальных потоков — ThreadLocal переменные. Мы выясним, какие проблемы они создают, и изучим современную, безопасную альтернативу — Scoped Values.