JVMTI
Java生态中有一些非常规的技术,它们能达到一些特别的效果。这些技术的实现原理不去深究的话一般并不是广为人知。这种技术通常被称为黑科技。而这些黑科技中的绝大部分底层都是通过JVMTI实现的。
形象地说,JVMTI是Java虚拟机提供的一整套后门。通过这套后门可以对虚拟机方方面面进行监控,分析。甚至干预虚拟机的运行。
JVMTI是什么?
JVMTI全称JVM Tool Interface,是JVM暴露出来的一些供用户扩展的接口集合。
JVMTI 是一个双向接口,支持 JVM 与本机代理程序之间进行通信.
JVMTI的工作原理?
JVMTI 本质上是在JVM内部的许多事件进行了埋点。通过这些埋点可以给外部提供当前上下文的一些信息。甚至可以接受外部的命令来改变下一步的动作。外部程序一般利用C/C++实现一个JVMTIAgent,在Agent里面注册一些JVM事件的回调。当事件发生时JVMTI调用这些回调方法。Agent可以在回调方法里面实现自己的逻辑。JVMTIAgent是以动态链接库的形式被虚拟机加载的。
Instrument说到javaagent,必须要讲的是一个叫做instrument的JVMTIAgent(Linux下对应的动态库是libinstrument.so),因为javaagent功能就是它来实现的.
另外instrument agent还有个别名叫JPLISAgent(Java Programming Language Instrumentation Services Agent),这个名字也完全体现了其最本质的功能:就是专门为Java语言编写的插桩服务提供支持的。
java.lang.instrument包是instrument的具体实现, 包结构如下:
java.lang.instrument - ClassDefinition - ClassFileTransformer - IllegalClassFormatException - Instrumentation - UnmodifiableClassException - UnmodifiableModuleException复制代码
Instrument的作用
Instrumentation类提供控制Java语言程序代码的服务。
Instrumentation可以实现在方法插入额外的字节码从而达到收集使用中的数据到指定工具的目的。
由于插入的字节码是附加的,这些更变不会修改原来程序的状态或者行为。通过这种方式实现的良性工具包括监控代理、分析器、覆盖分析程序和事件日志记录程序等等。
也就是说,java.lang.instrument包的最大功能就是可以在已有的类上附加(修改)字节码来实现增强的逻辑。
Instrument的两种运行方式
等同于javaagent的两种模式
- premain模式--on load
在jvm启动时加载复制代码
- agentmain模式 -- on attach
在jvm运行时加载复制代码
Instrument修改字节码的时机
instrument的底层实现依赖于JVMTI, 不管是启动时还是运行时加载的instrument,都关注着同一个JVMTI事件——ClassFileLoadHook,这个事件是在读取字节码文件之后回调时用的,通过这个回调事件, Instrument实现对原来的字节码做修改
核心方法及原理
核心方法
方法名 | 作用 |
---|---|
void addTransformer(ClassFileTransformer transformer, boolean canRetransform) | 注册ClassFileTransformer实例,注册多个会按照注册顺序进行调用 |
void addTransformer(ClassFileTransformer transformer) | 也就是上面方法canRetransform为false时, 表示通过ClassFileTransformer实例重定义的类不能进行回滚 |
boolean removeTransformer(ClassFileTransformer transformer) | 移除ClassFileTransformer实例 |
boolean isRetransformClassesSupported() | 返回当前JVM配置是否支持类重新转换 |
void retransformClasses(Class<?>... classes) | 重新转换类, 根据已经存在的字节码文件, 就行修改后再替换 |
boolean isRedefineClassesSupported() | 返回当前JVM配置是否支持重定义类(修改类的字节码) |
void redefineClasses(ClassDefinition... definitions) | 重新定义类, 以自己提供的字节码文件替换已存在的class文件 |
Transformer
Transformer是字节码转换的接口,Instrument是管理Transformer、调度Transformer进行字节码转换的门面。
Instrument通过TransformerManager来具体管理和使用Transformer, 例如下述情况:
当执行Instrument的addTransformer、removeTransformer方法时,最终是调用了TransformerManager的addTransformer、removeTransformer.
Instrument的retransformClasses、redefineClasses是用于通知TransformerManager调度字节码转换的
Transformer.transform()方法
重定义方法redefineClasses不会使用transform()方法
重转换方法retransformClasses通过在transform方法写代码, 做到对于既有二进制文件进行修改对目的
redefineClasses(重定义)
类加载过程中, defineClass()方法会将二进制文件读取得到class对象, redefineClasses就是可控的再次进行这一过程.
对于已经加载的类, 该方法通过使用提供的新的字节码文件进行替换, 现有的类文件字节不会被使用,就像从源头进行重新编译以进行修复和继续调试时一样。
redefineClasses的工作流程
重定义类的请求会被JVM包装成一个VM_RedefineClasses类型的VM_Operation,VM_Operation是JVM内部的一些操作的基类,包括GC操作等。VM_Operation由VMThread来执行,新的VM_Operation操作会被添加到VMThread的运行队列中去,VMThread会不断从队列里面拉取VM_Operation并调用其doit等函数执行具体的操作。
VM_RedefineClasses函数的流程较为复杂,下面是VM_RedefineClasses的大致流程:
加载新的字节码,合并常量池,并且对新的字节码进行校验工作
清除方法上的断点
JIT逆优化
进行字节码替换工作,需要进行更新类itable/vtable等操作
进行类重定义通知
重定义的类的特点
该方法对一组class进行操作,以便同时允许多个相互依赖的类的更改,如A类的重新定义可能需要重新定义B类。
重新定义一个类并不会导致它的初始化器被运行。 静态变量的值将保持在调用之前。重新定义的类的实例不受影响。
重新定义可能会改变方法体,常量池和属性。
重定义不能添加,删除或重命名字段或方法,更改方法的签名或更改继承
类文件字节不会被检查,验证和安装,直到应用转换为止,如果结果字节错误,则此方法将抛出异常。如果此方法抛出异常,则不会重新定义任何类。
重定义方法会使用一个全新的类文件来替换原有的, 并不会使用原有的类文件字节.复制代码
retransformClasses(重转换)
该方法类似一个管道, 会将输入的二进制文件根据代码逻辑进行修改, 然后用新的二进制class文件替换旧的.
该方法主要作用于已经加载过的class。无论输入的二进制文件以前是否发生转换,此函数都将重新运行转换过程。
retransformClasses的工作流程
调用jvmtiEnv.cpp中的RetransformClasses
jvm层的一通逻辑
利用JNI调用 java 层Instrument的transform()方法
TransformerManager的transform()方法会遍历它的注册数组, 调用每个ClassFileTransformer对象的transform()方法, 并将我们修改后的类字节码返回,返回后的字节码最终又回到了上面JVM层的transformClassFile()中, 并最终交还给给class_file_load_hook 消息的发送方。复制代码
- check_shared_class_file_load_hook()中会解析新返回的类字节码,构造出新的类new_ik,最后,基于修改后的字节码构造出来的新类new_ik 会被返回给上一层,完成类的链接等任务.
该方法运行的本质依旧会回归到redefineClasses方法上
Instrument的局限性
因为类的转换方法本质上还是类的重定义, 因此重定义类的限制就是Instrument的局限性
新类和老类的父类必须相同。
新类和老类实现的接口数也要相同,并且是相同的接口。
新类和老类访问符必须一致。
新类和老类字段数和字段名要一致。
新类和老类新增或删除的方法必须是private static/final修饰的。
Instrument能够在jvm运行时加载, 就是通过Attach的能力, 因此这里简单介绍一下.
Attach是什么?
Attach API 不是 Java 的标准 API,而是 Sun 公司提供的一套扩展 API,用来向目标 JVM “附着”(Attach)代理工具程序的。
Attach的功能
简单来说attach是一种进程间通信的工具. 可以用于jvm进程间通信 ,能让一个进程传命令给另一个进程, 命令另一个进程进行一些操作.
Attach机制可以对目标进程收集很多信息,如内存dump,线程dump,类信息统计(比如加载的类及大小以及实例个数等),动态加载agent,获取系统属性等等。
典型的应用例如jstack工具:
当我们需要某一个正在运行的jvm进程的线程使用情况时, 会运行jstack进程, 然后告诉它目标进程的pid, jstack就会利用attach机制在进程间进行通信, 完成相应数据的获取.
常用的几种Attach实现
HotSpotAgent.attach
HotSpotAgent.attach(采用Serviceability Agent,简称SA, 是一个用于分析HotSpot运行时进程和Core文件中数据的工具。
它可以attach到Java进程或分析Core文件中的数据,了解加载的class,是一个包含大量Java API和工具的工具集,目前实现只支持“snapshot”式的使用方式。
“snapshot”是指不支持在SA保持连接的同时让目标进程运行,就是说无论如何在SA进行attach的时候目标进程都要暂停的(SA在attatch到进程之后,会暂停当前进程的执行,拿到的是进程的一个snapshot,当前进程会在SA断开后继续执行),所以在线上使用这类工具进行dump时无论耗时长短必须要摘流量,否则可能会使服务不可用而带来一些不必要的影响。
VirtualMachine.attach
使用较多的是VirtualMachine.attach, jstack使用的就是该实现.
该实现主要是利用信号进行通信, 每个JVM都会有Signal Dispatcher线程,用于处理信号。
- 接受信号, 创建Attach Listener线程
jvm启动的时候并不会创建Attach Listener线程, 而是当自己的Signal Dispatcher线程接受到外部信号时, 创建一个临时的socket文件, 同时创建Attach Listener线程.
- 保持通信, 执行命令.
Attach Listener线程会通过Unix domain socket与外部进程建立连接,之后就可以基于这个socket进行通信了。
创建好的Attach Listener线程会负责执行这些命令(从队列里不断取AttachOperation,然后找到请求命令对应的方法进行执行,比如jstack命令,找到 { “threaddump”, thread_dump }的映射关系,然后执行thread_dump方法)并且把结果通过.java_pid文件返回给发送者。