Четверть века назад язык, придуманный для «умных тостеров», стал символом корпоративного софта и огромных систем. Сегодня Java продолжает эволюционировать, и каждая новая версия всё сильнее ломает стереотип о «тяжёлом корпоративном динозавре». Встречайте 25 версию Java вместе с командой Spring АйО!

Основные фичи:

JEP 520: Метод-трейсинг и тайминг в JFR — замер времени конкретных методов без кода и агентов

В Java 25 JFR (Java Flight Recorder) позволяет точно отслеживать выполнение конкретных методов — без изменения исходников, без логирования и без сторонних Java-агентов.

Зачем это нужно?

Допустим, приложение долго стартует или внезапно теряет соединения с БД. Раньше приходилось:

  • Логировать вручную

  • Добавлять JFR-события в код

  • Подключать агент через -javaagent

  • Пытаться что-то угадывать с sampling-профайлером

  • Использовать Spring AOP и @Around-аспекты

Теперь всё можно сделать проще: точно, из коробки и с минимальной настройкой.

Что добавили?

Два новых события в JFR:

  • jdk.MethodTiming — считает вызовы, замеряет среднее, мин/макс время выполнения

  • jdk.MethodTrace — пишет стек вызова и длительность каждого вызова

И самое главное: всё настраивается фильтрами, без изменений в коде!

Пример использования

Допустим, вы хотите посмотреть, как часто и как долго вызывается HashMap::resize.

Запуск:

java -XX:StartFlightRecording=method-trace=java.util.HashMap::resize,filename=resize.jfr ...

Анализ:

jfr print --events jdk.MethodTrace resize.jfr

Пример вывода:

jdk.MethodTrace {
startTime = 00:39:26.379
duration = 0.00113 ms
method = java.util.HashMap.resize()
eventThread = "main"
stackTrace = [
  java.util.HashMap.putVal(...)
  java.util.HashMap.put(...)
  ...
  java2d.J2Ddemo.main(String[]) line: 674
  ]
}

Как ещё можно использовать?

Замерить все <clinit> и понять, что тормозит:

-XX:StartFlightRecording:method-timing=::<clinit>,filename=init.jfr

Отслеживать методы с аннотацией @Get из JAX-RS:

jcmd <pid> JFR.start method-timing=@jakarta.ws.rs.GET

Сравнивать, сколько раз и с каким временем выполнялся метод:

<setting name="filter">

com.example.Foo::doSomething;

com.example.Bar::handle

</setting>

Какой результат?

После запуска вы получаете .jfr-файл, который можно:

  • Просмотреть через jfr print или jfr view

  • Подгрузить в JDK Mission Control

  • Анализировать удалённо через JMX/RemoteRecordingStream

В чём польза?

  • Быстро находим горячие методы

  • Точно отслеживаем, что вызывает FileDescriptor::close

  • Проверяем гипотезу, стал ли метод быстрее после оптимизации

  • Отлаживаем проблемы без доступа к коду сторонних библиотек

Можно будет не модифицировать код, городить прокси или запускать -javaagent. Достаточно задать нужный фильтр, чтобы точно знать, где и когда выполняется нужный метод.

JEP 502: Stable Values

502 JEP мы разбирали в этой статье, он наконец-то принесёт в Java полноценные ленивые конструкции. С этим нововведением мы получаем ленивые значения, функции и коллекции! Преимущество в том, что компилятор HotSpot JIT сможет выполнять свёртку констант (constant-folding) для всех вариаций объектов, которые оборачиваются ссылкой StableValue. Для этого ссылка типа StableValue должна быть константой (помечена модификаторами  static final), либо если ссылка на StableValue считается транзитивно надёжной (об этом ниже).

Быстрый старт

В следующем примере кода показано, как реализовать высокопроизводительный ленивый логгер с использованием stable значений в JDK 25:

public record OrderControllerImpl(Supplier<Logger> logger) implements OrderController {
    @Override
    public void submitOrder(User user, List<Product> products) {
        logger.get().info("order started");
        // ...
        logger.get().info("order submitted");
    }

    /**
     * {@return a new OrderController}
     */
    public static OrderController create() {
        return new OrderControllerImpl(
                StableValue.supplier(
                        () -> Logger.create(OrderControllerImpl.class)));
    }
}

private static final OrderControler ORDER_CONTROLLER = OrderControllerImpl.create();
ORDER_CONTROLLER.submitOrder(...);
Комментарий от эксперта Spring АйО, Павла Кислова:

Никто не страхует ваш код от того, что будет вызван конструктор record. В java нет возможности скрыть canonical constuctor в record, как в scala или переименовать его, как это делают в Dart. Поэтому - это договоренность "пользуемся методом create() вместо вызова конструктора". 

Важно отметить, что record вместо обычного класса здесь используется по причине того, что внутренние поля у record final по-умолчанию. JVM без проблем применяет к ним оптимизацию свертывания констант. Возможность использовать обычный класс с приватным конструктором как это делается для builder-pattern тоже присутствует. Здесь демонстрируется лишь один из возможных вариантов.

Здесь мы используем небольшой трюк, чтобы заставить JIT-компилятор поверить, что поле logger никогда не изменится, — через использование record. Компоненты record-классов очень сложно изменить, и благодаря этому JIT-компилятор доверяет им.

Комментарий от эксперта Spring АйО, Михаила Поливахи:

Код выше позволяет вам получить immutable logger, который создается лениво, и который при этом JIT может спокойно свертывать как final поле (В Java уже долгие годы есть аннотация @Stable, которая не доступна обычным разработчикам. Она имеет прямое отношение к этому делу, почитайте)

У опытного инженера наверняка возникает вопрос, чем этот пример отличается от использования logger напрямую, да и вообще зачем так усложнять жизнь? Ведь можно просто написать:

public record OrderControllerImpl(Logger logger) implements OrderController {

    @Override
    public void submitOrder(User user, List<Product> products) {
        logger.info("order started");
        // ...
        logger.info("order submitted");
    }

    /** {@return a new OrderController} */
    public static OrderController create() {
        return new OrderControllerImpl(Logger.create(OrderControllerImpl.class));
    }
}

Действительно, в таком случае тоже нет накладных расходов на различного рода виртуальные вызовы через абстракции, а идет прямое обращение к Logger. Но проблема в том, что сам объект logger в heap создается eagerly. Иногда это недопустимо. Иногда объект может быть довольно тяжелый, и не факт, что вообще он понадобиться. И его имеет смысл инициализировать лениво. И StableValue это по сути ваш контракт с JIT компилятором, где Вы ему говорите - дружище, смотри, я тебе гарантирую, что та ссылка, которую продьюсирует StableValue - она не изменится (на самом деле сам дизайн StableValue не даст вам её поменять). Её можно констант фолдить спокойно, можешь мне верить, точно так же, как ты делала с @Stable и внутренними полями в JVM. И вы получаете по сути ленивые финальные поля. Вы создаете immutable объект, но инициализируете его лениво, при этом не теряете в производительности. Вот в чём суть.

Вот ещё один пример кеширования значений без вытеснения (non-evicting), с использованием API stable значений:

private static final Function<Integer, Double> SQRT_CACHE =
        StableValue.function(ffff, i -> StrictMath.sqrt(i));
...
// Вычисляется лениво, но всё ещё может быть свёрнуто JIT-компилятором -> 4
double sqrt16 = SQRT_CACHE.apply(16);

Значения в SQRT_CACHE изначально не установлены. Как только значение, например 4, будет запрошено, соответствующее значение sqrt() будет вычислено и добавлено в SQRT_CACHE. И самое главное - когда JIT впоследствии скомпилирует участок кода выше, то он сможет выполнить свёртку для виртуального вызова sqrt(16) и схлопнуть его на простую константу - 4.

Комментарий от эксперта Spring АйО, Михаила Поливахи:

С простой самописной Lazy оберткой вы бы не смогли просигнализировать JIT-у тот факт, что результат sqrt() неизменяемый. Даже если бы результат был бы final полем, к сожалению, это ни о чём не говорит. Если будет интересно, мы в будущем выпустим статью о том, почему final поля на самом деле не совсем являются final.

Почему это не назвали Lazy?

Это, пожалуй, самый частый вопрос, который мы получали от сообщества. Свойства «ленивого» (lazy) поля — это подмножество свойств «стабильного» (stable) поля. Оба типа предполагают ленивое вычисление в более широком смысле. Тем не менее, стабильное поле гарантированно вычисляется не более одного раза и теоретически может быть вычислено ahead-of-time (например, во время предыдущего прогона приложения).

Если же вы хотите создать свою обертку вокруг StableValue и назвать её, как вам кажется, более выразительно, например Lazy, то сделать это очень просто:

@FunctionalInterface
interface Lazy<T> extends Supplier<T> {
    static <T> Lazy<T> of(Supplier<? extends T> original) {
        return StableValue.supplier(original)::get;
    }
}

private static final Lazy<String> httpResponse = Lazy.of(() -> retrieveHttp(...));
System.out.println("The response was "+httpResponse.get());

Ссылки на методы (как StableValue.supplier(original)::get выше) реализуются с помощью скрытых (hidden) классов. Им также доверяет JIT-компилятор. Поэтому данный класс обеспечит те же оптимизации свёртки констант, что и StableValue.supplier(), и в остальном будет иметь почти такие же характеристики производительности.

Комментарий от эксперта Spring АйО, Михаила Поливахи:

Речь про JEP 371. Hidden классы генерирует сама JVM в разных случаях, как вот например в случае с method reference в StableValue.supplier()::get, где VM сгенерирует экземпляр hidden класса, который реализует нужный Lazy/Supplier интерфейс.

JIT компилятор считает их тоже trusted source-ом по разному роду причин, можете почитать JEP для детального разбора.

Подробнее о final-полях

Когда сообщество JDK обеспечит доверие и к final-полям экземпляров, можно будет использовать любые классы для хранения stable значений, функций и коллекций — при этом всё ещё достигая свёртки констант. Первый шаг в этом направлении уже сделан.

Scoped Values (JEP 506)

Безопасная передача контекста в потоки:

ScopedValue<String> USER = ScopedValue.newInstance();
ScopedValue.where(USER, "admin").run(() ->
    System.out.println(USER.get()) // admin
);

ScopedValue это отдельная, большая тема, она заслуживает своей собственной статьи. Если у сообщества будет интерес, то мы напишем подробную статью и про ScopedValue

Structured Concurrency (JEP 505)

API structured concurrency вновь изменился — после двух инкубационных этапов и четырёх раундов превью. В идеале такой сценарий кажется неожиданным. Однако, учитывая статус API как предварительного, подобные изменения возможны — как и произошло в данном случае. Эти изменения значительно повлияли на зрелость API, и есть надежда, что теперь он стабилизируется и больше не потребует доработок.

Что на самом деле изменилось на этот раз

Когда я впервые начал работать со structured concurrency ещё в инкубационной фазе, меня вдохновила идея более чистого и понятного concurrent кода.

Идея была проста: рассматривать параллельные задачи как структурированный блок, в котором все порождённые (spawned) задачи завершаются до выхода из блока. В теории звучало идеально, но API продолжал меняться, и за этими изменениями было сложно уследить.

Последняя итерация в JEP 505 привнесла важные усовершенствования, которые, на мой взгляд, наконец-то сделали этот функционал устойчивым. Самое заметное изменение — это введение более гибкой обработки задач и лучшая интеграция с виртуальными потоками. В этой статье я расскажу о различиях и поясню, почему они важны.

Основная концепция остаётся неизменной

Прежде чем перейти к изменениям, давайте вспомним, какую проблему пытается решить structured concurrency. В традиционном конкурентном программировании управление тасками часто оказывается разрозненным:

import java.util.Random;

import java.util.concurrent.*;

public class TraditionalConcurrencyExample {

    private static final Random random = new Random();

    private static String fetchUserData(String userId) throws InterruptedException {
        Thread.sleep(1000 + random.nextInt(2000)); // 1-3 seconds
        if (random.nextBoolean()) {
            throw new RuntimeException("User service unavailable");
        }
        return "UserData[" + userId + "]";
    }

    private static String fetchUserPreferences(String userId) throws InterruptedException {
        Thread.sleep(800 + random.nextInt(1500)); // 0.8-2.3 seconds
        if (random.nextBoolean()) {
            throw new RuntimeException("Preferences service down");
        }
        return "Preferences[" + userId + "]";
    }

    private static String combineUserInfo(String userData, String preferences) {
        return userData + " + " + preferences;
    }

    public static String getUserInfoTraditional(String userId) throws Exception {

        try (ExecutorService executor = Executors.newCachedThreadPool()) {
            Future<String> future1 = executor.submit(() -> fetchUserData(userId));
            Future<String> future2 = executor.submit(() -> fetchUserPreferences(userId));

            try {
                String userData = future1.get();
                String preferences = future2.get();
                return combineUserInfo(userData, preferences);
            } catch (Exception e) {

                // Cleanup is messy - what about the other task?
                System.out.println("Error occurred, attempting cleanup...");
                future1.cancel(true);
                future2.cancel(true);
                throw e;
            }
        }
    }

    void main() {
        for (int i = 0; i < 5; i++) {
            try {
                System.out.println("Attempt " + (i + 1) + ": " +
                        getUserInfoTraditional("user123"));
            } catch (Exception e) {
                System.out.println("Attempt " + (i + 1) + " failed: " +
                        e.getMessage());
            }
            System.out.println();
        }
    }
}

Когда вы запускаете этот код, обычно проявляется несколько проблем:

  • Сложная обработка ошибок: если одна из задач завершается с ошибкой, приходится вручную отменять вторую. В противном случае она продолжит выполняться, несмотря на то что больше не нужна, что приводит к утечке ресурсов.

  • Управление жизненным циклом потоков: вы сами несёте ответственность за полный жизненный цикл потоков.

  • Передача исключений: проверяемые (checked) исключения часто оборачиваются не самым правильным образом.

  • Отсутствие гарантии очистки: если основной поток завершается неожиданно, задачи могут продолжить выполняться.

Structured concurrency призвана решить эти проблемы.

Главное изменение: статические фабричные методы

Наиболее заметное новшество в JEP 505 — больше не нужно вызывать new StructuredTaskScope<>(). Вместо этого используется метод open():

try (var scope = StructuredTaskScope.open()) {
 // ...
}

Метод open() без аргументов возвращает область (scope), которая ожидает успешного завершения всех подзадач либо сбоя хотя бы одной — это политика по умолчанию «все-или-ошибка» (“all-or-fail”). Если требуется более гибкое поведение, используйте перегруженный вариант open(joiner) и передайте пользовательскую политику завершения через Joiner (об этом чуть позже). Почему используется фабрика? Она предоставляет вменяемые значения по умолчанию и, что особенно важно, допускает развитие имплементации без нарушения совместимости с вашим кодом. Я считаю это изменение удачным: использование одного ключевого метода делает код более лаконичным и снижает вероятность ошибок.

Теперь перепишем предыдущий пример с использованием нового API:

public static String getUserInfoTraditional(String userId) throws Exception {
    try (var scope = StructuredTaskScope.open()) {
        StructuredTaskScope.Subtask<String> task1 = scope.fork(() -> fetchUserData(userId));
        StructuredTaskScope.Subtask<String> task2 = scope.fork(() -> fetchUserPreferences(userId));
        scope.join();
        String userData = task1.get();
        String preferences = task2.get();
        return combineUserInfo(userData, preferences);
    }
}

Разница колоссальна. С использованием structured concurrency очистка выполняется автоматически и гарантированно. Если какая-либо из задач завершается с ошибкой, все остальные задачи в области отменяются. Если область завершается (нормально или с исключением), все ресурсы освобождаются. Это сопоставимо с механизмом try-with-resources — но для параллельных задач.

Такой подход даёт несколько очевидных преимуществ, которые я особенно ценю:

  • Гарантированная очистка: задачи не могут пережить свою область.

  • Явное владение: задачи принадлежат конкретной области.

  • Безопасность при исключениях: сбои обрабатываются последовательно.

  • Управление ресурсами: управление пулами потоков не требуется.

  • Композиционность: scope-ы/области можно складывать и комбинировать.

Joiner'ы: задаём политику успеха

Joiner перехватывает события завершения задач и определяет:

  1. следует ли отменять одноуровневые (соседние) задачи,

  2. что должен возвращать метод join().

JDK включает несколько фабричных помощников для таких случаев:

«Побеждает первая» (также известен как гонка реплик).

try (var scope = StructuredTaskScope.open(Joiner.<String>anySuccessfulResultOrThrow())) {
   urls.forEach(url -> scope.fork(() -> fetchFrom(url)));
   return scope.join();             // returns first successful String
}

«Все должны завершиться успешно, и мне нужны их результаты»

try (var scope = StructuredTaskScope.open(Joiner.<Result>allSuccessfulOrThrow())) {
   tasks.forEach(scope::fork);
   return scope.join()              // Stream<Subtask<Result>>
                .map(Subtask::get)
                .toList();
}

Эти небольшие помощники упрощают распространённые шаблоны — «гонка» (“race”), «сбор» (“gather”), «ожидание всех» (“wait-for-all”).

Создание собственного Joiner'а

Иногда требуется своя политика. Предположим, я хочу собрать все успешные подзадачи, проигнорировав сбои:

import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.StructuredTaskScope;
import java.util.stream.Stream;

void main() {

  List<String> urls = List.of("https://bazlur.ca", "https://foojay.io", "https://github.com");

  try (var scope = StructuredTaskScope.open(new MyCollectingJoiner<String>())) {
    urls.forEach(url -> scope.fork(() -> fetchFrom(url)));
    List<String> fetchedContent = scope.join().toList();

    System.out.println("Total fetched content: " + fetchedContent.size());
  } catch (InterruptedException e) {
    throw new RuntimeException(e);
  }

}

private String fetchFrom(String url) {
  return "fetched from " + url + "";
}

class MyCollectingJoiner<T> implements StructuredTaskScope.Joiner<T, Stream<T>> {
  private final Queue<T> results = new ConcurrentLinkedQueue<>();

  @Override
  public boolean onComplete(StructuredTaskScope.Subtask<? extends T> st) {
    if (st.state() == StructuredTaskScope.Subtask.State.SUCCESS)
      results.add(st.get());
    return false;
  }

  @Override
  public Stream<T> result() {
    return results.stream();
  }
}

Интерфейс минимален — onFork, onComplete и result() — но при этом достаточно мощный для большинства пользовательских сценариев. Чтобы запустить этот код, потребуется JDK 25, и его можно выполнить из командной строки с помощью следующей команды:

java --enable-preview CollectingJoiner.java
Комментарий от эксперта команды Spring АйО, Михаила Поливахи:

Java 25 GA на момент публикации статьи ещё официально не вышла. Выход 25-ой Java планируется на Сентябрь 2025. Пока есть только early access билды

Улучшенная отмена и дедлайны

Правила отмены по сути не изменились, но API стал строже. Если поток-владелец прерывается до или во время вызова join(), область автоматически отменяет все незавершённые подзадачи. Подзадачи должны корректно обрабатывать InterruptedException; в противном случае close() будет блокироваться в ожидании их завершения. (Если вы используете блокирующий ввод-вывод — всё в порядке; если используете polling — не забудьте проверять Thread.currentThread().isInterrupted().)

Нужен дедлайн? Передайте конфигурационную лямбду:

try (var scope = StructuredTaskScope.open(
         Joiner.<String>anySuccessfulResultOrThrow(),
         cfg -> cfg.withTimeout(Duration.ofSeconds(2)))) {
    // ...
}

Если срабатывает таймаут, scope отменяется, а join() выбрасывает TimeoutException. На практике я привязываю таймаут к каждому внешнему вызову, чтобы держать неуправляемые задачи под контролем.

Также можно заменить фабрику виртуальных потоков по умолчанию на ту, которая задаёт имена или устанавливает thread-local переменные:

ThreadFactory tagged = Thread.ofVirtual().name("api-%d").factory();

try (var scope = StructuredTaskScope.open(
         Joiner.<Integer>allSuccessfulOrThrow(),
         cfg -> cfg.withThreadFactory(tagged))) {
    // ...
}

Одни только имена потоков делают дампы значительно читаемее.

ScopedValues передаются автоматически

Все подзадачи наследуют привязки ScopedValues, установленные в родительском потоке. Это означает, что можно передавать контекст запроса, учетные данные безопасности или информацию MDC без необходимости вручную упаковывать их в каждую лямбду. Опробовав эту возможность, к ThreadLocal возвращаться уже не хочется.

Комментарий от команды Spring АйО:

Про Scoped Values мы как-то уже рассказывали. Вот первая и вторая части.

Защита от неправильного использования

StructuredTaskScope строго соблюдает структуру. Если вызвать fork() из потока, отличного от владельца, будет выброшено исключение StructureViolationException. Забыли использовать try-with-resources и позволили scope выйти за пределы метода? Результат тот же. Подход строгий, но эффективно предотвращает случайное истощение ресурсов (по аналогии с «fork-бомбами»).

Комментарий от Михаила Поливахи

Речь про fork-bomb уязвимость, суть которой заключается в том, что вредоносный процесс fork`ается бесконтрольно, потребляя при этом все ресурсы сервера. https://en.wikipedia.org/wiki/Fork_bomb

Улучшения в Observability

Теперь thread дампы включают дерево скоупов, так что инструменты могут напрямую отображать отношения родитель–потомок. Когда я запускаю jcmd <pid> Thread.dump_to_file -format=json, каждый scope отображается со своими forked потоками, вложенными под владельцем. Найти зависшую задачу, блокирующую виртуальный пул, теперь занимает пару секунд с grep — вместо получасового разбора.

Ещё несколько примеров для практики

Пример 1 — 360°-обзор продукта (сбор–затем–ошибка)

Классический endpoint в e-commerce: один HTTP-запрос должен агрегировать основную информацию о продукте, данные о наличии в реальном времени и персонализированную цену. Каждый подсервис вызывается параллельно внутри StructuredTaskScope, где применяется политика «всё или ничего»: любая ошибка или превышение односекундного дедлайна отменяет всю группу и возвращает ошибку вызывающему.

Таймаут области, пользовательские имена потоков и joiner allSuccessfulOrThrow() позволяют выразить то, что обычно требует сложной конфигурации CompletableFuture, всего в трёх декларативных строках.

import java.time.Duration;
import java.util.Random;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.ThreadFactory;

public class ThreeSixtyProductView {
  record Product(long id, String name) {}
  record Stock(long productId, int quantity) {}
  record Price(long productId, double amount) {}
  record ProductPayload(Product core, Stock stock, Price price) {}

  private static Product coreApi(long id) throws InterruptedException {
    Thread.sleep(100); // simulate latency
    return new Product(id, "Gadget‑" + id);
  }

  private static Stock stockApi(long id) throws InterruptedException {
    Thread.sleep(120);
    return new Stock(id, new Random().nextInt(100));
  }

  private static Price priceApi(long id) throws InterruptedException {
    Thread.sleep(150);
    return new Price(id, 99.99);
  }

  static ProductPayload fetchProduct(long id) throws Exception {
    ThreadFactory named = Thread.ofVirtual().name("prod-%d", 1).factory();

    try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.<Object>allSuccessfulOrThrow(),
        cfg -> cfg.withTimeout(Duration.ofSeconds(1))
            .withThreadFactory(named))) {

      StructuredTaskScope.Subtask<Product> core = scope.fork(() -> coreApi(id));
      StructuredTaskScope.Subtask<Stock> stock = scope.fork(() -> stockApi(id));
      StructuredTaskScope.Subtask<Price> price = scope.fork(() -> priceApi(id));

      scope.join(); // throws on first failure / timeout
      return new ProductPayload(core.get(), stock.get(), price.get());
    }
  }

  void main() throws Exception {
    ProductPayload productPayload = fetchProduct(1L);
    System.out.println(productPayload);
  }
}

Пример 2 — «Гонка зеркал» для загрузки файлов

Крупные бинарные файлы размещаются на нескольких зеркалах CDN. Поскольку задержки различаются, запросы отправляются на все зеркала одновременно, а Joiner.anySuccessfulResultOrThrow() используется для выбора первого успешно возвращённого InputStream, при этом все остальные задачи отменяются.

Пропускная способность и соединения освобождаются мгновенно, а пользователи получают максимально быструю загрузку — без необходимости вручную реализовывать логику отмены.

import java.io.*;
import java.net.URI;
import java.nio.file.*;
import java.util.List;
import java.util.Random;
import java.util.concurrent.StructuredTaskScope;

public class MirrorDownloaderDemo {
  void main() throws Exception {
    List<URI> mirrors = List.of(
        URI.create("https://mirror‑a.example.com"),
        URI.create("https://mirror‑b.example.com"),
        URI.create("https://mirror‑c.example.com"));

    Path target = Files.createFile(Path.of("download1.txt"));
    download(target, mirrors);
    System.out.println("Saved to " + target.toAbsolutePath());
  }

  static Path download(Path target, List<URI> mirrors) throws Exception {
    try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.<InputStream>anySuccessfulResultOrThrow())) {

      mirrors.forEach(uri -> scope.fork(() -> fetchFromMirror(uri)));
      try (InputStream in = scope.join()) {
        Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
      }
      return target;
    }
  }

  private static InputStream fetchFromMirror(URI uri) throws InterruptedException {
    Thread.sleep(50 + new Random().nextInt(300));
    String data = "Downloaded from " + uri + "\n";
    return new ByteArrayInputStream(data.getBytes());
  }
}

Пример 3 — Пакетный генератор миниатюр с вложенными областями

Этап медиапайплайна получает каталог изображений. Внешний скоуп перебирает файлы, а внутренний — для каждого изображения — распараллеливает внутри ещё на три задачи ресайза (маленький, средний и большой размеры). Внутренний скоуп работает по принципу «быстрый отказ»: если один из ресайзов завершается с ошибкой, изображение пропускается, но внешний скуоп и его батчёвая обработка продолжается без прерывания.

Вложенные скоупы позволяют разделить консистентность на уровне отдельного элемента и общую производительность партии — при минимуме кода.

import java.io.IOException;
import java.nio.file.*;
import java.util.concurrent.StructuredTaskScope;

public class ThumbnailBatchDemo {
  enum Size {SMALL, MEDIUM, LARGE}

  void main() throws Exception {
    Path tmpDir = Files.createTempDirectory("images");
    for (int i = 0; i < 3; i++) Files.createTempFile(tmpDir, "img" + i, ".jpg");
    processBatch(tmpDir);
  }

  static void processBatch(Path dir) throws IOException, InterruptedException {
    try (var batch = StructuredTaskScope.open()) {
      try (var files = Files.list(dir)) {
        files.filter(Files::isRegularFile)
            .forEach(img -> batch.fork(() -> handleOne(img)));
      }
      batch.join();
    }
  }

  private static void handleOne(Path image) {
    try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.<Void>allSuccessfulOrThrow())) {
      scope.fork(() -> resizeAndUpload(image, Size.SMALL));
      scope.fork(() -> resizeAndUpload(image, Size.MEDIUM));
      scope.fork(() -> resizeAndUpload(image, Size.LARGE));
      scope.join();
    } catch (Exception ex) {
      System.err.println("Skipping " + image.getFileName() + ": " + ex);
    }
  }

  private static Void resizeAndUpload(Path image, Size size) throws InterruptedException {
    Thread.sleep(80); // simulate resize
    Thread.sleep(40); // simulate upload
    System.out.println("Uploaded " + image.getFileName() + " [" + size + "]");
    return null;
  }

Пример 4 — Служба котировок в реальном времени с резервным механизмом по таймауту

Трейдинговый UI требует котировку не позднее чем через 30 мс. Пользовательский joiner захватывает первую успешную цену из основного рыночного источника, с таймаутом области в 30 мс. Если источник зависает, scope.join() возвращает пустой результат, и сервис мгновенно переключается на вчерашнюю кэшированную цену закрытия.

Вызывающие стороны всегда получают значение вовремя, а вся логика обработки таймаута выражена одной декларативной строкой.

import java.time.Duration;
import java.util.*;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;

public class QuoteServiceDemo {
  void main() throws Exception {
    double q = quote("ACME");
    System.out.printf("Quote for ACME: %.2f%n", q);
  }

  static double quote(String symbol) throws InterruptedException {
    var firstSuccess = new StructuredTaskScope.Joiner<Double, Optional<Double>>() {
      private volatile Double value;

      public boolean onComplete(Subtask<? extends Double> st) {
        if (st.state() == Subtask.State.SUCCESS) value = st.get();
        return value != null;           // stop when we have one
      }

      public Optional<Double> result() {
        return Optional.ofNullable(value);
      }
    };

    try (var scope = StructuredTaskScope.open(firstSuccess,
        cfg -> cfg.withTimeout(Duration.ofMillis(30)))) {
      scope.fork(() -> marketFeed(symbol));
      Optional<Double> latest = scope.join();
      return latest.orElseGet(() -> cache(symbol));
    }
  }

  private static double marketFeed(String symbol) throws InterruptedException {
    long delay = new Random().nextBoolean() ? 20 : 60; // 50 % chance timeout
    Thread.sleep(delay);
    return 100 + new Random().nextDouble();
  }

  //for demo purposes only
  private static double cache(String symbol) {
    return 95.00;
  }
}

Заключительные мысли

Эти изменения представляют значительный этап в развитии API structured concurrency.

Сегодняшняя версия API structured concurrency значительно лучше той, с которой всё начиналось, и я уверен, что она станет прочной основой для многопоточного программирования на Java в будущем.

Primitive Types в pattern matching (JEP 507)

Java 25 продолжает развивать pattern matching: теперь в instanceof и switch можно будет использовать примитивные типы!

В чем была проблема?

Раньше pattern matching работал только с объектами:

if (obj instanceof String s) { ... }

А вот так было нельзя:

 if (x instanceof int i) { ... } // Ошибка

В примере выше, вы обязаны использовать Integer, а не int, тем самым осуществляя боксинг, что не очень хорошо. То же самое со switch — примитивы были ограничены: нельзя было использовать switch на boolean, float, double, long или с примитивными паттернами.

Что добавили?

Теперь можно будет:

Использовать примитивы в instanceof:

if (num instanceof byte b) { ... } // Автоматически проверит, влезает ли num в byte

Писать switch по boolean, long, float, double:

switch (flag) {
  case true -> System.out.println("Да");
  case false -> System.out.println("Нет");
}

Использовать примитивные паттерны в switch:

switch (value) {
  case 0 -> System.out.println("Ноль");
  case int i when i > 0 -> System.out.println("Положительное: " + i);
}

Почему это круто?

  • упрощаются безопасные преобразования типов без ручных проверок диапазонов.

  • уходит необходимость делать боксинг для ряда операций, благодаря чему улучшается общий перформанс

  • убираются лишние if перед кастами.

  • switch будет действительно универсальным: можно будет свитчить по любым типам.

  • можно будет использовать примитивы в record-паттернах и instanceof так же просто, как объекты.

Единые паттерны как для объектов, так и для примитивов делают код более простым и убирают ряд в прошлом необходимых боксингов, if стейтментов и т.д, чем улучшают читабельность кода и его performance.

Vector API (JEP 508, Tenth Incubator) 

Новый Vector API позволяет получить доступ к быстрым SIMD-операциям процессора прямо из Java:

var a = IntVector.fromArray(SPECIES, arr1, 0);
var b = IntVector.fromArray(SPECIES, arr2, 0);
var c = a.add(b); // параллельное сложение

На тему SIMD инструкций у нас тоже пока нет отдельной статьи. SIMD инструкции это тоже довольно большая тема, которая требует отдельного технического бекграунда для пояснения, короткой заметки тут не хватит. Если сообществу будет интересно - мы напишем подробную статью и про них.

Module Import Declarations (JEP 511)

В Java 25 появилась долгожданная фича: импорт модулей. Теперь можно одним импортом подключать все пакеты, которые экспортирует модуль. Это сильно упрощает работу с большими библиотеками, особенно в прототипах и обучении.

В чем проблема?

Например, чтобы работать с потоками, коллекциями и функциями, раньше приходилось писать кучу импортов:

import java.util.*;
import java.util.function.*;
import java.util.stream.*;

Теперь можно будет написать:

 import module java.base;

И все нужные классы из java.util, java.util.stream и других будут доступны сразу.

Зачем это нужно?

  • Быстрее подключать модули целиком без перечисления всех пакетов.

  • Удобно для прототипов, скриптов и JShell.

  • Упрощает жизнь новичкам — не нужно вспоминать, где в иерархии пакетов живет List или Stream.

Комментарий от эксперта Spring АйО, Михаила Поливахи:

На мой взгляд, такой module-импорт вносит некоторую неразбериху, и теперь, смотря на import statement-ы не всегда становится очевидно, откуда конкретно импортируется класс. Как раз за это многие ругали в своё время wildcard imports.

Как правило импорты резолвит сама IDE, и нам ничего вспоминать не надо, а размер секции импортов в исходном файле не так чтобы сильно доставляет проблемы, поэтому нововведение, конечно, спорное, но тем не менее, его надо осветить.

Пример:

import module java.sql;

public class Demo {

    public static void main(String[] args) throws Exception {
        Connection conn = DriverManager.getConnection("jdbc:h2:mem:");
        Statement stmt = conn.createStatement();
        stmt.execute("create table test(id int)");
        System.out.println("Таблица создана");
    }
}

Благодаря import module java.sql; доступны все классы из java.sql и javax.sql сразу.

Как это работает?

import module M; — подключает все публичные классы и интерфейсы из экспортируемых пакетов модуля M.

Также подключаются пакеты из модулей, от которых M зависит транзитивно.

Важно:

Это работает даже в обычных (не модульных) проектах.

Если в разных модулях встречаются классы с одинаковыми именами, компилятор сообщит об ошибке. В этом случае можно уточнить импорт:

import java.sql.Date; // Уточнение, какой именно класс Date использовать

Java постоянно расширяется, и стандартные библиотеки становятся все объемнее.

import module — это способ сделать работу с ними проще и быстрее, без потери совместимости.

Compact Object Headers (JEP 519)

Java 25 анонсирует ещё одну немаловажную фичу: компактные заголовки объектов (Compact Object Headers). Это позволит JVM экономить память при лейауте объектов в Java Heap.

В чем суть, если коротко?

У каждого объекта в Java есть заголовок — служебные данные, которые JVM использует для синхронизации, GC и т.д. Обычно заголовок занимает 96 бит (12 байт) на 64-битных платформах.

С JEP 450 в Java 24 появится экспериментальная опция сжать заголовки до 64 бит (8 байт). JEP 519 делает это стабильной, проверенной фичей, которую можно безопасно включать прямо в проде.

Зачем?

  • Минус 22% памяти в SPECjbb2015

  • Минус 8% CPU в среднем

  • Минус 15% сборок мусора в G1 и Parallel GC (т.е. сборки сами по себе проходят реже, что хорошо)

  • +10% скорость парсинга JSON

Комментарий от эксперта Spring АйО, Михаила Поливахи

Для тех, кто не в курсе, SPECjbb2015 это такой "well-known"ворклоад для бенчмарков + некоторый тулинг вокруг для сборка статистики и их репортинга. Его довольно часто используют в OpenJDK как плейграунд для бенчмаркинга каких-то изменений

Для распределённых систем, высоконагруженных сервисов и микросервисов — это простой способ сэкономить ресурсы без переписывания кода.

Как включить?

В Java 25 больше не понадобится флаг

-XX:+UnlockExperimentalVMOptions

Теперь достаточно:

java -XX:+UseCompactObjectHeaders ...

Пруфы?

  • Протестировано в проде на сотнях сервисов Amazon.

  • Прогнано по полному тест-сьюту JDK в Oracle.

  • Отдельные компании уже бэкпортят фичу на JDK 21 и 17.

Что важно знать?

  • По умолчанию выключено.

  • Не влияет на функциональность приложения.

  • Может потребовать внимания в будущем, если другие фичи потребуют больше битов в заголовке (но для этого есть решения в рамках проектов Valhalla и Lilliput).

Generational Shenandoah GC (JEP 521) 

Теперь сборщик разделяет кучу на молодое и старое поколение объектов. Ранее, Shenandoah была сборщиком без поколений. 

Ещё:

Улучшения в G1 GC, компактные исходники (JEP 512), гибкие конструкторы (JEP 513) и многое другое. А в контейнерах JVM стала меньше и умнее, лучше учитывает лимиты памяти и CPU. Для Kubernetes – меньше сюрпризов и затрат на облака.

Опять же, сборка мусора - отдельная большая тема. Можем рассказать более подробно про нововведения если будет потребность.

И напоследок... Oracle официально выводит GraalVM из Java Standard Edition.

Что это значит:

GraalVM для JDK 24 стал последним релизом, поддерживаемым в составе Oracle Java SE. Экспериментальный GraalVM JIT также более не будет поставляться OracleJDK. Native Image как Early Adopter-технология больше не входит в Java SE.

Дальше развитие GraalVM для Java переходит в Project Leyden — именно там теперь будут решать задачи ускорения старта, снижения footprint и улучшения по time-to-peak performance.

Сам же GraalVM сосредоточится на других языках — GraalPy, GraalJS и прочих.

Как быть?

  • Если вы использовали Graal JIT — переходите на стандартный C2 JIT в OpenJDK.

  • Если интересовались AOT — смотрите на Java 25: там уже JEP 514 (Ahead-of-Time Command-Line Ergonomics) и JEP 515 (Ahead-of-Time Method Profiling).

Комментарий от эксперта Spring АйО, Михаила Поливахи

Ну, то есть просто переходите на любой дистрибутив OpenJDK. Tiered компиляция работает уже давно по-умолчанию, поэтому, работая на любом OpenJDK дистрибутиве (например temurin), вы по-дефолту будете бенефитить от C2.

Это не значит, что GraalVM проект более не развивается. Как высказался Thomas Wuerthinger, Project Lead GraalVM и отдельный Product Manager Java Standard Edition в данном посте на Reddit - GraalVM как проект остается и никуда не уходит. Происходит лишь реорганизация продукта GraalVM JIT.

? Отмечаем релиз любимой Java в комментах!


Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано

Комментарии (0)