Как изменить код работающего Java-приложения? Пишем свой HotSwap
С помощью Attach API и java.lang.instrument можно подключиться к живой JVM, загрузить агент и переопределить байт-код уже загруженного класса.
163 открытий2К показов
Представьте: ваше Java-приложение запущено. Например, оно пишет в консоль каждые 5 секунд:
Вы его не перезапускали. Код не меняли. Но внезапно — без предупреждения — оно начинает писать:
Никаких рестартов. Никаких деплоев. Никакого git pull. Просто… поменялось.
Звучит как фокус? Но это — настоящая инструментация JVM, доступная каждому. И сегодня я покажу, как подключиться к живой JVM, загрузить свой агент и изменить поведение класса — без единой строчки в исходнике. Да, даже если метод final. (если только он не static final).
Это рантайм, а не компиляция
Допустим, по описанному примеру выше, у нас есть простой сервис:
Он вызывается в бесконечном цикле:
Приложение работает. Состояние в памяти. Соединения открыты. Кэши разогреты.
А мы хотим поменять сообщение. Прямо сейчас. Без перезапуска.
Перезапуск — это потеря данных, времени, пользовательских сессий. А что, если можно просто… заменить реализацию?
Как это вообще возможно?
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.
Да, это как ssh, но для Java-процесса. Вы находите PID (jps, ps aux | grep java), подключаетесь — и получаете полный контроль.
Через loadAgent() вы загружаете JAR-файл, который выполнится внутри целевого приложения. И самое важное: этот агент может получить экземпляр Instrumentation.
Этап 2: Агент с правами root
У Java-агента особая точка входа — agentmain, а не main:
Как только агент загружен, он может:
- Регистрировать
ClassFileTransformer - Модифицировать байт-код уже загруженных классов
- Заменять методы, поля, аннотации
И всё это — без перезапуска.
Этап 3: Меняем getMessage() на лету
С помощью [Byte Buddy](https://bytebuddy.net/) (обёртка над ASM) это выглядит почти как обычный Java-код:
Что происходит?
- Мы ищем
MessageServiceсреди всех загруженных классов. - Проверяем, что его можно переопределять (
isModifiableClass). - Через Byte Buddy заменяем тело
getMessage()на константу. - Генерируем новый байт-код.
- Передаём его 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 появляются предупреждения:
Я тестировал на 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К показов



