以上四个类就是全部涉及到的代码,读者能从中看出什么来吗?
本文开头也提到过了,该bug在本地环境下不能复现,所以你尽管调试尽管单步,能调出来哪里出了bug算我输。
这段代码看起来一点问题也没有,完成的逻辑也很清晰,从log4j2的properties文件里读入属性,保存下来。调试的结果也是一样的,所有地方运行都正常。其实想想也对,这是spring boot的启动逻辑的一部分,如果有bug早就被修复了。那问题就来了,一段按理说不可能出错的代码出错了,可能原因是什么?Spring aop?不会的,如果是aop导致的,那没道理本地不出错。唯一的可能是代码在线上的时候被改变了。
考虑到该bug出现是挑环境的,那么我就要检查一下线上运行时的参数了。登到线上机上看了一眼,发现丫在命令行attach了一个别的jar (premain方式),目测是运维部门用来收集信息的,罪魁祸首应该就是它了。
剩下的确认bug操作就略过不提了,想重点聊聊动态字节码相关的内容。
字节码、Instrument与hotswap那些事儿这次的问题,最后查出来原因是线上attach的那个jar文件修改了Log相关的类,在properties里面放入了非String类型的对象,然后上面的PropertiesPropertySource.java这个类的foreach方法默认从文本文件里读内容,所以就把key和value强转为String类型,这时就发生了异常。这里面的核心技术在于修改类的行为,是怎么做到的呢?
字节码生成技术:jdk cglib javassist与asmjdk的动态代理是最为大家所熟知的一种修改类的行为的技术,通过生成和目标对象相同接口的类,并将该新类的对象返回给用户使用。Spring框架的aop默认就选择了这种实现方式,只有在类继承时才选择使用cglib生成子类的方式实现。jdk代理与cglib的特点是不对原类代码进行修改,而是生成新的类,通过使用新的类来达到修改类行为的目的。
与之对比,javassist和asm可以直接生成字节码类文件,或者对现有类文件进行修改。直接用asm需要对java的字节码指令集很熟悉,所以我个人更倾向于用javassist提供的抽象api。当然,不管用什么方式去生成字节码,对于大量调用方法的场合使用反射的方式去调用代码总是最愚蠢的。在本文的bug里,运维就是用了javassist去修改了类文件。
那么,既然我们知道了生成字节码,或者说修改类,那么接下来的任务是,如何让jvm加载被修改过的类呢?
类替换:Instrument与hotswap对于jdk和cglib的生成方式来说,不存在这类烦恼,在程序运行时就可以以java的方式拿到新的对象。
而对于直接修改字节码的框架来说,生成新的字节码并加载并不是很困难的事情,难的是修改现有字节码,因为对于jvm来说,重新加载类并不像喝水那么简单。
最省事的方式,莫过于在jvm决定加载类之前,就把类修改掉——这正是premain所做的。它在正常程序的main方法之前运行,并且提供了ClassFileTransformer接口让我们可以在类加载之前注册一些处理逻辑,在这些逻辑里我们就可以对类进行修改。
有时候,在程序运行之前修改类还不够,尤其是当我们必须把程序运行起来才知道会不会出错的场合下。为了提供在运行时能够对类进行修改的能力,java1.6中提供了agentmain。这样,我们就可以启动我们的程序,然后启动VirtualMachine,开始修改类,修改完后,再调用Instrumentation.redefineClasses方法来更新类,这就是轻量级的hotswap。
截至目前,以上面这种方式来更新类有个弊端,就是只能对现有的方法进行修改,不能为类增加新字段或者新方法。网上很多讲Instrument的博文提到了这个问题,但是很少有说出原因的。其实原因也很简单:
考虑这样一个场景,假如我们允许为类增加新字段,那么我们是不是要为所有现存的对象都增加对应的字段,分配对应的内存?如何实现?如果该对象目前正在被使用呢?是不是还要找到所有的引用,给他们指定新位置?再比如如果我们允许增加新方法,那么新方法该如何添加到方法表里呢?已经被解析为直接引用的地址要不要调整?如果已经被调用了呢?如果你要调整的类的子类恰好有一个相同签名的方法呢?
更进一步说,如果赋予了更大的方法修改能力,应该如何处理已经被jit优化尤其是内联了的代码?
不管你疯不疯,反正我是疯了。