Java 24 убрала пиннинг synchronized. Почему виртуальные потоки всё ещё тормозят?

Виртуальные потоки приехали в Java 21 с оговоркой, которая полтора года ходила за ними по пятам: внутри synchronized поток намертво прилипает к своему носителю. Отсюда и советы: меняйте synchronized на ReentrantLock, иначе никакого Loom вы не увидите!
Совет был верным до марта 2025 года. Потом вышла Java 24 с JEP 491, и оговорку сняли: монитор больше не держит носитель, виртуальный поток спокойно отцепляется внутри synchronized и так же спокойно возвращается.
Но устаревший совет — это ещё полбеды.
Сами по себе виртуальные потоки систему быстрее не сделали: они убрали дефицит потоков, а заодно и заглушку, за которой много лет пряталось всё остальное.
Монитор отпустил носитель в марте 2025
Виртуальный поток живёт на носителе, обычном платформенном потоке из планировщика. Упёрся в блокирующую операцию — рантайм снимает его с носителя, сохраняет продолжение в куче и отдаёт носитель следующему, а когда операция завершится, продолжение достаётся обратно и поток едет дальше, уже, скорее всего, на другом носителе.
В этом вся суть: потоков может быть миллион, носителей по числу ядер.
До Java 24 монитор эту схему ломал. Пока виртуальный поток сидел внутри synchronized, отцепиться он не мог, и носитель простаивал вместе с ним.
Убедиться легко. Двести виртуальных потоков, каждый берёт свой собственный монитор, за который никто не конкурирует, и спит внутри пятьдесят миллисекунд:
static void ownLock() {
Object mine = new Object(); // монитор ни с кем не делится
synchronized (mine) {
try { Thread.sleep(50); } catch (InterruptedException e) {}
}
}
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 200; i++) ex.submit(Pin::ownLock);
}Взаимного исключения тут нет вообще, меряем чистый пиннинг. Один и тот же класс на двух рантаймах:
JDK 21 со своим synchronized 5043 мс без него 58 мс
JDK 25 со своим synchronized 54 мс без него 53 мс5 секунд против 54 миллисекунд, и в коде не изменилось ни символа.
На двадцать первой двести потоков выстроились в очередь к двум носителям: двести умножить на пятьдесят и поделить на два как раз и дают пять секунд. На двадцать пятой они разъехались все сразу.
Под JFR та же картина: событий jdk.VirtualThreadPinned на JDK 21 набирается четыреста штук, на JDK 25 ноль.
Совет про ReentrantLock устарел, а привычка осталась
Замена synchronized на ReentrantLock сегодня не даёт ничего, кроме более многословного кода и нового способа забыть unlock() в finally.
Достоинства у ReentrantLock никуда не делись, просто они не про виртуальные потоки: tryLock с таймаутом, прерываемое ожидание, честная очередь, несколько условий на один замок.
Нужна любая из этих вещей — берите. Не нужна — synchronized короче, компилятор про него знает больше, а JIT умеет схлопывать неконкурентные блокировки.
Прежде чем что‑то переписывать, посмотрите, на чём вы вообще стоите.
Если в проде JDK 21, пиннинг никуда не делся и совет всё ещё в силе — только смысл у него теперь другой: переписывать синхронизацию имеет смысл, пока апгрейд в планах не маячит, а после него вся эта работа окажется лишней.
Если 24 и выше — переписывать нечего, и время лучше потратить на то, о чём дальше.
В Java хватает подобных нюансов, которые легко упустить, даже если работаешь с ней каждый день. Проверить свои знания и найти пробелы можно, пройдя вступительный тест по Java.
Флаг, которого больше нет
Следующая проблема в диагностике.
Прилипания годами искали флагом Djdk.tracePinnedThreads=full, который печатал стектрейс на каждый случай.
В Java 24 его убрали. Не задеприкейтили с предупреждением, а именно убрали: на JDK 25 флаг принимается молча и не делает ничего.
Сборка, где он до сих пор прописан в JAVA_OPTS, продолжает исправно стартовать и создаёт полную иллюзию, что диагностика на месте.
На его место пришло событие JFR, и включать его отдельно не нужно — оно прописано и в default.jfc, и в profile.jfc:
<event name="jdk.VirtualThreadPinned">
<setting name="enabled">true</setting>
<setting name="threshold">20 ms</setting>
</event>Порог у события — двадцать миллисекунд, не ноль.
Прилипания короче в запись просто не попадают, так что отсутствие событий означает «долгих прилипаний нет», а не «пиннинга нет».
Три сотни виртуальных потоков, читающих четырёхмегабайтный файл, дали на JDK 25 ровно ноль событий. Но файл лежал в страничном кеше, чтение укладывалось в доли миллисекунды — показывать там было нечего.
Один случай прилипания JEP 491 всё‑таки оставил, и назван он JEP: нативный кадр.
Виртуальный поток уходит в нативный метод или в Foreign Function & Memory API, оттуда код зовёт обратно в Java и там блокируется — и отцепиться уже нельзя.
На практике это драйверы с JNI‑частью, старые нативные библиотеки и обёртки над системными вызовами, которых нет в стандартной библиотеке.
Снимается всё одной командой на работающем процессе:
jcmd <pid> JFR.start settings=profile duration=60s filename=/tmp/vt.jfr
jfr summary /tmp/vt.jfr | grep PinnedНенулевое число в выдаче — повод открыть стектрейсы и посмотреть, какая библиотека держит носитель.
Рядом в той же записи живёт второе событие, jdk.VirtualThreadSubmitFailed.
Срабатывает редко, зато означает серьёзное: планировщик не смог принять продолжение виртуального потока обратно в работу.
В штатной жизни такого не бывает, и появление этих событий обычно указывает либо на закрытый исполнитель, в который всё ещё что‑то шлют, либо на собственный планировщик вместо стандартного.
Алерт на ненулевое значение стоит одной строчки и однажды сэкономит вам вечер.
Привычный jstack при всём этом бесполезен. Он показывает носители, а их два десятка, и все интересные потоки ему попросту не видны.
Виртуальные выгружаются другой командой:
jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.jsonНа сервисе с десятками тысяч потоков другого способа увидеть, где они все стоят, нет вообще.
А ноль по обоим событиям при JDK 24 и выше означает, что тормоза приехали откуда‑то ещё.
Узкое место переехало вниз
Пока потоков было двести, остальные ограничения никто не видел: очередь стояла на входе, и до пула соединений доходило столько запросов, сколько сервлетный пул успевал пропустить.
Виртуальные потоки эту очередь убрали, и теперь весь трафик доезжает до следующего препятствия.
Посмотрим на семафоре, который изображает пул соединений: разрешений ограниченное число, «запрос к базе» занимает двадцать миллисекунд.
static void query(Semaphore pool) throws InterruptedException {
pool.acquire();
try { Thread.sleep(20); } finally { pool.release(); }
}Гоняем по две сотни и по две тысячи клиентов через пулы разного размера:
пул 10 200 клиентов: 418 мс, 478 rps 2000 клиентов: 4073 мс, 491 rps
пул 50 200 клиентов: 82 мс, 2439 rps 2000 клиентов: 812 мс, 2463 rps
пул 200 200 клиентов: 21 мс, 9524 rps 2000 клиентов: 206 мс, 9709 rpsКлиентов стало в десять раз больше, время выросло в десять раз, а пропускная способность не сдвинулась ни на процент. Потолок задаёт пул, и никакое количество виртуальных потоков его не поднимет.
Здесь вылезают сразу две неочевидные вещи.
Привычная формула «пул соединений по числу потоков» перестаёт работать: потоков теперь сколько угодно, и считать надо от базы — сколько параллельных запросов она держит без деградации.
А ограничивать параллелизм всё равно придётся, только теперь явно, и привязан этот ограничитель будет уже не к размеру пула потоков:
// сколько параллельных запросов держит база, а не сколько у нас потоков
private static final Semaphore DB = new Semaphore(64);
static <T> T withDb(Callable<T> q) throws Exception {
DB.acquire();
try { return q.call(); } finally { DB.release(); }
}
Раньше эту работу бесплатно делал размер пула потоков.
Тот же разговор касается файловых дескрипторов, лимитов внешних API и квот на соединения к брокеру: всё это было прикрыто тем, что запросов физически не могло прийти много.
Откуда брать само число, вопрос уже отдельный.
Для базы это не max_connections и не размер пула из конфига, а точка, после которой добавление параллельных запросов перестаёт увеличивать пропускную способность и начинает растить задержку: ищут её тем же прогоном, что и выше, только под реальным запросом, а не под sleep.
У внешнего API число обычно написано в документации.
Пул виртуальных потоков — это оксюморон
Дальше вступает мышечная память: раз поток дорогой, его надо переиспользовать.
Виртуальный поток не дорогой.
виртуальные 200 000 штук -> 0,8 мкс на поток
платформенные 20 000 штук -> 96,4 мкс на потокВ 120 раз дешевле на создании, и это без учёта памяти: платформенному потоку нужен стек в мегабайт, виртуальный держит продолжение в куче и растёт по мере надобности.
Переиспользовать такой объект незачем. Дешевле создать новый.
Из привычки вырастает антипаттерн:
// так не нужно: потолок в 200 задач вернулся на место
var bad = Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());
// так правильно: поток на задачу, ограничитель отдельно
var good = Executors.newVirtualThreadPerTaskExecutor();Первая строчка собирается и работает, но возвращает то самое ограничение, ради снятия которого всё затевалось: двести задач одновременно, остальные в очереди.
Разница между вариантами в том, где стоит ограничитель. Во втором его нет вовсе, и ставится он отдельно и осознанно, у того ресурса, который действительно не выдержит нагрузки.
ThreadLocal перестал быть кешем
Эта история самая тихая: ничего не падает, просто перестаёт работать оптимизация, о которой все давно забыли.
ThreadLocal десятилетиями использовали как кеш на поток — дорогой объект создаётся один раз и живёт, пока живёт поток из пула.
Классика — SimpleDateFormat, который не потокобезопасен:
static final ThreadLocal<SimpleDateFormat> FMT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));Чтобы увидеть, сколько раз объект создаётся на самом деле, хватает счётчика в фабрике:
static final AtomicInteger created = new AtomicInteger();
static final ThreadLocal<SimpleDateFormat> FMT = ThreadLocal.withInitial(() -> {
created.incrementAndGet();
return new SimpleDateFormat("yyyy-MM-dd");
});100 тысяч задач на пуле из восьми платформенных потоков — это восемь созданий объекта. Те же сто тысяч через newVirtualThreadPerTaskExecutor — уже сто тысяч.
Виртуальные потоки никто не переиспользует, так что ThreadLocal живёт всего одну задачу, и «кеш», который писали под долгоживущий поток из пула, превращается в аллокацию на каждый чих.
С SimpleDateFormat копейки. А вот если там лежит собранный парсер схемы, прогретый Kryo или клиент со своим буфером — тут уже счёт идёт на приличные деньги.
И подловить сложно: в профиле это не утечка, а ровненький рост аллокаций, который так и просится списать его на выросший трафик.
Чинится по‑разному, смотря зачем объект там вообще оказался.
Потокобезопасный — выносим в статическое поле и забываем.
Дорогой и не потокобезопасный — уезжает в явный пул объектов.
А если нужен был не кеш, а контекст запроса — для этого есть ScopedValue: значение живёт строго внутри вызова, неизменяемое, и убирать за ним не надо.
static final ScopedValue<RequestId> REQUEST = ScopedValue.newInstance();
ScopedValue.where(REQUEST, id).run(() -> handle(request));
// внутри handle и всего, что оно вызовет: REQUEST.get()Попытки положить туда изменяемое состояние компилятор пресечёт, и это как раз то, чего не хватало ThreadLocal.
Заодно ещё один аргумент за обновление: на JDK 21 ScopedValue живёт в превью и без --enable-preview не соберётся, а начиная с двадцать пятой это обычный финальный класс.
Потолок никуда не делся
Виртуальные потоки решают только одну задачу: убирают потолок на количество потоков.
Всё остальное, что тормозило систему раньше, тормозит её и сейчас — просто теперь это наконец видно.
Пул на двести потоков был одновременно и регулятором, и защитой от перегрузки для всей цепочки.
Убрали его — и вместе с узким местом убрали предохранитель. Вот, в общем, и вся история.

После перехода на новую версию Java приложение всё ещё тормозит под нагрузкой? Исправление пиннинга виртуальных потоков снимает одно ограничение, но может обнажить другие.
Чтобы разобраться, где теряется производительность и как избежать новых узких мест, важно понимать поведение Java‑сервисов под нагрузкой и работу с базами данных. Эти темы разбираем на бесплатных открытых уроках OTUS:
22 октября, 20:00. «Spring Boot под нагрузкой: почему сервис работает локально и падает в production». Записаться
28 октября, 20:00. «JDBC: как Java‑приложения взаимодействуют с базами данных». Записаться
16 ноября, 20:00. «Мигрировали на Java 25 и Spring Boot 4 — всё зелёное. Почему система всё равно сломана?» Записаться
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.