ThreadLocal хранит значение для каждого потока отдельно — удобно для контекста запроса: текущий пользователь, id трассировки, соединение.
Проблема в пуле. Сервер обслуживает запросы пулом из, например, 200 потоков. Запрос A кладёт в ThreadLocal пользователя и не очищает. Поток возвращается в пул и берёт запрос B — тот видит пользователя A. Это не только ошибка, но и уязвимость.
Утечка памяти. Значение живёт, пока жив поток, а потоки пула живут вечно. Большие объекты в ThreadLocal накапливаются. В серверах приложений ещё хуже: значение ссылается на класс веб-приложения, и при передеплое старый загрузчик классов не может быть собран.
Правильно:
CONTEXT.set(user);
try {
handle(request);
} finally {
CONTEXT.remove();
}
Передача в другие потоки. Значение не переходит в задачу, отправленную в пул или в стадию CompletableFuture — контекст переносят явно (обёртка над Runnable, TaskDecorator в Spring).
Виртуальные потоки: их миллионы, и ThreadLocal на каждом дорог; рекомендуемая замена — ScopedValue (Java 25), значение, привязанное к области выполнения.