[Transfer] Change the world and use JavaAgent to deceive your JVM

This article is reprinted from the article on the WeChat public account Nong Ginseng. Use JavaAgent to deceive your JVM. The following is the text:

Friends who are familiar with Spring should be familiar with aop. Aspect-oriented programming allows us to weave the logic we want to execute before and after the target method. The Java Agent technology I want to introduce to you today is similar in thought to aop. Translated, it can be called Java agent and Java probe technology.

Java Agent appears after JDK1.5. It allows programmers to use agent technology to build an application-independent agent program. It is also very versatile and can assist in monitoring, running, and even replacing programs on other JVMs. Let’s start with the following The picture below gives an intuitive look at the scenarios in which it is used:

Are you also curious after seeing this? What kind of magical technology can be applied in so many scenarios? Today we will dig into it and see how the magical Java Agent works at the bottom and silently supports so many excellent technologies. Applications.

Going back to the analogy at the beginning of the article, we still use the method of comparison with aop to first have a general understanding of Java Agent:

  • Action level: AOP runs at the method level within the application, while agent can act at the virtual machine level
  • Components: The implementation of aop requires target methods and logical enhancement methods, and Java Agent requires two projects to take effect, one is the agent agent, and the other is the main program that needs to be agented
  • Execution occasions: aop can run before, after, or around aspects, but there are only two ways to execute Java Agent. The preMain mode provided by jdk1.5 is executed before the main program runs, and the agentMain provided by jdk1.6 is executed after the main program runs. implement

Let’s take a look at how to implement an agent program in the two modes.

Premain mode

Premain mode allows an agent to be executed before the main program is executed. It is very simple to implement. Below we implement the two components respectively.

agent

First write a simple function that prints a sentence before the main program is executed and prints the parameters passed to the agent:

public class MyPreMainAgent {<!-- -->
    public static void premain(String agentArgs, Instrumentation inst) {<!-- -->
        System.out.println("premain start");
        System.out.println("args:" + agentArgs);
    }
}

After writing the agent’s logic, it needs to be packaged into a jar file. Here we directly use the maven plug-in packaging method and perform some configurations before packaging.

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-jar-plugin</artifactId>
            <version>3.1.0</version>
            <configuration>
                <archive>
                    <manifest>
                        <addClasspath>true</addClasspath>
                    </manifest>
                    <manifestEntries>
                        <Premain-Class>com.cn.agent.MyPreMainAgent</Premain-Class>
                        <Can-Redefine-Classes>true</Can-Redefine-Classes>
                        <Can-Retransform-Classes>true</Can-Retransform-Classes>
                        <Can-Set-Native-Method-Prefix>true</Can-Set-Native-Method-Prefix>
                    </manifestEntries>
                </archive>
            </configuration>
        </plugin>
    </plugins>
</build>

Among the configured packaging parameters, add attributes to the MANIFEST.MF file through manifestEntries. Explain the several parameters inside:

  • Premain-Class: The class containing the premain method needs to be configured as the full path of the class
  • Can-Redefine-Classes: When true, it means that the class can be redefined
  • Can-Retransform-Classes: When true, it means that the class can be re-transformed to achieve bytecode replacement.
  • Can-Set-Native-Method-Prefix: When true, it means that the prefix of the native method can be set

Among them, Premain-Class is a required configuration, and the remaining items are optional options. By default, they are all false. It is usually recommended to add them. We will introduce these functions in detail later. After the configuration is complete, use the mvn command to package:

mvn clean package

After the packaging is completed, the myAgent-1.0.jar file is generated. We can decompress the jar file and take a look at the generated MANIFEST.MF file:


You can see that the added attributes have been added to the file. At this point, the agent part is completed. Because the agent cannot be run directly and needs to be attached to other programs, a new project is created below to implement the main program.

Main program

In the main program project, you only need an entry to the main method that can be executed.

public class AgentTest {<!-- -->
    public static void main(String[] args) {<!-- -->
        System.out.println("main project start");
    }
}

After the main program is completed, what needs to be considered is how to connect the main program with the agent project. Here you can specify the running agent through the -javaagent parameter. The command format is as follows:

java -javaagent:myAgent.jar -jar AgentTest.jar

Moreover, there is no limit to the number of agents that can be specified. Each agent will be executed in sequence according to the specified order. If you want to run two agents at the same time, you can execute it according to the following command:

java -javaagent:myAgent1.jar -javaagent:myAgent2.jar -jar AgentTest.jar

Take our execution of the program in idea as an example, add startup parameters in VM options:

-javaagent:F:\Workspace\MyAgent\target\myAgent-1.0.jar=Hydra
-javaagent:F:\Workspace\MyAgent\target\myAgent-1.0.jar=Trunks

Execute the main method and view the output results:

According to the print statement of the execution result, we can see that our agent is executed twice before executing the main program. The execution sequence of the execution agent and the main program can be represented by the following figure.

Defects

While providing convenience, the premain mode also has some defects. For example, if an exception occurs during the operation of the agent, it will also cause the main program to fail to start. Let’s modify the agent code in the above example and manually throw an exception.

public static void premain(String agentArgs, Instrumentation inst) {<!-- -->
    System.out.println("premain start");
    System.out.println("args:" + agentArgs);
    throw new RuntimeException("error");
}

Run the main program again:

It can be seen that the main program did not start after the agent threw an exception. In response to some defects of premain mode, agentmain mode was introduced after jdk1.6.

Agentmain mode

The agentmain mode can be said to be an upgraded version of premain. It allows the JVM of the agent’s target main program to be started first, and then connects the two JVMs through the attach mechanism. Below we implement it in three parts.

agent

The agent part is the same as above, implementing a simple printing function:

public class MyAgentMain {<!-- -->
    public static void agentmain(String agentArgs, Instrumentation instrumentation) {<!-- -->
        System.out.println("agent main start");
        System.out.println("args:" + agentArgs);
    }
}

Modify the maven plug-in configuration and specify Agent-Class:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-jar-plugin</artifactId>
    <version>3.1.0</version>
    <configuration>
        <archive>
            <manifest>
                <addClasspath>true</addClasspath>
            </manifest>
            <manifestEntries>
                <Agent-Class>com.cn.agent.MyAgentMain</Agent-Class>
                <Can-Redefine-Classes>true</Can-Redefine-Classes>
                <Can-Retransform-Classes>true</Can-Retransform-Classes>
            </manifestEntries>
        </archive>
    </configuration>
</plugin>
Main program

Here we directly start the main program and wait for the agent to be loaded. System.in is used in the main program to block to prevent the main process from ending early.

public class AgentmainTest {<!-- -->
    public static void main(String[] args) throws IOException {<!-- -->
        System.in.read();
    }
}
attach mechanism

Different from the premain mode, we can no longer connect the agent and the main program by adding startup parameters. Here we need to use the VirtualMachine tool class under the com.sun.tools.attach package. It should be noted that this class is not a jvm standard specification and is composed of Implemented by Sun itself, dependencies need to be introduced before use:

<dependency>
    <groupId>com.sun</groupId>
    <artifactId>tools</artifactId>
    <version>1.8</version>
    <scope>system</scope>
    <systemPath>${<!-- -->JAVA_HOME}\lib\tools.jar</systemPath>
</dependency>

VirtualMachine represents a Java virtual machine to be attached, which is the target virtual machine that needs to be monitored in the program. An external process can use an instance of VirtualMachine to load the agent into the target virtual machine. Let’s take a look at its static method attach first:

public static VirtualMachine attach(String var0);

A jvm object instance can be obtained through the attach method. The parameter passed here is the process number pid of the target virtual machine when it is running. In other words, before we use attach, we need to get the pid of the main program we just started and use the jps command to check the thread pid:

11140
16372 RemoteMavenServer36
16392AgentmainTest
20204Jps
2460 Launcher

Obtain the running time pid of the main program AgentmainTest as 16392, and apply it to the virtual machine connection.

public class AttachTest {<!-- -->
    public static void main(String[] args) {<!-- -->
        try {<!-- -->
            VirtualMachine vm= VirtualMachine.attach("16392");
            vm.loadAgent("F:\Workspace\MyAgent\target\myAgent-1.0.jar","param");
        } catch (Exception e) {<!-- -->
            e.printStackTrace();
        }
    }
}

After obtaining the VirtualMachine instance, you can inject the agent class through the loadAgent method. The first parameter of the method is the local path of the agent, and the second parameter is the parameter passed to the agent. Execute AttachTest, and then return to the console of the main program AgentmainTest. You can see that the code in the agent is executed:

In this way, a simple agentMain mode agent is implemented. You can sort out the relationship between the three modules through the following picture.

Application

At this point, we have briefly understood the implementation methods of the two modes. However, as high-quality programmers, we must not be satisfied with just using agents to simply print statements. Let’s take a look at how we can use Java Agent to do something practical. s things.

In the above two modes, the logic of the agent part is implemented in the premain method and agentmain method respectively. Moreover, these two methods have strict requirements on the parameters in the signature. The premain method allows to be defined in the following two ways:

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

The agentmain method allows to be defined in the following two ways:

public static void agentmain(String agentArgs)
public static void agentmain(String agentArgs, Instrumentation inst)

If there are two signed methods in the agent at the same time, the method with the Instrumentation parameter has a higher priority and will be loaded by the jvm first. Its instance inst will be automatically injected by the jvm. Let’s see what functions can be achieved through Instrumentation. .

Instrumentation

First, let’s briefly introduce the Instrumentation interface. The methods in it allow operating Java programs at runtime, providing functions such as changing bytecode, adding jar packages, replacing classes, etc. Through these functions, Java has stronger dynamic control and Explanation ability. In the process of writing agents, the following three methods in Instrumentation are more important and commonly used. Let’s take a look at them.

addTransformer

The addTransformer method allows us to redefine Class before the class is loaded. Let’s first look at the definition of the method:

void addTransformer(ClassFileTransformer transformer);

ClassFileTransformer is an interface with only one transform method. Before the main method of the main program is executed, each loaded class must be transformed once. It can be called a converter. We can implement this method to redefine Class. Let’s take an example to see how to use it.

First, create a Fruit class in the main program project:

public class Fruit {<!-- -->
    public void getFruit(){<!-- -->
        System.out.println("banana");
    }
}

After compilation is completed, copy a class file and rename it to Fruit2.class, and then modify the method in Fruit as:

public void getFruit(){<!-- -->
    System.out.println("apple");
}

Create the main program, create a Fruit object in the main program and call its getFruit method:

public class TransformMain {<!-- -->
    public static void main(String[] args) {<!-- -->
        new Fruit().getFruit();
    }
}

At this time, the execution result will print apple, and then the premain agent part will be implemented.

In the premain method of the agent, use the addTransformer method of Instrumentation to intercept the loading of the class:

public class TransformAgent {<!-- -->
    public static void premain(String agentArgs, Instrumentation inst) {<!-- -->
        inst.addTransformer(new FruitTransformer());
    }
}

The FruitTransformer class implements the ClassFileTransformer interface, and the logic of converting the class part is in the transform method:

public class FruitTransformer implements ClassFileTransformer {<!-- -->
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain, byte[] classfileBuffer){<!-- -->
        if (!className.equals("com/cn/hydra/test/Fruit"))
            return classfileBuffer;

        String fileName="F:\Workspace\agent-test\target\classes\com\cn\hydra\test\Fruit2.class";
        return getClassBytes(fileName);
    }

    public static byte[] getClassBytes(String fileName){<!-- -->
        File file = new File(fileName);
        try(InputStream is = new FileInputStream(file);
            ByteArrayOutputStream bs = new ByteArrayOutputStream()){<!-- -->
            long length = file.length();
            byte[] bytes = new byte[(int) length];

            int n;
            while ((n = is.read(bytes)) != -1) {<!-- -->
                bs.write(bytes, 0, n);
            }
            return bytes;
        }catch (Exception e) {<!-- -->
            e.printStackTrace();
            return null;
        }
    }
}

In the transform method, two main things are done:

  • Because the addTransformer method cannot specify the class that needs to be converted, we need to use className to determine whether the currently loaded class is the target class we want to intercept. For non-target classes, the original byte array is returned directly. Pay attention to the format of className. You need to include the fully qualified name of the class. of .replaced with /
  • Read the class file we copied before, read in the binary character stream, replace the original classfileBuffer byte array and return, completing the replacement of the class definition

After packaging the agent part, add startup parameters to the main program:

-javaagent:F:\Workspace\MyAgent\target\transformAgent-1.0.jar

Execute the main program again and the result is printed:
banana

In this way, class replacement is achieved before the main method is executed.

redefineClasses

We can intuitively understand its function from the name of the method and redefine the class. In layman’s terms, it means replacing the specified class. The method is defined as follows:

void redefineClasses(ClassDefinition... definitions) throws ClassNotFoundException, UnmodifiableClassException;

Its parameter is a variable-length ClassDefinition array. Let’s take a look at the ClassDefinition construction method:

public ClassDefinition(Class<?> theClass,byte[] theClassFile) {<!-- -->...}

The Class object and modified bytecode array specified in ClassDefinition are, simply put, replacing the original class with the provided class file bytes. Moreover, during the redefinition process of the redefineClasses method, the array of ClassDefinition is passed in, and it will be loaded in the order of this array to meet the requirements for changes when classes are dependent on each other.

Let’s take a look at its effectiveness process through an example, the premain agent part:

public class RedefineAgent {<!-- -->
    public static void premain(String agentArgs, Instrumentation inst)
            throws UnmodifiableClassException, ClassNotFoundException {<!-- -->
        String fileName="F:\Workspace\agent-test\target\classes\com\cn\hydra\test\Fruit2.class";
        ClassDefinition def=new ClassDefinition(Fruit.class,
                FruitTransformer.getClassBytes(fileName));
        inst.redefineClasses(new ClassDefinition[]{<!-- -->def});
    }
}

The main program can directly reuse the above and print after execution:
banana
As you can see, the original class is replaced with the bytes of the class file we specified, that is, the replacement of the specified class is achieved.

retransformClasses

retransformClasses is applied in agentmain mode, and Class can be redefined after the class is loaded, which triggers the reloading of the class. First look at the definition of this method:

void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

Its parameter classes is an array of classes that need to be converted. The variable length parameter also shows that it is the same as the redefineClasses method and can also convert class definitions in batches.

Next, let’s take an example to see how to use the retransformClasses method. The agent code is as follows:

public class RetransformAgent {<!-- -->
    public static void agentmain(String agentArgs, Instrumentation inst)
            throws UnmodifiableClassException {<!-- -->
        inst.addTransformer(new FruitTransformer(),true);
        inst.retransformClasses(Fruit.class);
        System.out.println("retransform success");
    }
}

Take a look at the definition of the addTransformer method called here, which is slightly different from the above:

void addTransformer(ClassFileTransformer transformer, boolean canRetransform);

The ClassFileTransformer converter still reuses the FruitTransformer above. Focus on the newly added second parameter. When canRetransform is true, it means that the class is allowed to be redefined. At this time, it is equivalent to calling the transform method in the converter ClassFileTransformer, and the bytes of the converted class will be loaded as a new class definition.

In the main program code, we continuously execute print statements in an infinite loop to monitor whether the class has changed:

public class RetransformMain {<!-- -->
    public static void main(String[] args) throws InterruptedException {<!-- -->
        while(true){<!-- -->
            new Fruit().getFruit();
            TimeUnit.SECONDS.sleep(5);
        }
    }
}

Finally, use the attach api to inject the agent into the main program:

public class AttachRetransform {<!-- -->
    public static void main(String[] args) throws Exception {<!-- -->
        VirtualMachine vm = VirtualMachine.attach("6380");
        vm.loadAgent("F:\Workspace\MyAgent\target\retransformAgent-1.0.jar");
    }
}

Return to the main program console and view the running results:

You can see that after injecting the proxy, the print statement changes, indicating that the definition of the class has been changed and reloaded.

Others

In addition to these main methods, there are some other methods in Instrumentation. Here is a brief list of the functions of common methods:

  • removeTransformer: delete a ClassFileTransformer class converter
  • getAllLoadedClasses: Get the currently loaded Class
  • getInitiatedClasses: Get the Class loaded by the specified ClassLoader
  • getObjectSize: Get the size of the space occupied by an object
  • appendToBootstrapClassLoaderSearch: Add jar package to startup class loader
  • appendToSystemClassLoaderSearch: Add jar package to system class loader
  • isNativeMethodPrefixSupported: Determine whether a prefix can be added to the native method, that is, whether it can be intercepted
    native method
  • setNativeMethodPrefix: Set the prefix of the native method
Javassist

In the above examples, we directly read the bytes in the class file to redefine or convert the class. However, in the actual working environment, it may be more dynamic to modify the class file. Bytecode, at this time you can use javassist to modify the bytecode file more easily.

Simply put, javassist is a class library that analyzes, edits and creates Java bytecode. When using it, we can directly call the API it provides to dynamically change or generate the class structure in the form of coding. Compared with other bytecode frameworks such as ASM that require understanding of the underlying virtual machine instructions, javassist is really simple and fast.

Below, we will use a simple example to see how to use Java agent and Javassist together. First introduce the javassist dependency:

<dependency>
    <groupId>org.javassist</groupId>
    <artifactId>javassist</artifactId>
    <version>3.20.0-GA</version>
</dependency>

The function we want to implement is to calculate the method execution time through the proxy. The premain agent part is basically the same as before. First add a converter:

public class Agent {<!-- -->
    public static void premain(String agentArgs, Instrumentation inst) {<!-- -->
        inst.addTransformer(new LogTransformer());
    }

    static class LogTransformer implements ClassFileTransformer {<!-- -->
        @Override
        public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                                ProtectionDomain protectionDomain, byte[] classfileBuffer)
            throws IllegalClassFormatException {<!-- -->
            if (!className.equals("com/cn/hydra/test/Fruit"))
                return null;

            try {<!-- -->
                return calculate();
            } catch (Exception e) {<!-- -->
                e.printStackTrace();
                return null;
            }
        }
    }
}

In the calculate method, use javassist to dynamically change the method definition:

static byte[] calculate() throws Exception {<!-- -->
    ClassPool pool = ClassPool.getDefault();
    CtClass ctClass = pool.get("com.cn.hydra.test.Fruit");
    CtMethod ctMethod = ctClass.getDeclaredMethod("getFruit");
    CtMethod copyMethod = CtNewMethod.copy(ctMethod, ctClass, new ClassMap());
    ctMethod.setName("getFruit$agent");

    StringBuffer body = new StringBuffer("{\
")
            .append("long begin = System.nanoTime();\
")
            .append("getFruit$agent($$);\
")
            .append("System.out.println("use " + (System.nanoTime() - begin) + " ns");\
")
            .append("}");
    copyMethod.setBody(body.toString());
    ctClass.addMethod(copyMethod);
    return ctClass.toBytecode();
}

In the above code, these functions are mainly implemented:

  • Get the class CtClass using its fully qualified name
  • Get the method CtMethod based on the method name and copy a new method through the CtNewMethod.copy method
  • The method that modifies the old method is called getFruit$agent
  • Modify the content of the copied method through the setBody method, perform logical enhancements in the new method and call the old method, and finally add the new method to the class

The main program still reuses the previous code, executes and views the results, and completes the execution time statistics function in the agent:

image

At this time we can take a look again through reflection:

for (Method method : Fruit.class.getDeclaredMethods()) {<!-- -->
    System.out.println(method.getName());
    method.invoke(new Fruit());
    System.out.println("-------");
}

Looking at the results, you can see that a new method has indeed been added to the class:

In addition, javassist has many other functions, such as creating new Class, setting parent class, reading and writing bytecode, etc. You can learn its usage in specific scenarios.

Summary

Although we may not directly use Java Agent in many scenarios in our daily work, in hot deployment, monitoring, performance analysis and other tools, they may be hidden in the corner of the business system and have been silently playing a huge role. effect.

This article starts with the two modes of Java Agent, manually implements and briefly analyzes their workflow. Although they are only used to complete some simple functions here, it has to be said that it is the emergence of Java Agent that makes the program run. No longer following the rules, it also provides unlimited possibilities for our code.