Перетяжка, Премия ТПрогер, 13.11
Перетяжка, Премия ТПрогер, 13.11
Перетяжка, Премия ТПрогер, 13.11

Как изменить код работающего Java-приложения? Пишем свой HotSwap

С помощью Attach API и java.lang.instrument можно подключиться к живой JVM, загрузить агент и переопределить байт-код уже загруженного класса.

163 открытий2К показов
Как изменить код работающего Java-приложения? Пишем свой HotSwap

Представьте: ваше Java-приложение запущено. Например, оно пишет в консоль каждые 5 секунд:

			Hello, World!
		

Вы его не перезапускали. Код не меняли. Но внезапно — без предупреждения — оно начинает писать:

			Hello from Agent!
		

Никаких рестартов. Никаких деплоев. Никакого git pull. Просто… поменялось.

Звучит как фокус? Но это — настоящая инструментация JVM, доступная каждому. И сегодня я покажу, как подключиться к живой JVM, загрузить свой агент и изменить поведение класса — без единой строчки в исходнике. Да, даже если метод final. (если только он не static final).

Это рантайм, а не компиляция

Допустим, по описанному примеру выше, у нас есть простой сервис:

			class MessageService {
    public String getMessage() {
        return "Hello, World!";
    }
}
		

Он вызывается в бесконечном цикле:

			MessageService messageService = new MessageService();
while (true) {
    System.out.println(messageService.getMessage());
    Thread.sleep(5000);
}
		

Приложение работает. Состояние в памяти. Соединения открыты. Кэши разогреты.

А мы хотим поменять сообщение. Прямо сейчас. Без перезапуска.

Перезапуск — это потеря данных, времени, пользовательских сессий. А что, если можно просто… заменить реализацию?

Как это вообще возможно?

Java — не такой уж статичный язык, как кажется.

JVM предоставляет механизм динамической модификации байт-кода уже загруженных классов. Его используют:

  • IDE для HotSwap
  • Mockito — чтобы мокать final классы
  • Lombok — хотя чаще на этапе компиляции
  • Spring AOP — через CGLIB-прокси
  • OpenTelemetry, Datadog, New Relic — для трассировки без изменения кода

Всё это работает благодаря java.lang.instrument.Instrumentation — API, которое позволяет:

  • Перехватывать загрузку классов (ClassFileTransformer)
  • Изменять байт-код на лету
  • Заменять реализации методов через redefineClasses() или retransformClasses()

Но чтобы получить доступ к Instrumentation, нужно войти внутрь целевой JVM.

Этап 1: Подключение к JVM — Attach API

JVM позволяет «прицепиться» к себе извне — через Attach API.

			VirtualMachine vm = VirtualMachine.attach("12345"); // PID процесса
vm.loadAgent("/path/to/agent.jar");
vm.detach();
		

Да, это как ssh, но для Java-процесса. Вы находите PID (jps, ps aux | grep java), подключаетесь — и получаете полный контроль.

Через loadAgent() вы загружаете JAR-файл, который выполнится внутри целевого приложения. И самое важное: этот агент может получить экземпляр Instrumentation.

Этап 2: Агент с правами root

У Java-агента особая точка входа — agentmain, а не main:

			public class HotSwapAgent {
    public static void agentmain(String args, Instrumentation inst) {
        // Здесь — полная власть над JVM
    }
}
		

Как только агент загружен, он может:

  • Регистрировать ClassFileTransformer
  • Модифицировать байт-код уже загруженных классов
  • Заменять методы, поля, аннотации

И всё это — без перезапуска.

Этап 3: Меняем getMessage() на лету

С помощью [Byte Buddy](https://bytebuddy.net/) (обёртка над ASM) это выглядит почти как обычный Java-код:

			public static void agentmain(String args, Instrumentation inst) {
    Class<?> messageServiceClass = null;
    for (Class<?> clazz : inst.getAllLoadedClasses()) {
        if ("MessageService".equals(clazz.getSimpleName())) {
            messageServiceClass = clazz;
            break;
        }
    }

    if (messageServiceClass == null) {
        System.err.println("Класс MessageService не найден.");
        return;
    }

    if (!inst.isModifiableClass(messageServiceClass)) {
        System.err.println("Класс не поддерживает redefine.");
        return;
    }

    DynamicType.Unloaded<?> unloaded = new ByteBuddy()
        .redefine(messageServiceClass)
        .method(named("getMessage"))
        .intercept(FixedValue.value("Hello from Agent!"))
        .make();

    inst.redefineClasses(new ClassDefinition(messageServiceClass, unloaded.getBytes()));
}
		

Что происходит?

  1. Мы ищем MessageService среди всех загруженных классов.
  2. Проверяем, что его можно переопределять (isModifiableClass).
  3. Через Byte Buddy заменяем тело getMessage() на константу.
  4. Генерируем новый байт-код.
  5. Передаём его JVM через redefineClasses().

И вот — следующий вызов getMessage() уже возвращает "Hello from Agent!". Без прокси. Без интерфейсов. Без рефлексии. Прямая замена байт-кода.

Почему это важно? Потому что это не обёртка, а это оригинальный класс, но с другим телом метода.

Где это реально применяется?

Именно такой подход лежит в основе множества современных Java-инструментов:

  • Spring AOP — создаёт CGLIB-прокси, например для @Transactional, @Cacheable.
  • Mockito — мокает final классы через Byte Buddy
  • APM-агенты (OpenTelemetry, Datadog) — внедряют трассировку в HTTP-клиенты, БД-драйверы.

Все они используют те же самые механизмы. Эта возможность особенно мощна при работе с динамически генерируемыми классами. Например, когда Spring создаёт CGLIB-прокси для @Transactional-бинов, этот класс появляется в памяти во время выполнения. Ваш ClassFileTransformer может перехватить его загрузку и добавить логирование, метрики или аудит.

APM-системы именно так и работают: они не требуют изменения кода, но умеют измерять время выполнения методов, SQL-запросов, HTTP-вызовов — потому что могут модифицировать байт-код на лету.

Реальные трудности: что может пойти не так?

На практике всё не так гладко, тк работа с JVM требует некоторой осторожности и подготовленности. Даже незначительные ошибки могут "сломать" исходное приложение. При разработке я сталкнулся с такими проблемами:

🔹 "Agent JAR not found or no Agent-Class attribute"

> Причина: jar собирался без MANIFEST.MF. Также возможно, если в MANIFEST.MF не указать Agent-Class или путь к jar.

> Решение: добавил в pom.xml явное указание манифеста через maven-jar-plugin.

🔹 "Agent JAR loaded but agent failed to initialize"

> Причина: агент зависел от ByteBuddy, но зависимости не были встроены в JAR.

> Решение: перешёл на maven-shade-plugin — собрал fat-jar со всеми зависимостями.

🔹 "Cannot inject already loaded type"

> Причина: Я использовал .load(classLoader) после redefine, где пытался загрузить класс, а не переопределить.

> Решение: убрал .load(...), и вместо этого применил inst.redefineClasses(...) — ведь redefine не загружает, а заменяет существующий класс.

🔹 Динамический attach в будущем будет отключён по умолчанию

В новых версиях Java появляются предупреждения:

			WARNING: Dynamic loading of agents will be disallowed by default in a future release
		

Я тестировал на JDK 23. Возможно уже есть решение или потребуется использовать флаг -XX:+EnableDynamicAgentLoading.

Заключение

Java — это не только язык. Это платформа, которую можно программировать извне.

Знание java.lang.instrument, attach, redefine и работы с байт-кодом — это отдельный уровень мастерства.

Когда вы осваиваете эти инструменты, вы перестаёте быть просто пользователем фреймворков. Вы начинаете понимать, как они устроены изнутри.

Spring, Mockito, Lombok, OpenTelemetry — все они используют эти же механизмы. А теперь вы знаете, как они работают. И можете написать свой.

Код проекта на github:

Проект состоит из:

  • target-app — целевое приложение («жертва»)
  • hotswap-agent — агент для изменения классов на лету
  • attach-client — клиент для подключения к JVM

P.S. А вы когда-нибудь писали свой Java-агент? Делитесь в комментариях!

Следите за новыми постами
Следите за новыми постами по любимым темам
163 открытий2К показов