Перейти к содержимому

ClassLoader на практике: Tomcat, Spring Boot, плагины и утечки памяти

Разбираемся, зачем приложениям несколько ClassLoader-ов, как Tomcat изолирует web-приложения, зачем нужен Thread Context ClassLoader и почему после redeploy старые классы иногда остаются в памяти.

В предыдущей статье о ClassLoader-ах в JVM мы разобрались с базовой механикой: Bootstrap, Platform, Application, parent delegation и с тем, почему два одинаковых ru.acdev.User, загруженных разными loader-ами, для JVM являются разными типами.

Если остановиться на этой схеме:

Bootstrap

Platform

Application

может возникнуть ощущение, что ClassLoader — довольно академическая часть JVM. Есть три загрузчика, они ходят друг к другу по parent delegation, на собеседовании нужно помнить loadClass() и findClass() — и на этом всё.

В реальных приложениях ClassLoader становится заметен в других ситуациях: одной JVM нужно одновременно запускать несколько приложений с разными зависимостями, инфраструктурному коду приходится находить реализацию из приложения ниже по иерархии, а после reload старый набор классов должен исчезнуть из памяти. Отсюда появляются Tomcat WebAppClassLoader, Thread Context ClassLoader, plugin isolation и довольно неприятные утечки.

Зачем вообще несколько ClassLoader-ов

Представим сервер, внутри которого одновременно работают два приложения. У первого в зависимостях Guava 28, у второго — Guava 32:

server
├── application-1
│   └── guava 28
└── application-2
    └── guava 32

Если все классы загружать одним глобальным loader-ом, возникает неприятный вопрос: какая именно версия com.google.common.* должна существовать внутри JVM? Одна общая версия плохо подходит для независимых приложений — обновление одного начинает влиять на другое.

Отдельные ClassLoader-ы создают разные пространства загрузки:

Container ClassLoader

        ├── App1 ClassLoader
        │      └── guava 28

        └── App2 ClassLoader
               └── guava 32

Теперь одинаковое binary name не мешает двум версиям существовать одновременно: runtime identity класса включает defining ClassLoader. Получается, что ClassLoader задаёт не только способ найти .class, но и границу видимости, типов и жизненного цикла.

На этой идее построены servlet containers и application servers, IDE и plugin systems, Spring Boot DevTools и другие механизмы hot reload. OSGi использует ClassLoader isolation значительно глубже, хотя сегодня это скорее отдельная ниша, чем типичный способ собирать новый Spring-сервис.

Tomcat: изоляция web-приложений

Дальше будет классический пример с несколькими WAR внутри одного Tomcat. В современных Spring Boot-системах сервисы чаще запускают отдельными JVM или контейнерами, поэтому такую архитектуру сейчас встречаешь реже. Но для понимания ClassLoader isolation она почти идеальна, а сам механизм Tomcat никуда не исчез.

Упрощённая иерархия выглядит так:

Bootstrap

System

Common
   ↙   ↘
WebApp1 WebApp2

Bootstrap находится в основании загрузки, System используется для запуска самого Tomcat, а Common содержит классы, доступные контейнеру и всем web-приложениям. Для каждого deployed application Tomcat создаёт отдельный WebApp ClassLoader: /WEB-INF/classes и JAR-файлы из /WEB-INF/lib одного приложения не становятся автоматически видимыми соседнему.

Поэтому такая конфигурация нормальна:

app1
└── WEB-INF/lib/guava-28.jar

app2
└── WEB-INF/lib/guava-32.jar

Для JVM это будут типы с разной identity:

com.google.common.SomeClass
+
WebAppClassLoader@1

и:

com.google.common.SomeClass
+
WebAppClassLoader@2

Общий код при необходимости можно поднять на уровень Common, но тогда он перестаёт принадлежать конкретному приложению. Из-за этого при странных конфликтах зависимостей вопрос «какой JAR лежит в classpath?» иногда слишком общий. Полезнее уточнить: в classpath какого ClassLoader-а он лежит?

Tomcat и webapp-first loading

В обычной Java-модели мы привыкли к parent-first:

child

parent

parent

Загрузчик сначала спрашивает родителя, и только если тот не нашёл класс, ищет его сам. Для WebApp ClassLoader Tomcat по умолчанию использует другой порядок для прикладных классов: после базовых Java-классов он предпочитает /WEB-INF/classes и /WEB-INF/lib, а затем обращается к System/Common.

Упрощённо:

loadClass("ru.acdev.Library")

1. Bootstrap
2. WEB-INF/classes
3. WEB-INF/lib
4. System
5. Common

Это не безусловный child-first. Базовые классы Java и API Jakarta EE, которые реализует сам Tomcat, имеют специальные правила и не должны подменяться приложением. Но для обычной application library удобная модель именно такая:

сначала моя версия
потом общая

Причина хорошо видна на зависимостях:

Common
  └── guava 20

WebApp1
  └── guava 28

WebApp2
  └── guava 32

При строгом parent-first Common мог бы отдать Guava 20 раньше, чем WebApp ClassLoader добрался до собственной версии. Webapp-first сохраняет изоляцию /WEB-INF/lib. Если приложению всё же нужен стандартный delegate-first порядок, Tomcat позволяет включить delegate="true".

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

Spring Boot DevTools: тот же принцип, другой lifecycle

Spring Boot DevTools решает уже не задачу изоляции двух приложений, а задачу быстрого restart во время разработки. Полный перезапуск JVM заставил бы снова загружать Spring Framework, Jackson, Hibernate и остальные библиотеки, хотя разработчик обычно меняет только небольшой кусок собственного кода.

Поэтому DevTools использует два ClassLoader-а:

Base ClassLoader

Restart ClassLoader

Стабильные сторонние JAR-ы в основном остаются в base loader, а активно разрабатываемые классы загружаются restart loader-ом:

BaseClassLoader
├── spring-core.jar
├── jackson.jar
└── hibernate.jar

RestartClassLoader #1
└── MyApplication.class

После изменения кода старый restart loader выбрасывается, а новый получает обновлённые application classes:

BaseClassLoader

RestartClassLoader #2
└── MyApplication.class

Если RestartClassLoader #1 больше никто не удерживает, он вместе со своими классами становится кандидатом на сборку. Именно поэтому restart заметно дешевле холодного запуска JVM.

Два loader-а заодно объясняют часть странных ошибок DevTools. Если условный SharedModel оказался определён и base, и restart loader-ом:

ru.acdev.SharedModel @ BaseClassLoader
ru.acdev.SharedModel @ RestartClassLoader

для JVM это два разных типа. Spring Boot отдельно позволяет настраивать, какие элементы classpath должны попадать в base или restart loader через restart.exclude и restart.include, а проблемы особенно заметны в multi-module проектах.

DevTools здесь не просто удобная development-фича. Это компактный современный пример того, как ClassLoader задаёт отдельный lifecycle для части приложения.

Thread Context ClassLoader: когда направление delegation не подходит

Parent delegation хорошо решает ситуацию, когда application class хочет использовать тип из родительского loader-а:

Application

Platform

Bootstrap

Но иногда нужно пройти в обратную сторону. Представим framework, загруженный родительским ClassLoader-ом, и реализацию интерфейса внутри приложения:

Parent ClassLoader

    └── Framework

            │ хочет найти MyProvider

      Child ClassLoader
      └── MyProvider

Собственный ClassLoader framework-а может не видеть MyProvider: родитель знает о своих родителях, но не обязан знать о детях. Для таких сценариев у потока есть Thread Context ClassLoader, или TCCL:

ClassLoader loader =
    Thread.currentThread()
        .getContextClassLoader();

Контейнер может установить application loader как контекст текущего потока:

thread.setContextClassLoader(
    webAppClassLoader
);

и инфраструктурный код получает способ искать реализации в контексте приложения, а не через ClassLoader собственного класса.

ServiceLoader — практический пример

Java SPI как раз построен вокруг идеи «API знает интерфейс, но не знает реализацию»:

public interface PaymentProvider {

    void pay();
}

Приложение приносит provider:

public class CardPaymentProvider
    implements PaymentProvider {

    @Override
    public void pay() {
        // ...
    }
}

а инфраструктурный код ищет его:

ServiceLoader<PaymentProvider> providers =
    ServiceLoader.load(PaymentProvider.class);

Эта перегрузка ServiceLoader.load(service) использует context ClassLoader текущего потока. Концептуально вызов выглядит так:

ServiceLoader.load(
    PaymentProvider.class,
    Thread.currentThread().getContextClassLoader()
);

Именно поэтому TCCL нельзя считать внутренней особенностью Tomcat. Это часть стандартного Java-механизма поиска реализаций, когда implementation может находиться ниже по ClassLoader-иерархии.

JDBC использует близкую идею: DriverManager при ленивой инициализации ищет JDBC providers через service-provider mechanism с учётом TCCL. Здесь DriverManager интересен именно как пример глобальной инфраструктуры JVM; в обычном прикладном коде соединения сегодня чаще получают через DataSource и connection pool.

Где начинается ClassLoader leak

Теперь жизненный цикл становится важнее самой загрузки. Tomcat сделал deploy и создал:

WebAppClassLoader@123

после redeploy новый код уже работает через:

WebAppClassLoader@456

а @123 больше не нужен. Если на старый loader и объекты его классов не осталось внешних ссылок, весь этот граф может стать недостижимым.

Hot redeploy особенно удобен для демонстрации ClassLoader leak: JVM продолжает жить, а ClassLoader приложения должен умереть. В контейнеризированном Spring Boot сервисе процесс при deployment чаще заменяют целиком, поэтому такой leak может просто исчезать вместе со старой JVM. Но та же проблема остаётся актуальной для application servers, DevTools, plugin systems и любой среды, где ClassLoader живёт меньше процесса.

Самая важная деталь здесь — static-поле внутри webapp само по себе ещё не означает leak:

WebAppClassLoader

UserService.class

static cache

Если весь этот граф доступен только сам из себя, GC способен убрать его целиком. Утечка появляется, когда старое приложение удерживает что-то более долгоживущее:

GC Root

долгоживущий объект

application object / Class / ClassLoader

Old WebAppClassLoader

Поэтому искать нужно не «подозрительный static», а путь от GC Root до старого ClassLoader-а.

Что обычно удерживает старый ClassLoader

Незавершённый поток и TCCL

Приложение может создать собственный thread:

Thread worker = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        doSomething();
    }
});

worker.start();

Если при shutdown поток не остановлен, он продолжает жить после logical undeploy. Его Runnable, stack, ThreadLocal или context ClassLoader могут ссылаться на классы старого приложения:

JVM Thread

Runnable / TCCL

Application class

Old WebAppClassLoader

Tomcat поэтому отдельно проверяет оставшиеся после webapp потоки и имеет механизмы очистки некоторых ссылок при остановке приложения.

ThreadLocal

С ThreadLocal ситуация неприятнее, потому что поток может вообще принадлежать контейнеру и жить столько же, сколько Tomcat:

private static final ThreadLocal<UserContext>
    CONTEXT = new ThreadLocal<>();

try {
    CONTEXT.set(new UserContext());
    handleRequest();
} finally {
    CONTEXT.remove();
}

Если remove() забыть, значение может остаться в ThreadLocalMap долгоживущего worker thread:

Tomcat Thread

ThreadLocalMap

UserContext

UserContext.class

Old WebAppClassLoader

В request-scoped использовании ThreadLocal блок finally здесь не косметика, а часть lifecycle.

ExecutorService и старый Timer

Тот же принцип работает с executor-ами:

ScheduledExecutorService scheduler =
    Executors.newSingleThreadScheduledExecutor();

Если приложение создаёт такой scheduler, оно должно остановить его при shutdown:

scheduler.shutdown();

В старом коде можно встретить и java.util.Timer. Его полезно помнить как ещё один пример собственного фонового thread-а, но для нового кода ScheduledExecutorService обычно гораздо более естественный выбор.

JDBC и глобальные registry

Web-приложение может загрузить JDBC driver своим ClassLoader-ом, а driver зарегистрируется в DriverManager, который живёт дольше отдельного deployment. Если такая регистрация сохраняется после undeploy, глобальная инфраструктура JVM начинает удерживать объект, определённый старым WebAppClassLoader.

Именно поэтому в Tomcat существует JdbcLeakPrevention, который умеет deregister-ить JDBC drivers, забытые приложением. Это хороший пример утечки, где в application-коде вообще нет очевидного:

static List<Object> leakedObjects;

Приложение всего лишь зарегистрировалось в долгоживущем registry и не симметрично вышло из него.

Кэш в родительском ClassLoader-е

Похожая проблема возникает, когда framework, загруженный более долгоживущим родительским loader-ом, складывает application-specific объекты в глобальный cache:

Common ClassLoader

Framework

static cache

Application object

Old WebAppClassLoader

Сам framework должен жить столько же, сколько контейнер. Поэтому одна ссылка из его cache на Class, listener, callback, serializer metadata или proxy приложения уже способна пережить redeploy.

Общий рисунок у всех этих примеров один:

long-lived

short-lived application

Меняются только объекты между ними.

Почему redeploy хорошо показывает утечку

Без leak после нескольких redeploy в памяти концептуально остаётся только активное поколение:

#1 -> unreachable
#2 -> unreachable
#3 -> active

При утечке картина другая:

#1 -> retained
#2 -> retained
#3 -> active

Следующие reload-ы добавляют новые поколения, а старые не исчезают. В heap dump начинают одновременно встречаться несколько WebAppClassLoader или RestartClassLoader, хотя активное приложение одно.

Это затрагивает не только heap. Metadata загруженных классов JVM хранит в Metaspace, и пока старый ClassLoader остаётся достижимым, его классы нельзя нормально выгрузить. Поэтому постепенный рост Metaspace после серии reload/redeploy — хороший повод проверить ClassLoader lifecycle.

Важно только не переносить этот симптом механически на любой современный deployment. Если Spring Boot сервис запускается в отдельном контейнере и при каждом release старая JVM целиком завершается, накопления поколений ClassLoader внутри одного процесса обычно просто не происходит. Именно поэтому Tomcat hot redeploy — очень наглядный, но не единственный сценарий этой проблемы.

Как искать ClassLoader leak

Если после reload память не возвращается, я бы сначала доказывал не «утечку вообще», а более конкретный факт: старый ClassLoader всё ещё reachable.

В heap dump стоит найти экземпляры вроде:

ParallelWebappClassLoader
WebAppClassLoader
RestartClassLoader
URLClassLoader

и посмотреть, сколько поколений реально осталось. Если активное приложение одно, а похожих application loader-ов накопилось много, следующий вопрос уже конкретный:

GC Root

...

Old ClassLoader

Типичные промежуточные владельцы — thread, ThreadLocal, executor, TCCL, глобальный cache, listener или JDBC registration. Угадывать заранее, кто виноват, не нужно: heap analyzer должен показать retained path.

Ещё до heap dump иногда полезно вывести ClassLoader прямо из проблемного кода:

System.out.println(
    getClass().getClassLoader()
);

System.out.println(
    Thread.currentThread()
        .getContextClassLoader()
);

System.out.println(
    Thread.currentThread().getName()
);

В standalone приложении оба loader-а часто совпадут. В Tomcat, DevTools или plugin environment это совсем не обязательно, и различие иногда сразу показывает, почему объект оказался не в том lifecycle.

За годы с Java подобные проблемы обычно приходят не с красивым сообщением ClassLoader leak detected. Снаружи всё скучнее: после reload растёт память или Metaspace, dev-сервер со временем приходится перезапускать, в heap видны несколько поколений одних и тех же Spring bean-ов, а Tomcat жалуется на thread, который приложение создало и не остановило.

Одна маленькая ссылка здесь неприятна масштабом: она может удерживать не один объект, а ClassLoader, связанные классы и заметную часть графа предыдущего deployment.

Lifecycle должен быть симметричным

Практическое правило довольно простое: если приложение регистрирует или запускает что-то, живущее самостоятельно, должен существовать обратный шаг.

start thread       -> stop thread
create executor    -> shutdown executor
set ThreadLocal    -> remove ThreadLocal
register listener  -> unregister listener
register resource  -> deregister resource

В обычном standalone сервисе часть ошибок скрывается тем, что JVM всё равно завершится целиком. В container, plugin или reload environment приложение может закончить жизнь раньше процесса, поэтому полагаться на process shutdown уже нельзя.

И это, пожалуй, полезнее всего помнить из всей темы ClassLoader leaks: проблема редко находится в самом ClassLoader. Он просто оказывается большим объектом, который не может умереть из-за чужой ссылки.

Что спрашивают на собеседовании

Для middle/senior я бы проверял понимание такими вопросами:

  1. Зачем Tomcat создаёт отдельный ClassLoader для каждого web-приложения?
  2. Как два приложения в одной JVM могут использовать разные версии одной библиотеки?
  3. Чем Tomcat WebApp loading отличается от обычного parent-first?
  4. Что меняет delegate=true?
  5. Зачем существует Thread Context ClassLoader?
  6. Чем TCCL отличается от SomeClass.class.getClassLoader()?
  7. Почему ServiceLoader.load(service) использует TCCL?
  8. Как running thread или ThreadLocal может удержать старый WebAppClassLoader?
  9. Почему static-поле внутри самого webapp ещё не доказывает leak?
  10. Как JDBC registration может удержать ClassLoader?
  11. Почему после redeploy может расти Metaspace?
  12. Что искать в heap dump при подозрении на ClassLoader leak?

Если ответы сводятся к двум моделям — ClassLoader как граница видимости и путь от GC Root к старому loader-у — значит отдельные детали уже не приходится запоминать как набор несвязанных фактов.

Что оставить в голове

Tomcat показывает ClassLoader как механизм изоляции: разные web-приложения могут иметь независимые зависимости и даже использовать webapp-first loading вместо обычного parent-first. Spring Boot DevTools использует ту же идею для другого lifecycle — меняющийся application code можно перезагружать отдельно от стабильных библиотек.

Thread Context ClassLoader решает обратную задачу: инфраструктурный код получает возможность найти implementation, принадлежащую текущему приложению. А утечки начинаются, когда логический lifecycle приложения закончился, но долгоживущий thread, ThreadLocal, registry или cache всё ещё держит ссылку в старый classloader graph.