Java Конкурентность: потоки, executors, CompletableFuture, виртуальные потоки
0%

Конкурентность: потоки, executors, CompletableFuture, виртуальные потоки

Конкурентность: потоки, 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 — записи доехали до второго ядра в другом порядке.

Видимость записей между потоками и роль volatile

Java Memory Model (JLS, глава 17) задаёт частичный порядок happens-before: если A hb B, эффекты A гарантированно видны в B. Рёбра создают:

  1. Порядок программы внутри одного потока.
  2. Монитор — выход из synchronized hb следующего входа в тот же монитор.
  3. volatile — запись в volatile-поле hb последующего чтения этого поля.
  4. Старт и завершение потокаThread.start() hb первой инструкции потока; последняя инструкция потока hb возврата из join().
  5. final-поля — корректно сконструированный объект с final-полями безопасно публикуется без синхронизации.
  6. Библиотечные гарантииsubmit hb выполнения задачи; countDown() hb возврата из await(); put в BlockingQueue hb take.

Отношение транзитивно, поэтому достаточно сделать 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).

Отсюда главный сюрприз 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/.

Типичные ошибки

  1. Гонка без синхронизации — «работает у меня» до первого сервера с другим числом ядер.
  2. volatile для составных операцийcounter++, if (x == null) x = new ....
  3. Захват двух замков в разном порядке — классический дедлок.
  4. await/wait в if вместо while — ложное пробуждение и «украденное» состояние.
  5. unlock() не в finally — исключение навсегда оставляет замок захваченным.
  6. Безразмерная очередь в пулеmaximumPoolSize не работает, память течёт до OOM.
  7. newCachedThreadPool() под всплеск — взрывной рост числа потоков.
  8. submit без проверки Future — исключения исчезают бесследно.
  9. Исключение в scheduleAtFixedRate — периодическая задача молча умирает навсегда.
  10. Блокирующий вызов в parallelStream() или commonPool — останавливает все параллельные операции в JVM.
  11. CompletableFuture без явного executor — поведение зависит от числа ядер и молча меняется в контейнере.
  12. Тяжёлая работа в thenApply без Async — выполнится в потоке HTTP-клиента.
  13. Ленивый стрим перед allOf — задачи идут последовательно, параллелизма нет.
  14. Проглоченный InterruptedException — поток становится неостанавливаемым.
  15. ThreadLocal в пуле без remove() — утечка памяти и утечка данных между запросами.
  16. Пул виртуальных потоков — попытка переиспользовать то, что дешевле создать заново.
  17. synchronized в горячем пути на Java 21 с виртуальными потоками — pinning.
  18. Двойная проверка без volatile — другой поток увидит недостроенный объект.
  19. size() конкурентной коллекции как точное значение — оно приблизительное.
  20. Обращение к той же карте внутри 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.

Источники

Что дальше

Мы много раз упирались в вопросы «сколько стоит объект», «почему JIT переставил инструкции», «что такое mark word» и «почему пауза GC мешает предсказуемой задержке». Пора спуститься на уровень ниже и разобрать машину, на которой всё это работает: байткод, области памяти, устройство сборщиков мусора и то, как JIT превращает интерпретируемый код в машинный.

JVM изнутри: байткод, области памяти, сборщики мусора, JIT

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов