Anleitung zur Java-Instrumentierung

1. Einleitung

In diesem Tutorial werden wir über die Java Instrumentation API sprechen. Es bietet die Möglichkeit, vorhandenen kompilierten Java-Klassen Bytecode hinzuzufügen.

Wir werden auch über Java-Agenten sprechen und wie wir sie verwenden, um unseren Code zu instrumentieren.

2. Setup

Im gesamten Artikel erstellen wir eine App mit Instrumenten.

Unsere Anwendung besteht aus zwei Modulen:

  1. Eine Geldautomaten-App, mit der wir Geld abheben können
  2. Und ein Java-Agent, mit dem wir die Leistung unseres Geldautomaten anhand der investierten Zeit messen können

Der Java-Agent ändert den ATM-Bytecode, sodass wir die Auszahlungszeit messen können, ohne die ATM-App ändern zu müssen.

Unser Projekt wird folgende Struktur haben:

com.baeldung.instrumentation base 1.0.0 pom  agent application 

Bevor wir uns zu sehr mit den Details der Instrumentierung befassen, wollen wir uns ansehen, was ein Java-Agent ist.

3. Was ist ein Java-Agent?

Im Allgemeinen ist ein Java-Agent nur eine speziell gestaltete JAR-Datei. Es verwendet die Instrumentierungs-API, die die JVM bereitstellt, um vorhandenen Bytecode zu ändern, der in eine JVM geladen wird.

Damit ein Agent funktioniert, müssen zwei Methoden definiert werden:

  • premain - lädt den Agenten statisch mit dem Parameter -javaagent beim Start der JVM
  • agentmain - lädt den Agenten mithilfe der Java Attach-API dynamisch in die JVM

Ein interessantes Konzept ist, dass eine JVM-Implementierung wie Oracle, OpenJDK und andere einen Mechanismus zum dynamischen Starten von Agenten bereitstellen kann, dies ist jedoch keine Voraussetzung.

Lassen Sie uns zunächst sehen, wie wir einen vorhandenen Java-Agenten verwenden würden.

Danach schauen wir uns an, wie wir eine von Grund auf neu erstellen können, um die Funktionalität hinzuzufügen, die wir in unserem Bytecode benötigen.

4. Laden eines Java-Agenten

Um den Java-Agenten verwenden zu können, müssen wir ihn zuerst laden.

Wir haben zwei Arten von Lasten:

  • static - verwendet den Premain , um den Agenten mit der Option -javaagent zu laden
  • dynamisch - verwendet den Agentmain , um den Agenten mithilfe der Java Attach-API in die JVM zu laden

Als nächstes schauen wir uns die einzelnen Ladetypen an und erklären, wie sie funktionieren.

4.1. Statische Belastung

Das Laden eines Java-Agenten beim Start der Anwendung wird als statisches Laden bezeichnet. Durch statisches Laden wird der Bytecode beim Start geändert, bevor Code ausgeführt wird.

Beachten Sie, dass die statische Last die Premain- Methode verwendet, die ausgeführt wird, bevor ein Anwendungscode ausgeführt wird. Damit sie ausgeführt werden kann, können wir Folgendes ausführen:

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

Es ist wichtig zu beachten, dass wir den Parameter - javaagent immer vor den Parameter - jar setzen sollten .

Unten sind die Protokolle für unseren Befehl:

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!

Wir können sehen, wann die Premain- Methode ausgeführt wurde und wann die MyAtm- Klasse transformiert wurde. Wir sehen auch die beiden ATM-Abhebungsprotokolle, die die Zeit enthalten, die für jeden Vorgang benötigt wurde.

Denken Sie daran, dass wir in unserer ursprünglichen Anwendung diese Abschlusszeit für eine Transaktion nicht hatten. Sie wurde von unserem Java-Agenten hinzugefügt.

4.2. Dynamische Belastung

Das Laden eines Java-Agenten in eine bereits ausgeführte JVM wird als dynamisches Laden bezeichnet. Der Agent wird über die Java Attach-API angehängt.

A more complex scenario is when we already have our ATM application running in production and we want to add the total time of transactions dynamically without downtime for our application.

Let's write a small piece of code to do just that and we'll call this class AgentLoader. For simplicity, we'll put this class in the application jar file. So our application jar file can both start our application, and attach our agent to the ATM application:

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

Now that we have our AgentLoader, we start our application making sure that in the ten-second pause between transactions, we'll attach our Java agent dynamically using the AgentLoader.

Let's also add the glue that will allow us to either start the application or load the agent.

We'll call this class Launcher and it will be our main jar file class:

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); } } }

Starting the Application

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!

Attaching Java Agent

After the first operation, we attach the java agent to our 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 

Check Application Logs

Now that we attached our agent to the JVM we'll see that we have the total completion time for the second ATM withdrawal operation.

This means that we added our functionality on the fly, while our application was running:

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. Creating a Java Agent

After learning how to use an agent, let's see how we can create one. We'll look at how to use Javassist to change byte-code and we'll combine this with some instrumentation API methods.

Since a java agent makes use of the Java Instrumentation API, before getting too deep into creating our agent, let's see some of the most used methods in this API and a short description of what they do:

  • addTransformer – adds a transformer to the instrumentation engine
  • getAllLoadedClasses – returns an array of all classes currently loaded by the JVM
  • retransformClasses – facilitates the instrumentation of already loaded classes by adding byte-code
  • removeTransformer – unregisters the supplied transformer
  • redefineClasses – redefine the supplied set of classes using the supplied class files, meaning that the class will be fully replaced, not modified as with retransformClasses

5.1. Create the Premain and Agentmain Methods

We know that every Java agent needs at least one of the premain or agentmain methods. The latter is used for dynamic load, while the former is used to statically load a java agent into a JVM.

Let's define both of them in our agent so that we're able to load this agent both statically and dynamically:

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

In each method, we declare the class that we want to change and then dig down to transform that class using the transformClass method.

Below is the code for the transformClass method that we defined to help us transform MyAtm class.

In this method, we find the class we want to transform and using the transform method. Also, we add the transformer to the instrumentation engine:

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); } }

With this out of the way, let's define the transformer for MyAtm class.

5.2. Defining Our Transformer

A class transformer must implement ClassFileTransformer and implement the transform method.

We'll use Javassist to add byte-code to MyAtm class and add a log with the total ATW withdrawal transaction time:

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. Creating an Agent Manifest File

Finally, in order to get a working Java agent, we'll need a manifest file with a couple of attributes.

Daher finden wir die vollständige Liste der Manifestattribute in der offiziellen Dokumentation des Instrumentierungspakets.

In der endgültigen Java-Agent-JAR-Datei werden der Manifestdatei die folgenden Zeilen hinzugefügt:

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

Unser Java Instrumentation Agent ist jetzt vollständig. Informationen zum Ausführen finden Sie im Abschnitt Laden eines Java-Agenten in diesem Artikel.

6. Fazit

In diesem Artikel haben wir über die Java Instrumentation API gesprochen. Wir haben uns angesehen, wie ein Java-Agent sowohl statisch als auch dynamisch in eine JVM geladen wird.

Wir haben uns auch angesehen, wie wir unseren eigenen Java-Agenten von Grund auf neu erstellen würden.

Wie immer finden Sie die vollständige Implementierung des Beispiels auf Github.