Конкурентность: потоки, executors, CompletableFuture, виртуальные потоки
Конкурентность в Java начинается не с new Thread(), а с честного ответа на вопрос:
что именно вы ускоряете? Если задача упирается в процессор, потоки дадут ровно столько,
сколько у вас ядер, и ни граммом больше. Если задача упирается в ожидание — сеть, диск,
база — потоки не ускоряют ничего, они позволяют ждать несколько вещей одновременно.
Половина катастроф в проде происходит оттого, что эти два случая перепутали.
Java строит конкурентность на разделяемой изменяемой памяти: не так, как Elixir с изолированными процессами, и не так, как Go с каналом вместо мьютекса. Java не запрещает делиться данными между потоками — она даёт инструменты делать это корректно и полностью перекладывает ответственность на вас. Компилятор гонку не поймает. Тесты, скорее всего, тоже. Поймает прод. Хорошая новость: с Java 21 у платформы есть виртуальные потоки, убравшие главное историческое ограничение — дороговизну потока.
Идём снизу вверх: модель памяти → примитивы синхронизации → пулы → композиция → виртуальные потоки → структурированная конкурентность.
Конкурентность и параллелизм — разные вопросы
Конкурентность — свойство дизайна: несколько логически независимых задач, прогресс которых чередуется. Параллелизм — свойство железа: задачи физически идут одновременно на разных ядрах. Веб-сервер на одном ядре конкурентен, но не параллелен.
| Нагрузка | Что ограничивает | Сколько потоков | Инструмент |
|---|---|---|---|
| CPU-bound (расчёты, парсинг, сжатие) | число ядер | ≈ числу ядер | ForkJoinPool, parallel streams |
| IO-bound (HTTP, JDBC, файлы) | время ожидания | тысячи и десятки тысяч | виртуальные потоки |
| Смешанная | и то, и другое | разные пулы под разное | явное разделение пулов |
Самая частая ошибка проектирования — один общий пул на всё: медленный HTTP-вызов съедает потоки, нужные для расчёта. Разделение пулов по типу нагрузки — это bulkhead, переборка: авария в одном отсеке не топит корабль.
Обратите внимание на дугу: Java начинала с дешёвых пользовательских потоков, отказалась от них ради отладчиков и профайлеров и через двадцать лет вернулась — но уже с полноценной поддержкой инструментария.
Модель памяти: без неё всё остальное — суеверие
Код, который выглядит очевидно правильным и работает неправильно:
public class StopFlagDemo {
private static boolean ready = false; // ВНИМАНИЕ: намеренно сломано
private static int data = 0;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (!ready) { /* активное ожидание */ }
System.out.println("прочитали data = " + data);
}).start();
Thread.sleep(100); // даём JIT-у скомпилировать цикл
data = 42;
ready = true;
System.out.println("записали");
}
}
Ожидаемый вывод — две строки. Реальный на HotSpot в server-режиме — одна строка записали,
и программа висит навсегда. JIT увидел, что внутри цикла ready не меняется, и вынес
чтение наружу: получилось if (!ready) while (true) {}. Это не баг компилятора —
спецификация ему это разрешает, потому что между записью в одном потоке и чтением в другом
нет отношения happens-before. Второй возможный исход того же кода (воспроизводится
на ARM): цикл завершается, но печатается data = 0 — записи доехали до второго ядра
в другом порядке.
Java Memory Model (JLS, глава 17) задаёт частичный порядок happens-before: если A hb B,
эффекты A гарантированно видны в B. Рёбра создают:
- Порядок программы внутри одного потока.
- Монитор — выход из
synchronizedhb следующего входа в тот же монитор. - volatile — запись в volatile-поле hb последующего чтения этого поля.
- Старт и завершение потока —
Thread.start()hb первой инструкции потока; последняя инструкция потока hb возврата изjoin(). - final-поля — корректно сконструированный объект с final-полями безопасно публикуется без синхронизации.
- Библиотечные гарантии —
submithb выполнения задачи;countDown()hb возврата изawait();putвBlockingQueuehbtake.
Отношение транзитивно, поэтому достаточно сделать volatile одно поле-флаг, чтобы
«протащить» видимость всех записей, сделанных до него. Исправление примера — одно слово:
private static volatile boolean ready, а data остаётся обычным полем.
Чего volatile не делает — не даёт атомарности составных операций: counter++ —
это три действия (read, add, write). Восемь потоков по 100 000 инкрементов стабильно дадут
не 800 000, а тысяч четыреста. Нужен AtomicInteger или LongAdder. Ещё две тонкости:
volatile на массиве делает volatile ссылку, а не элементы (для них —
AtomicIntegerArray или VarHandle); запись в long и double без volatile не обязана
быть атомарной (JLS §17.7) — на 32-битных платформах допустимо увидеть половину значения.
Читать: JSR-133 FAQ Мэнсона и Гётца и «JMM Pragmatics» Алексея Шипилёва.
Жизненный цикл потока и блокировки
Это не академия, а инструкция по чтению jstack. Пул из 200 потоков, все в BLOCKED
на одном мониторе — найдено узкое место. Все в WAITING на Future.get — найден дедлок пула.
synchronized
synchronized — блокировка на мониторе объекта, состояние которой живёт в заголовке
объекта (mark word, см. https://courses.digitable.life/post/java/08-jvm-and-memory/). JVM применяет эскалацию:
сначала лёгкая блокировка через CAS в стек потока, при реальной конкуренции — «раздувание»
в полноценный монитор ОС с очередью. Biased locking отключили в JDK 15 и позже удалили.
Публичный объект как монитор — плохая практика: любой чужой код может захватить ваш замок;
используйте приватное поле private final Object lock = new Object().
Дедлок возникает при захвате двух замков в разном порядке — классический перевод денег,
где transfer(a, b) и transfer(b, a) идут параллельно:
// ПРАВИЛЬНО: глобальный порядок захвата по стабильному идентификатору
void transfer(Account from, Account to, long amount) {
Account first = from.id() < to.id() ? from : to;
Account second = from.id() < to.id() ? to : from;
synchronized (first) {
synchronized (second) {
from.withdraw(amount);
to.deposit(amount);
}
}
}
Правило: нужно больше одного замка — определите глобальный порядок и соблюдайте везде.
Альтернатива — tryLock с таймаутом и откатом.
ReentrantLock и Condition
synchronized прост, но негибок: нельзя прервать ожидание, поставить таймаут, иметь
несколько условий. Для этого есть java.util.concurrent.locks.
public void put(T x) throws InterruptedException {
lock.lockInterruptibly();
try {
while (count == items.length) notFull.await(); // ВСЕГДА while, не if
items[tail] = x;
tail = (tail + 1) % items.length;
count++;
notEmpty.signal();
} finally {
lock.unlock(); // unlock ОБЯЗАТЕЛЬНО в finally
}
}
Два правила отсюда стоят целой главы. await() всегда в цикле while: спецификация
разрешает ложные пробуждения, и между signal и реальным получением замка другой поток мог
изменить состояние. unlock() только в finally: исключение в критической секции без
finally оставит замок захваченным навсегда, и сервис встанет.
Остальное семейство: ReadWriteLock — много читателей ИЛИ один писатель, выигрывает при
длинных секциях и перевесе чтений; StampedLock — оптимистичное чтение по «штампу», но
не реентерабелен (рекурсивный захват вешает поток); Semaphore — ограничение числа
одновременных операций; CountDownLatch — одноразовый барьер; CyclicBarrier и Phaser —
многоразовые.
Атомарность без блокировок: CAS
В основе всего java.util.concurrent лежит одна аппаратная инструкция —
compare-and-swap: «запиши новое значение, только если текущее равно ожидаемому,
и скажи, получилось ли». На x86 это LOCK CMPXCHG.
// Так примерно устроен AtomicInteger.incrementAndGet
public int incrementAndGet() {
int prev, next;
do {
prev = get(); // volatile-чтение
next = prev + 1;
} while (!compareAndSet(prev, next)); // повторяем, пока не выиграем гонку
return next;
}
Подход оптимистичный: не блокируем, а пробуем и переспрашиваем. Плюс — нет переключения
контекста и дедлоков, поток не может «умереть с замком». Минус — при высокой конкуренции
цикл крутится вхолостую, и на 32 потоках AtomicLong может проиграть synchronized.
Для таких случаев есть LongAdder: он раскидывает инкременты по нескольким выровненным
ячейкам и суммирует их в sum(). Правило выбора: AtomicLong, если читают так же часто,
как пишут; LongAdder, если пишут много, а читают редко — то есть для метрик.
Проблема ABA: значение изменилось с A на B и обратно, CAS этого не заметит — для структур
со ссылками нужен AtomicStampedReference. Для низкоуровневого доступа с явной семантикой
памяти — VarHandle (Java 9), заменивший sun.misc.Unsafe. Отдельный эффект уровня железа —
false sharing: два независимых поля попали в одну кэш-линию (64 байта), и запись в одно
инвалидирует кэш другого на всех ядрах; см. кэш и локальность.
Конкурентные коллекции
Никогда не пишите свою потокобезопасную коллекцию — всё уже вылизано Дагом Ли за двадцать лет.
| Задача | Класс | Что важно знать |
|---|---|---|
| Общая map | ConcurrentHashMap |
Блокировка на уровне бакета, чтения без блокировок |
| Map с порядком | ConcurrentSkipListMap |
O(log n), навигационные методы |
| Список с редкими записями | CopyOnWriteArrayList |
Каждая запись копирует массив; годится для листенеров |
| Очередь producer-consumer | ArrayBlockingQueue |
Ограниченная — даёт backpressure |
| Очередь без ограничения | LinkedBlockingQueue |
Удобно и опасно: OOM при отставании потребителя |
| Передача из рук в руки | SynchronousQueue |
Нулевая ёмкость, put ждёт take |
| Неблокирующая очередь | ConcurrentLinkedQueue |
Без блокировок, но size() — O(n) |
Главная ловушка ConcurrentHashMap: отдельные операции атомарны, их комбинация — нет.
// СЛОМАНО: между get и put влезет другой поток
Integer old = counts.get(key);
counts.put(key, old == null ? 1 : old + 1);
// ПРАВИЛЬНО: атомарная составная операция
counts.merge(key, 1, Integer::sum);
Connection conn = pool.computeIfAbsent(host, this::openConnection);
Но и здесь капкан: внутри лямбды compute/merge нельзя обращаться к той же карте —
вы держите блокировку бакета, и рекурсивное обновление даёт IllegalStateException либо
реальный дедлок. Функция должна быть короткой, без IO и побочных эффектов. И ещё:
Collections.synchronizedMap не замена — итерация по нему требует внешней синхронизации.
Executors: перестаём создавать потоки руками
Прямое new Thread() в прикладном коде почти всегда ошибка: нет ограничения на количество,
переиспользования, обработки ошибок, управления временем жизни. Правильная абстракция —
задача (Runnable/Callable) и исполнитель (ExecutorService).
меньше corePoolSize?"} B -- да --> C["создать новый поток
и отдать задачу ему"] B -- нет --> D{"очередь приняла
задачу?"} D -- да --> E["задача ждёт в очереди"] D -- нет --> F{"живых потоков
меньше maximumPoolSize?"} F -- да --> G["создать поток сверх core"] F -- нет --> H["RejectedExecutionHandler"] H --> I["AbortPolicy — бросить исключение"] H --> J["CallerRunsPolicy — выполнить
в вызывающем потоке = backpressure"] H --> K["DiscardPolicy — молча потерять"] C --> L["поток берёт следующую
задачу из очереди"] E --> L G --> L L --> M{"простаивал дольше keepAliveTime
и потоков больше core?"} M -- да --> N["поток завершается"] M -- нет --> L
Отсюда главный сюрприз ThreadPoolExecutor: maximumPoolSize работает только при
ограниченной очереди. Безразмерная очередь примет всё, ветка F никогда не выполнится,
пул навсегда останется размером corePoolSize, а память будет расти до OutOfMemoryError.
Поэтому фабрики Executors опасны в проде: newFixedThreadPool(n)
и newSingleThreadExecutor() используют безразмерную LinkedBlockingQueue (отставание
потребителя = OOM), а newCachedThreadPool() — SynchronousQueue с
maximumPoolSize = Integer.MAX_VALUE (всплеск нагрузки = десятки тысяч потоков).
Настраивайте пул явно:
ThreadFactory factory = Thread.ofPlatform()
.name("http-worker-", 0) // http-worker-0, http-worker-1, ...
.factory();
ExecutorService io = new ThreadPoolExecutor(
16, 64, // core / max
60L, TimeUnit.SECONDS, // keepAlive для потоков сверх core
new ArrayBlockingQueue<>(1000), // ОГРАНИЧЕННАЯ очередь
factory,
new ThreadPoolExecutor.CallerRunsPolicy() // отказ = замедлить источник
);
CallerRunsPolicy недооценён: при переполнении задачу выполняет тот, кто её отправил,
поэтому поставщик нагрузки автоматически притормаживает — естественный backpressure
без единой строки специального кода.
Сколько потоков? Формула из «Java Concurrency in Practice»: N = Ncpu × Ucpu × (1 + W/C),
где Ucpu — целевая утилизация, W — время ожидания, C — время вычисления в задаче.
Для чистого счёта W/C = 0 и N = Ncpu. Для запроса, где 95 мс ждём базу и 5 мс считаем,
W/C = 19, и на 8 ядрах выходит 160 потоков. Формула — отправная точка, дальше измеряйте
(см. https://courses.digitable.life/post/java/13-performance/).
Завершение. С Java 19 ExecutorService реализует AutoCloseable, и close() делает
shutdown() плюс ожидание; если нужен таймаут — классическая последовательность:
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
pool.submit(this::doWork);
} // блокируемся, пока все задачи не закончатся
pool.shutdown(); // новые задачи не принимаем
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow(); // прерываем текущие
}
Незакрытый пул с не-daemon потоками не даст JVM завершиться: приложение отработало, а процесс висит.
Куда деваются исключения. pool.submit(task) упаковывает исключение в Future — если
его никто не смотрит, ошибка исчезает бесследно; pool.execute(task) доводит её
до UncaughtExceptionHandler. Либо всегда проверяйте Future, либо оборачивайте тело задачи
в try/catch с логированием. У ScheduledExecutorService последствие злее: необработанное
исключение в scheduleAtFixedRate навсегда отменяет периодическую задачу — она молча
перестаёт запускаться.
ForkJoinPool и work stealing решают другую задачу: рекурсивное разбиение вычисления. Каждый поток имеет собственный deque и, опустошив его, крадёт задачу с хвоста чужого — балансировка без центральной очереди.
class SumTask extends RecursiveTask<Long> {
private static final int THRESHOLD = 10_000;
private final long[] a; private final int lo, hi;
@Override protected Long compute() {
if (hi - lo <= THRESHOLD) { // мелко — считаем сами
long s = 0;
for (int i = lo; i < hi; i++) s += a[i];
return s;
}
int mid = (lo + hi) >>> 1;
SumTask left = new SumTask(a, lo, mid);
left.fork(); // отдали в пул
long right = new SumTask(a, mid, hi).compute(); // правую считаем сами
return right + left.join();
}
}
parallelStream() работает на общем ForkJoinPool.commonPool() размером ядра − 1.
Отсюда правило: никогда не блокируйтесь в parallel stream. Один JDBC-вызов внутри
parallelStream() в одном месте приложения способен остановить все параллельные стримы
во всём процессе.
CompletableFuture: композиция асинхронности
Future из Java 5 умел только get(), то есть блокироваться. CompletableFuture (Java 8)
позволяет описать граф зависимостей и не блокироваться нигде: суммарная задержка
становится max(A, B) + C вместо A + B + C.
public CompletableFuture<Page> load(long userId) {
var user = CompletableFuture.supplyAsync(() -> userClient.find(userId), io);
var orders = CompletableFuture.supplyAsync(() -> orderClient.byUser(userId), io);
return user.thenCombine(orders, Pair::new) // два независимых результата в один
.thenCompose(p -> CompletableFuture // следующий шаг сам асинхронный
.supplyAsync(() -> recommender.forUser(p.user(), p.orders()), io)
.thenApply(recs -> new Page(p.user(), p.orders(), recs)))
.orTimeout(2, TimeUnit.SECONDS) // жёсткий предел на всю цепочку
.exceptionally(ex -> { // деградация вместо падения
log.warn("дашборд деградировал", ex);
return Page.empty(userId);
});
}
1. thenApply vs thenCompose — это map и flatMap. Если функция возвращает
CompletableFuture<T>, а вы применили thenApply, получится
CompletableFuture<CompletableFuture<T>>.
2. На каком потоке выполнится продолжение. Без суффикса Async этап выполняет тот поток,
который завершил предыдущий, а если тот уже завершён — вызывающий. То есть тяжёлое
преобразование в thenApply может внезапно выполниться внутри потока Netty вашего
HTTP-клиента и заблокировать event loop. Правило: лёгкое — thenApply, тяжёлое или
блокирующее — thenApplyAsync(fn, myExecutor).
3. Executor по умолчанию — commonPool(). В контейнере с одним CPU его параллелизм равен
нулю, и JDK подставляет исполнитель, создающий новый поток на каждую задачу. Поведение
на ноутбуке и в проде разное. Всегда передавайте свой executor.
4. Ошибки. handle видит и результат, и исключение; whenComplete — побочный эффект,
не меняющий результат; exceptionally — только ошибку; exceptionallyCompose (Java 12) —
асинхронный fallback. Исключение оборачивается в CompletionException — всегда разворачивайте
причину.
5. Комбинаторы для списков. allOf возвращает CompletableFuture<Void>, результаты
забираются отдельно:
List<CompletableFuture<Quote>> futures = suppliers.stream()
.map(s -> CompletableFuture.supplyAsync(() -> s.quote(request), io))
.toList(); // toList ОБЯЗАТЕЛЕН здесь
CompletableFuture<List<Quote>> all = CompletableFuture
.allOf(futures.toArray(CompletableFuture[]::new))
.thenApply(v -> futures.stream().map(CompletableFuture::join).toList());
Без toList() стрим остаётся ленивым, задачи не стартуют одновременно, и вы получаете
последовательное исполнение вместо параллельного: код выглядит асинхронным, работает
синхронно, ошибки нигде нет.
6. Отмена не работает так, как вы думаете. cancel(true) у CompletableFuture
не прерывает выполняющуюся задачу, а лишь переводит future в отменённое состояние.
Поток продолжит считать.
Прерывание: единственный механизм отмены
У Java нет способа насильно убить поток (Thread.stop() удалён — он оставлял объекты
в неконсистентном состоянии). Есть только кооперативное прерывание: флаг, который
вежливо просят проверять.
public void process(BlockingQueue<Item> queue) {
try {
while (!Thread.currentThread().isInterrupted()) {
heavyWork(queue.take()); // take сам бросит InterruptedException
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // ВОССТАНАВЛИВАЕМ флаг
} finally {
cleanup();
}
}
Смертный грех Java-конкурентности — пустой catch (InterruptedException e) {}, который за вас
сгенерировала IDE. Поймав это исключение, JVM сбрасывает флаг прерывания; проглотив
исключение и не восстановив флаг, вы делаете поток неостанавливаемым. Правило без исключений:
либо пробросьте InterruptedException выше, либо вызовите Thread.currentThread().interrupt().
Прерывание разблокирует sleep, wait, join, операции BlockingQueue
и lockInterruptibly, но не заблокированный сокет классического java.io и не вход
в synchronized. Отсюда практика: всегда ставьте таймауты на сетевые операции.
Виртуальные потоки: платформа догоняет задачу
Тридцать лет Java-разработчики боролись с тем, что поток дорог. Отсюда пулы, отсюда
реактивный стиль, отсюда CompletableFuture как способ не занимать поток ожиданием.
JEP 444 (Java 21) убрал корень проблемы.
Виртуальный поток — объект в куче с собственным стеком, тоже живущим в куче. Планирует его
не ядро ОС, а JVM, используя выделенный ForkJoinPool в режиме FIFO. При блокирующей операции
JDK снимает поток с несущего (unmount): кадры копируются в кучу, carrier мгновенно берёт
следующий. Когда операция завершилась — кадры возвращаются на любой свободный carrier.
Ключевая мысль: исходный код не меняется. Тот же socket.read(), тот же
jdbcTemplate.query(), тот же try/finally и читаемый человеком стектрейс. Это иной ответ
на ту же задачу, чем в .NET, где async/await окрашивает функции и делит кодовую базу
на два цвета: Java поменяла реализацию потока, а не язык. Цена — отсутствие явных точек
приостановки в коде и более тонкая отладка производительности. Подход .NET разобран
в статье трека C#.
// Один поток на задачу — снова разумная архитектура
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
int id = i;
executor.submit(() -> {
var resp = httpClient.send(request(id), BodyHandlers.ofString());
repository.save(parse(resp.body())); // обычный блокирующий код
return null;
});
}
} // close ждёт завершения всех задач
Миллион платформенных потоков — терабайт зарезервированного адресного пространства и гарантированный отказ. Миллион виртуальных — несколько гигабайт кучи и работающая программа.
Что меняется в правилах
1. Не пулите виртуальные потоки. Пул существует, чтобы переиспользовать дорогой ресурс; виртуальный поток дёшев и одноразов.
2. Ограничивайте не потоки, а ресурс. Раньше пул из 20 потоков был неявным ограничителем нагрузки на базу; теперь ограничитель нужен явный:
private final Semaphore dbPermits = new Semaphore(20);
public Order find(long id) throws InterruptedException {
dbPermits.acquire();
try { return jdbc.queryForObject(SQL, mapper, id); }
finally { dbPermits.release(); }
}
3. ThreadLocal перестаёт быть бесплатным. Он работает, но миллион потоков — это миллион
ThreadLocalMap. Замена — ScopedValue (финализирован в Java 25): неизменяемое значение,
видимое только в динамической области вызова и наследуемое дочерними задачами.
private static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();
ScopedValue.where(CONTEXT, new RequestContext(traceId, userId))
.run(() -> handleRequest()); // внутри CONTEXT.get() доступен по всему стеку
4. Pinning. Если виртуальный поток нельзя снять с carrier, он его занимает, и параллелизм
схлопывается до числа ядер. Причины — кадр нативного метода (JNI) и статический инициализатор
класса. До JDK 24 к ним относился и блок synchronized; JEP 491 это исправил, но код
на Java 21 всё ещё уязвим, поэтому в горячем пути меняйте synchronized на ReentrantLock.
Диагностика — JFR-событие jdk.VirtualThreadPinned (флаг -Djdk.tracePinnedThreads удалён
в JDK 24); настройка планировщика — -Djdk.virtualThreadScheduler.parallelism
и -Djdk.virtualThreadScheduler.maxPoolSize.
5. Виртуальные потоки не ускоряют вычисления. Если задача жжёт CPU, миллион виртуальных
потоков только добавит накладных расходов. Для CPU-bound остаётся ForkJoinPool.
Структурированная конкурентность
Обычный ExecutorService разрывает связь между задачей и её порождением: упал родитель —
дочерние задачи продолжают работать; упала дочерняя — родитель узнает только на get().
Утечки потоков и «зомби-запросы» родом отсюда. Структурированная конкурентность (JEP 505,
всё ещё preview в Java 25) навязывает дереву задач ту же дисциплину, что try/finally
навязывает управлению: область задачи не переживает свой блок.
// Preview API, требует --enable-preview. Форма JDK 21;
// в JDK 25 API изменился на StructuredTaskScope.open(...)
Response handle(long userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> user = scope.fork(() -> userService.find(userId));
Subtask<List<Order>> orders = scope.fork(() -> orderService.byUser(userId));
scope.join(); // ждём обе
scope.throwIfFailed(); // упала любая — отменяем остальные и бросаем
return new Response(user.get(), orders.get());
} // выход из блока = все дочерние задачи гарантированно завершены
}
По сравнению с CompletableFuture.allOf это даёт автоматическую отмену по ошибке (упал один
вызов — второй прерывается, а не досчитывает впустую), читаемое дерево задач в дампе
и невозможность оставить задачу, пережившую метод. Пока фича в preview, на Java 21 честнее
использовать newVirtualThreadPerTaskExecutor в try-with-resources: он даёт часть тех же
гарантий, но без каскадной отмены.
Отладка и тестирование
Обычные unit-тесты гонки не ловят: race condition воспроизводится раз на десять тысяч запусков, а на CI-машине с двумя ядрами — вообще никогда.
jstack <pid>/jcmd <pid> Thread.print— снимок потоков, автоматически находит дедлоки (Found one Java-level deadlock). Снимайте три дампа с интервалом 5 секунд.- JDK Flight Recorder — события
jdk.JavaMonitorEnter(кто на чём блокируется и сколько),jdk.ThreadPark,jdk.VirtualThreadPinned. Накладные расходы около 1%, можно держать включённым в проде. - jcstress — стресс-тесты моделей памяти от OpenJDK; единственный честный способ проверить lock-free структуру.
- Вместо
Thread.sleepв тестах —CountDownLatchиCyclicBarrier, чтобы принудительно свести потоки в одну точку; время инъектируйте черезClock. Тест, который «иногда падает», — это найденная гонка, а не флейки-тест. Подробнее — https://courses.digitable.life/post/java/09-testing/.
Типичные ошибки
- Гонка без синхронизации — «работает у меня» до первого сервера с другим числом ядер.
volatileдля составных операций —counter++,if (x == null) x = new ....- Захват двух замков в разном порядке — классический дедлок.
await/waitвifвместоwhile— ложное пробуждение и «украденное» состояние.unlock()не вfinally— исключение навсегда оставляет замок захваченным.- Безразмерная очередь в пуле —
maximumPoolSizeне работает, память течёт до OOM. newCachedThreadPool()под всплеск — взрывной рост числа потоков.submitбез проверкиFuture— исключения исчезают бесследно.- Исключение в
scheduleAtFixedRate— периодическая задача молча умирает навсегда. - Блокирующий вызов в
parallelStream()илиcommonPool— останавливает все параллельные операции в JVM. CompletableFutureбез явного executor — поведение зависит от числа ядер и молча меняется в контейнере.- Тяжёлая работа в
thenApplyбезAsync— выполнится в потоке HTTP-клиента. - Ленивый стрим перед
allOf— задачи идут последовательно, параллелизма нет. - Проглоченный
InterruptedException— поток становится неостанавливаемым. ThreadLocalв пуле безremove()— утечка памяти и утечка данных между запросами.- Пул виртуальных потоков — попытка переиспользовать то, что дешевле создать заново.
synchronizedв горячем пути на Java 21 с виртуальными потоками — pinning.- Двойная проверка без
volatile— другой поток увидит недостроенный объект. size()конкурентной коллекции как точное значение — оно приблизительное.- Обращение к той же карте внутри
computeIfAbsent— дедлок илиIllegalStateException.
Честно о месте Java
Где выигрывает. Зрелость java.util.concurrent — двадцать лет боевой эксплуатации,
формально специфицированная модель памяти и, вероятно, лучший в индустрии инструментарий
наблюдения за многопоточностью (JFR, async-profiler, jstack, JMH). Виртуальные потоки закрыли
главный пробел: можно писать простой блокирующий код и получать масштабируемость реактивного,
не платя читаемостью. Для сервиса, который держит десятки тысяч соединений и ходит в пять
внешних систем, Java 21+ — очень сильный выбор.
Где проигрывает. Разделяемая изменяемая память остаётся разделяемой: компилятор
не гарантирует отсутствие гонок, в отличие от Rust с его borrow checker, и в отличие
от Elixir, где состояние физически изолировано по процессам
(трек Elixir). Отказоустойчивости уровня OTP —
супервизоров, перезапуска поддерева, изоляции сбоя — в Java нет на уровне платформы.
Планирование виртуальных потоков кооперативное: бесконечный цикл без точек приостановки займёт
carrier и не будет вытеснен, тогда как BEAM вытесняет процессы по редукциям. Отмена через
прерывание слабее, чем CancellationToken в .NET: её нужно помнить и поддерживать вручную
в каждом слое.
Куда не стоит тащить. Жёсткий реальный режим и предсказуемая задержка в микросекундах — паузы GC и деоптимизация JIT мешают даже с ZGC. Массивно-параллельные численные вычисления — это GPU и специализированные библиотеки. Системы, где ключевое требование — изоляция отказов между миллионами независимых сущностей (телеком-коммутация, чаты с сотнями тысяч комнат): там модель акторов лучше на уровне архитектуры, а не библиотеки.
Мини-итог
- Сначала определите тип нагрузки: CPU-bound ограничен ядрами, IO-bound — временем ожидания. Лечение разное, а общий пул на всё — источник каскадных отказов.
- Модель памяти — фундамент. Без ребра happens-before нет никаких гарантий видимости, и «работает на моей машине» ничего не доказывает.
volatileдаёт видимость и порядок, но не атомарность. Атомарность — это CAS (Atomic*,LongAdder) или замок.- Не пишите свои конкурентные структуры; помните, что атомарны отдельные операции, а не их комбинации.
- Пул настраивайте руками: ограниченная очередь, осмысленные имена потоков, явная политика
отказа. Фабрики
Executorsудобны для примеров и опасны в проде. CompletableFuture— про композицию, а не про скорость: всегда передавайте свой executor и понимайте, на каком потоке выполнится каждый этап.- Виртуальные потоки возвращают модель «поток на задачу»: не пулить, ограничивать нагрузку семафором, следить за pinning, не ждать ускорения вычислений.
- Отмена в Java кооперативная. Никогда не проглатывайте
InterruptedException.
Источники
- JLS SE 21, глава 17: Threads and Locks — формальное определение модели памяти.
- JSR-133 (Java Memory Model) FAQ — то же самое человеческим языком.
- Goetz, Peierls, Bloch, Bowbeer, Holmes, Lea, Java Concurrency in Practice — книгу по этой теме нужно прочитать целиком.
- Javadoc java.util.concurrent — читайте описание пакета, а не только методов.
- Oracle: Virtual Threads — официальная методичка по миграции.
- JEP 444: Virtual Threads и JEP 491: Synchronize Virtual Threads without Pinning.
- JEP 505: Structured Concurrency, JEP 506: Scoped Values.
- Concurrency Interest Дага Ли — первоисточник по
java.util.concurrent. - Aleksey Shipilëv: Close Encounters of The Java Memory Model Kind — разбор реальных примеров.
- OpenJDK: исходники ThreadPoolExecutor — комментарии в начале файла лучше любой статьи.
Что дальше
Мы много раз упирались в вопросы «сколько стоит объект», «почему JIT переставил инструкции», «что такое mark word» и «почему пауза GC мешает предсказуемой задержке». Пора спуститься на уровень ниже и разобрать машину, на которой всё это работает: байткод, области памяти, устройство сборщиков мусора и то, как JIT превращает интерпретируемый код в машинный.