⏱️ Розділ 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 і стандартного логування.

Пов’язані теми