Сегодня вы узнаете о ThreadLocal, одном из распространенных классов, используемых при разработке Java-приложений. Коротко: ThreadLocal дает каждому потоку собственную копию значения, поэтому потоки не мешают друг другу и синхронизация для такой переменной не нужна. Хранятся эти копии не внутри самого ThreadLocal, а в карте ThreadLocalMap у каждого потока, где ключом служит сам объект ThreadLocal. Главное практическое правило: в пуле потоков значение надо убирать вызовом remove(), иначе оно переживет задачу и останется висеть в потоке.

Кратко

  • Один объект ThreadLocal в разных потоках хранит разные значения, и поток видит только свое.
  • Это способ обойтись без синхронизации там, где данные и не нужно делить между потоками.
  • Значения лежат в карте ThreadLocalMap внутри самого потока, а ThreadLocal работает лишь ключом к ним.
  • Если значение не задано, get() вернет null. Значение по умолчанию задают через ThreadLocal.withInitial().
  • В пуле потоков обязательно вызывайте remove() в блоке finally: поток живет дольше задачи, и забытое значение утечет вместе с ним.
  • С JDK 25 у класса появилась альтернатива, ScopedValue, которую документация советует предпочитать для односторонней передачи данных.

Что такое ThreadLocal?

Класс ThreadLocal хранит локальные переменные для потоков. Эти переменные изолированы между разными потоками и могут быть доступны только своему собственному потоку. Варианты применения ThreadLocal:
  1. Изоляция данных между потоками.
  2. Управление сессиями для подключения к базе данных.
  3. Хранение транзакционной информации потока.
Группа аквалангистов погружается в синей воде, заставка к статье о ThreadLocal

Как использовать ThreadLocal?

Давайте рассмотрим простой пример.

public static void main(String[] args) {
    //Создаем ThreadLocal
    ThreadLocal<String> local = new ThreadLocal<>();
    //Создаем новый класс Random
    Random random = new Random();
    //Создаем 5 потоков
    IntStream.range(0, 5).forEach(a-> new Thread(()-> {
        //Присваиваем значение каждому потоку
        local.set(a+"  "+random.nextInt(100));
        System.out.println("Thread number and its local value  "+ local.get());
    }).start());
}
В приведенном выше коде мы создаем класс ThreadLocal, создаем 5 потоков, присваиваем значение ThreadLocal в каждом потоке и выводим на печать. При выводе получаем: Вывод программы: пять строк Thread number and its local value с номером потока и его значением, порядок строк не совпадает с порядком запуска потоков

Что под капотом?

Если посмотреть внимательно, то можно понять, что в ThreadLocal из этого примера кода есть два важных метода.
  • public T get() {}

  • public void set (T value) {}

Давайте посмотрим на метод setter в исходном коде ThreadLocal:

public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
}
Метод setter сначала получает текущий поток и вызывает метод getMap() для получения класса ThreadLocalMap. Если map существует, возьмите текущий поток t в качестве ключа, входной параметр в качестве значения и установите пару {key:value} в map. Если нет, то создайте map. Теперь у вас может появиться вопрос: что такое ThreadLocalMap?

static class ThreadLocalMap {
   /**
    * The entries in this hash map extend WeakReference, using
    * its main ref field as the key (which is always a
    * ThreadLocal object).  Note that null keys (i.e. entry.get()
    * == null) mean that the key is no longer referenced, so the
    * entry can be expunged from table.  Such entries are referred to
    * as "stale entries" in the code that follows.
    */
    static class Entry extends WeakReference<ThreadLocal<?>> {
       /** The value associated with this ThreadLocal. */
       Object value;
       Entry(ThreadLocal<?> k, Object v) {
           super(k);
           value = v;
       }
    }
}
ThreadLocalMap это внутренний статический класс в ThreadLocal, определяющий класс Entry для хранения данных. Entry использует экземпляр ThreadLocal в качестве ключа и устанавливает значение, которое мы передаем. Если на данном этапе это звучит слишком запутанно, просто запомните, что именно класс Entry в ThreadLocalMap выполняет фактическое хранение значений. Чтобы получить данные из ThreadLocal, мы используем метод getter:

public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
}
В методе getter мы будем использовать currentThread в качестве ключа для получения ThreadLocalMap. Затем map получит getEntry() на основе экземпляра ThreadLocal и вернет экземпляр Entry, а затем сохраненное значение. Вот схема, которая поможет разобраться: Схема: внутри потока лежит ThreadLocalMap, в ней записи Entry, где ключами служат объекты ThreadLocalA и ThreadLocalB, а значениями ValueA и ValueBСхема связей: у одного потока один ThreadLocalMap, а в нем N записей Entry, каждая из которых пара ThreadLocal и значение
  1. Каждый поток поддерживает ссылку на ThreadLocalMap.

  2. ThreadLocalMap является внутренним статическим классом ThreadLocal и использует класс Entry для хранения.

  3. Ключ ThreadLocalMap является экземпляром ThreadLocal и может иметь несколько ThreadLocal.

  4. Сам ThreadLocal не хранит значение, но это ключ для потока, который поможет получить значение из ThreadLocalMap.

Обратите внимание на главную практическую ловушку: значение из ThreadLocal стоит убирать вызовом remove(), иначе можно поймать OOM (Out-of-Memory Error, ошибка нехватки памяти). Причина видна прямо в коде Entry: ключом там служит слабая ссылка на объект ThreadLocal, а вот значение хранится обычной, сильной ссылкой. Если сам объект ThreadLocal стал недоступен, ключ обнулится, а значение так и останется лежать в карте живого потока. В обычной программе поток рано или поздно завершится и заберет карту с собой, а вот в пуле потоков потоки живут месяцами, и такие записи копятся. В документации класса ThreadLocal это описано так: поток держит неявную ссылку на свою копию значения все время, пока жив сам и пока доступен объект ThreadLocal. Поэтому в коде, который выполняется в пуле, вызов remove() обычно ставят в блок finally. Впрочем, в свежих версиях Java у ThreadLocal появилась альтернатива, у которой этой проблемы нет по устройству. О ней дальше.

Методы ThreadLocal

В примерах выше участвовали только get() и set(), но у класса есть еще три метода, и без двух из них в реальном коде обойтись трудно:
Метод Что делает Когда нужен
get() Возвращает значение текущего потока. Если его нет, вызывает initialValue() основное чтение
set(T value) Записывает значение для текущего потока основная запись
remove() Удаляет значение текущего потока обязательно в пуле потоков, лучше в блоке finally
withInitial(Supplier) Создает ThreadLocal со значением по умолчанию, появился в Java 8 когда не хочется получать null при первом чтении
initialValue() Защищенный метод, который возвращает начальное значение; по умолчанию это null старый способ задать значение по умолчанию, через наследование

Современная альтернатива: ScopedValue

В JDK 25 в язык добавили класс ScopedValue, и его документация прямо советует предпочитать его ThreadLocal там, где данные передаются в одну сторону, вглубь вызовов, без параметров метода. В том же примечании перечислены три претензии к ThreadLocal: значение может переписать любой вызванный код, время жизни значения ничем не ограничено, пока его не удалили вручную, а наследование значений дочерними потоками требует копирования всей карты. Выглядит это так:

public class Demo {
    // Значение обычно объявляют статической константой
    private static final ScopedValue<String> USER = ScopedValue.newInstance();

    public static void main(String[] args) {
        // Значение связано только на время выполнения run
        ScopedValue.where(USER, "duke").run(() -> handleRequest());
    }

    private static void handleRequest() {
        // Параметр сюда никто не передавал, а значение доступно
        System.out.println("Обрабатываем запрос пользователя " + USER.get());
        // Вывод: Обрабатываем запрос пользователя duke
    }
}
Разница с ThreadLocal в двух вещах. Во-первых, значение связывается не навсегда, а только на время выполнения кода, переданного в run(): как только он завершился, связь исчезает сама, и никакого remove() звать не нужно. Во-вторых, связать значение может только тот, кто вызывает where(), а вызванный код может его лишь прочитать. Отсюда и поведение get(): если значение не связано, метод не вернет null, а бросит NoSuchElementException. Проверить связь заранее можно методом isBound(), подставить запасной вариант через orElse(). А связать несколько значений сразу позволяет цепочка: ScopedValue.where(k1, v1).where(k2, v2).run(...). Значит ли это, что про ThreadLocal пора забыть? Нет. ScopedValue закрывает самый частый сценарий, передачу данных вглубь вызовов, но доступен он только с JDK 25, а его значение нельзя менять по ходу дела. Если данные нужно именно накапливать или переписывать в пределах потока, ThreadLocal остается на месте.

Вопросы и ответы

Что такое ThreadLocal простыми словами?

Это переменная, у которой в каждом потоке свое значение. Вы объявляете один объект ThreadLocal, но когда поток пишет в него через set(), значение попадает в личную карту именно этого потока. Другой поток, обратившись к тому же объекту, увидит свое значение или null, если ничего не записывал.

Зачем нужен ThreadLocal, если есть synchronized?

Это решения двух разных задач. synchronized нужен, когда потоки работают с общими данными и им надо договариваться об очереди. ThreadLocal убирает саму необходимость договариваться: общих данных больше нет, у каждого потока своя копия. Поэтому он и не стоит ничего по скорости, но и не годится там, где потокам действительно нужно обмениваться информацией.

Почему ThreadLocal может привести к утечке памяти?

Из-за несимметричных ссылок внутри Entry: ключ там слабая ссылка на объект ThreadLocal, а значение обычная, сильная. Когда сам ThreadLocal становится недоступен, ключ обнуляется, но значение продолжает лежать в карте потока. Если поток короткоживущий, проблемы нет: он умрет и заберет карту. В пуле потоков поток не умирает, и забытые значения копятся до OutOfMemoryError.

Когда нужно вызывать remove()?

Всегда, когда код выполняется в пуле потоков: в сервлетах, в обработчиках запросов, в задачах для ExecutorService. Правильное место для вызова это блок finally, чтобы значение удалялось даже при исключении. В короткой программе, где поток создается под одну задачу и завершается, вызов remove() не обязателен.

Как задать значение по умолчанию, чтобы get() не возвращал null?

Проще всего создать переменную фабричным методом ThreadLocal.withInitial(), передав ему лямбду, которая вернет начальное значение. Метод появился в Java 8 и заменяет старый прием с анонимным наследником и переопределением initialValue(). Начальное значение вычисляется лениво, при первом обращении потока к get().

Есть ли в современной Java замена ThreadLocal?

Да, в JDK 25 появился класс ScopedValue. Документация советует предпочитать его там, где данные передаются в одну сторону, вглубь вызовов, без параметров метода. У такого значения ограниченное время жизни: оно существует только внутри своей области и не требует ручной очистки, поэтому и утечек, свойственных ThreadLocal, у него нет. Читайте также: