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

Що робить метод orTimeout() в Java 9+

Метод orTimeout(long timeout, TimeUnit unit), що з'явився в Java 9, встановлює неблокуючий таймаут на виконання CompletableFuture.


🟢 Junior Level

Метод orTimeout(long timeout, TimeUnit unit), що з’явився в Java 9, встановлює неблокуючий таймаут на виконання CompletableFuture.

Якщо операція не встигає завершитися за відведений інтервал часу, CompletableFuture примусово переводиться в стан помилки з винятком java.util.concurrent.TimeoutException.

Базовий приклад

CompletableFuture<String> paymentFuture = paymentClient.chargeAsync(orderId);

// Обмежуємо очікування платежу 2 секундами:
paymentFuture.orTimeout(2, TimeUnit.SECONDS)
    .thenAccept(receipt -> System.out.println("Платіж пройшов: " + receipt))
    .exceptionally(ex -> {
        // При перевищенні 2 секунд керування потрапляє сюди:
        // ex.getCause() містить TimeoutException
        System.err.println("Таймаут платежу: " + ex.getMessage());
        return null;
    });

Відмінність від completeOnTimeout()

Метод Результат при таймауті Типовий сценарій
orTimeout(timeout, unit) Викидає TimeoutException Критична операція (платіж, запис замовлення)
completeOnTimeout(fallback, timeout, unit) Успішно повертає fallback Необов’язкові дані (кеш, блок рекомендацій)

🟡 Middle Level

Найважливіша особливість: orTimeout() повертає this

На відміну від методів трансформації (thenApply, thenCompose), які породжують новий залежний вузол у дереві, метод orTimeout():

  • Мутує поточний екземпляр і повертає посилання на самого себе (return this).
  • Реєстрація таймера відбувається безпосередньо на тому об’єкті, у якого викликано метод.
  • Якщо на один і той самий CompletableFuture викликати orTimeout() кілька разів із різними значеннями, переможе найкоротший таймаут (оскільки перше спрацьовування переведе ф’ючер у термінальний стан через CAS).

Обробка винятку таймауту в ланцюжку

Помилка TimeoutException автоматично загортається в CompletionException:

future.orTimeout(500, TimeUnit.MILLISECONDS)
    .handle((res, ex) -> {
        if (ex != null) {
            Throwable root = (ex instanceof CompletionException) ? ex.getCause() : ex;
            if (root instanceof TimeoutException) {
                log.warn("Сервіс відповів занадто повільно, активуємо fallback");
                return getFallbackData();
            }
            throw new CompletionException(ex);
        }
        return res;
    });

🔴 Senior Level

Внутрішня будова: Архітектура Delayer

Під капотом orTimeout() не створює окремий потік операційної системи на кожен виклик:

  1. У JDK ініціалізується приватний синглтон Delayer:
    static final class Delayer {
        static final ScheduledThreadPoolExecutor delayer;
        static {
            (delayer = new ScheduledThreadPoolExecutor(
                1, new DaemonThreadFactory())).setRemoveOnCancelPolicy(true);
        }
    }
    
  2. Метод orTimeout() ставить у чергу delayer легковажне завдання:
    delayer.schedule(new Timeout(this), timeout, unit);
    
  3. При настанні часу завдання Timeout виконує:
    public void run() {
        if (f != null && !f.isDone())
            f.completeExceptionally(new TimeoutException());
    }
    
  4. Усунення витоку пам’яті: Метод orTimeout одразу ж реєструє на ф’ючері слухач Canceller через whenComplete. Якщо вихідне завдання завершується штатно за 10 мс при таймауті в 60 с, Canceller викликає scheduledFuture.cancel(false). Налаштування setRemoveOnCancelPolicy(true) гарантує негайне видалення скасованого завдання з черги шедулера.

Смертельна пастка: Блокування єдиного потоку Delayer

Коли спрацьовує таймаут, метод Timeout.run() виконується всередині єдиного на всю JVM системного потоку Delayer. Якщо після orTimeout зареєстровано синхронний обробник:

// ❌ КАТАСТРОФА ДЛЯ ВСІЄЇ JVM:
future.orTimeout(1, TimeUnit.SECONDS)
    .exceptionally(ex -> {
        // Цей код виконується ВСЕРЕДИНІ ЄДИНОГО ПОТОКУ Delayer!
        Thread.sleep(5000); // БЛОКУЄ Delayer на 5 секунд!
        return fallback;
    });

Затримка або синхронний I/O в цьому обробнику заблокує потік таймера всієї віртуальної машини. У цей період жоден інший orTimeout() або completeOnTimeout() у всьому застосунку не зможе спрацювати вчасно! Рішення: Якщо логіка обробки помилки важка, використовуйте exceptionallyAsync(..., dedicatedExecutor) (Java 12+) або handleAsync(..., dedicatedExecutor).


4 Tricky Questions

1. У якому потоці виконається лямбда всередині .exceptionally(...), якщо на етапі спрацював orTimeout()?

Відповідь: Колбек виконається всередині єдиного фонового потоку шедулера CompletableFuture$Delayer! Це критично важливий нюанс: коли спрацьовує таймер, завдання Timeout.run() викликається потоком Delayer і переводить ф’ючер у статус помилки через completeExceptionally(). Це негайно і синхронно розкручує стек підписників у тому самому потоці. Будь-які важкі обчислення або блокування в такому exceptionally паралізують роботу всіх таймерів у всій JVM. Для ізоляції слід використовувати handleAsync(..., customExecutor).


2. Чи скасовує orTimeout() фізичне виконання фонового завдання при спрацьовуванні?

Відповідь: Ні, абсолютно не скасовує. Метод orTimeout() лише змінює стан самого об’єкта CompletableFuture на TimeoutException. Потік пулу, що виконує тіло завдання (наприклад, довгий SQL-запит або розрахунок), продовжить працювати у фоні до свого природного завершення. Щоб зупинити саме завдання, необхідно при виникненні TimeoutException явно скасовувати операцію через низькорівневі інтерфейси (наприклад, переривати потік, закривати мережевий сокет або скасовувати Statement.cancel()).


3. Що станеться, якщо викликати cf.orTimeout(5, SECONDS) і потім на тому самому об’єкті cf.orTimeout(2, SECONDS)?

Відповідь: Спрацює таймаут на 2 секунди. Обидва виклики зареєструють завдання в шедулері Delayer. Через 2 секунди перше завдання переведе ф’ючер у стан TimeoutException через CAS-операцію. Коли через 5 секунд спрацює друге завдання, воно виявить, що f.isDone() == true, і не матиме жодного ефекту.


4. Чи мутує виклик orTimeout() вихідний об’єкт CompletableFuture, чи повертає новий екземпляр?

Відповідь: Мутує вихідний об’єкт і повертає this. На відміну від методів thenApply(), thenAccept() або thenCompose(), які повертають нові незалежні екземпляри етапів, метод orTimeout() оголошений як:

public CompletableFuture<T> orTimeout(long timeout, TimeUnit unit) {
    if (unit == null) throw new NullPointerException();
    if (result == null)
        whenComplete(new Canceller(Delayer.delay(new Timeout(this), timeout, unit)));
    return this; // Повертає ТОЙ САМИЙ об'єкт!
}

Це означає, що таймаут навішується безпосередньо на поточний ф’ючер, а не на його дочірню копію.


🎯 Шпаргалка для інтерв’ю

Головне за 60 секунд

  • Метод orTimeout(timeout, unit) додано в Java 9. При закінченні часу переводить CompletableFuture у стан TimeoutException.
  • Мутує вихідний ф’ючер (return this).
  • Використовує один-єдиний системний потік-демон ScheduledThreadPoolExecutor (Delayer) на всю JVM.
  • Автоматично скасовує таймер при достроковому успішному завершенні завдання (setRemoveOnCancelPolicy(true)).
  • Смертельна пастка: синхронний exceptionally після orTimeout виконується в потоці Delayer. Блокувати його не можна!
  • Таймаут не перериває фоновий потік, що виконує завдання в пулі.

Типові помилки на інтерв’ю

  • Думати, що orTimeout() доступний у Java 8 (метод з’явився тільки в Java 9).
  • Вважати, що orTimeout() створює новий залежний ф’ючер (він мутує поточний).
  • Виконувати блокуючі операції в обробниках помилок після таймауту без суфікса *Async.

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