谁还没遇上过NoClassDefFoundError咋地——浅谈字节码生成与热部署

时间:2023-12-25 11:25:43

谁还没遇上过NoClassDefFoundError咋地——浅谈字节码生成与热部署


前言

在Java程序员的世界里,NoClassDefFoundError是一类相当令人厌恶的错误,因为这类错误通常非常隐蔽,难以调试。

通常,NoClassDefFoundError被认为是运行时类加载器无法在classpath下找不到需要的类,而该类在编译时是存在的,这就通常预示着一些很麻烦的情况,例如:

  • 不同版本的包冲突。这是最最最常见的情况,尤其常见于用户代码需要运行于容器中,而本地容器和线上容器版本不同时;
  • 使用了多个classloader。要用的类被另一个类加载器加载了,导致当前类加载器作用域内找不到这个类,在破坏双亲委托时容易出这样的问题;

除了上面提到的这几种问题,还有一些可能导致这个错误的特殊案例,比如今天我遇到的这个:

问题背景

一个spring boot程序,maven打包本地运行毫无问题,发布到生产环境就会biang的报一个错说NoClassDefFoundError。

该问题的隐蔽之处在于没有办法在本地复现,所以觉得有必要跟大家分享。

分析过程

  1. 第一反应,maven环境问题。我本地的maven连的是central仓库,而线上环境连得是公司的私有仓库。我司的maven仓库被各种开发人员胡乱上传的包弄的很像薛定谔的猫,鬼才知道它给你的哪个包是不是你想要的。

    如果它提供的包事实上是错误的,或者经过第三方(其他开发)的修改,那很容易造成这个错误。

排查这个其实也好办,两种方式一是打thin jar然后自己上传依赖,二是找运维做一套独立的maven环境,使用和本地相同的配置,总之一通折腾之后,重新部署,发现错误还在。

  1. 不是包版本错误的话,就比较隐蔽了。因为该程序在本地运行可以通过所有测试用例,也没有在不同的线程里狂秀classloader骚操作,所以也基本排除上面提到的2和3的可能性。

都不是的情况下,返回头去重新看了一下错误日志,发现虽然报的是NoClassDefFoundError,但后面跟的消息是类实例化失败,这个消息给了我关键的提醒。

  1. NoClassDefFoundError是一个非常晦涩的错误,有一些意外的情况我认为其实不适合归到这个错误里,比如这次的类实例化错误,或者确切的说,类初始化错误

回到本文来,这个错误日志里写了什么呢?日志告诉我,我的一个类cinit失败,错误在第多少多少行。只有这一个错误堆栈,没有输出任何其他的错误信息,比如到底什么原因导致这个类cinit失败了。出错的代码在org.apache.logging.log4j.status.StatusLogger这个类中,代码如下所示:

private static final PropertiesUtil PROPS = new PropertiesUtil("log4j2.StatusLogger.properties");

这里就是另外一种会导致NoClassDefFoundError发生的场合:在静态字段和静态代码块初始化时的异常导致类初始化失败,会产生NoClassDefFoundError。

光看这句话是看不出什么可能出错的地方来的,我们跟进去看看里面的代码有哪个地方有问题:

//PropertyUtil.java
private static final String LOG4J_PROPERTIES_FILE_NAME = "log4j2.component.properties";
private static final PropertiesUtil LOG4J_PROPERTIES = new PropertiesUtil(LOG4J_PROPERTIES_FILE_NAME);
public PropertiesUtil(final String propertiesFileName) {
this.environment = new Environment(new PropertyFilePropertySource(propertiesFileName));
} //Enviroment.java
private final Set<PropertySource> sources = new TreeSet<>(new PropertySource.Comparator());
private final Map<CharSequence, String> literal = new ConcurrentHashMap<>();
private final Map<CharSequence, String> normalized = new ConcurrentHashMap<>();
private final Map<List<CharSequence>, String> tokenized = new ConcurrentHashMap<>();
private Environment(final PropertySource propertySource) {
sources.add(propertySource);
for (final PropertySource source : ServiceLoader.load(PropertySource.class)) {
sources.add(source);
}
reload();
} private synchronized void reload() {
literal.clear();
normalized.clear();
tokenized.clear();
for (final PropertySource source : sources) {
source.forEach(new BiConsumer<String, String>() {
@Override
public void accept(final String key, final String value) {
literal.put(key, value);
final List<CharSequence> tokens = PropertySource.Util.tokenize(key);
if (tokens.isEmpty()) {
normalized.put(source.getNormalForm(Collections.singleton(key)), value);
} else {
normalized.put(source.getNormalForm(tokens), value);
tokenized.put(tokens, value);
}
}
});
}
} //PropertyFilePropertySource.java
public PropertyFilePropertySource(final String fileName) {
super(loadPropertiesFile(fileName));
} private static Properties loadPropertiesFile(final String fileName) {
final Properties props = new Properties();
for (final URL url : LoaderUtil.findResources(fileName)) {
try (final InputStream in = url.openStream()) {
props.load(in);
} catch (IOException e) {
LowLevelLogUtil.logException("Unable to read " + url, e);
}
}
return props;
}
//PropertiesPropertySource.java PropertyFilePropertySource类的父类
public PropertiesPropertySource(final Properties properties) {
this.properties = properties;
} @Override
public void forEach(final BiConsumer<String, String> action) {
for (final Map.Entry<Object, Object> entry : properties.entrySet()) {
action.accept(((String) entry.getKey()), ((String) entry.getValue()));
}
}

以上四个类就是全部涉及到的代码,读者能从中看出什么来吗?

本文开头也提到过了,该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与asm

jdk的动态代理是最为大家所熟知的一种修改类的行为的技术,通过生成和目标对象相同接口的类,并将该新类的对象返回给用户使用。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优化尤其是内联了的代码?

不管你疯不疯,反正我是疯了。

那么,我们是不是就无计可施了?并不是。java仍然给了我们一种方式,来完全的控制和修改类:利用classloader。java并不允许我们扔掉已经加载的类,但是却不限制我们利用一个新的classloader来加载一个同名新类。这样的话,如果我们需要对一个类的功能做出修改,那么我们只需要丢弃它的类加载器(和它的对象),然后重新创建一个类加载器,再加载修改过的类,从而绕过了jvm的限制,实现了hotswap的功能。事实上,Tomcat和OSGi就是这么做的。以Tomcat为例,当我们修改了一个jsp页面,reload一下,然后刷新页面发现页面已经做出了响应,这背后就是tomcat丢弃了加载了上一个jsp文件的加载器和jsp文件,重新创建了一个加载器,然后重新加载修改过的jsp文件,就是这么简单。

当然,在使用这种方式的hotswap时,你必须足够小心,以避免因为类泄露造成OOM(说的更确切一点,不要让对象在不经意间逃逸出当前classloader的context,特别要注意...线程池)。


全文完