JVM插庄之一:JVM字节码增强技术介绍及入门示例

时间:2023-03-08 16:24:52

字节码增强技术:AOP技术其实就是字节码增强技术,JVM提供的动态代理追根究底也是字节码增强技术。

目的:在Java字节码生成之后,对其进行修改,增强其功能,这种方式相当于对应用程序的二进制文件进行修改。Java字节码增强主要是为了减少冗余代码,提高性能等。

应用场景:某一天系统出现OOM,通过工具分析,是莫各类的对象占用了很大空间,但是这个对象被许多程序访问,那么就很难找到,工程的全文匹配也只能找到自己的业务代码调用的地方,深入的反射,三方包调用无法匹配。这个时候AOP就可以帮助完成。

两种实现机制:

动态代理(jdk动态代理,springAOP动态代理):一种是通过创建原始类的一个子类,也就是动态创建的这个类继承原来的类,现在的SpringAOP正式通过这种方式实现;

动态代理(Cglib,ASM,javassist):另一种是非常暴力的,即直接修改原先的Class字节码,在许多类的跟踪过程中会用到这技术(类加载时修改字节码信息,运行时修改)。《Java动态代理机制详解(JDK 和CGLIB,Javassist,ASM)

代理:Java之代理(jdk静态代理,jdk动态代理,cglib动态代理,aop,aspectj)

实现字节码增强的主要步骤为:

1、修改字节码

1.在内存中获取到原始的字节码,然后通用一些开源提供的API来修改它的byte[]数组,得到一个新的byte[]数组。(ASM,javassist,cglib等技术)

2、使修改后的字节码生效

有两种方法:

1) 自定义ClassLoader来加载修改后的字节码;

2)替换掉原来的字节码(将这个新的数组写到PermGen区域,也就是加载它或替换原来的Class字节码(也可以在进程外部调用完成)):在JVM加载用户的Class时,拦截,返回修改后的字节码;或者在运行时,使用Instrumentation.redefineClasses方法来替换掉原来的字节码;

使用Java代理和ASM字节码技术开发java探针工具可以修改字节码

备注:javassist是一个库,实现ClassFileTransformer接口中的transform()方法。ClassFileTransformer 这个接口的目的就是在class被装载到JVM之前将class字节码转换掉,从而达到动态注入代码的目的

备注:ASM是一个java字节码操纵框架,它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。Java class 被存储在严格格式定义的 .class文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。ASM从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。


详细总结

1 使用Java代理和Java字节码注入技术

开发java探针工具来分析复杂的接口方法,无需修改代码,简单部署就可以实时抓取代码的运行轨迹和方法耗时。

2 基于Java代理和Java字节码注入技术的java探针工具技术原理

 JVM插庄之一:JVM字节码增强技术介绍及入门示例
动态代理功能实现说明

我们利用Java代理和ASM字节码技术开发java探针工具,实现原理如下:

jdk1.5以后引入了Java代理技术,Java代理是运行方法之前的拦截器。我们利用Java代理和ASM字节码技术,在JVM加载class二进制文件的时候,利用ASM动态的修改加载的class文件,在监控的方法前后添加计时器功能,用于计算监控方法耗时,同时将方法耗时及内部调用情况放入处理器,处理器利用栈先进后出的特点对方法调用先后顺序做处理,当一个请求处理结束后,将耗时方法轨迹和入参map输出到文件中,然后根据map中相应参数或耗时方法轨迹中的关键代码区分出我们要抓取的耗时业务。最后将相应耗时轨迹文件取下来,转化为xml格式并进行解析,通过浏览器将代码分层结构展示出来,方便耗时分析,如图所示:

JVM插庄之一:JVM字节码增强技术介绍及入门示例

java探针工具原理图

1:在JVM加载class二进制文件的时候,利用ASM动态的修改加载的class文件,在监控的方法前后添加计时器功能,用于计算监控方法耗时;
2:将监控的相关方法 和 耗时及内部调用情况,按照顺序放入处理器;
3:处理器利用栈先进后出的特点对方法调用先后顺序做处理,当一个请求处理结束后,将耗时方法轨迹入参map输出到文件中;
4:然后区分出耗时的业务,转化为xml格式进行解析和分析。

Java探针工具功能点:

1、支持方法执行耗时范围抓取设置,根据耗时范围抓取系统运行时出现在设置耗时范围的代码运行轨迹。

2、支持抓取特定的代码配置,方便对配置的特定方法进行抓取,过滤出关系的代码执行耗时情况。

3、支持APP层入口方法过滤,配置入口运行前的方法进行监控,相当于监控特有的方法耗时,进行方法专题分析。

4、支持入口方法参数输出功能,方便跟踪耗时高的时候对应的入参数。

5、提供WEB页面展示接口耗时展示、代码调用关系图展示、方法耗时百分比展示、可疑方法凸显功能。


2.1、入门示例

JavaAgent 是运行在 main方法之前的拦截器,它内定的方法名叫 premain ,也就是说先执行 premain 方法然后再执行 main 方法。

那么如何实现一个 Java代理呢?很简单,只需要增加 premain 方法即可。

package com.dxz.chama.javaagent;

import java.lang.instrument.Instrumentation;

/**
* agent的入口类
*/
public class SampleAgent {
/**
* 该方法在main方法之前运行,与main方法运行在同一个JVM中 并被同一个System ClassLoader装载 被统一的安全策略(security
* policy)和上下文(context)管理
*/
public static void premain(String agentOps, Instrumentation inst) {
System.out.println("====premain 方法执行");
System.out.println(agentOps);
} /**
* 如果不存在 premain(String agentOps, Instrumentation inst) 则会执行 premain(String
* agentOps)
*/
public static void premain(String agentOps) {
System.out.println("====premain方法执行2====");
System.out.println(agentOps);
}
}

修改pom文件(完整的pom文件见:《java类加载及动态代理之字节码插庄技术》)

指定prmain的路径。

javaagent打包,mvn clean package,生成jar:chama-0.0.1-SNAPSHOT.jar

业务类

package com.dxz.chama.service;

public class SampleService {

    public static void main(String[] args) {
System.out.println("SampleService main()==========");
}
}

运行配置

JVM插庄之一:JVM字节码增强技术介绍及入门示例

运行结果:

JVM插庄之一:JVM字节码增强技术介绍及入门示例

2.2 基于 JavaAgent 的应用实例

JDK5中只能通过命令行参数在启动JVM时指定javaagent参数来设置代理类,而JDK6中已经不仅限于在启动JVM时通过配置参数来设置代理类,JDK6中还可以通过 Java Tool API 中的 attach 方式,我们也可以很方便地在运行过程中动态地设置加载代理类,以达到 instrumentation 的目的。

Instrumentation 的最大作用,就是类定义动态改变和操作。

基于JavaAgent的Instrumentation代理示例

最简单的一个例子,计算某个方法执行需要的时间,不修改源代码的方式,使用Instrumentation 代理来实现这个功能,给力的说,这种方式相当于在JVM级别做了AOP支持,这样我们可以在不修改应用程序的基础上就做到了AOP,是不是显得略吊。

创建一个 ClassFileTransformer 接口的实现类 MyTransformer实现 ClassFileTransformer 这个接口的目的就是在class被装载到JVM之前将class字节码转换掉,从而达到动态注入代码的目的。那么首先要了解MonitorTransformer 这个类的目的,就是对想要修改的类做一次转换,这个用到了javassist对字节码进行修改,可以暂时不用关心jaavssist的原理,用ASM同样可以修改字节码,只不过比较麻烦些。

JVM插庄之一:JVM字节码增强技术介绍及入门示例
项目结构

MyAgent.MyAgent.java

package com.dxz.chama.javaagent;
import java.lang.instrument.Instrumentation; public class StatAgent {
/**
* 该方法在main方法之前运行,与main方法运行在同一个JVM中 并被同一个System ClassLoader装载
* 被统一的安全策略(security policy)和上下文(context)管理
*/
public static void premain(String agentOps, Instrumentation inst)
{
System.out.println("=========premain方法执行========");
System.out.println(agentOps);
// 添加Transformer
inst.addTransformer(new StatTransformer()); } /**
* 如果不存在 premain(String agentOps, Instrumentation inst) 则会执行 premain(String
* agentOps)
*/
public static void premain(String agentOps)
{ System.out.println("====premain方法执行2====");
System.out.println(agentOps);
} public static void main(String[] args)
{ } }

StatTransformer.java

package com.dxz.chama.javaagent;

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import javassist.CtNewMethod; /**
* 检测方法的执行时间
*/
public class StatTransformer implements ClassFileTransformer { final static String prefix = "\nlong startTime = System.currentTimeMillis();\n";
final static String postfix = "\nlong endTime = System.currentTimeMillis();\n"; // 被处理的方法列表
final static Map<String, List<String>> methodMap = new HashMap<String, List<String>>(); public StatTransformer() {
add("com.shanhy.demo.TimeTest.sayHello");
add("com.shanhy.demo.TimeTest.sayHello2");
} private void add(String methodString) {
String className = methodString.substring(0, methodString.lastIndexOf("."));
String methodName = methodString.substring(methodString.lastIndexOf(".") + 1);
List<String> list = methodMap.get(className);
if (list == null) {
list = new ArrayList<String>();
methodMap.put(className, list);
}
list.add(methodName);
} // 重写此方法
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
className = className.replace("/", ".");
if (methodMap.containsKey(className)) {
// 判断加载的class的包路径是不是需要监控的类
CtClass ctclass = null;
try {
ctclass = ClassPool.getDefault().get(className);// 使用全称,用于取得字节码类<使用javassist>
for (String methodName : methodMap.get(className)) {
String outputStr = "\nSystem.out.println(\"this method " + methodName
+ " cost:\" +(endTime - startTime) +\"ms.\");"; CtMethod ctmethod = ctclass.getDeclaredMethod(methodName);// 得到这方法实例
String newMethodName = methodName + "$old";// 新定义一个方法叫做比如sayHello$old
ctmethod.setName(newMethodName);// 将原来的方法名字修改 // 创建新的方法,复制原来的方法,名字为原来的名字
CtMethod newMethod = CtNewMethod.copy(ctmethod, methodName, ctclass, null); // 构建新的方法体
StringBuilder bodyStr = new StringBuilder();
bodyStr.append("{");
bodyStr.append(prefix);
bodyStr.append(newMethodName + "($$);\n");// 调用原有代码,类似于method();($$)表示所有的参数
bodyStr.append(postfix);
bodyStr.append(outputStr);
bodyStr.append("}"); newMethod.setBody(bodyStr.toString());// 替换新方法
ctclass.addMethod(newMethod);// 增加新方法
System.err.println(outputStr);
}
return ctclass.toBytecode();
} catch (Exception e) {
System.out.println(e.getMessage());
e.printStackTrace();
}
}
return null;
}
}

修改pom文件(完整的pom文件见:《java类加载及动态代理之字节码插庄技术》)

JVM插庄之一:JVM字节码增强技术介绍及入门示例

javaagent打包

mvn clean package

META-INF/MANIFEST.MF

JVM插庄之一:JVM字节码增强技术介绍及入门示例

业务测试类:

package com.dxz.chama.service;
public class TimeTest
{ public static void main(String[] args)
{
System.err.println("======TimeTest执行========");
sayHello();
sayHello2("hello world222222222");
} public static void sayHello()
{
try
{
Thread.sleep(2000);
System.out.println("hello world!!");
}
catch (InterruptedException e)
{
e.printStackTrace();
}
} public static void sayHello2(String hello)
{
try
{
Thread.sleep(1000);
System.out.println(hello);
}
catch (InterruptedException e)
{
e.printStackTrace();
}
}
}

运行配置:

JVM插庄之一:JVM字节码增强技术介绍及入门示例

结果:

JVM插庄之一:JVM字节码增强技术介绍及入门示例
结果

3 使用 spring-loaded 实现 jar 包热部署

在项目开发中我们可以把一些重要但又可能会变更的逻辑封装到某个 logic.jar 中,当我们需要随时更新实现逻辑的时候,可以在不重启服务的情况下让修改后的 logic.jar 被重新加载生效。

spring-loaded是一个开源项目,项目地址:https://github.com/spring-projects/spring-loaded

使用方法:

在启动主程序之前指定参数
-javaagent:C:/springloaded-1.2.5.RELEASE.jar -noverify
123

如果你想让 Tomat 下面的应用自动热部署,只需要在 catalina.sh 中添加:

set JAVA_OPTS=-javaagent:springloaded-1.2.5.RELEASE.jar -noverify1

这样就完成了 spring-loaded 的安装,它能够自动检测Tomcat 下部署的webapps ,在不重启Tomcat的情况下,实现应用的热部署。

通过使用 -noverify 参数,关闭 Java 字节码的校验功能。
使用参数 -Dspringloaded=verbose;explain;watchJars=tools.jar 指定监视的jar (verbose;explain; 非必须),多个jar用“冒号”分隔,如 watchJars=tools.jar:utils.jar:commons.jar

当然,它也有一些小缺限:

  1. 目前官方提供的1.2.4 版本在linux上可以很好的运行,但在windows还存在bug,官网已经有人提出:https://github.com/spring-projects/spring-loaded/issues/145
  2. 对于一些第三方框架的注解的修改,不能自动加载,比如:spring mvc的@RequestMapping
  3. log4j的配置文件的修改不能即时生效。