Що таке блокуючий код і як його відрізнити від неблокуючого
Багато розробників вважають блокуваннями лише явні виклики 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-застосунках приховані десятки типів блокуючих операцій:
- Класичний I/O (
java.io.*):FileInputStream,FileOutputStream,BufferedReader.readLine(),Socket.getInputStream(). - Реляційні бази даних через JDBC:
Connection.prepareStatement(),Statement.executeQuery(), Hibernate/JPA-запити (традиційний драйвер JDBC завжди синхронно блокує потік ОС). - Примітиви синхронізації: вхід у блок
synchronized, захопленняReentrantLock.lock(),CountDownLatch.await(),BlockingQueue.take(). - Синхронне логування:
log.info(...)у стандартній конфігурації Logback/Log4j синхронно скидає дані на диск (FileAppender) або блокується на м’ютексі форматування. - 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:
- Синхронний блокуючий I/O (
read/recvfrom):- Потік робить системний виклик
read(fd). - Ядро переводить процес у стан очікування (Wait Queue сокета) і віддає CPU іншому процесу (Context Switch).
- Потік утримує стек (за замовчуванням 1 МБ пам’яті
-Xss) та дескриптор. - Масштабованість: 1 000 підключень = 1 000 потоків ОС (~1 ГБ RAM тільки на стеки, лавиноподібні витрати CPU на перемикання контексту).
- Потік робить системний виклик
- Неблокуючий мультиплексований 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% гарантією переконатися, що він не виконує блокуючих операцій у викликаючому потоці?
Відповідь:
- Аналіз вихідного коду: перевірити, що вся робота винесена в
supplyAsync(..., executor)або неблокуючі драйвери (Netty, WebClient, R2DBC), а до виклику фабричного методу не відбувається звернень до мережі чи читання файлів. - Динамічний аналіз: запустити виклик методу під керуванням BlockHound у потоці, позначеному як неблокуючий.
- Перевірка ідентифікатора потоку: заміряти
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 і стандартного логування.