入口
对JVM中的字节码进行替换,这是JVM通过jvmti接口对外提供的扩展功能。如果要通过Java语言来实现(jvmti提供的是C接口),
可以通过javaagent的方式或者通过tools.jar提供的attach接口进行jar包的加载。直接使用jvmti接口,则可以参照jvmti文档编写一个agent让JVM加载。
javaagent实现
注意: 本文仅介绍在线插装方式
javaagent需要以jar包的形式加载,和普通的jar没什么区别,唯一需要在MANIFEST.MF
文件中增加一些入口。
对于maven工程,可以使用maven-jar-plugin
来生成该文件内容:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<archive>
<manifestEntries>
<Agent-Class>
com.cainiao.isimu.instrument.test.inst.InstrumentMain
</Agent-Class>
<Can-Redefine-Classes>
true
</Can-Redefine-Classes>
<Can-Retransform-Classes>
true
</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
这样会在最终生成的MANIFEST.MF
中增加Agent-Class
、Can-Redefine-Classes
和Can-Retransform-Classes
这三项,
注意修改Agent-Class
项的值,设置成agent入口类。
入口类和标准Java入口不一样,需要在类中定义签名为public static void agentmain(String agentArgs, Instrumentation inst)
的方法。JVM加载该类之后,会执行这个函数。
通过这个入口就可以获取到Instrumentation
对象了,后面所有的类转换操作都需要通过该对象进行。对于插桩来说,主要是addTransformer
方法来添加类文件转换器,retransformClasses
来发起类转换。
具体让JVM动态加载该包的方式,详见JVMTI那些事——加载。
直接使用jvmti接口
通过jvmti接口编写基于c/c++语言的agent可以参考:JVMTI那些事——c++编写的agent。
该文没有尝试调用jvmti中的字节码转换函数。
JVM开始工作
通过jvmti接口提交了类转换请求之后,JVM就需要开始工作了。hotspot对jvmti的实现在hotspot/src/share/vm/prims/jvmtiEnv.cpp
文件中,对应转换类的函数实现是jvmtiError
。
JvmtiEnv::RetransformClasses(jint class_count, const jclass* classes)
这个函数前面是各种检查和验证,我们唯一关心的是最后两行:
```C++
VM_RedefineClasses op(class_count, class_definitions, jvmti_class_load_kind_retransform);
VMThread::execute(&op);
这里创建了一个名为`VM_RedefineClasses`的vmop,然后通知VMThread进行处理。
### VM_Operation
`VM_Operation`是虚拟机级别的操作,这些操作包含了所有JVM的内置操作,例如GC、获取线程栈等等。
这个类是所有这些操作的基类,该类定义在`hotspot/src/share/vm/runtime/vm_operations.hpp`我们来看下一些默认的操作。
首先,该类定义了`Mode`和`VMOp_Type`两个枚举。第一个表示该操作的模式,第二个表示该操作的类型。事实上所有的类型都在文件开头的宏定义中写明了,这里我们只关心`RedefineClasses`这个类型。`Mode`包括这四种:
```C++
enum Mode {
_safepoint, // blocking, safepoint, vm_op C-heap allocated
_no_safepoint, // blocking, no safepoint, vm_op C-Heap allocated
_concurrent, // non-blocking, no safepoint, vm_op C-Heap allocated
_async_safepoint // non-blocking, safepoint, vm_op C-Heap allocated
};
</code></pre>
根据注释可以知道,<code>VM_Operation</code>的模式主要分为阻塞和非阻塞。
主要成员函数有:
```C++
// Called by VM thread - does in turn invoke doit(). Do not override this
void evaluate();
// evaluate() is called by the VMThread and in turn calls doit().
// If the thread invoking VMThread::execute((VM_Operation) is a JavaThread,
// doit_prologue() is called in that thread before transferring control to
// the VMThread.
// If doit_prologue() returns true the VM operation will proceed, and
// doit_epilogue() will be called by the JavaThread once the VM operation
// completes. If doit_prologue() returns false the VM operation is cancelled.
virtual void doit() = 0;
virtual bool doit_prologue() { return true; };
virtual void doit_epilogue() {}; // Note: Not called if mode is: _concurrent
// Configuration. Override these appropriatly in subclasses.
virtual VMOp_Type type() const = 0;
virtual Mode evaluation_mode() const { return _safepoint; }
virtual bool allow_nested_vm_operations() const { return false; }
virtual bool is_cheap_allocated() const { return false; }
virtual void oops_do(OopClosure f) { /* do nothing */ };
virtual bool evaluate_at_safepoint() const {
return evaluation_mode() == _safepoint ||
evaluation_mode() == _async_safepoint;
}
virtual bool evaluate_concurrently() const {
return evaluation_mode() == _concurrent ||
evaluation_mode() == _async_safepoint;
}
注释里面说明了`evaluate`函数不能覆盖,实际业务逻辑必须在`doit`函数中实现。如果需要在实际操作之前有前置操作,可以在`doit_prologue`函数中执行。特别注意的是,`doit_prologue`函数是在Java线程中运行的,这个执行线程实际就是提交`VM_Operation`的线程。
`evaluation_mode`函数返回当前操作的模式,如果子类不覆盖,默认值是阻塞模式。
### VM_RedefineClasses
`VM_RedefineClasses`是`VM_Operation`的子类,实现了类转换的所有逻辑。该类定义和实现分别在`hotspot/src/share/vm/prims/jvmtiRedefineClasses.hpp`和`hotspot/src/share/vm/prims/jvmtiRedefineClasses.cpp`。
对于一个`VM_Operation`的子类,首先需要关心`evaluation_mode`函数。`VM_RedefineClasses`类中找不到该函数,因此它是一个需要在safepoint阻塞的操作。
然后就是核心操作,按照前文说的调用顺序,既`doit_prologue`、`doit`、`doit_epilogue`。
代码比较复杂,我们先从注释上了解每个步骤做了什么:
```C++
// 1) doit_prologue() is called by the JavaThread on the way to a
// safepoint. It does parameter verification and loads scratch_class
// which involves:
// - parsing the incoming class definition using the_class' class
// loader and security context
// - linking scratch_class
// - merging constant pools and rewriting bytecodes as needed
// for the merged constant pool
// - verifying the bytecodes in scratch_class
// - setting up the constant pool cache and rewriting bytecodes
// as needed to use the cache
// - finally, scratch_class is compared to the_class to verify
// that it is a valid replacement class
// - if everything is good, then scratch_class is saved in an
// instance field in the VM operation for the doit() call
//
// Note: A JavaThread must do the above work.
</code></pre>
在<code>doit_prologue</code>阶段,整个操作都是在Java线程中进行的,因此不会阻塞VMThread,也不会被计入safepoint的耗时。
注意整个源码中<code>the_class</code>表示待替换的类,<code>scratch_class</code>表示新的类。该阶段主要做的就是准备需要的字节码,
如果业务代码中准备新的字节码时间比较长(前面提到的获取新字节码的回调也是在这里发生),这个阶段时间就会变长,但是不会阻塞JVM的核心线程。
然后是最核心的<code>doit</code>函数。
```c++
// 2) doit() is called by the VMThread during a safepoint. It installs
// the new class definition(s) which involves:
// - retrieving the scratch_class from the instance field in the
// VM operation
// - house keeping (flushing breakpoints and caches, deoptimizing
// dependent compiled code)
// - replacing parts in the_class with parts from scratch_class
// - adding weak reference(s) to track the obsolete but interesting
// parts of the_class
// - adjusting constant pool caches and vtables in other classes
// that refer to methods in the_class. These adjustments use the
// ClassLoaderDataGraph::classes_do() facility which only allows
// a helper method to be specified. The interesting parameters
// that we would like to pass to the helper method are saved in
// static global fields in the VM operation.
// - telling the SystemDictionary to notice our changes
//
// Note: the above work must be done by the VMThread to be safe.
该函数**必须**在`VMThread`中进行,且调用会从safepoint开始阻塞。该函数的操作主要氛围两个阶段:
* 第一阶段主要是准备工作,执行内容包括:
- 移除所有断点信息(所以进行过转换的类,需要重新debug,否则是没法断点的)
- 去优化(Deoptimize)
- 交换方法和常量池
- 交换内部类
- 初始化虚函数表(vtable)和接口函数表(itable)
- 复制源码相关信息
- 替换类版本信息
* 第二阶段是依赖处理,主要内容有:
- 调整所有依赖被替换方法的类的常量池缓存和vtable
- [JSR-292](https://jcp.org/en/jsr/detail?id=292)支持(invokedynamic)
- 刷新对象实例缓存,去除废弃的方法
- 增加被修改类(及其内联类)的`classRedefinedCount`属性
最后的`doit_epilogue`函数比较简单,主要做清理工作和最终统计工作。
## VM_RedefineClasses中的计时器
在看`VM_RedefineClasses`类,我们可以发现有几个定时器相关的字段:
```C++
elapsedTimer _timer_rsc_phase1;
elapsedTimer _timer_rsc_phase2;
elapsedTimer _timer_vm_op_prologue;
前两个用于对doit
函数中的两个阶段分别计时,最后一个为前置操作计时(主要是加载和解析新类时间)。
整个类替换的计时和日志,定义在jvmtiRedefineClassesTrace.hpp
文件中,里面通过宏来判断是否输出
该阶段的日志。如果需要打印对应的日志,可以在JVM启动的时候添加-XX:TraceRedefineClasses=XX
来指定。
其中XX参考头文件中的注释,如果要打印所有日志,可以设置为33554431;如果只需要打印计时器时间,可以设置为4。
另外,如果还需要了解整个替换过程中对JVM的开销,还以通过在JVM启动时增加参数-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1
。这样JVM会在标准输出中打印所有vmop的时间消耗。注意这个时间仅仅是在VMThread中运行的时间,对于像VM_RedefineClasses
这种前置有大量时间消耗的,都不会计入其中的vmop时间消耗。