⏱️ Раздел 19 · Вопрос #14

Что такое блокирующий код и как его отличить от неблокирующего

Многие разработчики считают блокировками только явные вызовы Thread.sleep() или future.get(). В реальности в Java-приложениях скрыты десятки типов блокирующих операций:


🟢 Junior Level

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

Неблокирующий код — это код, который инициирует операцию и немедленно возвращает управление вызывающему коду. Поток остаётся свободным для обработки других задач, а результат вычисления передаётся позже через колбэк (функцию обратного вызова), CompletableFuture или реактивный поток.

Пример для сравнения

// ❌ БЛОКИРУЮЩИЙ КОД:
// Поток "висит" 200 мс в ожидании байт по сети. Поток ОС удерживает память и простаивает.
InputStream in = socket.getInputStream();
int data = in.read(); 

// ❌ БЛОКИРУЮЩИЙ ВЫЗОВ В АСИНХРОННОМ КОДЕ:
CompletableFuture<String> future = service.fetchAsync();
String result = future.get(); // ТЕКУЩИЙ ПОТОК ЗАБЛОКИРОВАН ДО ОТВЕТА

// ✅ НЕБЛОКИРУЮЩИЙ КОД:
// Запрос отправлен, поток немедленно освобождается.
// Лямбда выполнится только тогда, когда байты реально придут в сокет.
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
    .thenAccept(response -> System.out.println("Тело ответа: " + response.body()));

🟡 Middle Level

Каталог распространённых источников блокировки

Многие разработчики считают блокировками только явные вызовы Thread.sleep() или future.get(). В реальности в Java-приложениях скрыты десятки типов блокирующих операций:

  1. Классический I/O (java.io.*): FileInputStream, FileOutputStream, BufferedReader.readLine(), Socket.getInputStream().
  2. Реляционные базы данных через JDBC: Connection.prepareStatement(), Statement.executeQuery(), Hibernate/JPA-запросы (традиционный драйвер JDBC всегда синхронно блокирует поток ОС).
  3. Примитивы синхронизации: вход в блок synchronized, захват ReentrantLock.lock(), CountDownLatch.await(), BlockingQueue.take().
  4. Синхронное логирование: log.info(...) в стандартной конфигурации Logback/Log4j синхронно сбрасывает данные на диск (FileAppender) или блокируется на мьютексе форматирования.
  5. DNS-резолвинг: вызов InetAddress.getByName(host) блокирует поток при опросе DNS-сервера.

Как отличить блокирующий код от неблокирующего

Признак Блокирующий код Неблокирующий код
Возврат управления Только после фактического завершения операции Немедленно (возвращает Future, Stage, Mono или void)
Состояние потока в OS WAITING, TIMED_WAITING, BLOCKED (или D-state в ядре Linux) RUNNABLE (активно выполняет инструкции процессора)
Сетевые API java.net.Socket, Apache HttpClient, Spring RestTemplate Java 11 HttpClient.sendAsync(), Netty, Spring WebClient
Работа с БД JDBC, Spring Data JPA, MyBatis R2DBC, Vert.x Reactive SQL, Mongo Reactive Streams

[!WARNING] Наличие в сигнатуре метода возвращаемого типа CompletableFuture<T> не гарантирует, что метод неблокирующий!

// ❌ ОБМАНЧИВАЯ АСИНХРОННОСТЬ (Fake Async):
public CompletableFuture<User> getUser(Long id) {
    // Внутри вызывается блокирующий JDBC прямо в вызывающем потоке!
    User user = jdbcTemplate.queryForObject("SELECT ...", id); 
    return CompletableFuture.completedFuture(user);
}

🔴 Senior Level

Механика ядра ОС: Системные вызовы read() vs epoll()

Понимание разницы между блокирующим и неблокирующим кодом базируется на системных вызовах ядра Linux:

  1. Синхронный блокирующий I/O (read/recvfrom):
    • Поток делает системный вызов read(fd).
    • Ядро переводит процесс в состояние ожидания (Wait Queue сокета) и отдаёт CPU другому процессу (Context Switch).
    • Поток удерживает стек (по умолчанию 1 МБ памяти -Xss) и дескриптор.
    • Масштабируемость: 1 000 подключений = 1 000 потоков ОС (~1 ГБ RAM только на стеки, лавинообразные затраты CPU на контекстное переключение).
  2. Неблокирующий мультиплексированный I/O (epoll/kqueue / Java NIO):
    • Сетевые сокеты регистрируются в селекторе epoll.
    • Один единственный поток (EventLoop в Netty) вызывает epoll_wait() и опрашивает готовность тысяч сокетов.
    • Когда пакет данных приходит на сетевую карту, ядро будит EventLoop, который считывает данные и триггерит цепочку CompletableFuture.
    • Масштабируемость: 1 поток обслуживает 50 000+ активных соединений.

Автоматическое обнаружение скрытых блокировок: BlockHound

В современных неблокирующих архитектурах ручной поиск блокировок неэффективен. Проект Reactor разработал специальный Java-агент BlockHound, который на уровне байт-кода перехватывает системные вызовы и бросает исключение при попытке заблокировать поток из неблокирующего пула:

// Инициализация при старте тестов или приложения:
BlockHound.install();

// Если код внутри ForkJoinPool или Netty EventLoop вызовет Thread.sleep() или JDBC,
// BlockHound мгновенно выбросит:
// reactor.blockhound.BlockingOperationError: Blocking call! java.io.FileInputStream#readBytes

4 Tricky Questions

1. Является ли логирование через log.info("Order processed: {}", id) блокирующей операцией? Чем это грозит в реактивных и асинхронных конвейерах?

Ответ: По умолчанию в большинстве конфигураций — ДА, является блокирующей! Стандартные аппендеры Logback и Log4j (ConsoleAppender, FileAppender) синхронно выполняют форматирование сообщения, захватывают внутренний мьютекс потокобезопасности (synchronized) и производят запись в файловый дескриптор или стандартный вывод ОС. В высоконагруженном конвейере CompletableFuture вызов log.info может привести к задержкам на дисковом I/O и создать точку синхронизации (Lock Contention) для всех воркеров. Решение: Использовать неблокирующие асинхронные аппендеры (AsyncAppender в Logback или Disruptor-аппендер в Log4j2).

2. Если метод объявлен как public CompletableFuture<String> getDataAsync(), как со 100% гарантией убедиться, что он не выполняет блокирующих операций в вызывающем потоке?

Ответ:

  1. Анализ исходного кода: проверить, что вся работа вынесена в supplyAsync(..., executor) или неблокирующие драйверы (Netty, WebClient, R2DBC), а до вызова фабричного метода не происходит походов в сеть или чтения файлов.
  2. Динамический анализ: запустить вызов метода под управлением BlockHound в потоке, помеченном как неблокирующий.
  3. Проверка идентификатора потока: замерить Thread.currentThread().getId() до входа в метод, внутри выполнения вычислений и в колбэке завершения. Если вычисления происходят в вызывающем потоке и длятся больше пары миллисекунд — код блокирующий.

3. В чём разница между состояниями потока BLOCKED, WAITING и TIMED_WAITING при блокировках?

Ответ:

  • BLOCKED: поток пытается войти в синхронизированный блок/метод (synchronized) или ожидает освобождения монитора объекта, который в данный момент занят другим потоком.
  • WAITING: поток добровольно уступил процессор и ожидает явного пробуждения от другого потока. Возникает при вызовах Object.wait(), Thread.join(), LockSupport.park() (например, внутри CompletableFuture.get()).
  • TIMED_WAITING: ожидание с ограниченным тайм-аутом, по истечении которого поток проснётся сам: Thread.sleep(millis), Object.wait(timeout), CompletableFuture.get(timeout, unit).

4. Почему в Java 21 с появлением Virtual Threads концепция блокирующего кода кардинально изменилась?

Ответ: До Java 21 блокирующий вызов блокировал дорогой поток операционной системы (Carrier Thread), замораживая ~1 МБ памяти стека и лишая JVM возможности использовать поток ядра. В Java 21 виртуальный поток (Virtual Thread) при выполнении блокирующего I/O (сокет, файл, LockSupport.park()) автоматически отмонтируется (unmount) от несущего потока ОС (Carrier Thread). Несущий поток ядра немедленно берет на исполнение другой виртуальный поток. Блокирующий вызов на виртуальном потоке становится таким же легковесным и масштабируемым, как реактивный неблокирующий код, полностью устраняя необходимость в сложных асинхронных цепочках колбэков.


🎯 Шпаргалка для интервью

Главное за 60 секунд

  • Блокирующий код приостанавливает поток ОС (системные вызовы read, recv, парковка futex), переводя его в состояние ожидания.
  • Неблокирующий код не держит поток: он регистрирует событие (epoll, callback) и немедленно освобождает поток для других задач.
  • Источники блокировок: классический java.io, JDBC, future.get(), Thread.sleep(), мьютексы synchronized, синхронный логгинг.
  • Наличие возвращаемого типа CompletableFuture не гарантирует неблокирующее исполнение (опасность Fake Async).
  • В Java 21 Virtual Threads превращают традиционный императивный блокирующий код в неблокирующий на уровне потоков ОС.

Типичные ошибки на интервью

  • Полагать, что блокировка — это только Thread.sleep().
  • Считать, что CompletableFuture автоматически делает любой блокирующий метод неблокирующим (запуск блокирующего кода в пуле просто переносит блокировку в поток пула).
  • Забывать о блокирующей природе классического JDBC и стандартного логирования.

Связанные вопросы