Java ThreadLocal Memory Leaks



This content originally appeared on DEV Community and was authored by faangmaster

Утечки памяти (Memory Leaks) в Java — это ситуации, когда объекты или метаданные классов больше не используются приложением, но сборщик мусора не может их удалить. В результате память расходуется неэффективно, что приводит к падению производительности и в крайних случаях — к ошибке OutOfMemoryError.
Существует множество сценариев, которые могут к этому привести. В этой статье я рассмотрю лишь пару примеров, связанных с ThreadLocal в Java.

Конструкция ThreadLocal позволяет хранить данные, которые будут доступны только конкретному thread-у.

Например,

public class Main {
    private static final ThreadLocal<Integer> counter =
            ThreadLocal.withInitial(() -> 0);

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 3; i++) {
                int current = counter.get();
                counter.set(current + 1);
                System.out.printf("%s -> %d%n",
                        Thread.currentThread().getName(), counter.get());
            }
            counter.remove();
        };

        Thread t1 = new Thread(task, "Worker-1");
        Thread t2 = new Thread(task, "Worker-2");

        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}

такой код напечатает:

Worker-2 -> 1
Worker-1 -> 1
Worker-2 -> 2
Worker-2 -> 3
Worker-1 -> 2
Worker-1 -> 3

Может напечатать в разном порядке, но каждый поток (Worker) будет считать независимо от другого начиная с 1. Эти потоки не будут влиять друг на друга через переменную counter.

Как ThreadLocal работает внутри

У всех объектов класса Thread в Java есть поле

ThreadLocal.ThreadLocalMap threadLocals;

Ключ в этой мапе – объект класса ThreadLocal (наша ThreadLocal переменная counter), а значение – значение нашей ThreadLocal переменной для этого потока.

Таким образом каждый поток может менять значения этой переменной не пересекаясь с другими потоками.

ThreadLocal и Thread Pools Java Heap Leaks

В Java можно использовать пулы долгоживущих потоков. Например, создать пул вручную:

ExecutorService workers = Executors.newFixedThreadPool(4);

Либо использовать пул потоков, который предоставляет контейнер приложений (Tomcat, GlassFish, Spring Boot со встроенным Tomcat). В этом случае каждый запрос к вашему приложению обрабатывается в отдельном потоке, при этом потоки не создаются каждый раз с нуля — они переиспользуются из пула.

Т.к. потоки живут долго, то важно правильно использовать ThreadLocal в связке в такими пулами.

Если не вызывать метод remove для ThreadLocal переменных, то это может привести к утечкам памяти.

Например, рассмотрим код:

public class Main {
 private static final ExecutorService workers = Executors.newFixedThreadPool(4);
 private static final ThreadLocal<byte[]> state = new ThreadLocal<>();

 public static void main(String[] args) {
  //Using this thread pool
  for (int i = 0; i < 100_000; i++) {
   workers.submit(() -> {
    state.set(some long payload);
    ......//do some work
   });
  }
  //Doing some other work
  .......
  //Shutdown of thread pool
  workers.shutdown();
 }
}

В данном случае у нас пул из четырёх потоков. В него мы отправляем 100 000 задач. В каждой задаче мы переопределяем значение ThreadLocal-переменной state в контексте одного из этих четырёх потоков. После того как мы закончили отправлять задачи и даже после того, как все они завершились, сами потоки продолжают жить. Соответственно, у каждого потока по-прежнему есть ссылка на значение вашей ThreadLocal-переменной. Даже если дальше в приложении этот ThreadPool или сама переменная уже не используется, потоки всё равно будут ссылаться на очень объёмные объекты, которые не будут собираться GC и будут ухудшать производительность приложения.
Поэтому после завершения каждой задачи нужно вызывать метод remove на ThreadLocal переменной:

public class Main {
 private static final ExecutorService workers = Executors.newFixedThreadPool(4);
 private static final ThreadLocal<byte[]> state = new ThreadLocal<>();

 public static void main(String[] args) {
  //Using this thread pool
  for (int i = 0; i < 100_000; i++) {
   workers.submit(() -> {
    try {
     state.set(some long payload);
     ......//do some work
    } finally {
     state.remove();
    }
   });
  }
  //Doing some other work
  .......
  //Shutdown of thread pool
  workers.shutdown();
 }
}

Добавление:

try {
 .....
} finally {
    state.remove();
}

позволяет отвязать значения переменной от объектов долгоживущих потоков, благодаря чему GC сможет их собрать.

ThreadLocal и Web Containers and Metaspace Memory Leaks

Давайте рассмотрим другой пример:

public final class MyState {
 private final byte[] payload = new byte[10 * 1024 * 1024]; // 10MB для наглядности
  .....
}
.....
Веб-фильтр - выполняется на каждом запросе к контейнеру приложения (например, в Томкате)

@WebFilter("/*")
public class MyFilter implements Filter {
 private static final ThreadLocal<MyState> state = new ThreadLocal<>();

 @Override 
 public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
  state.set(new MyState()); 
  ....
  chain.doFilter(req, res);
 }
}

В этом кейсе у нас есть веб-приложение, которое работает в контейнере (например, Tomcat). Есть веб-фильтр, который выполняется на каждый входящий запрос. Выполнение происходит в одном из потоков пула, предоставляемого контейнером приложений. В таком случае в качестве значения переменной ThreadLocal используется объект класса MyState.

Более того, в контейнере приложений существуют WebappClassLoader’ы: у каждого приложения внутри контейнера — свой, чтобы классы разных приложений не пересекались.

Если при redeploy приложения не перезапускать весь контейнер (просто положили новый JAR/WAR или нажали в админке redeploy), контейнер создаёт новый WebappClassLoader, а затем удаляет старый. Но т.к. у нас пул поток все еще живет, эти потоки все еще держат ссылки на MyState объекты. Более того, будет жив старый class definition MyClass. А он в свою очередь будет держать старую версию WebappClassLoader. Чем больше таких redeploy мы сделаем, тем больше старых версий WebappClassLoader у нас будет. Это все приводит к утечкам не только в Java Heap, но и в Metaspace. И можно получить

java.lang.OutOfMemoryError: Metaspace

Для фикса нам также нужно добавить:

try {
 .....
} finally {
    state.remove();
}


This content originally appeared on DEV Community and was authored by faangmaster