⏱️ Розділ 19 · Питання #25

Що робить метод 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)

  1. get() викликає waitingGet(true) (режим, що переривається): Якщо потік, заблокований у get(), отримує сигнал thread.interrupt(), він негайно припиняє очікування, видаляє свій вузол із черги очікування (Signaller) і викидає InterruptedException.
  2. 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)

Ключові правила на співбесіді

  1. У тестах та Stream API: використовуйте .join(), щоб не захаращувати код сигнатурами checked exceptions.
  2. У бойовому production коді: ніколи не викликайте голий join() без таймауту! Або використовуйте get(timeout, unit), або orTimeout(...).join().
  3. Обробка помилок: пам’ятайте, що join() викидає CompletionException, а оригінальну причину потрібно витягувати через .getCause().

Антипатерни

  • ❌ Вважати join() неблокуючим методом: join() блокує поточний потік точно так само, як і get().
  • ❌ Спроба перервати завислий join() через thread.interrupt(): потік продовжить висіти намертво, оскільки waitingGet(false) ігнорує переривання під час очікування.
  • ❌ Блокуючий join() на потоці введення-виведення (Netty event-loop): це паралізує обробку десятків тисяч мережевих з’єднань.

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