本文主要由如何保证服务的可用性也就是系统监控问题逐步引出 Java 语言的高级特性,也就是 Java Agent 的使用。系统代码可以零倾入就能够引入监控服务。本文的主要讨论的有以下几个议题:

  • 为什么系统需要监控
  • Java 语言如何实现监控
  • Java Agent 简单示例
  • 开源项目使用 Java Agent

1、为什么系统需要监控

随着分布式服务框架的流行,特别是微服务等设计理念在系统中应用,业务的调用链越来越复杂。

在这里插入图片描述
可以看到,随着服务的拆分,系统的模块变得越来越多,不同的模块可能由不同的团队维护。

一个调用请求可能会涉及到几十个服务协同处理,牵扯到多个团队的业务系统,那么如何快速定位到线上故障?如何有效的进行相关的数据分析工作?

对于大型网站系统,如淘宝、京东等大型互联网公司,这些问题尤其突出。

2、如何对服务埋点

我们如何在代码中添加监控信息,一般有以下三种情况:

  • 在系统中使用硬编码的形式来添加监控代码
  • 使用 AOP 面向切面的形式来添加监控代码
  • 使用 Java 高级特性 Java Agent 在 JVM 层面来 AOP 添加监控代码

在系统中使用硬编码的情况对于单体系统来说来可以用用,但是对于分布式系统,就不太适合了。同样的使用 AOP 编程对于每一个系统都必须引入切面以及相应的切面配置,对于小型分布式系统来说还勉强可以。对于成百上千个服务集群来说简直是一个噩梦。所以对于大型系统一般都是采用 Java Agent 这个 JVM 层面的 AOP 来添加监控逻辑。也就是字节码增强,这样对于业务代码可以零倾入。

xxx.java ==> xxx.class ==> jvm 指令码 ==> 汇编 ==> CPU

如上图所示, Java 程序需要运行时:首先 Java 源代码需要编译成 Class 文件,文件的内容就由若干条 JVM 指令组成的集合(即代码逻辑)。插桩的过程就是将这些指令,拆开来,然后再插入监控所需指令,最后进行重新组装生成新的 Class 字节。

3、Java Agent

javaagent 是 java 1.5 之后引进的特性,其主要作用是在 class 被加载之前对其拦截,已插入我们的监听字节码。Agent分为两种,一种是在 main 程序之前运行的 Agent,一种是在主程序之后运行的 Agent(前者的升级版,1.6以后提供)下面我们分别来举例说明。

3.1 主程序之前运行代理程序

1、首先编写一个 agent 程序:

下面我们就使用 Javassist(JAVA programming ASSISTant) 来编写 agent 程序,实现对类进行增强。 Javassist 是一个开源的分析、编辑和创建 Java 字节码的类库。其主要的优点,在于简单,而且快速。直接使用 Java 编码的形式,而不需要了解虚拟机指令,就能动态改变类的结构,或者动态生成。

**注:Javassist 也是基于 ASM 实现的,并且 ASM 的功能更加全面。通过 ASM 实现实现类增强需要会操作字符码指令,学会使用成本高。 **

package cn.carlzone.learn.agent;

import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;

public class PreMainAgent {

	public static void premain(String agentArgs, Instrumentation instrumentation){
		System.out.println("hello pre main agent ...");
		// 打印出传入参数
		System.out.println(agentArgs);
		// 添加类加载过滤器
		instrumentation.addTransformer(new ClassFileTransformer(){

			public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
				if(!className.equals("cn/carlzone/learn/kafka/test/PreMainAgentTest")){
					return null;
				}
				// javassist 类池
				ClassPool pool = new ClassPool();
				pool.appendSystemPath();
				try {
					CtClass ctClass = pool.get("cn.carlzone.learn.kafka.test.PreMainAgentTest");
					CtMethod sayHello = ctClass.getDeclaredMethod("sayHello");
					sayHello.insertBefore("System.out.println(\"system current time millis is :\" + System.currentTimeMillis());");
					return ctClass.toBytecode();
				} catch (Exception e) {
					e.printStackTrace();
				}
				return null;
			}
		});

	}

}

添加代码分为三个部分:

  • 第一个是添加简单的打印 hello pre main agent ....
  • 第二个是打印出运行 JVM 传入的参数
  • 第三个是从 PreMainAgentTest 类中获取到 sayHello 方法并在方法执行之前添加打印当前系统时间

2、接着编写 MANIFEST.MF 文件

因为我们的项目是基于 maven 来管理的,所在需要在 resoures 目录下编写MANIFEST.MF文件。MANIFEST.MF文件是用来描述 Jar 包的信息,例如指定入口函数等。我们需要在该文件中加入如下配置,指定我们编写的含有premain方法类的全路径,然后将 agent 类打成 Jar 包。

MANIFEST.MF

Manifest-Version: 1.0
Premain-Class: cn.carlzone.learn.agent.PreMainAgent

并且在 pom.xml 添加上 maven-jar-plugin 这个 maven 插件:

<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>cn.carlzone.learn</groupId>
    <artifactId>agent-test</artifactId>
    <version>1.0-SNAPSHOT</version>

    <name>agent-test</name>

    <dependencies>
        <dependency>
            <groupId>org.javassist</groupId>
            <artifactId>javassist</artifactId>
            <version>3.25.0-GA</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.0.2</version>
                <configuration>
                    <archive>
                        <manifest>
                            <addClasspath>true</addClasspath>
                        </manifest>
                        <manifestEntries>
                            <Premain-Class>
                                cn.carlzone.learn.agent.PreMainAgent
                            </Premain-Class>
                        </manifestEntries>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

3、编写我们的 main 方法类

这里的程序就是我们要代理的程序。我们在主程序的VM options添加上启动参数。

-javaagent: /Users/carl/.m2/repository/cn/carlzone/learn/agent-test/1.0-SNAPSHOT/agent-test-1.0-SNAPSHOT.jar=carlgood

其中 carlgood 为上文中传入permain方法的agentArgs参数。
在这里插入图片描述

运行我们的主程序:

在这里插入图片描述
本来当前 cn.carlzone.learn.kafka.test.PreMainAgentTest#main 通过字节码增强技术在 main 方法运行之前添加了三段逻辑。

注意:如果运行的主程序的 classpath 下面没有 Javassist jar 包,就不会打印系统当前时间。

3.2 在主程序运行之后的代理程序

在主程序运行之前的agent模式有一些缺陷,例如需要在主程序运行前就指定 javaagent 参数,premain方法中代码出现异常会导致主程序启动失败等,并且有的逻辑是需要在项目启动之后不需要重启项目就能够对启动的项目进行修改,比如说热部署。为了解决这些问题,JDK1.6以后提供了在程序运行之后改变程序的能力。它的实现步骤和之前的模式类似:

1、编写agent类

我们复用上面的类,将premain方法修改为agentmain方法,由于是在主程序运行后再执行,意味着我们可以获取主程序运行时的信息,这里我们打印出来主程序中加载的类名。

public class AgentMain {

	public static void agentmain(String args, Instrumentation instrumentation) {
		System.out.println("agent main start ...");
		Class[] classes = instrumentation.getAllLoadedClasses();
		for (Class loadedClass : classes) {
			System.out.println(loadedClass.getName());
		}
		System.out.println("agent main end ...");
	}

}

2、修改MANIFEST.MF文件

Manifest-Version: 1.0
Agent-Class: cn.carlzone.learn.agent.AgentMain

个性 pom.xml 打包插件:

<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>cn.carlzone.learn</groupId>
        <artifactId>carl-learn</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <groupId>cn.carlzone.learn</groupId>
    <artifactId>agent-test</artifactId>
    <version>1.0-SNAPSHOT</version>

    <name>agent-test</name>

    <dependencies>
        <dependency>
            <groupId>org.javassist</groupId>
            <artifactId>javassist</artifactId>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.0.2</version>
                <configuration>
                    <archive>
                        <manifest>
                            <addClasspath>true</addClasspath>
                        </manifest>
                        <manifestEntries>
<!--                            <Premain-Class>-->
<!--                                cn.carlzone.learn.agent.PreMainAgent-->
<!--                            </Premain-Class>-->
                            <Agent-Class>
                                cn.carlzone.learn.agent.AgentMain
                            </Agent-Class>
                        </manifestEntries>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

3、启动主程序,编写加载agent类的程序

在程序运行后加载,我们不可能在主程序中编写加载的代码,只能另写程序,那么另写程序如何与主程序进行通信?这里用到的机制就是attach机制,它可以将JVM A连接至JVM B,并发送指令给JVM B执行,JDK自带常用工具如jstack,jps等就是使用该机制来实现的。在这里我们运行一个 Spring boot Web 项目。

@RestController
@SpringBootApplication
public class Bootstrap {
	
	@GetMapping("/index")
	public String index(){
		return "index";
	}

	public static void main(String[] args) {
		SpringApplication.run(Bootstrap.class, args);
	}
	
}

通过 jps 命令查到 Spring boot 项目运行的 PID.

在这里插入图片描述
编写 A 程序代码:

public class AgentMainTest {

	public static void main(String[] args) {
		try {
			VirtualMachine vm = VirtualMachine.attach("34437");
			vm.loadAgent("/Users/carl/.m2/repository/cn/carlzone/learn/agent-test/1.0-SNAPSHOT/agent-test-1.0-SNAPSHOT.jar");
		} catch (Exception e) {
			e.printStackTrace();
		}
	}

}

我们使用VirtualMachine attach到目标进程,其中 34437 为Spring boot Web 进程的 PID,可以使用 jps 命令获得,也可以使用VirtualMachine.list方法获取本机上所有的Java进程,再来判断tomcat进程,loadAgent方法第一个参数为Jar包在本机中的路径,第二个参数为传入 agentmain 的 args 参数,此处为null,运行程序:
在这里插入图片描述
然而什么都没有打印啊!是不是什么地方写错了呢?仔细想想就会发现,我们是将进程 attach到了Spring boot web 项目的进程上,agent其实是在主程序 B 中运行的,所以程序 A 中自然就不会进行打印,我们跳回Spring boot web 程序的控制台,查看结果。
在这里插入图片描述

4、总结

以上就是Java Agent的俩个简单小例子,Java Agent 十分强大,它能做到的不仅仅是打印几个监控数值而已,还包括使用Transformer(推荐观看)等高级功能进行类替换,方法修改等,要使用Instrumentation的相关API则需要对字节码等技术有较深的认识。

在开源框架中,skywalkingpinpoint 这两款 APM 框架都使用到了 premain 这个主程序之前运行代理程序来收集系统监控信息。而阿里巴巴开源的另一款 Java诊断工具Arthas:也使用到了 premain 这个主程序之前运行代理程序以及 agentmain 这个在主程序运行之后的代理程序来实现不重启项目对线上项目进行 Java 诊断。
对于系统监控我就不做过多介绍了,下面简单的介绍一下 Arthas:

Arthas 是Alibaba开源的Java诊断工具,深受开发者喜爱。

  • 当你遇到以下类似问题而束手无策时,Arthas可以帮助你解决:
  • 这个类从哪个 jar 包加载的?为什么会报各种类相关的 Exception?
  • 我改的代码为什么没有执行到?难道是我没 commit?分支搞错了?
  • 遇到问题无法在线上 debug,难道只能通过加日志再重新发布吗?
  • 线上遇到某个用户的数据处理有问题,但线上同样无法 debug,线下无法重现!
  • 是否有一个全局视角来查看系统的运行状况?
  • 有什么办法可以监控到JVM的实时运行状态?
  • 怎么快速定位应用的热点,生成火焰图?

Arthas支持JDK 6+,支持Linux/Mac/Winodws,采用命令行交互模式,同时提供丰富的 Tab 自动补全功能,进一步方便进行问题的定位和诊断。

参考文章:

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐