暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

java黑科技-字节码揭秘

乐信技术精英社 2020-06-09
303

作者 | zackhu(胡中春)

导读

作为一个java开发人员,我们每天都在与字节码打交道,我们经常使用的功能和工具,比如:IDE的debug功能、Spring的AOP、诊断工具Arthas、各种Profile工具,背后都和字节码有莫大的关联。本文带你一起走进java字节码的世界,彻底弄明白字节码背后的故事。

问题引出

  小A是一名java程序员,最近开发上线了一个NB的项目,一直运行良好,用户反馈也不错,小A心想着这下离小目标(当上CEO,迎娶白富美)又近了一步。但就在昨天,系统报了莫名错误,并且从已有日志看不出什么端倪,小A撸了代码后发现如果X位置当前有打印日志,肯定可以查到原因,但当前情况紧急,已使用重启大法解决问题。事后,小A心想能不能无侵入的动态给代码增加一些日志呢?经过一番搜索,小A发现其实这项技术在java领域里面已经很成熟了,比如大名鼎鼎的Btrace、Arthas、Bistoury,那这些工具背后的原理是怎样的呢?这就是本文要扒的事情啦,后面将从以下几个点来阐述:

  • java 字节码

    • 为什么要有字节码

    • 字节码长啥样

    • 我们可以干些啥

  • 代理技术

    • 静态代理

    • 动态代理

    • 还没能解决小A的问题

  • 动态调试

    • 目标

    • Java Agent

    • 随Java进程启动的Agent

    • 运行时载入的Agent

    • 动态替换字节码的限制

  • 尾声

字节码

为什么要有字节码

  笔者曾经做过几年的c/c++程序员,在c/c++中,如果要实现在window和linux都能运行的功能,比如sleep,需要这么写:

#include <iostream>
#ifdef _WIN32_
#include <windows.h>
#ifdef _LINUX_
#include <unistd.h>
#endif
using namespace std;
void sleepcp(int milliseconds) // 跨平台 sleep 函数
{
#ifdef _WIN32_
Sleep(milliseconds);
#ifdef _LINUX_
usleep(milliseconds * 1000);
#endif
}

需要在源码层面区分底层系统,然后需要在不同的平台上编译打包。需要这样做的原因就是c/c++编译后的结果是汇编代码,是与平台相关的。这里面很多工作与我们需要完成的系统功能相关性不大,如果有一种机制可以“一次编译,到处运行”就好了,某大佬说过,计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决,如果一个不行,那就再加一个。JVM作为字节码与底层操作系统的中间层,让源码与平台无关,编译为固定格式的字节码(.class文件)供JVM使用,实现了“一次编译,到处运行”。笔者认为这是java能够迅速占据市场的重要原因之一(还有另外一个重要原因提供一个托管环境:比如解决内存管理和垃圾回收问题)。

字节码长啥样

一个.java文件从编译到运行的示例如图:

Java运行示意图


可以看到,java类源文件经过编译后生成了.class即字节码文件,然后由JVM进行加载、链接、初始化后,这个类就可以被使用了。
看下源码与字节码的对应关系:


可以看到,ByteCode.java(第一列)编译后生成ByteCode.class文件(第二列),class文件由十六进制值组成,而JVM以两个十六进制值为一组,即以字节为单位进行读取,这也是为什么叫做字节码的原因。JVM对于字节码是有规范要求的,那么看似杂乱的十六进制符合什么结构呢?JVM规范要求每一个字节码文件都要由十部分按照固定的顺序组成,整体结构如下图所示(可以对照上图的第三列查看):

字节码文件格式


详细的每部分的含义这里就不一一列举了,有兴趣的可以自行搜索了解。这里提比较有意思的一点,魔数的固定值是Java之父James Gosling制定的,为CafeBabe(咖啡宝贝),而Java的图标为一杯咖啡。

我们可以干些啥

  从上面java代码从编写到运行的几个阶段我们知道JVM关心的是class字节码,也就是说,我们只要有符合JVM规范的字节码,就可以在JVM上运行了,这也是为什么有些语言(比如Scala、Groovy、Kotlin)也可以在JVM上运行的原因。那我们就可以在JVM加载字节码那个点,对原字节码做一些增强来解决文章开头小A的问题,这是后面要谈到的动态调试。

代理技术

  因为动态代理和字节码操纵有着千丝万缕的关系,我们先聊下动态代理。首先快速过一下代理模式:代理模式给某一个对象提供一个代理对象,并由代理对象控制对原对象的引用及调用。可以在代理对象上面给原对象加一些预处理功能,比如日志、缓存,而不用修改原对象,符合代码设计的开闭原则。

静态代理

  程序员编辑代理类代码,实现代理模式,在编译期就生成了代理类。举个栗子:

//接口类
public interface UserService {
void sayHello();
}
//实现类
public class UserServiceImpl implements UserService {
@Override
public void sayHello() {
System.out.println("exec real sayHello method!!!");
}
}
//静态代理实现
public class StaticProxy implements UserService {
private UserService userService;
public StaticProxy(UserService userService) {
this.userService = userService;
}
@Override
public void sayHello() {
System.out.println("static proxy before sayHello...");
userService.sayHello();
System.out.println("static proxy after sayHello...");
}
}
//使用静态代理
StaticProxy staticProxy = new StaticProxy(new UserServiceImpl());
staticProxy.sayHello();

//输出:

在编码阶段实现这种代理关系,Proxy类通过编译器编译成class文件,当系统运行时,此class已经存在了。这种静态的代理模式固然在访问无法访问的资源,增强现有的接口业务功能方面有很大的优点,但是大量使用这种静态代理,对每个方法都要写代理方法会显得很繁琐,接口变更代理类也要修改,并且在有些框架中(比如RPC框架dubbo)无法提前硬编码出所有的的代理类。

动态代理

  相比静态代理类由Java代码硬编码定义,动态代理类的代理类并不是在Java代码中定义的,而是在运行时根据我们在Java代码中的“指示”动态生成的类,避免了为每个类/方法都实现代理的繁琐工作,也让根据上下文动态创建代理类成为了可能,我们先以最常见的JDK自带的InvocationHandler举例说明:

/**
1.首先提供一个InvocationHandler实现
*/

public class InvocationHandlerImpl implements InvocationHandler {
//代理的目标对象
private Object targetObject;
public InvocationHandlerImpl(Object targetObject){
this.targetObject = targetObject;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
//调用目标对象的每个方法都会走到这里,method是具体的方法
System.out.println("before invoke real method-->"+method.getName());
//基于反射实现
Object object = method.invoke(targetObject,args);
System.out.println("after invoke real method-->"+method.getName());
return object;
}
}
//生成动态代理并调用
public static void main(String[] args) {
UserServiceImpl userService = new UserServiceImpl();
ClassLoader classLoader = userService.getClass().getClassLoader();
//找出目标类的所有接口,使用InvocationHandler实现动态代理,某个类必须有实现的接口,
//而生成的代理类也只能代理某个类接口定义的方法,也就是说如果UserServiceImpl定义了个方法sayBye,但UserService没有定义,那sayBye是不能被代理的
Class[] interfaces = userService.getClass().getInterfaces();
InvocationHandler handler = new InvocationHandlerImpl(userService);
//创建动态代理对象
Object o = Proxy.newProxyInstance(classLoader, interfaces, handler);
((UserService)o).sayHello();
}
//输出:
before invoke real method-->sayHello
exec real sayHello method!!!
after invoke real method-->sayHello

从上面我们可以看到,InvocationHandlerImpl类没有与具体的类或方法耦合,当有其他类或方法需要实现同样的预处理时,可以复用InvocationHandlerImpl,而不用每一个都去创建一个类。
看一下JDK生成的动态代理类是什么样的:

//JDK动态代理生成的动态代理类$Proxy0,继承了Proxy类,并实现了目标接口,通过重写目标接口方法实现了对目标接口的代理,是基于接口实现的方案,而后面要讲的CGlib方案是基于继承的。
public final class $Proxy0 extends Proxy implements UserService {
private static Method m3;
private static Method m1;
private static Method m0;
private static Method m2;
//实现的InvocationHandlerImpl类被父类(Proxy)引用,后面调用方法时均委托给InvocationHandlerImpl
public $Proxy0(InvocationHandler var1) throws {
super(var1);
}

public final void sayHello() throws {
try {
super.h.invoke(this, m3, (Object[])null);
} catch (RuntimeException | Error var2) {
throw var2;
} catch (Throwable var3) {
throw new UndeclaredThrowableException(var3);
}
}

public final boolean equals(Object var1) throws {
try {
return (Boolean)super.h.invoke(this, m1, new Object[]{var1});
} catch (RuntimeException | Error var3) {
throw var3;
} catch (Throwable var4) {
throw new UndeclaredThrowableException(var4);
}
}

public final int hashCode() throws {
try {
return (Integer)super.h.invoke(this, m0, (Object[])null);
} catch (RuntimeException | Error var2) {
throw var2;
} catch (Throwable var3) {
throw new UndeclaredThrowableException(var3);
}
}

public final String toString() throws {
try {
return (String)super.h.invoke(this, m2, (Object[])null);
} catch (RuntimeException | Error var2) {
throw var2;
} catch (Throwable var3) {
throw new UndeclaredThrowableException(var3);
}
}

static {
try {
m3 = Class.forName("com.fenqile.arch.dubbo.jvm.proxy.UserService").getMethod("sayHello");
m1 = Class.forName("java.lang.Object").getMethod("equals", Class.forName("java.lang.Object"));
m0 = Class.forName("java.lang.Object").getMethod("hashCode");
m2 = Class.forName("java.lang.Object").getMethod("toString");
} catch (NoSuchMethodException var2) {
throw new NoSuchMethodError(var2.getMessage());
} catch (ClassNotFoundException var3) {
throw new NoClassDefFoundError(var3.getMessage());
}
}
}

除了JDK自带的动态代理实现,比较熟知的还有CGlib,相比JDK自带实现,突破了JDK只能对接口方法实现代理的限制,先来看下例子:

//CGlib方法拦截器,调用目标对象的方法由此类代理
public class MethodInterceptorImpl implements MethodInterceptor {
@Override
public Object intercept(Object obj, Method method, Object[] args,
MethodProxy proxy) throws Throwable {
System.out.println("before invoke real method-->"+method.getName());
Object object = proxy.invokeSuper(obj, args);
System.out.println("after invoke real method-->"+method.getName());
return object;
}
}
//生成动态代理并调用
public static void main(String[] args) {
MethodInterceptorImpl methodInterceptor = new MethodInterceptorImpl();
//cglib 中加强器,用来创建动态代理
Enhancer enhancer = new Enhancer();
//设置要创建动态代理的类
UserServiceImpl userServiceImpl = new UserServiceImpl();
enhancer.setSuperclass(userServiceImpl.getClass());
// 设置回调,这里相当于是对于代理类上所有方法的调用,都会调用CallBack,而Callback则需要实行intercept()方法进行拦截
enhancer.setCallback(methodInterceptor);
UserServiceImpl proxy =(UserServiceImpl)enhancer.create();
proxy.sayHello();
}
//输出:
before invoke real method-->sayHello
exec real sayHello method!!!
after invoke real method-->sayHello

可以看到,用CGLIB实现动态代理非常方便,其底层是基于ASM(一款操纵字节码的神器)来生成的动态代理类。
看下CGLIB生成的代理类是什么样的(除了这个类,还有一些辅助类这里没有列举):

//通过继承目标类来实现,内容太多,只截取片段
public class UserServiceImpl$$EnhancerByCGLIB$$2e1d1cc3 extends UserServiceImpl implements Factory {
private boolean CGLIB$BOUND;
public static Object CGLIB$FACTORY_DATA;
private static final ThreadLocal CGLIB$THREAD_CALLBACKS;
private static final Callback[] CGLIB$STATIC_CALLBACKS;
private MethodInterceptor CGLIB$CALLBACK_0;
private static Object CGLIB$CALLBACK_FILTER;
private static final Method CGLIB$sayHello$0$Method;
private static final MethodProxy CGLIB$sayHello$0$Proxy;
private static final Object[] CGLIB$emptyArgs;
private static final Method CGLIB$equals$1$Method;
private static final MethodProxy CGLIB$equals$1$Proxy;
private static final Method CGLIB$toString$2$Method;
private static final MethodProxy CGLIB$toString$2$Proxy;
private static final Method CGLIB$hashCode$3$Method;
private static final MethodProxy CGLIB$hashCode$3$Proxy;
private static final Method CGLIB$clone$4$Method;
private static final MethodProxy CGLIB$clone$4$Proxy;

static void CGLIB$STATICHOOK1() {
CGLIB$THREAD_CALLBACKS = new ThreadLocal();
CGLIB$emptyArgs = new Object[0];

Cglib的一些点:

  • CGlib可以传入接口也可以传入普通的类,接口使用实现的方式,普通类使用会使用继承的方式生成代理类;

  • 由于是继承方式,如果是 static方法,private方法,final方法等描述的方法是不能被代理的;

  • 做了方法访问优化,使用建立方法索引的方式避免了传统JDK动态代理需要通过Method方法反射调用;

  • 提供callback 和filter设计,可以灵活地给不同的方法绑定不同的callback,编码更方便灵活;

  • CGLIB会默认代理Object中equals,toString,hashCode,clone等方法,比JDK代理多了clone;

总结下静态代理、基于JDK动态代理、基于Cglib 动态代理:

  • 静态代理是通过在代码中显式编码定义一个业务实现类的代理类,在代理类中对同名的业务方法进行包装,用户通过代理类调用被包装过的业务方法;

  • JDK动态代理是通过接口中的方法名,在动态生成的代理类中调用业务实现类的同名方法;

  • CGlib动态代理是通过继承业务类,生成的动态代理类是业务类的子类,通过重写业务方法进行代理;
    静态代理在编译时产生class字节码文件,可以直接使用,效率高。动态代理必须实现InvocationHandler接口,通过invoke调用被委托类接口方法是通过反射方式,比较消耗系统性能,但可以减少代理类的数量,使用更灵活。cglib代理无需实现接口,通过生成类字节码实现代理,比反射稍快,不存在性能问题,但cglib会继承目标对象,需要重写方法,所以目标对象不能为final类;

基于JDK的动态代理与基于CGLIB动态代理是目前比较常用的代理模式,大名鼎鼎的Spring AOP就是包装了这2种实现。JDK与CGLIB的方式是通过代理原接口/类的方式实现对目标对象的增强,生成了新的代理类。想想代理的本质,生成了一个新的类,代理了对原对象的访问,并且与原对象对外暴露同样的接口。最终的结果就是一份新的字节码,那像Javassist、ASM这样的字节码操纵工具,自然也可以用于生成动态代理,并且没有接口或类的限制。事实上,CGLIB就是基于ASM的,RPC框架dubbo内部就是使用Javassist生成的接口代理。ASM太底层,这里不展开讲了,只列举一个片段:

//回顾下前面讲到的字节码格式,ASM是按照字节码的格式要求直接拼装的方式生成字节码
ClassWriter classWriter = new ClassWriter(0);
classWriter.visit(Opcodes.V1_8,// java版本
Opcodes.ACC_PUBLIC,// 类修饰符
"HelloASM", // 类的全限定名
null, "java/lang/Object", null);

可以看到ASM虽然功能强大,但太底层,效率太低,所以有了CGLIB这样的封装。
再来看下Javassist的动态代理实现:

/**
使用Javassist动态的生成代理类
返回生成好的代理类Class对象
*/

public static Class createProxyByteCode() throws Exception {

ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.makeClass("DynamicProxy");
//设置接口
CtClass interface1 = pool.get("com.fenqile.arch.dubbo.jvm.proxy.UserService");
cc.setInterfaces(new CtClass[]{interface1});
//设置Field
CtField field = CtField.make("private com.fenqile.arch.dubbo.jvm.proxy.UserService userService;", cc);
cc.addField(field);
CtClass userImplClass = pool.get("com.fenqile.arch.dubbo.jvm.proxy.UserService");
CtClass[] arrays = new CtClass[]{userImplClass};
CtConstructor ctc = CtNewConstructor.make(arrays, null, CtNewConstructor.PASS_NONE, null, null, cc);
//设置构造函数内部信息
ctc.setBody("{this.userService=$1;}");
cc.addConstructor(ctc);

//sayHello
CtMethod sayHello = CtMethod.make("public void sayHello(){}", cc);
sayHello.setBody("{System.out.println(\"dynamic proxy before sayHello...\");"
+ "userService.sayHello();"
+ "System.out.println(\"dynamic proxy after sayHello...\");}");
cc.addMethod(sayHello);
//至此,动态类的字节码已生成
return cc.toClass();
}
//生成动态代理并调用
Class clazz = JavassistDynamicProxy.createProxyByteCode();
Constructor constructor= clazz.getConstructor(UserService.class);
UserService o = (UserService)constructor.newInstance(new UserServiceImpl());
o.sayHello();
//输出:
dynamic proxy before sayHello...
exec real sayHello method!!!
dynamic proxy after sayHello...

实现思路非常简单,按照代理的标准实现方式,生成代理类对应的字节码。
看下直接使用Javassist生成的代理类是什么样的:

//基于接口实现的动态代理,是不是有点似曾相识的感觉,是不是发现和静态代理是一模一样的
//对,就是动态生成了一个静态代理类。
public class DynamicProxy implements UserService {
private UserService userService;

public DynamicProxy(UserService var1) {
this.userService = var1;
}

public void sayHello() {
System.out.println("dynamic proxy before sayHello...");
this.userService.sayHello();
System.out.println("dynamic proxy after sayHello...");
}
}

还没能解决小A的问题

  动态代理可以很方便对目标对象实现功能增强,但生成的代理类必须被使用方主动引用后增强功能才能升生效,举个栗子,A类是原始类,现在已经生成一个对象a被使用方引用,那现在对A类生成了一个增强代理类AA,AA有一个对象aa,如果要使增强功能生效,必须让使用方改向引用aa才可以,所以动态代理是解决不了小A的问题的。
小A的需求是在一个持续运行并已经加载了所有类的JVM中,还能利用字节码增强技术对其中的类行为做替换并重新加载,这时就要使用Java Agent技术了。

动态调试

目标

/**
这是一个目标Java进程,它做了个非常简单的事情:
每隔3秒运行process方法,打印process字符串
*/

public class Base {
public static void main(String[] args) {
String name = ManagementFactory.getRuntimeMXBean().getName();
String s = name.split("@")[0];
//打印当前Pid
System.out.println("pid:" + s);
while (true) {
try {
Thread.sleep(3000L);
} catch (Exception e) {
break;
}
process();
}
}
public static void process() {
System.out.println("process");
}
}

我们的目标是在目标JVM运行中的时候,对process()方法做增强与卸载,也就是在运行中时,每3秒打印的内容由"process"变为打印"start process end",然后将打印的内容恢复至原状态(只打印“process”)。为了达到这样动态调试的目标,我们需要借助Agent的能力。

Java Agent

  在JDK1.5以后,可以使用agent技术构建一个独立于应用程序的代理程序(即为Agent),用来协助监测、运行甚至替换其他JVM上的程序,使用它可以实现虚拟机级别的AOP功能。先讲几个概念:

  • JVMTI & Instrument & Agent & Attach API

  • JVM TI(JVM TOOL INTERFACE,JVM工具接口)是JVM提供的一套对JVM进行操作的工具接口。通过JVMTI可以实现对JVM的多种操作,然后通过接口注册各种事件勾子。在JVM事件触发时,同时触发预定义的勾子,以实现对各个JVM事件的响应,事件包括类文件加载、异常产生与捕获、线程启动和结束、进入和退出临界区、成员变量修改、GC开始和结束、方法调用进入和退出、临界区竞争与等待、VM启动与退出等等。在Java SE 5之前,要实现一个Agent只能通过编写Native代码来实现。从Java SE 5开始,可以使用Java的Instrumentation接口(java.lang.instrument)来编写Agent。无论是通过Native的方式还是通过Java Instrumentation接口的方式来编写Agent,它们的工作都是借助JVMTI来进行完成,下面介绍通过Java Instrumentation接口编写Agent的方法。

  • Instrument是JVM提供的一个可以修改已加载类的类库,专门为Java语言编写的插桩服务提供支持。在JDK 1.6以前,Instrument只能在JVM刚启动开始加载类时生效,而在JDK 1.6之后,Instrument支持了在运行时对类定义的修改。

  • Agent就是JVMTI的一种实现,借助Instrument类库提供的各种API编写Agent,Agent有两种启动方式,一是随Java进程启动而启动,经常见到的java -agentlib就是这种方式;二是运行时载入,通过Attach API,将模块(jar包)动态地Attach到指定进程id的Java进程内。

  • Attach API 的作用是提供JVM进程间通信的能力,如果需要动态的Attach到指定进程,就需要用到这个能力。比如说我们为了让另外一个JVM进程把线上服务的线程Dump出来,会运行jstack或jmap的进程,并传递pid的参数,告诉它要对哪个进程进行线程Dump,这就是Attach API做的事情。后面我们将举例通过Attach API的loadAgent()方法,将打包好的Agent jar包动态Attach到目标JVM上。

  • 编写Agent的基本流程

  • 实现Agent启动方法
    Java Agent支持目标JVM启动时加载,也支持在目标JVM运行时加载,这两种不同的加载模式会使用不同的入口函数,如果需要在目标JVM启动的同时加载Agent,那么可以选择实现下面的方法:

[1public static void premain(String agentArgs, Instrumentation inst);
[2public static void premain(String agentArgs);

如果需要在目标JVM运行时加载,可以选择实现下面的方法:

[1public static void agentmain(String agentArgs, Instrumentation inst);
[2public static void agentmain(String agentArgs);

这两组方法的第一个参数AgentArgs是随同 “– javaagent”一起传入的程序参数,如果这个字符串代表了多个参数,就需要自己解析这些参数。inst是Instrumentation类型的对象,是JVM自动传入的,我们可以拿这个参数进行类增强等操作。

  • 指定Main-Class
    启动时Agent的打包插件配置(以maven为例):


挂载到目标JVM
将编写的Agent打成jar包后,就可以挂载到目标JVM上去了。如果选择在目标JVM启动时加载Agent,则可以使用 "-javaagent:<jarpath>[=<option>]"。如果想要在运行时挂载Agent到目标JVM,就需要调用JVM的API手工去Attach了,方法如下:

//目标JVM运行时挂载Agent
public class Attacher {
public static void main(String[] args) throws AttachNotSupportedException, IOException, AgentLoadException, AgentInitializationException {
// 传入目标 JVM pid
VirtualMachine vm = VirtualMachine.attach("25248");
//将agent 挂载至目标进程 vm.loadAgent("E:\\develop\\TestProject\\test\\test_agent\\target\\test_agent-1.0-SNAPSHOT.jar");
vm.detach();
System.out.println(new Date() + ",Attacher Over...");
}
}

随Java进程启动的Agent

利用Java进程启动时Agent实现前面的目标:

//增强Transformer实现类
public class EnhanceTransformer implements ClassFileTransformer {

public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) {
System.out.println("EnhanceTransformer Transforming " + className);
try {
//利用Javassist做字节码替换
ClassPool cp = ClassPool.getDefault();
CtClass cc = cp.get("com.lexin.bytecode.business.Base");
CtMethod m = cc.getDeclaredMethod("process");
m.insertBefore("{ System.out.println(\"start\"); }");
m.insertAfter("{ System.out.println(\"end\"); }");
System.out.println("EnhanceTransformer Transform " + className + " done");
return cc.toBytecode();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
//重置Transformer实现类
public class ResetTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
System.out.println("ResetTransformer Transform " + className);
return null;
}
}
public class PreMainAgent {

//启动时agent的入口方法
public static void premain(String args, Instrumentation inst) {
System.out.println("Premain Agent Load Start...");
new Thread(new Runnable() {
@Override
public void run() {
//增强实现,在agent挂载后15秒后执行,使目标方法增加start与end打印
ClassFileTransformer enhanceTransformer = new EnhanceTransformer();
//重置增强类至初始状态,在agent挂载后30秒后执行,目标方法重置为只打印process
ClassFileTransformer resetTransformer = new ResetTransformer();
int enhancePeriod = 5;
int resetPeriod = 10;
while (true) {
try {
if (enhancePeriod -- == 0) {
//重定义类并载入新的字节码
System.out.println("add enhanceTransformer");
inst.addTransformer(enhanceTransformer, true);
System.out.println("retransform class: com.lexin.bytecode.business.Base");
inst.retransformClasses(Class.forName("com.lexin.bytecode.business.Base"));
System.out.println("remove enhanceTransformer");
inst.removeTransformer(enhanceTransformer);
}
if (resetPeriod-- == 0) {
System.out.println("add ResetTransformer");
inst.addTransformer(resetTransformer, true);
System.out.println("retransform class: com.lexin.bytecode.business.Base");
inst.retransformClasses(Class.forName("com.lexin.bytecode.business.Base"));
System.out.println("remove ResetTransformer");
inst.removeTransformer(resetTransformer);
break;
}
Thread.sleep(3000);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}).start();
System.out.println("Premain Agent Load End...");
}



将此Agent打包成jar后,在目标JVM(即前面的Base类)启动参数中加上-javaagent=xxx.jar,启动目标JVM,只看到如下的效果:

至此,我们完成了在不重启目标JVM进程的情况下,动态调试目标JVM进程。

运行时载入的Agent

  在主程序运行之前的agent模式有一些缺陷,例如需要在主程序运行前就指定javaagent参数,不能按需增加与卸载,需要等premain方法执行完之后,主程序才能启动等,为了解决这些问题,JDK1.6以后提供了在程序运行之后改变程序的能力。
Agent的实现方法与启动时挂载的方式几乎一样,需要注意的点是前面说的agent启动方法的问题,需要定义agentmain入口方法:

public static void agentmain(String args, Instrumentation inst) ;

打包好jar包后,按照上面的方法利用vm.loadAgent挂载agent至目标JVM。运行时加载Agent的能力给我们提供了很强的动态性,我们可以在需要的时候加载Agent来进行一些工作。因为是动态的,我们可以按照需求来加载所需要的Agent,下面来分析一下动态加载Agent的的一些技术细节点。

  • 技术细节:如何通信(外部)
    Attach机制是什么?说简单点就是jvm提供一种jvm进程间通信的能力,能让一个进程传命令给另外一个进程,并让它执行内部的一些操作,比如说我们为了让另外一个jvm进程把线程dump出来,那么我们跑了一个jstack的进程,然后传了个pid的参数,告诉它要哪个进程进行线程dump,既然是两个进程,那肯定涉及到进程间通信,以及传输协议的定义,比如要执行什么操作,传了什么参数等。
    运行时加载关键是通过一个外部进程调用VirtualMachine的attach方法进行Agent挂载的实现。下面我们就来分析一下VirtualMachine的attach方法具体是怎么实现的(以下代码以为Linux环境下)。

这个方法通过attachVirtualMachine方法进行attach操作,在Linux系统中,AttachProvider的实现类是LinuxAttachProvider。我们来看一下LinuxAttachProvider的attachVirtualMachine方法是如何实现的:

LinuxVirtualMachine(AttachProvider paramAttachProvider, String paramString)
throws AttachNotSupportedException, IOException{
super(paramAttachProvider, paramString);
int i;
try{
i = Integer.parseInt(paramString);
}
catch (NumberFormatException localNumberFormatException) {
throw new AttachNotSupportedException("Invalid process identifier");
}
this.path = findSocketFile(i);
if (this.path == null){
File localFile = createAttachFile(i);
try{
if (isLinuxThreads){
try{
k = getLinuxThreadsManager(i);
}
catch (IOException localIOException) {
throw new AttachNotSupportedException(localIOException.getMessage());
}
assert (k >= 1);
sendQuitToChildrenOf(k);
}
else{
sendQuitTo(i);
}
int k = 0;
long l = 200L;
int m = (int)(attachTimeout() / l);
do
{
try{
Thread.sleep(l);
}
catch (InterruptedException localInterruptedException) {}
this.path = findSocketFile(i);
k++;
} while ((k <= m) && (this.path == null));
if (this.path == null) {
throw new AttachNotSupportedException("Unable to open socket file: target process not responding or HotSpot VM not loaded");
}
}
finally{
localFile.delete();
}
}
checkPermissions(this.path);
int j = socket();
try{
connect(j, this.path);
}
finally {
close(j);
}
}
private String findSocketFile(int paramInt) {
File localFile = new File("/tmp", ".java_pid" + paramInt);
if (!localFile.exists()) {
return null;
}
return localFile.getPath();
}

private File createAttachFile(int paramInt)
throws IOException {
String str1 = ".attach_pid" + paramInt;
String str2 = "/proc/" + paramInt + "/cwd/" + str1;
File localFile = new File(str2);
try {
localFile.createNewFile();
}
catch (IOException localIOException) {
localFile = new File("/tmp", str1);
localFile.createNewFile();
}
return localFile;
}

findSocketFile方法用来查询目标JVM上是否已经启动了Attach Listener,它通过检查"tmp/"目录下是否存在java_pid{pid}来进行实现。如果已经存在了,则说明Attach机制已经准备就绪,可以接受客户端的命令了,这个时候客户端就可以通过connect连接到目标JVM进行命令的发送,比如可以发送“load”命令来加载Agent。如果java_pid{pid}文件还不存在,则创建"/proc/{pid}/cwd/.attach_pid{pid}"文件, 然后通过sendQuitTo方法向目标JVM发送一个“SIGBREAK”信号,让它初始化Attach Listener线程并准备接受客户端连接。可以看到,发送了信号之后客户端会循环等待java_pid{pid}这个文件,之后再通过connect连接到目标JVM上。

  • 技术细节:如何通信(目标JVM端)
    上面提到了Attach Listener线程,除了这个线程,还有另外一个线程Signal Dispatcher,这2个线程配合来完成整个attach过程。

  • Attach Listener线程的创建
    在上面我们知道外部进程创建了Attach_pid文件后,发送了SIGQUIT信号,这个信号是发送给Signal Dispatcher,下面是Signal Dispatcher线程的entry实现:

//省略若干细节
static void signal_thread_entry(JavaThread* thread, TRAPS) {
while (true) {
switch (sig) {
case SIGBREAK: {
if (!DisableAttachMechanism) {
// Attempt to transit state to AL_INITIALIZING.
AttachListenerState cur_state = AttachListener::transit_state(AL_INITIALIZING, AL_NOT_INITIALIZED);
if (cur_state == AL_INITIALIZING) {
// Attach Listener has been started to initialize. Ignore this signal.
continue;
} else if (cur_state == AL_NOT_INITIALIZED) {
// Start to initialize.
if (AttachListener::is_init_trigger()) {
// Attach Listener has been initialized.
// Accept subsequent request.
continue;
}

会触发AttachListener::is_init_trigger()的执行:

bool AttachListener::is_init_trigger() {
if (init_at_startup() || is_initialized()) {
return false; // initialized at startup or already initialized
}
char fn[PATH_MAX+1];
sprintf(fn, ".Attach_pid%d", os::current_process_id());
int ret;
struct stat64 st;
RESTARTABLE(::stat64(fn, &st), ret);
if (ret == -1) {
snprintf(fn, sizeof(fn), "%s/.Attach_pid%d",
os::get_temp_directory(), os::current_process_id());
RESTARTABLE(::stat64(fn, &st), ret);
}
if (ret == 0) {
// simple check to avoid starting the Attach mechanism when
// a bogus user creates the file
if (st.st_uid == geteuid()) {
init();
return true;
}
}
return false;
}

看到了Attach_pid文件,是不是发现距离真相已经不远了??一开始会判断当前进程目录下是否有个.Attach_pid文件(前面提到了),如果没有就会在/tmp下创建一个/tmp/.Attach_pid,当那个文件的uid和自己的uid是一致的情况下(为了安全)再调用init方法,此时水落石出了,看到创建了一个线程,并且取名为Attach Listener。再看看其子类LinuxAttachListener的init方法,看到其创建了一个监听套接字,并创建了一个文件/tmp/.java_pid,这个文件就是外部进程之前一直在轮询等待的文件,随着这个文件的生成,意味着Attach的过程圆满结束了。

  • Attach Listener接收请求
    看看它的entry实现Attach_listener_thread_entry:

static void Attach_listener_thread_entry(JavaThread* thread, TRAPS) {
os::set_priority(thread, NearMaxPriority);
thread->record_stack_base_and_size();
if (AttachListener::pd_init() != 0) {
return;
}
AttachListener::set_initialized();
for (;;) {
AttachOperation* op = AttachListener::dequeue();
if (op == NULL) {
return; // dequeue failed or shutdown
}
ResourceMark rm;
bufferedStream st;
jint res = JNI_OK;

// handle special detachall operation
if (strcmp(op->name(), AttachOperation::detachall_operation_name()) == 0) {
AttachListener::detachall();
} else {
// find the function to dispatch too
AttachOperationFunctionInfo* info = NULL;
for (int i=0; funcs[i].name != NULL; i++) {
const char* name = funcs[i].name;
assert(strlen(name) <= AttachOperation::name_length_max, "operation <= name_length_max");
if (strcmp(op->name(), name) == 0) {
info = &(funcs[i]);
break;
}
}
// check for platform dependent Attach operation
if (info == NULL) {
info = AttachListener::pd_find_operation(op->name());
}
if (info != NULL) {
// dispatch to the function that implements this operation
res = (info->func)(op, &st);
} else {
st.print("Operation %s not recognized!", op->name());
res = JNI_ERR;
}
}
// operation complete - send result and output to client
op->complete(res, &st);
}
}

最终调用的是LinuxAttachListener::dequeue():

LinuxAttachOperation* LinuxAttachListener::dequeue() {
for (;;) {
int s;
// wait for client to connect
struct sockaddr addr;
socklen_t len = sizeof(addr);
RESTARTABLE(::accept(listener(), &addr, &len), s);
if (s == -1) {
return NULL; // log a warning?
}
// get the credentials of the peer and check the effective uid/guid
// - check with jeff on this.
struct ucred cred_info;
socklen_t optlen = sizeof(cred_info);
if (::getsockopt(s, SOL_SOCKET, SO_PEERCRED, (void*)&cred_info, &optlen) == -1) {
int res;
RESTARTABLE(::close(s), res);
continue;
}
uid_t euid = geteuid();
gid_t egid = getegid();

if (cred_info.uid != euid || cred_info.gid != egid) {
int res;
RESTARTABLE(::close(s), res);
continue;
}

// peer credential look okay so we read the request
LinuxAttachOperation* op = read_request(s);
if (op == NULL) {
int res;
RESTARTABLE(::close(s), res);
continue;
} else {
return op;
}
}
}

我们看到如果没有请求的话,会一直accept在那里,当来了请求,然后就会创建一个套接字,并读取数据,构建出LinuxAttachOperation返回并执行。
整个过程就这样了,从Attach线程创建到接收请求,处理请求。

动态替换字节码的限制

运行时直接替换类很不安全,比如新的class文件引用了一个不存在的类,或者把某个类的一个field给删除了等等,这些情况都会引发异常。如果没有限制,会让程序行为与预期不符,并且非常难以定位。所以如文档中所言,instrument存在诸多的限制:

   * The redefinition may change method bodies, the constant pool and attributes.
* The redefinition must not add, remove or rename fields or methods, change the
* signatures of methods, or change inheritance. These restrictions maybe be
* lifted in future versions. The class file bytes are not checked, verified and installed
* until after the transformations have been applied, if the resultant bytes are in
* error this method will throw an exception.

这里面提到,我们不可以增加、删除或者重命名字段和方法,改变方法的签名或者类的继承关系。认识到这一点很重要,当我们通过ASM获取到增强的字节码之后,如果增强后的字节码没有遵守这些规则,那么调用retransformClasses方法来进行类的重定义就会失败。

尾声

基于字节码动态替换技术,我们可以在运行时对JVM中的类进行修改并重载了。通过这种手段,可以做的事情就变得很多了:

  • 热部署:不部署服务而对运行中服务做修改,可以做打点、增加日志等操作;

    • Mock:测试时候对某些服务做Mock;

    • 性能诊断工具:比如bTrace、arthas、JProfiler、Jvisualvm就是利用Instrument,实现无侵入地跟踪一个正在运行的JVM,监控到类和方法级别的状态信息;
      特别值得一提的是,技术中台(基础服务组)最近也在内部上线了一款应用在线诊断工具,就是基于bistoury(集成arthas、vjtools)改造而来,提供所见即所得的web shell视图,类似idea界面化在线DEBUG功能让问题定位轻松高效。

      乐信在线应用诊断

参考资料

美团:Java字节码增强探秘
Java动态代理
openjdk
JVM Attach机制实现


end




在看点这里






文章转载自乐信技术精英社,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论