一、前言
于一个即将上线的应用来说,系统监控是必不可少的,为什么需要监控呢?应用是跑在服务器上的,应用在运行过程中会发生各自意想不到的问题,像大家熟知的OOM,mysql故障,服务器宕机,程序500等等,因此为了能够第一时间掌握应用运行过程中的各自异常状况,对于应用系统的监控是必要的,而在大多数情况下,应用自身的运行时异常占据了绝大的比例,因此合理的监控显得非常重要。
二、应用监控来源
设想一个场景,如果我们要监控一段程序的执行耗时,通常会有哪些做法呢?
- 方法前后添加起始,结束耗时;
- 使用aop技术跟踪方法的执行耗时;
- ......
上面谈到,在大多数情况下,应用自身的运行时异常占据了绝大的比例,所以在日常开发中,通过程序中日志埋点可以很好的帮助分析和定位问题,而说到底,还是监控代码段的执行,如果是一个简单的单体应用,怎么样都无所谓,毕竟简单嘛,但对于一个成熟的运行中的系统来说,尤其是那种分布式的应用平台,不管使用上面哪种技术,都涉及到应用内部程序的开发工作量,面临这种情况该怎么办呢?
是不是有一种技术可以在需要对既有代码做改动的前提下就能够监控代码运行的一些指标呢?
答案是肯定的,那就是接下来要讲的 java agent技术,也就是Java探针技术。
三、Java agent 是什么?
Java Agent技术,也称为Java代理、Java探针,它允许程序员利⽤其构建⼀个独⽴于应⽤程序的代理程序。
- Java Agent 本质上就是一个 jar 包。
- 对于普通的Jar包,通过指定类的 main 函数进行启动,但是 Java Agent 并不能单独启动,必须依附在一个 Java 应用程序运行。
- 使用Java Agent可以实现在Java程序运行的过程中对其进行修改。
四、Java Agent 主要功能点
- 在加载java文件前可以拦截字节码并做修改;
- 在运行期间变更已加载的类的字节码;
- 获取所有已经被加载过的类;
- 获取所有已经被初始化过了的类;
- 获取某个对象的大小;
以上这些功能,使得Java Agent在作为一个独立于Java应用程序的代理程序的同时,可以协助监测、运行甚至替换 JVM 上的程序。Agent的应用十分广泛,可用于实现Java IDE的调试功能、热部署功能、线上诊断⼯具和性能分析⼯具。
例如,百度网络攻击防护工具OpenRASP中就使用了Java Agent来对敏感函数进行插桩,以此实现攻击检测。大名鼎鼎的skywalking对于各类应用监测其底层实现也是基于Java Agent。
五、Java agent 加载过程
在开始编码之前,有必要对Java agent的原理做一些了解,我们知道,一个普通的java程序要运行起来,可以通过main函数运行,或者打成jar包之后指定启动类,说的再简单点,调用执行命令之后,jvm会通过类加载器加载class文件,然后运行起来;
有了上面对于java agent的了解,java agent可以在加载java文件前拦截字节码并做修改的,执行过程如下图:
如果将两者结合起来看,更具体的过程如下图,Java agent的入场时机以及作用一目了然;
六、Java agent 代理的两个入口函数
JVM启动支持加载agent代理,而agent代理本身就是一个JVM TI的客户端,其通过监听事件的方式获取Java应用运行状态,调用JVM TI提供的接口对应用进行控制。两个核心的入口函数如下:
// 用于JVM刚启动时调用,其执行时应用类文件还未加载到JVM
public static void premain(String agentArgs, Instrumentation inst);
// 用于JVM启动后,在运行时刻加载
public static void agentmain(String agentArgs, Instrumentation inst);
这两个入口函数分别对应于JVM TI专门提供了执行 字节码增强(bytecode instrumentation) 的两个接口,对这两个入口函数再做一些补充说明:
- premain加载时刻增强(JVM 启动时加载),类字节码文件在JVM加载的时候进行增强;
- agentMain动态增强(JVM 运行时加载),已经被JVM加载的class字节码文件,当被修改或更新时进行增强;
这两个接口都是从JDK 1.6开始支持,我们无需对上面JVM TI提供的两个接口规范了解太多,Java Agent和 Java Instrument类包 封装好了字节码增强的上述接口通信,JVM在加载agent时会先找函数1,如果没有发现函数1,则会寻找函数2;
七、Java agent 初体验
有了上面的基础储备后,接下来通过实际的代码演示来体验下Java agent的使用吧;
premain 使用演示一
1、变现一个类,提供一个premain的入口方法
import java.lang.instrument.Instrumentation;
public class MyPreMainAgent {
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("hello javaAgent");
}
}
2、在工程的pom中添加premain打包插件
要注意的是<Premain-Class>标签中写的是上面premain所在类的完整路径
<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.congge.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>
3、将模块编译打成jar包
将这个jar包解压之后,里面有一个非常重要的文件
打开该文件,内容如下,通过该文件,应用程序在使用java agent的jar包的时候才会正确加载premain对应的入口函数;
4、编写测试类,启动引用上述的jar
public class AgentTest {
public static void main(String[] args) {
new UserService().sayHello("hell agent");
}
}
启动时在启动参数中指定上面的jar
然后运行测试类,可以看到jar包中的premain方法的打印输出结果,是在测试类的方法打印结果之前输出;
premain 使用演示二
使用javaagent监控运行时内存使用情况
1、Metric 监控指标类
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryUsage;
import java.util.Arrays;
import java.util.List;
public class Metric {
private static final long MB = 1048576L;
public static void printMemoryInfo() {
MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
MemoryUsage headMemory = memory.getHeapMemoryUsage();
String info = String.format("\ninit: %s\t max: %s\t used: %s\t committed: %s\t use rate: %s\n",
headMemory.getInit() / MB + "MB",
headMemory.getMax() / MB + "MB", headMemory.getUsed() / MB + "MB",
headMemory.getCommitted() / MB + "MB",
headMemory.getUsed() * 100 / headMemory.getCommitted() + "%"
);
System.out.print(info);
MemoryUsage nonheadMemory = memory.getNonHeapMemoryUsage();
info = String.format("init: %s\t max: %s\t used: %s\t committed: %s\t use rate: %s\n",
nonheadMemory.getInit() / MB + "MB",
nonheadMemory.getMax() / MB + "MB", nonheadMemory.getUsed() / MB + "MB",
nonheadMemory.getCommitted() / MB + "MB",
nonheadMemory.getUsed() * 100 / nonheadMemory.getCommitted() + "%"
);
System.out.println(info);
}
public static void printGCInfo() {
List<GarbageCollectorMXBean> garbages = ManagementFactory.getGarbageCollectorMXBeans();
for (GarbageCollectorMXBean garbage : garbages) {
String info = String.format("name: %s\t count:%s\t took:%s\t pool name:%s",
garbage.getName(),
garbage.getCollectionCount(),
garbage.getCollectionTime(),
Arrays.deepToString(garbage.getMemoryPoolNames()));
System.out.println(info);
}
}
}
2、premain入口函数类
import java.lang.instrument.Instrumentation;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class MyJvmAgent {
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("this is an perform monitor agent.");
Executors.newScheduledThreadPool(1).scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
Metric.printMemoryInfo();
Metric.printGCInfo();
}
}, 0, 3000, TimeUnit.MILLISECONDS);
}
}
3、测试类
import java.util.ArrayList;
import java.util.List;
public class AgentTest {
public static void main(String[] args) throws Exception {
System.out.println("hello javaAgent");
boolean is = true;
while (is) {
List<Object> list = new ArrayList<>();
list.add("hello agent");
}
}
}
将上面的MyJvmAgent 配置到pom中,打包,然后像上面的案例那样运行,观察输出结果
八、javassit 介绍与使用
在上面的java agent的类加载原理图中,其最终在编辑字节码文件时,具体的技术实现可以有多种,比如cglib,asm或javassist等;
javassit介绍
javassit是一个开源的分析、编辑和创建Java字节码的类库,主要优点是简单且快速,直接使用java编码的形式,不需要了解虚拟机指令,就能动态改变类的结构,或者动态生成字节码,相对于asm技术,学习和使用成本要低很多;
javassit依赖
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.27.0-GA</version>
</dependency>
javassit 案例使用
1、打印输出方法耗时
通常为了输出一个方法的执行耗时,最简单的办法就是在方法头和方法尾部获取当前的执行时间最后再相减,即可得到方法的执行时长;
如果是在生产系统中来做这个事情,一个工程中随便几十几百个类,方法更多,这样加起来就相当费事了,就可以考虑使用java agent的方式来做,下面来看看使用java agent的实现过程;
提供一个业务类
在该类中有一个普通的方法,为了模拟执行业务,休眠1秒
public class UserService {
public void sayHello(String name){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("sayHello()");
}
}
提供premain方法
import javassist.*;
import java.io.IOException;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
public class AgentMain {
public static void premain(String agentArgs, Instrumentation instrumentation) {
final ClassPool pool = new ClassPool();
pool.appendSystemPath();
//基于工具,在运行的时候修改class字节码,即动态插装
instrumentation.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
if (!"com/congge/agent/UserService".equals(className)) {
return null;
}
try {
CtClass ctClass = pool.get("com.congge.agent.UserService");
CtMethod sayHello = ctClass.getDeclaredMethod("sayHello");
// 打印方法耗时 ================
//FIXME 1、复制一个方法
CtMethod copy = CtNewMethod.copy(sayHello, ctClass, null);
copy.setName("sayHelloCopy");
ctClass.addMethod(copy);
//2、 改变原有的方法
sayHello.setBody("{long begin = System.currentTimeMillis();\n" +
" sayHelloCopy($1);\n" +
" System.out.println(System.currentTimeMillis()-begin);}");
//打印方法耗时 ================
//sayHello.insertBefore("System.out.println(System.currentTimeMillis());");
return ctClass.toBytecode();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
});
}
}
在该方法中,需要说明的是,通过javassit的编码方式,可以通过下面这种方式将一段程序以字符串的形式动态植入,像其中的 $1 就属于javassit的解析参数的一种指令,有兴趣的同学可以深入学习javassit做进一步的了解;
测试类
public class AgentTest {
public static void main(String[] args) {
new UserService().sayHello("hell agent");
}
}
然后像上面的操作那样,将premain所在的类配置到pom打包,然后运行测试类,控制台就可以输出本次方法的执行耗时;
2、打印输出方法耗时改进
上面的案例使用javassit实现了对某个方法的执行耗时的监控,现在进一步思考下,如果希望这个功能做的更加通用些,比如说在实际业务中,我们不仅仅是输出某个方法的执行耗时,而是输出某一个包下的类的方法执行耗时,或者某一些特定类的方法的执行耗时又该怎么做呢?接下来,基于上面的案例做进一步的优化改进,其目的是为了尽可能做到通用性;
改进premain方法
该方法的改进点涉及下面几点:
- 加载java agent的jar包时,可由外部传递参数,即外部传入要执行方法耗时的规则;
- 可以对类中的所有方法进行插桩;
- 对有返回值和无返回值的方法均可适用;
import javassist.*;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
public class AgentMain1 {
public static void premain(String agentArgs, Instrumentation instrumentation) {
final String config = agentArgs;
final ClassPool pool = new ClassPool();
pool.appendSystemPath();
//基于工具,在运行的时候修改class字节码,即动态插装
instrumentation.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
if (className == null || !className.replaceAll("/", ".").startsWith(config)) {
return null;
}
try {
className = className.replaceAll("/", ".");
CtClass ctClass = pool.getCtClass(className);
for (CtMethod ctMethod : ctClass.getDeclaredMethods()) {
newMethod(ctMethod);
}
return ctClass.toBytecode();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
});
}
private static CtMethod newMethod(CtMethod oldMethod) throws CannotCompileException, NotFoundException {
CtMethod copy = CtNewMethod.copy(oldMethod, oldMethod.getDeclaringClass(), null);
copy.setName(oldMethod.getName() + "$agent");
oldMethod.getDeclaringClass().addMethod(copy);
if (oldMethod.getReturnType().equals(CtClass.voidType)) {
oldMethod.setBody(String.format(voidSource, oldMethod.getName()));
} else {
oldMethod.setBody(String.format(source, oldMethod.getName()));
}
return copy;
}
final static String source = "{\n" +
"long begin = System.currentTimeMillis();\n" +
" Object result;\n" +
" try {\n" +
" result = ($w)%s$agent($$);\n" +
" }finally {\n" +
" long end = System.currentTimeMillis();\n" +
" System.out.println(end-begin);\n" +
" }\n" +
" return ($r) result;\n" +
"}\n";
final static String voidSource = "{\n" +
"long begin = System.currentTimeMillis();\n" +
" try {\n" +
" %s$agent($$);\n" +
" }finally {\n" +
" long end = System.currentTimeMillis();\n" +
" System.out.println(end-begin);\n" +
" }\n" +
"}\n";
}
UserService 类再追加一个方法
public String sayHello1(String name){
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("sayHello() " + name);
return name;
}
启动类
public class AgentTest {
public static void main(String[] args) {
new UserService().sayHello("hell agent");
new UserService().sayHello1("hell agent");
}
}
按照上面案例的步骤再次运行,不过这一次启动类中需要添加如下参数,表示以 com.congge.agent.User 这个为前置的类才会被监控执行方法的耗时;
-javaagent:E:\code-self\spi\java-agent\target\java-agent-1.0-SNAPSHOT.jar=com.congge.agent.User
输出控制台结果如下,
通过这种方式,就可以让agent的jar具备了更多的灵活性和一定的通用性,即只要符合匹配规则的类都可以适用这个jar来得到方法的执行耗时;
未完待续 ... 感谢您的观看!