В пуле потоков после обработки множества задач растёт heap. Определите, какая ссылка в этом коде может удерживать большие массивы после завершения задач.
import java.util.concurrent.*;
public class Demo {
static void handle() {
ThreadLocal<byte[]> local = new ThreadLocal<>();
local.set(new byte[10_000_000]);
}
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(1);
for (int i = 0; i < 1_000; i++) {
pool.submit(Demo::handle).get();
}
pool.shutdown();
}
}
Большой массив удерживается значением в ThreadLocalMap рабочего потока. После выхода из handle() объект ThreadLocal становится недостижимым, но его ключ в карте хранится через слабую ссылку, тогда как значение хранится сильной ссылкой. Пока поток из пула жив, такая запись может сохранять массив; очистка произойдёт только при последующих операциях с ThreadLocalMap, а не обязательно сразу после сборки мусора.
ThreadLocal предназначен для хранения состояния, изолированного между потоками, без передачи этого состояния через параметры и без общей синхронизации. Такой подход удобен для контекста запроса, форматтеров и других данных, привязанных к потоку.
Пулы потоков изменили профиль риска: поток теперь обычно живёт дольше конкретной задачи. Поэтому значение, безопасное для короткоживущего потока, может стать утечкой памяти при повторном использовании рабочего потока.
В примере каждая задача создаёт новый ключ ThreadLocal и помещает в него массив. После завершения метода сильная локальная ссылка на ключ исчезает, но поток пула продолжает существовать.
Слабая ссылка на ключ позволяет сборщику мусора удалить сам ключ. Однако запись в ThreadLocalMap содержит сильную ссылку на значение, поэтому массив остаётся достижимым через рабочий поток. При большом числе задач это может привести к росту heap и задержкам GC.
Внутри потока находится ThreadLocalMap, фактически принадлежащая этому потоку. Её записи содержат слабую ссылку на ключ ThreadLocal и обычную сильную ссылку на значение.
После сборки мусора запись может выглядеть как ключ = null, значение = byte[]. Это называется устаревшей записью. JVM не удаляет её отдельным автоматическим проходом сразу после исчезновения ключа: очистка обычно выполняется во время последующих операций ThreadLocal, когда карта сканируется или изменяется.
Надёжный шаблон — явно удалять значение в finally:
remove() разрывает связь значения с картой сразу после задачи. Это не освобождает объект синхронно и не запрещает GC, но делает его недостижимым, если других ссылок нет.
Использование одного статического ключа без remove() обычно удерживает последнее значение на каждый рабочий поток, а не бесконечную последовательность значений. В примере создаются новые ключи, поэтому устаревшие записи могут накапливаться до очистки карты. Нельзя рассчитывать на то, что слабые ключи автоматически освобождают их сильные значения.
В HTTP-сервисе после добавления диагностического контекста в ThreadLocal heap рос между запросами, а потоки пула не завершались. В heap dump путь до массивов проходил через Thread и ThreadLocalMap, что подтвердило удержание значений рабочими потоками.
Рассматривались три варианта:
ThreadLocal в finally — минимальное изменение и предсказуемое освобождение памяти;Выбрали remove() в общем обёрточном коде выполнения задач и постепенно заменили неявный контекст явным. После этого долгоживущие потоки перестали удерживать данные завершённых запросов, а частота GC снизилась.
Вопрос: гарантирует ли слабая ссылка на ключ немедленное освобождение значения?
Нет. Слабой является только ссылка на ключ. Значение остаётся сильной ссылкой в записи ThreadLocalMap, поэтому после очистки ключа оно может временно или длительно сохраняться через поток. Слабая ссылка не распространяет своё свойство на объект, связанный с ней в другой части структуры.
Вопрос: исчезнет ли проблема, если после каждой задачи создавать новый ThreadLocal?
Нет, это как раз может ухудшить ситуацию. Каждый новый ключ после потери внешних ссылок превращается в потенциально устаревшую запись, а значения продолжают удерживаться потоком. Нужно переиспользовать контролируемый ключ и вызывать remove() в finally, либо вообще передавать контекст явно.
Вопрос: почему heap dump может показывать такие объекты, хотя ключи ThreadLocal уже недостижимы?
Heap dump строит граф достижимости от GC-корней. Живой поток является таким корнем, а его ThreadLocalMap достижима через внутреннее состояние потока. Внутри карты значение всё ещё связано сильной ссылкой, поэтому оно попадает в граф достижимых объектов, даже если ссылка на ключ уже равна null.
position: Java-разработчик серверных приложений level: middle