Що робить метод join() і чим він відрізняється від get()
Методи join() і get() призначені для однієї мети: вони переводять поточний потік у режим очікування (блокують його) доти, доки асинхронне обчислення в CompletableFuture не завер...
🟢 Junior Level
Методи join() і get() призначені для однієї мети: вони переводять поточний потік у режим очікування (блокують його) доти, доки асинхронне обчислення в CompletableFuture не завершиться, після чого повертають отриманий результат або викидають виняток.
Головна практична відмінність: Сигнатура винятків
| Метод | Тип винятків | Навіщо потрібен |
|---|---|---|
get() |
Checked (InterruptedException, ExecutionException) |
Прийшов з інтерфейсу java.util.concurrent.Future (Java 5). Вимагає явного блоку try-catch або throws. |
join() |
Unchecked (CompletionException) |
Доданий у CompletableFuture (Java 8) спеціально для зручного використання всередині лямбда-виразів та Stream API без нагромадження boilerplate-коду. |
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Hello, World!");
// 1. Використання get() — класичний Java 5 стиль з обробкою checked exceptions
try {
String res = future.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // відновлюємо прапорець переривання
} catch (ExecutionException e) {
log.error("Task failed: ", e.getCause());
}
// 2. Використання join() — сучасний функціональний стиль
String res = future.join(); // викидає unchecked CompletionException при помилці
Використання у Stream API
join() незамінний, коли потрібно дочекатися результатів пачки асинхронних завдань у потоці:
List<CompletableFuture<User>> futures = ids.stream()
.map(userService::fetchUserAsync)
.toList();
// Завдяки unchecked CompletionException, join() чисто лягає в лямбду:
List<User> users = futures.stream()
.map(CompletableFuture::join) // з get() тут знадобився б громіздкий try-catch
.toList();
🟡 Middle Level
1. Наявність таймауту: get(timeout) vs join()
Метод Future.get має перевантажену версію з таймаутом:
// Блокує потік максимум на 2 секунди; викидає TimeoutException, якщо не встиг
String result = future.get(2, TimeUnit.SECONDS);
У методу join() немає перевантаження з таймаутом у стандартному API. Якщо викликати join() на завислому завданні, викликаючий потік зависне назавжди.
Починаючи з Java 9, цю проблему вирішують ланцюжковим викликом .orTimeout() перед .join():
// Ідіоматичний сучасний варіант без checked exceptions:
String result = future.orTimeout(2, TimeUnit.SECONDS).join();
// При перевищенні ліміту викине CompletionException з причиною TimeoutException
2. Специфіка розпакування винятків
При збої фонового завдання методи упаковують оригінальний виняток (наприклад, IllegalStateException) по-різному:
CompletableFuture<String> failing = CompletableFuture.failedFuture(new IllegalArgumentException("Invalid ID"));
// При виклику get():
try {
failing.get();
} catch (ExecutionException e) {
Throwable original = e.getCause(); // IllegalArgumentException
}
// При виклику join():
try {
failing.join();
} catch (CompletionException e) {
Throwable original = e.getCause(); // IllegalArgumentException
}
[!IMPORTANT] Виняток-сюрприз —
CancellationException: Якщо завдання було скасовано черезfuture.cancel(true), обидва методи — іget(), іjoin()— викидаютьCancellationExceptionнапряму як unchecked runtime exception, без загортання вExecutionExceptionабоCompletionException!
🔴 Senior Level
Анатомія вихідного коду JDK: Interruptible vs Non-Interruptible очікування
Різниця між get() і join() не обмежується типами винятків. У вихідному коді CompletableFuture (JDK) вони по-різному взаємодіють із механізмом переривання потоків (Thread Interruption):
// Вихідний код CompletableFuture в OpenJDK:
public T get() throws InterruptedException, ExecutionException {
Object r;
if ((r = result) == null)
r = waitingGet(true); // true -> INTERRUPTIBLE
return (T) reportGet(r);
}
public T join() {
Object r;
if ((r = result) == null)
r = waitingGet(false); // false -> UNINTERRUPTIBLE
return (T) reportJoin(r);
}
Внутрішній цикл waitingGet(boolean interruptible)
get()викликаєwaitingGet(true)(режим, що переривається): Якщо потік, заблокований уget(), отримує сигналthread.interrupt(), він негайно припиняє очікування, видаляє свій вузол із черги очікування (Signaller) і викидаєInterruptedException.join()викликаєwaitingGet(false)(режим, що не переривається): Якщо потік, що очікує вjoin(), переривається черезthread.interrupt(), він продовжує спати й чекати завершення завдання! Він запам’ятовує факт переривання у внутрішньому прапорціinterrupted = true. Тільки колиCompletableFutureбуде остаточно завершений іншим потоком,waitingGetвідновлює статус переривання поточного потоку:if (interrupted) Thread.currentThread().interrupt(); // Відновлення статусу перериванняПісля чого
reportJoin(r)повертає результат або кидаєCompletionException.
Логіка розпакування: reportGet vs reportJoin
Внутрішній результат завершення зберігається в полі volatile Object result у вигляді спеціальної обгортки AltResult:
// Внутрішня реалізація розпакування в JDK:
private static Object reportGet(Object r) throws ExecutionException {
if (r instanceof AltResult) {
Throwable x = ((AltResult)r).ex;
if (x == null) return null; // NIL result
if (x instanceof CancellationException) throw (CancellationException)x;
Throwable cause;
// Розгортає проміжний CompletionException, якщо він є!
if ((x instanceof CompletionException) && (cause = x.getCause()) != null)
x = cause;
throw new ExecutionException(x); // Огортає в ExecutionException
}
return r;
}
private static Object reportJoin(Object r) {
if (r instanceof AltResult) {
Throwable x = ((AltResult)r).ex;
if (x == null) return null;
if (x instanceof CancellationException) throw (CancellationException)x;
if (x instanceof CompletionException) throw (CompletionException)x;
throw new CompletionException(x); // Огортає будь-яке інше Throwable в CompletionException
}
return r;
}
Зверніть увагу на тонку деталь: якщо вихідна стадія вже була завершена з CompletionException, reportJoin прокидає його як є, не створюючи повторної вкладеності new CompletionException(new CompletionException(...)). Водночас reportGet завжди розгортає CompletionException і кладе в ExecutionException оригінальний cause.
Швидкий шлях (Fast-path) без блокувань
Якщо result != null на момент входу в get() або join(), жодного паркування потоку не відбувається. Метод моментально зчитує volatile-змінну result і повертає значення за одиниці наносекунд. Саме тому виклик .join() усередині колбеків після CompletableFuture.allOf(...) абсолютно безкоштовний і не блокує системні потоки.
4 Tricky Questions
1. Що станеться, якщо перервати потік (thread.interrupt()), заблокований усередині cf.join() vs cf.get()?
Відповідь:
- Потік, що очікує в
get(), негайно прокинеться, очистить свій статус переривання і викинеInterruptedException. - Потік, що очікує в
join(), не перерве своє очікування. Він продовжить перебувати у стані очікування (блокування/паркування) доти, докиcfне буде фізично завершений (успішно або з помилкою). Тільки після настання результату методjoin()завершиться, відновить статус переривання викликаючого потоку черезThread.currentThread().interrupt()і поверне значення (або викинеCompletionException).
2. Чи кидає get() завжди ExecutionException при помилці, а join() — CompletionException? Що відбувається при скасуванні через cancel()?
Відповідь:
Ні, не завжди. Якщо CompletableFuture був скасований викликом cancel(true) або cancel(false), внутрішній результат позначається як AltResult(new CancellationException()).
У цьому випадку і get(), і join() викидають напряму CancellationException (нащадок IllegalStateException $\rightarrow$ RuntimeException), без огортання в ExecutionException або CompletionException. Цей виняток є неперевірюваним (unchecked) в обох методах!
3. Якщо всередині ланцюжка кинуто MyCustomBusinessException, в якому вигляді його отримають get() і join()? Скільки рівнів вкладеності буде у стек-трейсі?
Відповідь:
- При виклику
join(): буде викинутоCompletionException, у якогоe.getCause()є екземпляромMyCustomBusinessException. Якщо ланцюжок складався з декількох етапів,reportJoinне нарощує вкладеність і залишає один рівеньCompletionException. - При виклику
get(): методreportGetвсередині JDK явно перевіряє: якщо спіймана помилка —CompletionException, він бере їїgetCause()і лише потім упаковує вExecutionException. Томуe.getCause()уExecutionExceptionповерне напрямуMyCustomBusinessException, минаючи проміжнийCompletionException.
4. Чи безпечно викликати .join() всередині колбека allOf(...).thenApply(...)? Чи блокує він потік у цей момент?
Відповідь: Так, це абсолютно безпечно і є стандартним ідіоматичним патерном у Java.
Метод CompletableFuture.allOf(f1, f2) завершується лише тоді, коли всі передані стадії f1 та f2 перейшли у стан Completed (або Failed). Тому на момент виконання стадії thenApply поле result у f1 і f2 гарантовано не дорівнює null. Виклик f1.join() відпрацьовує за швидким шляхом (fast-path): просто зчитує volatile Object result без звернення до планувальника, блокування або паркування потоку (ForkJoinPool.managedBlock навіть не викликається).
🎯 Шпаргалка для інтерв’ю
Зведена шпаргалка відмінностей
| Характеристика | Future.get() |
CompletableFuture.join() |
|---|---|---|
| Походження | Java 5 (java.util.concurrent.Future) |
Java 8 (CompletableFuture) |
| Сигнатура винятків | Checked (InterruptedException, ExecutionException) |
Unchecked (CompletionException) |
Реакція на Thread.interrupt() |
Негайно викидає InterruptedException |
Чекає завершення завдання, потім відновлює прапорець interrupt |
| Підтримка таймауту | Є вбудоване перевантаження get(timeout, unit) |
Немає (потрібно .orTimeout(timeout, unit).join()) |
Поведінка при cancel() |
Кидає CancellationException (unchecked) |
Кидає CancellationException (unchecked) |
| Застосування у Stream API | Незручний (вимагає try-catch всередині лямбди) | Ідеальний для map(CompletableFuture::join) |
Ключові правила на співбесіді
- У тестах та Stream API: використовуйте
.join(), щоб не захаращувати код сигнатурами checked exceptions. - У бойовому production коді: ніколи не викликайте голий
join()без таймауту! Або використовуйтеget(timeout, unit), абоorTimeout(...).join(). - Обробка помилок: пам’ятайте, що
join()викидаєCompletionException, а оригінальну причину потрібно витягувати через.getCause().
Антипатерни
- ❌ Вважати
join()неблокуючим методом:join()блокує поточний потік точно так само, як іget(). - ❌ Спроба перервати завислий
join()черезthread.interrupt(): потік продовжить висіти намертво, оскількиwaitingGet(false)ігнорує переривання під час очікування. - ❌ Блокуючий
join()на потоці введення-виведення (Netty event-loop): це паралізує обробку десятків тисяч мережевих з’єднань.