Золотая богиня (Java) на льве (JVM/Native Image) в цифровом мире
Золотая богиня (Java) на льве (JVM/Native Image) в цифровом мире

Почему ваша Java-система буксует там, где должна летать? Мы привыкли доверять магии JVM, но в мире Java 21 и Native Image правила игры изменились. От микро-оптимизаций байт-кода до радикальной смены парадигмы с Scoped Values – разбираем 11 “золотых правил”, которые заставят JIT петь, а ваш бинарник – стартовать за миллисекунды. Никакой “воды”, только хардкор, регистры процессора и “голоса” компиляторов внутри вашего кода.

Работая с кодом, я не раз ловил азарт: а как этот метод можно ускорить ещё? Какую гайку подкрутить, чтобы JVM не просто работала, а буквально летела? Что изменить в архитектуре, чтобы Native Image стал ещё компактнее, а холодный старт – ещё быстрее?

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

Это не просто советы по стилю кода. Это “10+1 Золотых правил оптимизации Java 21+”.

Это те рычаги, которые заставляют JIT-компилятор петь, а GraalVM – генерировать бинарники с хирургической точностью.

Приготовьтесь! Мы начинаем оптимизировать!

Правило №1. Records как DTO (Immutability & Heap)

В чём боль: Обычные POJO с сеттерами – это “чёрный ящик”. Компилятор постоянно начеку: вдруг кто-то изменит состояние объекта в середине метода? Это мешает глубокой оптимизации и усложняет анализ графа объектов.

Золотое решение: Используйте record для всех данных, которые просто “летят” сквозь систему.

public record UserUpsertRequest (

        @NotBlank(message = VALIDATE_USER_USERNAME_BLANK)
        @Size(min = NAME_SIZE_MIN, max = NAME_SIZE_MAX, message = VALIDATE_USER_USERNAME_INCORRECT_SIZE)
        @Pattern(regexp = LATIN_REGEX, message = VALIDATE_USER_USERNAME_INCORRECT_REGEX)
        String username,

        @NotBlank(message = VALIDATE_USER_PASSWORD_BLANK)
        @Size(min = PASSWORD_SIZE_MIN, max = PASSWORD_SIZE_MAX, message = VALIDATE_USER_PASSWORD_INCORRECT_SIZE)
        String password,

        @NotBlank(message = VALIDATE_USER_FIRSTNAME_BLANK)
        @Size(min = NAME_SIZE_MIN, max = NAME_SIZE_MAX, message = VALIDATE_USER_FIRSTNAME_INCORRECT_SIZE)
        @Pattern(regexp = CYRILLIC_REGEX, message = VALIDATE_USER_FIRSTNAME_INCORRECT_REGEX)
        String firstName,

        @Size(min = NAME_SIZE_MIN, max = NAME_SIZE_MAX, message = VALIDATE_USER_SECONDNAME_INCORRECT_SIZE)
        @Pattern(regexp = CYRILLIC_REGEX, message = VALIDATE_USER_SECONDNAME_INCORRECT_REGEX)
        String secondName,

        @NotBlank(message = VALIDATE_USER_LASTNAME_BLANK)
        @Size(min = NAME_SIZE_MIN, max = NAME_SIZE_MAX, message = VALIDATE_USER_LASTNAME_INCORRECT_SIZE)
        @Pattern(regexp = CYRILLIC_REGEX, message = VALIDATE_USER_LASTNAME_INCORRECT_REGEX)
        String lastName,

        @NotBlank(message = VALIDATE_USER_EMAIL_BLANK)
        @Size(min = NAME_SIZE_MIN, max = NAME_SIZE_MAX, message = VALIDATE_USER_EMAIL_INCORRECT_SIZE)
        @Email(regexp = EMAIL_REGEX, message = VALIDATE_USER_EMAIL_INCORRECT_REGEX)
        String email
) {

}

Голос JIT: “О, record! Наконец-то я вижу final поля по умолчанию. Теперь я точно знаю, что данные не изменится после создания. Я могу агрессивнее применять Scalar Replacement (разложить объект на переменные) и заинлайнить доступ к ним прямо в регистры процессора”.

Шёпот AOT: “Поскольку структура record известна мне ещё на этапе сборки, я могу оптимизировать маппинг этих данных в бинарный код гораздо агрессивнее, чем для обычных классов с их динамической природой”.

Правило №2. fillInStackTrace(null) в бизнес-исключениях

В чём боль: Мы часто используем Exception для логики (например, UserNotFound). Но создание исключения – это не просто создание объекта, это дорогое “путешествие” по всему стеку вызовов для заполнения массива StackStraceElement[]. На это уходит до 90% времени "жизни" исключения.

Золотое решение: Для предсказуемых бизнес-ошибок, где вам не нужен лог со всеми внутренностями фреймворка, переопределите сбор стектрейса.

public class EntityNotFoundException extends RuntimeException {
    public EntityNotFoundException(String message) {
        super(message, null, false, false); // ещё быстрее через конструктор
    }

    @Override
    public synchronized Throwable fillInStackTrace() {
        // Мы не собираем трассировку стека, что позволяет экономить ресурсы CPU
        return this;
    }
}

Голос JIT: “Спасибо! Когда вы создаёте обычное исключение, я вынужден бросить всё и побайтово восстанавливать цепочку вызовов. Этот как ставить фильм на паузу, чтобы пересчитать все кадры. С этим правилом я просто создаю объект и бегу дальше, сохраняя темп выполнения”.

Шёпот AOT: “В Native Image каждый стектрейс – это дополнительный мета-код, который я должен уметь восстанавливать в рантайме. Убирая fillInStackTrace, вы не только ускоряете логику, но и делаете мой бинарник компактнее, избавляя меня от лишних таблиц метаданных”.

Правило №3. Final везде

В чём боль: Неопределённость. Если переменная не помечена как final, компилятор должен учитывать возможность её изменения в любой момент. Это раздувает граф состояний, который нужно анализировать при оптимизации.

Золотое решение: Делайте final локальные переменные, парамет��ы методов и поля классов. Оптимизированный код – это прежде всего предсказуемый код. Чем меньше переменных могут изменить своё состояние, тем агрессивнее работают оптимизаторы.

public Resource exportDataToCsvResource() {
    final List<Statistics> data = statisticsRepository.findAll();

    final StringBuilder builder = new StringBuilder(data.size() * 64);

    builder.append(FIRST_ROW_OF_CSV);

    for (final Statistics stat : data) {
        builder.append(stat.getId()).append(DELIMITER_CSV)
                .append(stat.getType()).append(DELIMITER_CSV)
                .append(stat.getUserId()).append(DELIMITER_CSV)
                .append(stat.getCheckIn()).append(DELIMITER_CSV)
                .append(stat.getCheckOut()).append(DELIMITER_CSV)
                .append(stat.getCreatedAt()).append('\n');
    }

    final byte[] bytes = builder.toString().getBytes(StandardCharsets.UTF_8);
    return new ByteArrayResource(bytes);
}

Голос JIT: “Вижу final – делаю Constant Folding. Если я уверен, что ссылка на объект или значение переменной не изменится, я могу выкинуть лишние проверки из машинного кода и даже заранее вычислить результат некоторых операций. Для меня final – это не ограничение, а зелёный свет: “Здесь безопасно, жми на газ!”.

Шёпот AOT: “Для меня final – это база для Dead Code Elimination. Если я вижу константное условие, я могу просто “отрезать” целые ветки кода, которые никогда не исполнятся. Это делает бинарный файл меньше, а логику - прямолинейнее”.

Правило №4. Смерть Рефлексии (AOT-friendly)

В чём боль: Рефлексия – это “чёрная дыра” для производительности. JIT не может заранее заглянуть внутрь вызова через Method.invoke(), а Native Image и вовсе требует описывать каждый такой "чих" в JSON-конфигах. Если вы используете рефлексию в критическом узле, вы добровольно отказываетесь от 30-50% потенциал��ной скорости.

Золотое решение: Используйте MapStruct, JOOQ и другие библиотеки, работающие через кодогенерацию (APT). Они создают чистый Java-код на этапе компиляции, который выглядит так, будто вы написали его руками - с прямыми вызовами геттеров и сеттеров.

@Mapper(componentModel = MappingConstants.ComponentModel.SPRING,
        unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface UserMapper {

    User requestToUser(UserUpsertRequest request);

    UserResponse userToResponse(User user);

    default UserListResponse userListToUserListResponse(List<User> users) {
        return new UserListResponse(users
                .stream()
                .map(this::userToResponse)
                .toList());
    }

    @Mapping(target = "id", ignore = true)
    @Mapping(target = "username", ignore = true)
    @Mapping(target = "password", ignore = true)
    @Mapping(target = "roles", ignore = true)
    @Mapping(target = "createAt", ignore = true)
    @Mapping(target = "updateAt", ignore = true)
    void updateUser(UserUpsertRequest request, @MappingTarget User user);
}

Голос JIT: “Рефлексия для меня – это как туман на трассе. Я не вижу, что впереди и снижаю скорость до минимума, отключая все свои суперспособности. А код от MapStruct – это прямой автобан. Я вижу entity.getName() -> dto.setName() и просто “прошиваю” этот вызов насквозь через Inlining”.

Шёпот AOT: “Рефлексия – мой ночной кошмар. Чтобы она заработала в Native Image, мне нужно тащить за собой кучу метаданных, что раздувает бинарник. Кодогенерация позволяет мне выкинуть всё лишнее ещё при сборке. Меньше рефлексии – меньше reflect-config.json и быстрее старт”.

Правило №5. Короткие методы (Inlining Threshold)

В чём боль: Гигантские методы на 500 строк, которые делают всё: валидируют, считают, логируют и сохраняют. JIT не может их “проглотить” (встроить один в другой), потому что они превышают лимиты по размеру байт-кода. В итоге каждый вызов этого метода – это честный переход по адресу в памяти, создание фрейма в стеке и куча лишних тактов процессора.

Золотое решение: Дробите логику на мелкие методы. Идеальный размер для инлайнинга – до 35 байт байт-кода. Красивый код по Clean Code внезапно оказывается самым быстрым для машины.

@Before("@annotation(AuthoriseUserCreateByAnonymous)")
public void validateRoleTypeForAnonymousUserCreate(JoinPoint joinPoint) {
    HttpServletRequest request = getRequest();

    loggingOperation(joinPoint, request);

    Authentication auth = getAuth();

    if (!auth.getName().equals(ANONYMOUS_USER)) {
        AppUserPrincipal principal =
                ((AppUserPrincipal) auth.getPrincipal());

        if (isAdmin(principal)) {
            return;
        }

        throw new ForbiddenException(TEMPLATE_OPERATION_FORBIDDEN);
    }

    if (isRoleTypeUser(joinPoint)) {
        return;
    }

    throw new ForbiddenException(TEMPLATE_OPERATION_FORBIDDEN);
}

private HttpServletRequest getRequest() {
    RequestAttributes requestAttributes =
            RequestContextHolder.getRequestAttributes();

    if (requestAttributes == null) {
        throw new ForbiddenException(TEMPLATE_OPERATION_FORBIDDEN);
    }

    return ((ServletRequestAttributes) requestAttributes).getRequest();
}

private void loggingOperation(JoinPoint joinPoint,
                              HttpServletRequest request) {
    Map<String, String> pathVariables =
            (Map<String, String>) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);

    Authentication auth =
            SecurityContextHolder.getContext().getAuthentication();

    log.info(CALL_OPERATION,
            auth.getName(),
            joinPoint.getSignature().getName(),
            pathVariables.toString(),
            Arrays.toString(joinPoint.getArgs()));
}

private Authentication getAuth() {
    return SecurityContextHolder.getContext().getAuthentication();
}

private AppUserPrincipal getUserDetails() {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();

    if (auth == null || !auth.isAuthenticated()) {
        throw new UserNotAuthenticatedException(TEMPLATE_OPERATION_UNAUTHORIZED);
    }

    return (AppUserPrincipal) auth.getPrincipal();
}

Голос JIT: “У меня есть жёсткий лимит MaxInlineSize. Если метод крохотный, я просто копирую его тело в место вызова. Границы между методами исчезают, код становится монолитным и летит со скоростью света. Огромные методы я вынужден вызывать “по старинке” - с сохранением состояния стека и прыжками по адресами. Будьте проще, и я сделаю ваш код по-настоящему быстрым!"

Шёпот AOT: “В Native Image я провожу глубокий анализ достижимости кода. Мелкие методы позволяют мне точнее определить, какие части программы никогда не будут вызваны, и полностью вырезать их при сборке. Чем чище структура ваших методов, тем стройнее и быстрее итоговый бинарник”.

Правило №6. Преаллокация коллекций

В чём боль: Создавая new ArrayList<>(), new HashMap<>() и другие структуры данных без параметров, вы подписываете JVM на серию “переездов”. Как только список наполняется, он создаёт массив побольше и копирует туда старые данные. Это лишние аллокации, фрагментация памяти и работа для GC.

Золотое решение: Задавайте Initial Capacity. В Java 19+ для этого появились ещё более удобные статические методы, которые сами учитывают коэффициент загрузки (Load Factor).

private Mono<ServerResponse> renderErrorResponse(ServerRequest request) {
    // Предварительное выделение 16 buckets
    Map<String, Object> errorProperties = LinkedHashMap.newLinkedHashMap(16);

    errorProperties.putAll(getErrorAttributes(request,
            ErrorAttributeOptions.defaults()));

    int status =
            (int) errorProperties.getOrDefault("status", 500);

    return ServerResponse.status(status)
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValue(errorProperties)
            .doOnNext(resp -> log.error("Ошибка запроса: [{}]: {}",
                    status,
                    errorProperties));
}

Голос JIT: “Каждый раз, когда массив внутри коллекции расширяется, я слышу плач Garbage Collector’a. Заранее заданный размер – это как забронированный столик в ресторане: никакой суеты и лишних движений. Я просто выделяю один кусок памяти и спокойно работаю с ним, не отвлекаясь на перекладывание байтов”.

Шёпот AOT: “В Native Image управление памятью ещё более строгое. Преаллокация позволяет мне лучше предсказать пиковое потребление RAM вашим приложением. Чем меньше у вас динамических расширений массивов, тем стабильнее и предсказуемее ведёт себя бинарник под нагрузкой”.

Правило №7. BigDecimal vs Long (Битва за примитивы)

В чём боль: BigDecimal – это тяжёлый объект, внутри которого скрыт массив int[]. Любая арифметическая операция с ним – это создание нового объекта в памяти. В биллинге или высоконагруженных расчётах это генерирует тонны мусора в памяти, заставляя GC работать на износ.

Золотое решение: Храните денежные значения в минимальных единицах (копейки, центы) в типе long. Переходите на BigDecimal только в самый последний момент – при выводе пользователю.

public record WalletResponse(
    long amount, // long - это быстро
    String currency
) {
    // Только для красоты при выводе
    public BigDecimal getDisplayAmount() {
        return BigDecimal.valueOf(amount, 2);
    }
}

Голос JIT:long – это просто 64 бита в моём регистре. Я храню его прямо в регистрах процессора. Сложение двух long занимает доли наносекунды. С BigDecimal я вынужден прыгать в кучу (Heap) за каждым числом и его масштабом. Выбирайте long, если не хотите, чтобы я буксовал на элементарной арифметике”.

Шёпот AOT: “В бинарном коде Native Image работа с long превращается в одну инструкцию ассемблера. BigDecimal же тянет за собой дерево зависимостей и сложную логику управления памятью. Чем больше примитивов, тем меньше мой исполняемый файл и тем быстрее он “прогревается””.

Правило №8. Избегайте прокси в критических узлах

В чём боль: @Transactional и @Async, @Cacheable - это удобно, но за кулисами они создают динамические обертки (Proxy) в рантайме. Каждый вызов проксированного метода - это лишний "прыжок" по объектам-перехватчикам, раздутый стек вызовов и невозможность глубокого инлайнинга. В высоконагруженных циклах это становится настоящим “стек-киллером”.

Золотое решение: Держите бизнес-логику "чистой". Используйте аннотации только на верхнем уровне (Entry Points), а внутри сервисов вызывайте обычные private/package-private методы. Если вам нужно вызвать @Transactional метод из того же класса - вы уже знаете, что прокси не сработает (self-invocation), и это отличный повод задуматься о декомпозиции.

@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class UserService {

    @Value("${app.kafka.kafkaStatisticsService.kafkaUserCreatedStatus}")
    private String kafkaUserCreatedStatus;

    private final PasswordEncoder passwordEncoder;

    private final UserRepository userRepository;

    private final UserMapper userMapper;

    private final KafkaTemplate<String, Object> kafkaTemplate;

    public List<User> findAll(UserFilter userFilter) {
        return userRepository.fetchAll(
                UserSpecification.withFilter(userFilter),
                PageRequest.of(
                        userFilter.pageNumber(),
                        userFilter.pageSize()
                )
        ).getContent();
    }

    public User findById(UUID id) {
        return userRepository.findById(id)
                .orElseThrow(() ->
                        new EntityNotFoundException(MessageFormat.format(TEMPLATE_USER_NOT_FOUND_EXCEPTION, id)));
    }

    public User findByUsername(String username) {
        return userRepository.findByUsername(username)
                .orElseThrow(() ->
                        new UsernameNotFoundException("Username not found!"));
    }

    @AuthoriseUserCreateByAnonymous
    @Transactional
    public UUID save(User user, RoleType roleType) {
        // обычный вызов метода
        userRepository.findUserIdByUsernameAndEmail(
                user.getUsername(),
                user.getEmail()
        ).ifPresent(id -> {
                    throw new UserAlreadyExistsException(
                            MessageFormat.format(TEMPLATE_USER_ALREADY_EXISTS_EXCEPTION,
                                    user.getUsername(),
                                    user.getEmail()));
        });

        user.setRoles(new ArrayList<>(List.of(roleType)));
        user.setPassword(passwordEncoder.encode(user.getPassword()));

        UUID userId = userRepository.saveAndFlush(user).getId();

        TransactionSynchronizationManager.registerSynchronization(
                new TransactionSynchronization() {
                    @Override
                    public void afterCommit() {
                        kafkaTemplate.send(kafkaUserCreatedStatus,
                                new UserRegistrationEvent(userId)
                        );
                    }
                }
        );

        return userId;
    }

    @AuthoriseUserUpdateAndDelete
    @Transactional
    public UUID update(UUID userId, UserUpsertRequest request, RoleType roleType) {
      // обычный вызов метода
      User existedUser = findById(userId);

        userMapper.updateUser(request, existedUser);

        if (request.password() != null && !request.password().isBlank()) {
            existedUser.setPassword(passwordEncoder.encode(request.password()));
        }

        if (roleType != null) {
            existedUser.getRoles().clear();
            existedUser.getRoles().add(roleType);
        }

        return userRepository.saveAndFlush(existedUser).getId();
    }

    @AuthoriseUserUpdateAndDelete
    @Transactional
    public void delete(UUID id) {
        findById(id);

        userRepository.deleteById(id);
    }
}

Голос JIT: “Прокси для меня – это лабиринт. Я вижу цепочку сгенерированных классов-оберток, через которые нужно продраться к реальному коду. Чем меньше магии между мной и вашим кодом, тем быстрее я построю прямой граф вызовов и при��еню inlining”.

Шёпот AOT: “Динамические прокси – мой враг номер один. Чтобы они работали в Native Image, мне приходится генерировать их заранее или тащить тяжёлый механизм рефлексии. Убирая лишние прокси, вы уменьшаете количество сгенерированных классов в бинарнике и ускоряете запуск приложения”.

Правило №9. Generics: Избегаем лишних кастов

В чём боль: Хотя в рантайме типы стираются (Type Erasure), использование Object или Raw Types заставляет вас (и JVM) постоянно вставлять в байт-код инструкцию checkcast. Это не только небезопасно, но и заставляет процессор тратить лишние такты на проверку иерархии классов при каждом обращении к объекту.

Золотое решение: Используйте строго типизированные дженерики везде, где важна производительность. Это позволяет компилятору гарантировать типы на этапе сборки, а JIT-компилятору - строить машинный код без лишних "контрольно-пропускных пунктов".

@RequiredArgsConstructor
@Component
public class ValidatorHandler {

    private final Validator validator;

    // Использование <T> гарантирует, что на выходе будет тот же тип, что и на входе
    // Никаких ручных (Cast) в вызывающем коде!
    public <T> Mono<T> validate(T body) {
        return Mono.fromCallable(() -> {
                    var violations = validator.validate(body);

                    if (violations.isEmpty()) {
                        return body;
                    }

                    throw new ValidationException(buildErrorMessage(violations));
                })
                .subscribeOn(Schedulers.boundedElastic());
    }

    private String buildErrorMessage(Set<? extends ConstraintViolation<?>> violations) {
        return violations
                .stream()
                .map(v ->
                        v.getPropertyPath() + ": " +
                                v.getMessage())
                .collect(Collectors.joining("; "));
    }
}

Голос JIT: “Каждый раз, когда я вижу checkcast, я вынужден притормозить и проверить: а точно ли этот объект в памяти совпадает с ожидаемым типом? С правильными дженериками я доверяю вашему коду на 100%. Для меня это скоростная трасса без светофоров и лишних проверок документов”.

Шёпот AOT: “В Native Image я провожу Static Analysis всей программы. Строгая типизация помогает мне точнее определить границы типов и исключить лишние проверки из бинарника. Чем меньше “неизвестных” Object, тем меньше проверок типов в рантайме и выше производительность”.

Правило №10. Статический анализ вместо динамического (MapStruct)

В чём боль: Библиотеки вроде ModelMapper или злоупотребление ObjectMapper.convertValue() - это "динамический налог" на производительность. Они используют интроспекцию в рантайме, буквально "ощупывая” каждый объект в поисках подходящих полей через getField() и setAccessible(true). Для JIT это непрозрачный код, а для Native Image - конфиг-ад на тысячи строк.

Золотое решение: Переносите всю сложность на этап компиляции. Используйте кодогенерацию. MapStruct генерирует обычный Java-код target.set(source.get()) на этапе компиляции. Мы уже видели его мощь в Правиле №4, но здесь подчеркнём: нулевой оверхед в рантайме и полная прозрачность для оптимизаторов.

@RequiredArgsConstructor
@RequestMapping("/api/v1/user")
@RestController
public class UserController {

    private static final String pathToUserResource = "/api/v1/user/{id}";

    private final UserMapper userMapper;

    private final UserService userService;

    @GetMapping
    public ResponseEntity<UserListResponse> findAll(@Valid UserFilter userFilter) {
        // Чистый маппинг: ни одной операции рефлексии в рантайме!
        return ResponseEntity.ok(
                userMapper.userListToUserListResponse(
                        userService.findAll(userFilter)
                )
        );
    }

    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> findById(@PathVariable UUID id) {
        return ResponseEntity.ok(
                userMapper.userToResponse(userService.findById(id))
        );
    }

    @PostMapping
    public ResponseEntity<Void> create(@RequestBody @Valid UserUpsertRequest request,
                                               @RequestParam RoleType roleType) {
        return ResponseEntity.created(getUri(
                userService.save(
                        userMapper.requestToUser(request),
                        roleType
                )
        )).build();
    }

    @PutMapping("/{id}")
    public ResponseEntity<Void> update(@PathVariable("id") UUID userId,
                                       @RequestBody @Valid UserUpsertRequest request,
                                       @RequestParam RoleType roleType) {
        return ResponseEntity.ok()
                .location(getUri(
                        userService.update(userId,
                                request,
                                roleType)
                ))
                .build();
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable("id") UUID userId) {
        userService.delete(userId);
        return ResponseEntity.noContent().build();
    }

    private URI getUri(UUID id) {
        return UriComponentsBuilder.fromPath(pathToUserResource)
                .buildAndExpand(id)
                .toUri();
    }
}

Голос JIT: “Для меня код от MapStruct – это подарок. Это прямой, понятный и предсказуемый поток инструкций. Я инлайню такие методы мгновенно, превращая маппинг в практически бесплатную операцию. Никаких поисков по именам полей в HashMap метаданных - только работа с регистрами процессора”.

Шёпот AOT: “Статический анализ – мой лучший друг. Поскольку MapStruct генерирует обычные вызовы методов, я вижу все связи между классами ещё при сборке бинарника. Мне не нужно гадать, какие поля вы захотите “потрогать” через р��флексию, поэтому я могу смело вырезать всё лишнее из итогового файла”.

Правило №11. Scoped Values вместо ThreadLocal

В чём боль: Мы десятилетиями хранили контекст пользователя или транзакции в ThreadLocal. Это работало, когда потоков было 200-500. Но в Java 21 вы включаете spring.threads.virtual.enabled: true и запускаете 100_000 виртуальных потоков. Если каждый из них “присосётся” к тяжёлому объекту в ThreadLocal, ваш Heap лопнет быстрее, чем сработает первый GC. К тому же, ThreadLocal - это изменяемая структура, что само по себе усложняет оптимизацию.

Золотое решение: Используйте Scoped Values (JEP 446 / 464). Они позволяют безопасно передать неизменяемые данные вниз по дереву вызовов. Как только выполнение выходит из блока - данные становятся недоступны, а память мгновенно готова к очистке. Никаких утечек из-за забытого .remove().

private final static ScopedValue<UserContext> CURRENT_USER = ScopedValue.newInstance();

public void handleRequest(UserContext context) {
    // Привязываем данные к области видимости. (Scope)
    ScopedValue.where(CURRENT_USER, context).run(() -> {
        // Внутри этого блока и всех вложенных методов
        // CURRENT_USER.get() вернёт наш контекст.
        // Никаких утечек, никакой привязки к жизни потока!
        processOrder();
    });
}

Голос JIT: ThreadLocal для меня - это тяжёлый рюкзак, который поток не снимает до самой смерти. Я не могу предсказать, когда он его снимет. Scoped Value – это лёгкая эстафетная палочка: подержал, передал, и она исчезла. В мире миллиона виртуальных потоков это единственный способ не утонуть в бесконечных мапах внутри потоков”.

Шёпот AOT: “Поскольку область видимости Scoped Value жёстко ограничена блоком кода, я могу гораздо эффективнее анализировать время жизни объектов. Это помогает мне оптимизировать распределение памяти и уменьшить накладные расходы на переключение контекста в Native-бинарнике”.

Финал: Слово AOT

Мы закончили наш список. Теперь, как и договаривались, выпускаем на сцену “судью”.

AOT: “Друзья, я всё видел. Пока вы пишете код, я строю его карту. Если вы соблюдаете эти правила, мой статический анализ проходит гладко, а бинарник получается лёгким и быстрым.

Обо всех неоднозначных моментах – будь то забытая рефлексия или динамический прокси – я сообщу вам либо на этапе компиляции, либо уже в процессе работы. Мой вердикт прост: пишите прозрачно, и я сделаю вашу Java быстрее нативного C++”.

Резюме-таблица для тех, кто крутит:

Золотое правило

Профит для JVM (JIT)

Профит для Native Image (AOT)

1

Record вместо POJO

Агрессивный инлайнинг final полей.

Быстрый статический анализ графа объектов.

2

fillInStackTrace(null)

Экономия CPU (в 10-50 раз) при создании Exception.

Меньше нативного кода для обхода стека в бинарнике.

3

Final везде

Constant Folding (вычисления на этапе компиляции).

Более компактный и предсказуемый код.

4

Смерть Рефлексии

Прямые вызовы методов без поиска в рантайме.

Критично: Избавляет от громоздких reflect-config.json.

5

Короткие методы

Гарантированное встраивание (inlining)

Оптимизация размера исполняемого файла.

6

Преаллокация

Меньше пауз Garbage Collector на “переезды” массивов.

Снижение пикового потребления RAM.

7

Long вместо BigDecimal

Работа на регистрах CPU без аллокаций в куче.

Радикальное уменьшение объёма живых объектов.

8

Минимум прокси

Прозрачный граф вызовов без интерцепторов.

Меньше динамической генерации байт-кода.

9

Чёткие Generics

Отказ от лишних проверок типов (checkcast).

Упрощение статической типизации при сборке.

10

CodeGen вместо Reflection

Скорость прямого присваивания a = b.

Нулевой оверхед: никакой магии в рантайме.

11

Scoped Values

Безопасная передача контекста в Virtual Threads.

Стабильный Heap при миллионах потоков.

Голос AOT (финальный аккорд):

“Посмотрите на эту таблицу. Если ваш проект следует этим правилам, я соберу его в бинарник, который стартует за миллисекунды. Если нет – я сообщу вам о каждой “грязной” рефлексии или прокси, либо на этапе компиляции, либо прямо в лоб во время работы.

Выбор за вами!”