Anleitung zur Java-Instrumentierung

Anleitung zur Java Instrumentation

1. Einführung

In diesem Tutorial werden wir überJava 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. Konfiguration

Im gesamten Artikel erstellen wir eine App mithilfe von Instrumenten.

Unsere Anwendung wird aus zwei Modulen bestehen:

  1. Eine ATM-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.example.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. It utilizes the Instrumentation API that the JVM provides to alter existing byte-code that is loaded in a JVM.

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

  • premain - lädt den Agenten beim Start der JVM statisch mit dem Parameter -javaagent

  • agentmain - lädt den Agenten mithilfe vonJava 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.

Danach werden wir uns ansehen, 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 Ladung:

  • statisch - verwendetpremain, um den Agenten mit der Option -javaagent zu laden

  • dynamisch - verwendetagentmain, um den Agenten mitJava Attach API in die JVM zu laden

Als Nächstes werfen wir einen Blick auf die einzelnen Ladetypen und erklären, wie sie funktionieren.

4.1. Statische Belastung

Das Laden eines Java-Agenten beim Start der Anwendung wird als statisches Laden bezeichnet. Static load modifies the byte-code at startup time before any code is executed.

Beachten Sie, dass die statische Last die Methodepremainverwendet, 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 immer den Sparameter -javaagent vor den Sparameter -jar etzen 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 Methodepremainausgeführt wurde und wann die KlasseMyAtm transformiert wurde. Wir sehen auch die zwei ATM-Abhebungs-Transaktionsprotokolle, die die Zeit enthalten, die jeder Vorgang für den Abschluss benötigte.

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

The procedure of loading a Java agent into an already running JVM is called dynamic load. Der Agent wird mitJava Attach API gebunden.

Ein komplexeres Szenario ist, wenn unsere ATM-Anwendung bereits in der Produktion ausgeführt wird und die Gesamtzeit der Transaktionen dynamisch ohne Ausfallzeiten für unsere Anwendung addiert werden soll.

Schreiben wir dazu einen kleinen Code und nennen diese KlasseAgentLoader. . Der Einfachheit halber fügen wir diese Klasse in die Anwendungs-JAR-Datei ein. Unsere Anwendungs-JAR-Datei kann also sowohl unsere Anwendung starten als auch unseren Agenten an die ATM-Anwendung anhängen:

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

Nachdem wir unsereAgentLoader haben, starten wir unsere Anwendung und stellen sicher, dass wir in der Pause von zehn Sekunden zwischen den Transaktionen unseren Java-Agenten dynamisch mit denAgentLoader anhängen.

Fügen wir auch den Kleber hinzu, mit dem wir entweder die Anwendung starten oder den Agenten laden können.

Wir nennen diese KlasseLauncher und es wird unsere Haupt-JAR-Dateiklasse sein:

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

Starten der Anwendung

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 Agent anhängen

Nach der ersten Operation hängen wir den Java-Agenten an unsere JVM an:

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

Überprüfen Sie die Anwendungsprotokolle

Nachdem wir unseren Agenten an die JVM angeschlossen haben, sehen wir, dass wir die Gesamtabschlusszeit für den zweiten Geldautomatenabhebungsvorgang haben.

Dies bedeutet, dass wir unsere Funktionen im laufenden Betrieb hinzugefügt haben, während unsere Anwendung ausgeführt wurde:

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. Erstellen eines Java-Agenten

Nachdem wir gelernt haben, wie man einen Agenten verwendet, wollen wir sehen, wie wir einen erstellen können. Wir werden unshow to use Javassist ansehen, um den Bytecode zu ändern, und dies mit einigen Instrumentierungs-API-Methoden kombinieren.

Da ein Java-Agent dieJava Instrumentation API verwendet, bevor wir uns zu sehr mit der Erstellung unseres Agenten befassen, sehen wir uns einige der am häufigsten verwendeten Methoden in dieser API und eine kurze Beschreibung ihrer Funktionsweise an:

  • addTransformer - fügt der Instrumentierungsmaschine einen Transformator hinzu

  • getAllLoadedClasses - gibt ein Array aller Klassen zurück, die derzeit von der JVM geladen werden

  • retransformClasses - erleichtert die Instrumentierung bereits geladener Klassen durch Hinzufügen von Bytecode

  • removeTransformer - hebt die Registrierung des mitgelieferten Transformators auf

  • redefineClasses - Definieren Sie den bereitgestellten Satz von Klassen mithilfe der bereitgestellten Klassendateien neu. Dies bedeutet, dass die Klasse vollständig ersetzt und nicht wie beiretransformClasses geändert wird

5.1. Erstellen Sie die MethodenPremain undAgentmain

Wir wissen, dass jeder Java-Agent mindestens eine der Methodenpremain oderagentmainbenötigt. Letzteres wird für das dynamische Laden verwendet, während ersteres zum statischen Laden eines Java-Agenten in eine JVM verwendet wird.

Definieren wir beide in unserem Agenten, damit wir diesen Agenten sowohl statisch als auch dynamisch laden können:

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

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

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

In jeder Methode deklarieren wir die Klasse, die wir ändern möchten, und graben dann nach unten, um diese Klasse mit der MethodetransformClasszu transformieren.

Unten finden Sie den Code für die MethodetransformClass, die wir definiert haben, um die KlasseMyAtmzu transformieren.

In dieser Methode finden wir die Klasse, die wir transformieren möchten, und verwenden dietransform -Smethod. Außerdem fügen wir den Transformator der Instrumentierungs-Engine hinzu:

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

Definieren wir den Transformator für die KlasseMyAtm.

5.2. Definieren unsererTransformer

Ein Klassentransformator mussClassFileTransformer implementieren und die Transformationsmethode implementieren.

Wir werdenJavassist verwenden, um der Klasse vonMyAtmBytecode hinzuzufügen, und ein Protokoll mit der gesamten ATW-Auszahlungstransaktionszeit hinzufügen:

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. Erstellen einer Agentenmanifestdatei

Um einen funktionierenden Java-Agenten zu erhalten, benötigen wir eine Manifestdatei mit einigen Attributen.

Daher finden wir die vollständige Liste der Manifestattribute in der offiziellen Dokumentation vonInstrumentation Package.

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

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

Unser Java Instrumentation Agent ist jetzt vollständig. Informationen zum Ausführen finden Sie im AbschnittLoading a Java Agentdieses Artikels.

6. Fazit

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

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

Wie immer kann die vollständige Implementierung des Beispielsover on Github gefunden werden.