❌ ThreadLocal + Virtual Threads = утечка памяти
❌ ThreadLocal + Virtual Threads = утечка памяти Если вы перешли на virtual threads, но продолжаете использовать ThreadLocal, то у вас проблема, которая пока просто не выстрелила. ThreadLocal хранит данные в ThreadLocalMap — по одной map на каждый тред. Когда тредов было 200 в пуле, это было терпимо. Когда виртуальных тредов сотни тысяч, каждый несёт свою map, и heap начинает гореть. Плюс мутабельность: ThreadLocal.set() в глубоком call stack — это неявное состояние, которое легко забыть почистить через .remove(). В try-finally это работает до первого необработанного исключения. Java 21 предлагает замену — ScopedValue. 🔹 Ключевые отличия ScopedValue.where(TENANT_ID, tenant).run(() -> { ... }) — значение привязано к scope, а не к треду. Вышли из блока и значение автоматически недоступно. Никакого .remove(), никакого «забыл почистить». Иммутабельность по дизайну. Нельзя сделать .set(). Если нужно другое значение, создаёте вложенный scope. Это убирает целый класс багов с «протеканием» контекста между запросами. Легковесность. Внутри массив вместо HashMap. При масштабе в 100k+ тредов разница в потреблении памяти ощутимая. Работает в связке со StructuredTaskScope: дочерние виртуальные треды автоматически наследуют scoped values родителя. Не нужно вручную пробрасывать контекст. Практический вывод: если вы мигрируете на virtual threads, аудит ThreadLocal — обязательный шаг. ScopedValue пока preview, но API стабилизируется, и направление очевидно. Как минимум стоит уже сейчас заменить ThreadLocal на ReentrantLock-protected shared state или ScopedValue там, где передаёте tenant ID, request context, trace ID и подобные per-request данные. Знаете, что в Java можно не писать отдельный класс с полями, конструктором, геттерами и toString() вручную? Например, для DTO часто создают обычный класс: public class UserDto { private final Long id; private final String name; private final String email; public UserDto(Long id, String name, String email) { this.id = id; this.name = name; this.email = email; } public Long getId() { return id; } public String getName() { return name; } public String getEmail() { return email; } } Но если объект нужен просто для хранения данных, можно использовать record: public record UserDto( Long id, String name, String email ) {} Java сама создаст конструктор, методы доступа, equals(), hashCode() и toString(). Обращение к полям выглядит немного иначе: user.name() user.email() record хорошо подходит для DTO, ответов API, событий, команд, настроек и небольших неизменяемых объектов. Например, можно удобно вернуть данные из REST-контроллера: return new UserDto(1L, "Alex", "alex@mail.com"); Важно помнить: record по смыслу должен быть простым носителем данных. Если внутри начинает появляться много бизнес-логики, сложных состояний и изменяемых полей, лучше подумать об обычном классе. Так код становится короче, понятнее и меньше засоряется шаблонными методами. Java Библиотека | Мы в Телеграм