PunchAPGA backs Soludo’s appointment to APC presidential campaign councilThe Jerusalem PostHow do Israeli elections work? A guide to voting, Knesset seats, and coalitions - explainerCNN TürkElektrikli skuter yönetmeliği değişti: İşte yeni ceza tutarlarıESPNTransfer rumors, news: Barcelona, Real Madrid scout Erling Haaland at AnfieldInquirerPiston to hold caravan with remains of 1 of its former leaders Elmer CorderoUOLConfira os falecimentos desta terça-feira (13) em ApucaranaZDF heuteEntdecken Sie das ZDF-NachrichtenstudioPremium TimesCatholic diocese shuts schools, hospital, parish over repeated attacksDeadlineJ.K. Rowling To Be Subject Of BBC Documentary Examining Her Rise & Gender Identity ViewsThe Sydney Morning Herald'Dream Test' for Renshaw after wicketGolem.dePolizei: 16-Jährige in Köln von Roboter sexuell belästigtRMF24Zełenski: Partnerzy nie uprzedzili nas o balistycznym ataku na Kijów
The Daily Newsstand · Free, Always
Tuesday, October 13, 2026

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

Translate

Виртуальные потоки приехали в 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 — всё зелёное. Почему система всё равно сломана?» Записаться

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.