Що таке Callable та Future
Інтерфейс Future визначає п'ять ключових методів:
🟢 Junior Level
Короткий опис для співбесіди (30 секунд)
Callable<V> та Future<V> — це базові інтерфейси пакета java.util.concurrent, що з’явилися в Java 5 для подолання фундаментальних обмежень класичного Runnable:
Callable<V>представляє собою завдання, яке, на відміну відRunnable.run(), повертає типізований результат типуVчерез методV call()і здатне прокидати перевірювані винятки (checked exceptions).Future<V>виступає асинхронним дескриптором («обіцянкою» / квитанцією на результат) запущеного завдання. ЧерезFutureвикликаючий потік може:- Перевірити статус виконання завдання (
isDone(),isCancelled()); - Спробувати скасувати завдання (
cancel()); - Отримати обчислений результат із блокуванням (
get()) або з обмеженням за таймаутом (get(timeout, unit)).
- Перевірити статус виконання завдання (
Порівняльна таблиця: Runnable vs Callable vs Future vs CompletableFuture
| Характеристика | Runnable |
Callable<V> |
Future<V> |
CompletableFuture<V> |
|---|---|---|---|---|
| Роль | Завдання без результату | Завдання з результатом | Дескриптор результату | Реактивний конвеєр завдань |
| Сигнатура методу | void run() |
V call() throws Exception |
Методи інспекції (get, cancel) |
60+ методів трансформації |
| Checked Exceptions | ❌ Заборонені | ✅ Дозволені (throws Exception) |
Прокидаються в ExecutionException |
Огортаються в CompletionException |
| Отримання результату | Неможливо | Повертається з call() |
Блокуючий виклик get() |
Неблокуючі колбеки (thenApply) |
| З’явився у версії | Java 1.0 | Java 5 | Java 5 | Java 8 |
Компільований приклад у Java 21
import java.util.concurrent.*;
public class CallableAndFutureBasicsDemo {
public static void main(String[] args) {
// 1. Визначення завдання, що повертає значення і може кинути виняток
Callable<Integer> heavyCalculation = () -> {
System.out.println("Обчислення розпочато в потоці: " + Thread.currentThread().getName());
Thread.sleep(500); // Імітація тривалих обчислень
return 42 * 2;
};
// 2. Запуск через ExecutorService
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// submit() негайно повертає Future (неблокуюча операція)
Future<Integer> future = executor.submit(heavyCalculation);
System.out.println("Завдання відправлено. Чи завершено воно одразу? " + future.isDone());
try {
// 3. Отримання результату із захисним таймаутом (Best Practice!)
Integer result = future.get(2, TimeUnit.SECONDS);
System.out.println("Результат успішно отримано: " + result);
} catch (TimeoutException e) {
System.err.println("Перевищено таймаут очікування! Скасовуємо завдання...");
future.cancel(true); // Посилаємо сигнал переривання
} catch (ExecutionException e) {
// Якщо всередині call() впав виняток, він упакований у getCause()
System.err.println("Помилка всередині завдання: " + e.getCause().getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("Поточний потік було перервано під час очікування");
}
}
}
}
🟡 Middle Level
Методи інтерфейсу Future<V> та керування скасуванням
Інтерфейс Future визначає п’ять ключових методів:
V get(): блокує викликаючий потік до завершення завдання, повертаючи результат або викидаючи виняток.V get(long timeout, TimeUnit unit): блокує викликаючий потік максимум на вказаний час. Якщо завдання не встигло завершитися, викидаєтьсяTimeoutException.boolean isDone(): повертаєtrue, якщо завдання завершено (успішно, через помилку або скасовано).boolean isCancelled(): повертаєtrue, якщо завдання було скасовано до свого природного завершення.boolean cancel(boolean mayInterruptIfRunning): намагається скасувати виконання завдання.
Механіка скасування: cancel(true) vs cancel(false)
cancel(false): якщо завдання ще очікує в черзі пулу потоків, воно видаляється з черги і ніколи не запуститься. Проте якщо робочий потік уже почав виконуватиcall(), його виконання не переривається, хоча результат буде проігноровано.cancel(true): якщо завдання вже виконується, JVM надсилає потоку-виконавцю сигналThread.interrupt().[!IMPORTANT] Сигнал
interrupt()не «вбиває» потік миттєво. Завдання зобов’язане самостійно перевіряти прапорецьThread.currentThread().isInterrupted()або викликати стандартні блокуючі методи (sleep,read), інакше воно продовжить роботу до кінця!
Масові операції: invokeAll() vs invokeAny()
Інтерфейс ExecutorService надає зручні методи для роботи з колекціями Callable:
import java.util.List;
import java.util.concurrent.*;
public class BatchExecutionDemo {
public static void main(String[] args) throws Exception {
try (var executor = Executors.newFixedThreadPool(4)) {
List<Callable<String>> tasks = List.of(
() -> { Thread.sleep(200); return "Fast"; },
() -> { Thread.sleep(500); return "Medium"; },
() -> { Thread.sleep(800); return "Slow"; }
);
// 1. invokeAll: блокує викликаючий потік, поки ВСІ завдання не завершаться
// Завжди рекомендується вказувати таймаут!
List<Future<String>> results = executor.invokeAll(tasks, 1, TimeUnit.SECONDS);
for (Future<String> f : results) {
if (!f.isCancelled()) {
System.out.println("Отримано: " + f.get());
}
}
// 2. invokeAny: повертає результат НАЙШВИДШОГО завдання, що успішно завершилося!
// Усі інші завдання автоматично скасовуються (отримують cancel(true))
String fastest = executor.invokeAny(tasks);
System.out.println("Найшвидша відповідь: " + fastest);
}
}
}
Обгортання винятків у ExecutionException
Коли метод Callable.call() викидає перевірюваний або неперевірюваний виняток (наприклад, SQLException), викликаючий потік не може зловити його безпосередньо, оскільки виконання відбувалося в іншому потоці ОС.
Future.get() упаковує реальний виняток у ExecutionException:
try {
future.get();
} catch (ExecutionException e) {
Throwable cause = e.getCause(); // Витягуємо першопричину збою
if (cause instanceof SQLException sqlEx) {
log.error("Збій БД: {}", sqlEx.getSQLState());
} else {
log.error("Невідома помилка", cause);
}
}
🔴 Senior Level
Архітектура HotSpot: Будова FutureTask<V> під капотом
Коли викликається executor.submit(callable), завдання упаковується в стандартну реалізацію — java.util.concurrent.FutureTask<V>, яка одночасно реалізує інтерфейси Runnable та Future (RunnableFuture<V>):
[ RunnableFuture<V> ]
▲
┌───────────┴───────────┐
│ │
[ Runnable ] [ Future<V> ]
Машина станів FutureTask:
Всередині FutureTask синхронізація побудована на атомарному полі volatile int state без використання важких блокувань (synchronized). Переходи між станами здійснюються через процесорні інструкції CAS:
┌──► COMPLETING ──► NORMAL (Результат записано в outcome)
│
NEW (0) ─┼──► COMPLETING ──► EXCEPTIONAL (Виняток записано в outcome)
│
├──► CANCELLED (Скасовано без переривання)
│
└──► INTERRUPTING ──► INTERRUPTED (Скасовано з Thread.interrupt())
NEW = 0: завдання створено, ще не завершено;COMPLETING = 1: перехідний стан: результат обчислення вже отримано, але ще записується в полеObject outcome;NORMAL = 2: завдання успішно завершено,outcomeмістить значення;EXCEPTIONAL = 3: завдання завершилося збоєм,outcomeміститьThrowable;CANCELLED = 4: скасовано черезcancel(false);INTERRUPTING = 5: перехідний стан при викликуcancel(true)до відправлення переривання;INTERRUPTED = 6: потік перервано.
Механіка очікування: Черга Treiber Stack (WaitNode)
Якщо потік викликає future.get(), а завдання перебуває в стані NEW або COMPLETING, потік повинен заснути:
FutureTaskне використовуєObject.wait(). Натомість він організовує бессерверний стек очікування Трейбера (Treiber Stack) з об’єктівWaitNode.- Потік створює вузол
WaitNode, атомарно через CAS розміщує його на вершину стекаwaitersі засинає за допомогоюLockSupport.park(this). - Коли робочий потік завершує виконання завдання всередині методу
FutureTask.run(), він переводить статус уNORMAL/EXCEPTIONAL, записує результат у полеoutcome, а потім викликає приватний методfinishCompletion():- Проходить стеком
waiters; - Викликає
LockSupport.unpark(node.thread)для кожного потоку, що чекає; - Звільняє посилання для збирача сміття.
- Проходить стеком
Архітектурний антипатерн «Синхронний Future.get() у Web-контейнерах»
Класична помилка проєктування Middle-розробників: створення асинхронних завдань у фоновому пулі з негайним синхронним очікуванням future.get() у потоці HTTP-обробника:
// ❌ АНТИПАТЕРН: Подвійне блокування ресурсів
public UserResponse handleRequest(Long id) {
Future<User> f1 = pool.submit(() -> fetchUser(id));
Future<Orders> f2 = pool.submit(() -> fetchOrders(id));
// Потік сервлета Tomcat заблокований на get(), а два потоки pool зайняті розрахунком!
return new UserResponse(f1.get(), f2.get());
}
Чому це призводить до каскадного збою:
- Задіюються 3 потоки ОС замість одного (1 потік сервлета + 2 потоки пулу);
- При високому навантаженні потоки сервлета виснуть в очікуванні
get(), а черга пулу завдань переповнюється, викликаючи Thread Starvation і вичерпання пулу потоків.
Сучасні рішення:
- Java 8+: перехід на неблокуючий конвеєр
CompletableFuture.thenCombine(); - Java 21+: перехід на Virtual Threads та Structured Concurrency (
StructuredTaskScope), де блокуючийjoin()розмонтовує віртуальний потік, не забираючи фізичні потоки ОС.
🎯 Шпаргалка для інтерв’ю
1. 30-секундний есенс (Elevator Pitch)
Callable<V> — це функціональний інтерфейс параметризованого завдання («що виконати»), що повертає значення V і декларує throws Exception. Future<V> — дескриптор життєвого циклу цього завдання («квитанція на результат»), що дозволяє перевірити готовність (isDone), скасувати виконання (cancel) та отримати значення (get). Всередині FutureTask побудований на неблокуючій машині станів (CAS над змінною state) та стеку очікування Трейбера з примітивом LockSupport.park(). У сучасному коді блокуючий Future.get() поступається місцем неблокуючому CompletableFuture або віртуальним потокам зі StructuredTaskScope.
2. 4 каверзних питання з глибокими відповідями
Питання 1: Що відбувається всередині FutureTask.run(), якщо метод Callable.call() викидає виняток?
Відповідь:
Метод FutureTask.run() перехоплює будь-який виняток (catch (Throwable rx)). Він не дає потоку-виконавцю впасти, а зберігає посилання на спійманий виняток у внутрішньому нетипізованому полі Object outcome. Після цього статус завдання атомарно через CAS переводиться в COMPLETING, а потім в EXCEPTIONAL. Далі викликається finishCompletion(), який будить усі потоки, що очікують на get(). Коли викликаючий потік звертається до get(), метод перевіряє статус, бачить EXCEPTIONAL і прокидає збережений виняток, огорнувши його в ExecutionException(cause).
Питання 2: У чому різниця між викликами future.cancel(true) та future.cancel(false)?
Відповідь: Обидва методи переводять завдання в статус скасованого, якщо воно ще не завершилося.
- Якщо завдання ще перебуває в черзі пулу і не почало виконуватися, обидва виклики однаково запобіжать його старту.
- Якщо завдання вже виконується робочим потоком:
cancel(false)не перериває виконання потоку; завдання допрацює до кінця, але результат буде відкинуто.cancel(true)викликаєThread.interrupt()на робочому потоці, що виконує завдання. Якщо код завдання перевіряє статус переривання або перебуває в блокуючому виклику (sleep,wait, сокет), він негайно перерветься черезInterruptedException.
Питання 3: Чому виклик future.get() на завданні, у якого isDone() == true, виконується зі швидкістю читання звичайної змінної?
Відповідь:
Оскільки у FutureTask стан state є volatile-змінною. Якщо state >= NORMAL, метод get() одразу переходить до виклику report(s), минаючи створення вузла WaitNode та системний виклик LockSupport.park(). Значення витягується безпосередньо з пам’яті поля outcome за кілька тактів процесора завдяки гарантіям Happens-Before моделі пам’яті Java.
Питання 4: Як FutureTask захищає від стану гонки між скасуванням завдання (cancel()) та його одночасним завершенням (run())?
Відповідь:
Захист реалізований на базі атомарних CAS-операцій над полем state. Перед тим як записати результат або викинути виняток, метод run() виконує:
UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)
Метод cancel() так само намагається виконати CAS із NEW у CANCELLED або INTERRUPTING. Переможцем стає рівно один потік:
- Якщо
cancel()встиг першим,run()провалює CAS і не публікує результат; - Якщо
run()встиг перевести стан уCOMPLETING, викликcancel()провалює CAS і повертаєfalse, оскільки завдання вже неможливо скасувати.
3. Типові помилки та Red Flags
❌ «Виклик future.get() без вказівки таймауту в продакшен-коді.»
Правильно: Необмежений future.get() може намертво підвісити викликаючий потік назавжди, якщо зовнішня служба зависне. Завжди слід вказувати таймаут: future.get(5, TimeUnit.SECONDS).
❌ «Припущення, що future.cancel(true) примусово вбиває потік.»
Правильно: Метод лише виставляє прапорець переривання. Якщо всередині завдання крутиться нескінченний цикл while(true) без перевірки isInterrupted(), завдання продовжить споживати CPU.
❌ «Використання Callable<Void> замість Runnable для завдань без значення, що повертається.»
Правильно: Це породжує зайвий оверхед і вимагає явного повернення return null;. Для завдань без результату призначений Runnable.