最近写个profiler,写完发觉大概有点不切实际.
开始是基于一个相对简单的想法.
因为一般debug java的一些线上问题无非就是看下哪些是hot method.
suspect之后一层一层顺着堆栈定位下去.
过程大体是相对繁琐的.
所以想着把这段稍微自动化点.
给定一个entry point,把整个execution过程的调用做个统计列出来.
免得再人工一个个去深入.
于是一个直接的想法就是做instrument.
在每个方法的调用前后加上time span,做耗时统计.
这里当时想了两种方式.
一种是在每个invoke*指令前后插入enter/leave的时间戳.
一种是在每个方法的entry/exit做这个时间戳统计.
第一种相对来说,最后生成的字节码会比较繁琐.
而且如果考虑到异常情况的化,这个time span可能还需要针对每个invoke做一个try-catch/异常处理block.
所以选择是在entry/exit插入.
从另一个角度来说的话,选择后者也实现上会相对的leap/优雅一些.
因为就是一个简单直观的rule.
而且具有相对的普适性.
即使再考虑异常情况的话,也又一个比较简洁的框架模式可以套.
比如对于一个
void target(){
// some code
}
形态的method来说.
插入之后大概就是
void target(){
Bridge.enter();
try{
// orignal
Bridge.leave();
}catch(Throwable e){
Bridge.leave();
throw e;
}
}
针对正常和异常都能相对结构简洁优雅地处理设计统计.
这里一个题外话就是Bridge的设计.
之所以叫Bridge主要是实现上用了一个小trick.
考虑到targe JVM一般是不太能够重启的,而attach的instrument agent有时候代码需要做改动.
尤其是开发时候.
所以实现上这里做了个classloader的处理.
agent在host vm里加载的时候会污染一部分host vm的classloader.
也即是agentmain部分的class会在host classloader里存在.
而如果全部agent code都是在这个classloader里的话,那么要达到代码的hot swap是不太可能的.
除非是transform自身.
于是,这里在agentmain的入口里做个简单的壳.
在agent load的时候切换切换class loader,使得每次attach的session使用自己独立的classloader.
以达到避免污染和hot reload的效果.
大致效果上类似于JSP的一些使用.
因为本质上来说,java的class实例是跟当前调用method的所属的class的classloader绑定的.
这样的话,只要创建一个目标classloader的shell class就能让后续的execution的常规class创建绑定在这个session bounded的loader里了.
在处理了这个热加在和基本的字节码处理框架之后,剩下的就是调用链条的crawl了.
直觉上来说,这也是个相对简单的过程.
因为jvm无非就几个invoke指令,scan一些method的bytecode做个筛选然后递归一下就是了.
只是实际麻烦的点就在于这几个invoke指令的语义.
尤其是invoke virtual和invoke interface.
根据jvm specification.
method resolution主要是基于class往上回溯的.
在interface和class method存在同名的情况下,class method的resolve规则相对靠前.
也就是相对的是favor的一方.
对于两者来说,给定一个invoke some_class some_method的指令.
形式上都是做一个virtual dispatch.
是运行时需要根据具体的instance类型做vtable/itable的lookup找到实际bind的method的.
也就是说,在scan到类似invoke some_class some_method的时候,并不能直接对some_class some_method做递归instrument.
因为除了some_class可能并不是concerted的情况意外,还有就是实际是一个sub class override的情形.
因此实际上要递归的对象/目标是从class/type hierarchy中upper bound为some_class的所有子类.
这样的话,就跟最初预期有了第一个意外.
当初预期做stack trace/调用链递归分析的出发点在于尽可能地只trace interested的部分.
也就是只在调用链条内的方法开销.
所以,在具体的tracing统计了,还做了个简单的per thread的stack matching.
保证被profile的方法的call stack里包含想要trace的entry method.
使得并不是所有特定方法的调用耗时都会包括进去,而是只有特定的调用栈的会被统计进来.
而如果作为upper bounded class的话,虽然形式上来说并没有打破这个约束/优化.
但是实际上的结果是会包含引入非常多的class被instrument.
一个测试方法的数据就是大概2000多个类会被触及到.
尽管理论上来说,这里是可以做一个静态分析的去除一部分的.
因为毕竟是实际上又所有被加载类的信息,加上制定了特定堆栈约束.
也就是相当于给定的一个execution state的initial parameter.
基于此做一个静态分析/执行引擎对涉及到的invoke做类型限定推导是有可能的.
只是一个是这个代价稍微有点大和复杂.
因为本质上相当于做个interpreter.
尽管如果写了之后可以进一步作为一个fuzzer的基础.
然而即使有了这个interpreter也并不难100%的限定类型.
因为存在一个动态类型是只有运行时才能确定的.
一个简单的例子就是反射.
以及一些涉及类似serviceloader加载机制的纯运行时特性.
于是,既然这种裁剪方式并没有一个相对完美的效果的话,那就不如不做筛选.
而是直接对所有class做instrument.
这样的话,实现上也更直接,不需要去做invoke scan的递归递归扫描操作.
而是直接无差别的transform,然后再在运行时再根据call stack决定需不需要做accounting.
唯一需要考虑的就是避免在做accepting的时候陷入递归.
因为做call stack check的代码也是被instrument的.
所有如果不对一些类做白名单处理的话,就直接递归了.
白名单的构建也尝试过用invoke scan的方式尽可能地构建一个小而完整的集合.
但问题依然是没办法100%保证确切的类型限定.
最终也是会指向type upper bounded的道路.
所以最终实现是用了限定java.lang和java.util将大多数jdk class排除在外.
在这个方案基础上出来的结果是第二个不预期的情况.
最初的构想是做了stack tracing之后,直接列出整个call stack的每一步的耗时.
而实际的结果是,尽管确实有了完整的call stack的每个step的耗时统计.
但问题在于涉及的method invoke太多.
一个方法trace有时候会展开成数十甚至上百的invoke method.
这个在事后其实也不难理解.
简单考虑一个method有个k个invoke,每个invoke针对m个class.
那么一级就是大概有k*m的调用统计.
再对每个k递归的话,就是理论上指数级的展开.
即使考虑其中可能会有重复的部分.
但理论上越是复杂的函数,最终的展开级数就越多.
所以这个在实际上是没有什么太大意义.
而且另外一个问题是因为是transform几乎所有class.
所以attch的time会明显地跟class数量线性相关.
更主要的是之前提到过的.
transform实际上会触发JIT的de-optimize.
针对所有class做instrument的话,基本上就是整个jvm打回warmup状态.
尽管理论上来说,会eventually JIT的.
但是从这个角度来说,基于instrument的profiler是会有一定程度的失真的.
尤其如果问题的实际不在bytecode层面,是VM本身的一些特性的话.
所以可能确实是基于perf couter和jvmti event的方案会更优雅准确一些.
至少,它们对VM的观察不会影响已有的JIT状态和实际的VM的代码层面允许逻辑.