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

Руководство по инструментарию Java

· 8 мин. чтения

1. Введение

В этом уроке мы поговорим о Java Instrumentation API. Он предоставляет возможность добавлять байт-код к существующим скомпилированным классам Java.

Мы также поговорим об агентах Java и о том, как мы используем их для инструментирования нашего кода.

2. Настройка

На протяжении всей статьи мы будем создавать приложение, используя инструменты.

Наше приложение будет состоять из двух модулей:

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

Агент Java изменит байт-код банкомата, что позволит нам измерить время снятия средств без необходимости изменять приложение банкомата.

Наш проект будет иметь следующую структуру:

<groupId>com.foreach.instrumentation</groupId>
<artifactId>base</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<modules>
<module>agent</module>
<module>application</module>
</modules>

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

3. Что такое Java-агент

В общем, java-агент — это просто специально созданный файл jar. Он использует инструментальный API , предоставляемый JVM, для изменения существующего байт-кода, загруженного в JVM.

Чтобы агент работал, нам нужно определить два метода:

  • premain — статически загружать агент с помощью параметра -javaagent при запуске JVM.
  • agentmain — будет динамически загружать агент в JVM с помощью Java Attach API .

Следует иметь в виду интересную концепцию, заключающуюся в том, что реализация JVM, такая как Oracle, OpenJDK и другие, может предоставить механизм для динамического запуска агентов, но это не является обязательным требованием.

Во-первых, давайте посмотрим, как мы будем использовать существующий агент Java.

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

4. Загрузка агента Java

Чтобы иметь возможность использовать агент Java, мы должны сначала загрузить его.

У нас есть два типа нагрузки:

  • static — использует premain для загрузки агента с помощью опции -javaagent
  • динамический — использует agentmain для загрузки агента в JVM с помощью Java Attach API

Далее мы рассмотрим каждый тип нагрузки и объясним, как он работает.

4.1. Статическая нагрузка

Загрузка агента Java при запуске приложения называется статической загрузкой. Статическая загрузка изменяет байт-код во время запуска, прежде чем какой-либо код будет выполнен.

Имейте в виду, что статическая загрузка использует метод premain , который запускается до запуска любого кода приложения, чтобы запустить его, мы можем выполнить:

java -javaagent:agent.jar -jar application.jar

Важно отметить, что мы всегда должны помещать параметр –javaagent перед параметром –jar .

Ниже приведены логи для нашей команды:

22:24:39.296 [main] INFO - [Agent] In premain method
22:24:39.300 [main] INFO - [Agent] Transforming class MyAtm
22:24:39.407 [main] INFO - [Application] Starting ATM application
22:24:41.409 [main] INFO - [Application] Successful Withdrawal of [7] units!
22:24:41.410 [main] INFO - [Application] Withdrawal operation completed in:2 seconds!
22:24:53.411 [main] INFO - [Application] Successful Withdrawal of [8] units!
22:24:53.411 [main] INFO - [Application] Withdrawal operation completed in:2 seconds!

Мы можем видеть, когда выполнялся метод premain и когда класс MyAtm был преобразован. Мы также видим два журнала транзакций снятия средств в банкомате, в которых указано время, которое потребовалось для завершения каждой операции.

Помните, что в нашем исходном приложении у нас не было этого времени завершения транзакции, оно было добавлено нашим Java-агентом.

4.2. Динамическая нагрузка

Процедура загрузки агента Java в уже работающую JVM называется динамической загрузкой. Агент подключается с помощью Java Attach API .

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

Давайте напишем для этого небольшой фрагмент кода и назовем этот класс AgentLoader. Для простоты мы поместим этот класс в jar-файл приложения. Таким образом, наш jar-файл приложения может как запускать наше приложение, так и прикреплять нашего агента к приложению банкомата:

VirtualMachine jvm = VirtualMachine.attach(jvmPid);
jvm.loadAgent(agentFile.getAbsolutePath());
jvm.detach();

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

Давайте также добавим клей, который позволит нам либо запустить приложение, либо загрузить агент.

Мы назовем этот класс Launcher , и это будет наш основной класс файла jar:

public class Launcher {
public static void main(String[] args) throws Exception {
if(args[0].equals("StartMyAtmApplication")) {
new MyAtmApplication().run(args);
} else if(args[0].equals("LoadAgent")) {
new AgentLoader().run(args);
}
}
}

Запуск приложения

java -jar application.jar StartMyAtmApplication
22:44:21.154 [main] INFO - [Application] Starting ATM application
22:44:23.157 [main] INFO - [Application] Successful Withdrawal of [7] units!

Присоединение агента Java

После первой операции мы присоединяем java-агент к нашей JVM:

java -jar application.jar LoadAgent
22:44:27.022 [main] INFO - Attaching to target JVM with PID: 6575
22:44:27.306 [main] INFO - Attached to target JVM and loaded Java agent successfully

Проверьте журналы приложений

Теперь, когда мы подключили нашего агента к JVM, мы увидим, что у нас есть общее время завершения второй операции снятия средств через банкомат.

Это означает, что мы добавили наш функционал на лету, пока наше приложение работало:

22:44:27.229 [Attach Listener] INFO - [Agent] In agentmain method
22:44:27.230 [Attach Listener] INFO - [Agent] Transforming class MyAtm
22:44:33.157 [main] INFO - [Application] Successful Withdrawal of [8] units!
22:44:33.157 [main] INFO - [Application] Withdrawal operation completed in:2 seconds!

5. Создание агента Java

Узнав, как использовать агент, давайте посмотрим, как мы можем его создать. Мы рассмотрим , как использовать Javassist для изменения байт-кода, и объединим это с некоторыми инструментальными методами API.

Поскольку java-агент использует Java Instrumentation API , прежде чем слишком углубляться в создание нашего агента, давайте рассмотрим некоторые из наиболее часто используемых методов в этом API и краткое описание того, что они делают:

  • addTransformer — добавляет трансформатор в инструментальный движок
  • getAllLoadedClasses — возвращает массив всех классов, загруженных в данный момент JVM.
  • retransformClasses — облегчает инструментирование уже загруженных классов, добавляя байт-код
  • removeTransformer — отменяет регистрацию предоставленного трансформатора
  • redefineClasses — переопределить предоставленный набор классов, используя предоставленные файлы классов, что означает, что класс будет полностью заменен, а не изменен, как в случае с retransformClasses

5.1. Создайте методы Premain и Agentmain

Мы знаем, что каждому агенту Java нужен хотя бы один из методов premain или agentmain . Последний используется для динамической загрузки, а первый используется для статической загрузки агента Java в JVM.

Давайте определим их обоих в нашем агенте, чтобы мы могли загружать этот агент как статически, так и динамически:

public static void premain(
String agentArgs, Instrumentation inst) {

LOGGER.info("[Agent] In premain method");
String className = "com.foreach.instrumentation.application.MyAtm";
transformClass(className,inst);
}
public static void agentmain(
String agentArgs, Instrumentation inst) {

LOGGER.info("[Agent] In agentmain method");
String className = "com.foreach.instrumentation.application.MyAtm";
transformClass(className,inst);
}

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

Ниже приведен код метода transformClass , который мы определили, чтобы помочь нам преобразовать класс MyAtm .

В этом методе мы находим класс, который хотим преобразовать, и используем метод преобразования . Также мы добавляем преобразователь в инструментальный движок:

private static void transformClass(
String className, Instrumentation instrumentation) {
Class<?> targetCls = null;
ClassLoader targetClassLoader = null;
// see if we can get the class using forName
try {
targetCls = Class.forName(className);
targetClassLoader = targetCls.getClassLoader();
transform(targetCls, targetClassLoader, instrumentation);
return;
} catch (Exception ex) {
LOGGER.error("Class [{}] not found with Class.forName");
}
// otherwise iterate all loaded classes and find what we want
for(Class<?> clazz: instrumentation.getAllLoadedClasses()) {
if(clazz.getName().equals(className)) {
targetCls = clazz;
targetClassLoader = targetCls.getClassLoader();
transform(targetCls, targetClassLoader, instrumentation);
return;
}
}
throw new RuntimeException(
"Failed to find class [" + className + "]");
}

private static void transform(
Class<?> clazz,
ClassLoader classLoader,
Instrumentation instrumentation) {
AtmTransformer dt = new AtmTransformer(
clazz.getName(), classLoader);
instrumentation.addTransformer(dt, true);
try {
instrumentation.retransformClasses(clazz);
} catch (Exception ex) {
throw new RuntimeException(
"Transform failed for: [" + clazz.getName() + "]", ex);
}
}

С этим покончено, давайте определим преобразователь для класса MyAtm .

5.2. Определение нашего трансформатора

Преобразователь класса должен реализовать ClassFileTransformer и реализовать метод преобразования.

Мы будем использовать Javassist для добавления байт-кода в класс MyAtm и добавления журнала с общим временем транзакции вывода средств ATW:

public class AtmTransformer implements ClassFileTransformer {
@Override
public byte[] transform(
ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
byte[] byteCode = classfileBuffer;
String finalTargetClassName = this.targetClassName
.replaceAll("\\.", "/");
if (!className.equals(finalTargetClassName)) {
return byteCode;
}

if (className.equals(finalTargetClassName)
&& loader.equals(targetClassLoader)) {

LOGGER.info("[Agent] Transforming class MyAtm");
try {
ClassPool cp = ClassPool.getDefault();
CtClass cc = cp.get(targetClassName);
CtMethod m = cc.getDeclaredMethod(
WITHDRAW_MONEY_METHOD);
m.addLocalVariable(
"startTime", CtClass.longType);
m.insertBefore(
"startTime = System.currentTimeMillis();");

StringBuilder endBlock = new StringBuilder();

m.addLocalVariable("endTime", CtClass.longType);
m.addLocalVariable("opTime", CtClass.longType);
endBlock.append(
"endTime = System.currentTimeMillis();");
endBlock.append(
"opTime = (endTime-startTime)/1000;");

endBlock.append(
"LOGGER.info(\"[Application] Withdrawal operation completed in:" +
"\" + opTime + \" seconds!\");");

m.insertAfter(endBlock.toString());

byteCode = cc.toBytecode();
cc.detach();
} catch (NotFoundException | CannotCompileException | IOException e) {
LOGGER.error("Exception", e);
}
}
return byteCode;
}
}

5.3. Создание файла манифеста агента

Наконец, чтобы получить работающий агент Java, нам понадобится файл манифеста с парой атрибутов.

Следовательно, мы можем найти полный список атрибутов манифеста в официальной документации пакета инструментов .

В окончательный файл jar агента Java мы добавим следующие строки в файл манифеста:

Agent-Class: com.foreach.instrumentation.agent.MyInstrumentationAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Premain-Class: com.foreach.instrumentation.agent.MyInstrumentationAgent

Теперь наш агент инструментирования Java завершен. Чтобы запустить его, обратитесь к разделу Загрузка агента Java этой статьи.

6. Заключение

В этой статье мы говорили об API инструментов Java. Мы рассмотрели, как загрузить агент Java в JVM как статически, так и динамически.

Мы также рассмотрели, как создать собственный Java-агент с нуля.

Как всегда, полную реализацию примера можно найти на Github .