Статьи

Исправление ошибок при запуске кода Java с помощью динамического присоединения

Большинство разработчиков знают, что функция HotSwap в Java является инструментом отладки, встроенным в большинство JVM. Используя эту функцию, можно изменить реализацию метода Java без перезапуска процесса Java, который обычно используется через IDE при разработке кода. Однако HotSwap можно использовать в производственной среде. При этом его можно использовать для расширения работающего приложения или для исправления незначительных ошибок в работающей программе без промежуточного сбоя. В этой статье я хочу продемонстрировать динамическое вложение и применить изменение кода времени выполнения с помощью API присоединения и инструментария, а также представить Byte Buddy , библиотеку, которая предлагает API-интерфейсы для более удобного изменения кода.

В качестве примера рассмотрим работающее приложение, которое проверяет наличие HTTP-заголовка с именем X-Priority в запросе сервера на специальную обработку. Проверка применяется следующим служебным классом:

 class HeaderUtility { static boolean isPriorityCall(HttpServletRequest request) { return request.getHeader("X-Pirority") != null; } } 

Вы заметили опечатку? Ошибки, подобные этим, слишком распространены, особенно если постоянные значения переносятся в статические поля, которые повторно используются в тестовом коде. В неудачном случае эта ошибка будет обнаружена только в производственной установке, где заголовок генерируется другим приложением без орфографической ошибки.

Исправление ошибки, как указано выше, не может быть проблемой. В эпоху непрерывных поставок повторное развертывание новой версии может быть не более чем нажатием кнопки. В других случаях изменения могут быть не такими простыми, и повторное развертывание может быть сложной процедурой, когда простои недопустимы, и лучше использовать ошибку. Однако с HotSwap есть еще один вариант применения небольших изменений, избегая перезапуска приложения.

Attach API: проникновение в другую JVM с динамическим вложением

Чтобы изменить живую Java-программу, нам сначала нужен способ связи с работающей JVM. Поскольку виртуальная машина Java реализует управляемую среду, к счастью, существует стандартный API для этого. Рассматриваемый API также известен как API вложения, который является частью официального инструментария Java. Используя этот API, предоставляемый работающей JVM, второй Java-процесс может обмениваться данными с ним.

Фактически, мы все уже использовали этот API: он применяется любым инструментом отладки и мониторинга, таким как VisualVM или Java Mission Control . Однако API-интерфейсы для применения таких вложений не связаны со стандартными API-интерфейсами Java, которые мы все знаем и используем в нашей повседневной работе. Вместо этого API упакован в специальный файл tools.jar, который включен только в дистрибутив виртуальной машины, связанный с JDK. Что еще хуже, расположение этого JAR-файла не задано, оно отличается на виртуальных машинах для Windows, Linux и особенно Macintosh, где файл не только находится в другом месте, но и в некоторых дистрибутивах имеет название classes.jar . Наконец, IBM решила даже переименовать некоторые классы, содержащиеся в этом JAR, переместив все классы com.ibm пространство имен com.ibm , добавив еще одну стычку. В Java 9 этот беспорядок был окончательно исправлен, и вместо tools.jar использовался модуль Jigsaw jdk.attach .

динамически присоединять

После нахождения API JAR (или модуля) мы должны сделать его доступным для процесса присоединения . В OpenJDK класс, используемый для подключения к другой виртуальной VirtualMachine называется VirtualMachine который предлагает точку входа в любую виртуальную VirtualMachine которая запускается JDK или обычной JVM HotSpot на том же физическом компьютере. После подключения к другому процессу виртуальной машины через его идентификатор процесса мы можем запустить файл JAR в назначенном потоке целевой виртуальной машины:

 // the following strings must be provided by us String processId = processId(); String jarFileName = jarFileName(); VirtualMachine virtualMachine = VirtualMachine.attach(processId); try { virtualMachine.loadAgent(jarFileName, "World!"); } finally { virtualMachine.detach(); } 

После получения файла JAR целевая виртуальная машина ищет манифест JAR и находит класс в Premain-Class . Это очень похоже на то, как виртуальная машина выполняет main метод. Однако с агентом Java виртуальная машина с идентифицированным идентификатором процесса ищет метод с именем agentmain который затем выполняется удаленным процессом в выделенном потоке:

 public class HelloWorldAgent { public static void agentmain(String arg) { System.out.println("Hello, " + arg); } } 

Используя этот API, мы теперь можем напечатать Hello, World! сообщение на любой JVM, если мы знаем его идентификатор процесса. Можно даже связаться с JVM, которые не являются частью дистрибутива JDK, если подключающаяся виртуальная машина является установкой JDK для доступа к tools.jar .

Instrumentation API: изменение программы целевой виртуальной машины

Все идет нормально. Но, несмотря на это успешное взаимодействие с целевой ВМ, мы еще не смогли изменить код на целевой ВМ, и ошибка преобладает. Для последнего изменения Java-агенты могут определить второй параметр, принимая экземпляр типа Instrumentation . Реализации последнего интерфейса предоставляют доступ к нескольким низкоуровневым функциям, одной из которых является возможность изменять загруженный код.

Чтобы исправить опечатку "X-Pirority" , давайте сначала предположим, что мы включили файл фиксированного класса для HeaderUtility именем typo.fix в JAR-файл нашего агента рядом с BugFixAgent мы разрабатываем ниже. Кроме того, нам нужно дать нашему агенту возможность заменить существующие классы, добавив Can-Redefine-Classes: true в его файл манифеста. С этим пресетом мы можем теперь переопределить рассматриваемый класс, используя API инструментария, который принимает пары загруженных классов и байтовые массивы для выполнения переопределения класса:

 public class BugFixAgent { public static void agentmain(String arg, Instrumentation inst) throws Exception { // only if header utility is on the class path; otherwise, // a class can be found within any class loader by iterating // over the return value of Instrumentation::getAllLoadedClasses Class<?> headerUtility = Class.forName("HeaderUtility"); // copy the contents of typo.fix into a byte array ByteArrayOutputStream output = new ByteArrayOutputStream(); try (InputStream input = BugFixAgent.class.getResourceAsStream("/typo.fix")) { byte[] buffer = new byte[1024]; int length; while ((length = input.read(buffer)) != -1) { output.write(buffer, 0, length); } } // Apply the redefinition instrumentation.redefineClasses( new ClassDefinition(headerUtility, output.toByteArray())); } } 

После запуска приведенного выше кода класс HeaderUtility переопределяется для представления его исправленной версии. Любой последующий вызов isPrivileged теперь будет читать правильный заголовок. Как небольшое предостережение, JVM, вероятно, выполнит полную сборку мусора после применения переопределения класса, а также нуждается в повторной оптимизации любого уязвимого кода. Вместе это приводит к небольшому скачку производительности приложения. Однако в большинстве случаев это все же гораздо лучший вариант по сравнению с полным перезапуском процесса.

При применении изменений кода важно убедиться, что новый класс определяет те же поля, методы и модификаторы, что и класс, который он заменяет. Попытка переопределения класса, которое изменяет любое такое свойство, в противном случае вызывает UnsupportedOperationException . Однако команда HotSpot однажды пытается устранить это ограничение . Кроме того, виртуальная машина динамической эволюции кода на основе OpenJDK позволяет предварительно просмотреть эту функцию.

байт-кода манипуляции

Отслеживание утечек памяти с помощью Byte Buddy

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

Манипулирование байтовым кодом

Скомпилированный код Java представлен в виде списка инструкций байтового кода. В этом смысле метод Java представляет собой не что иное, как байтовый массив, где каждый байт представляет собой инструкцию для среды выполнения или аргумент для самой последней инструкции. Сопоставление любого байта с его значением определено в Спецификации виртуальной машины Java, где, например, байт 0xB1 указывает ВМ на возврат из метода с пустым типом возврата. Следовательно, расширение байтового кода — это не что иное, как расширение байтового массива метода для включения дополнительных инструкций, которые представляют дополнительную логику, которую мы хотим применить.

Конечно, побайтовые манипуляции с кодом громоздки и подвержены ошибкам. Чтобы избежать ручного процесса, различные библиотеки предлагают высокоуровневые API, которые не требуют немедленной работы с байтовым кодом Java. Одна из таких библиотек — Byte Buddy (автором которой я являюсь). Одной из его особенностей является возможность определения шаблонных методов, которые будут выполняться до и после исходного кода метода.

Анализ утечки приложений

В качестве примера, предположим, что приложение, которое работало некоторое время, обнаружило утечку ресурсов после нескольких недель работы. Подобные ошибки трудно отследить, так как зачастую трудно воссоздать подобное плохое состояние в наблюдаемой среде тестирования. Следовательно, вместо того, чтобы просто перезапустить сломанное приложение, чтобы устранить проблему (на данный момент), мы можем изменить код приложения, чтобы отследить утечку в текущем процессе. Чтобы выяснить, где приложение утечка ручек, мы хотим отслеживать жизненный цикл любого объекта Closable . Используя Java-агент, мы можем изменить любую конструкцию или закрытие любого такого объекта. С помощью этой информации мы можем определить, какие объекты протекают при каких обстоятельствах.

Манипуляции с шаблонами

Чтобы реализовать это поведение, мы сначала определяем шаблоны, которые содержат код, который мы хотим добавить в конструктор любого объекта Closeable или вызов метода close . Чтобы узнать, где создаются или закрываются протекающие объекты, мы хотим напечатать трассировку стека для каждого такого события. Методы шаблона для этой логики выглядят следующим образом:

 class ConstructionTemplate { @Advice.OnMethodExit static void exit(@Advice.This Object self) { new RuntimeException("Created: " + self).printStackTrace(); } } class CloseTemplate { @Advice.OnMethodEnter static void enter(@Advice.This Object self) { new RuntimeException("Closed: " + self).printStackTrace(); } } 

Методы шаблона аннотируются с помощью OnMethodExit и OnMethodEnter чтобы сообщить Byte Buddy, когда мы хотим, чтобы они были вызваны. Любой из их параметров аннотируется, чтобы указать значение, которое он представляет в переопределенном методе. При применении шаблона, Byte Buddy затем отображает любой доступ к параметру для загрузки значения аннотации, как экземпляр метода, который обычно обозначается this .

Чтобы применить это изменение к любому классу, реализующему интерфейс Closable , Byte Buddy предлагает специфичный для домена язык для создания агента Java, который соответствует типам и методам, и применяет приведенные выше шаблоны, если они применимы:

 public class TracingAgent { public static void agentmain(String arg, Instrumentation inst) { new AgentBuilder.Default() // by default, JVM classes are not instrumented .ignore(none()) .disableClassFormatChanges() .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION) .type(isSubTypeOf(Closable.class)) .transform((builder, type, loader) -> builder .visit(Advice .to(ConstructionTemplate.class) .on(isConstructor())) .visit(Advice .to(CloseTemplate.class) .on(named("close").and(takesArguments(0))) )) .installOn(inst); } } 

По умолчанию Byte Buddy не обрабатывает классы, определенные в пространстве имен java.* Которое изменяется путем установки неактивного сопоставления игнорирования. Кроме того, Байту Бадди нужно дать указание никогда не изменять формат файла класса, как обсуждалось ранее, когда мы применяем ретрансформацию. При этом Byte Buddy автоматически обнаруживает любой существующий или будущий тип, который реализует интерфейс Closable и добавляет приведенный выше код шаблона в свои конструкторы и метод close.

Прикрепление агента

Byte Buddy также предлагает удобные методы для подключения агента во время выполнения. Чтобы иметь дело с различными местоположениями tools.jar и VirtualMachine , Byte Buddy добавляет абстракцию для вложения, где правильная настройка определяется автоматически. После упаковки вышеуказанного агента в файл JAR его можно присоединить к процессу утечки ресурсов, вызвав:

 File jarFile = getAgent(); String processId = getProcessId(); ByteBuddyAgent.install(jarFile, processId); 

Конечно, вышеупомянутый агент быстро создаст слишком много выходных данных для ручной обработки. Чтобы предложить больше удобства, было бы более разумно собирать все созданные объекты, пока они не будут выпущены. Создание агента Java, однако, так же просто, как написание приложения Java, которое реализует это поведение.

Резюме

В этой статье мы рассмотрели API присоединения, который позволяет внедрять агент Java в любой работающий процесс JVM по порядку. Агент представлен файлом JAR, который содержит класс с методом agentmain который удаленный процесс выполняет в выделенном потоке. В качестве одного аргумента этот метод получает экземпляр интерфейса Instrumentation который позволяет переопределять загруженные классы. Код переопределяется либо путем замены целых файлов классов исправленными версиями, либо путем динамического изменения байтового кода существующего класса, что можно упростить с помощью таких библиотек, как Byte Buddy .