谁还没遇上过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,特别要注意...线程池)。


全文完

谁还没遇上过NoClassDefFoundError咋地——浅谈字节码生成与热部署的更多相关文章

  1. Java代理全攻略【有瑕疵:字节码生成部分没看到,最后两节没仔细看,累了】

    Java代理 1.代理模式 定义:给某个对象提供一个代理对象,并由代理对象控制对于原对象的访问,即客户不直接操控原对象,而是通过代理对象间接地操控原对象. 其实就是委托.聚合.中间人. 为了保持行为的 ...

  2. 真的懂了:TCP协议中的三次握手和四次挥手(关闭连接时, 当收到对方的FIN报文时, 仅仅表示对方不在发送数据了, 但是还能接收数据, 己方也未必全部数据都发送对方了。相当于一开始还没接上话不要紧,后来接上话以后得让人把话讲完)

    一.TCP报文格式 下面是TCP报文格式图: (1) 序号, Seq(Sequence number), 占32位,用来标识从TCP源端向目的端发送的字节流,发起方发送数据时对此进行标记. (2) 确 ...

  3. 项目目前展示图 有几个Activity页还没连上不能一次展示出来

  4. 当创业遇上O2O,新一批死亡名单,看完震惊了!

    当创业遇上O2O,故事就开始了,总投入1.6亿.半年开7家便利店.会员猛增至10万……2015半年过去后,很多故事在后面变成了一场创业“事故”,是模式错误还是烧钱过度?这些项目的失败能给国内创业者带来 ...

  5. 当KDS晶振遇上爱普生晶振国内生产厂家该如何抉择?

    当KDS晶振遇上爱普生晶振国内生产厂家该如何抉择?       全球做晶振行业的公司有很多,单说深圳一个城市就有几十上百家正规的晶振厂家,深圳市金洛电子就是其中之一.我们不光代理日本和台湾多家排得上名 ...

  6. 前端遇上Go: 静态资源增量更新的新实践

    前端遇上Go: 静态资源增量更新的新实践https://mp.weixin.qq.com/s/hCqQW1F8FngPPGZAisAWUg 前端遇上Go: 静态资源增量更新的新实践 原创: 洋河 美团 ...

  7. 当 Go struct 遇上 Mutex

    struct 是我们写 Go 必然会用到的关键字, 不过当 struct 遇上一些比较特殊类型的时候, 你注意过你的程序是否正常吗 ? 一段代码 type URL struct { Ip string ...

  8. 敏捷遇上UML-需求分析及软件设计最佳实践(郑州站 2014-6-7)

      邀请函: 尊敬的阁下:我们将在郑州为您奉献高端知识大餐,当敏捷遇上UML,会发生怎样的化学作用呢?首席专家张老师将会为您分享需求分析及软件设计方面的最佳实践,帮助您掌握敏捷.UML及两者相结合的实 ...

  9. 敏捷遇上UML—软创基地马年大会(广州站 2014-4-19)

        我们将在广州为您奉献高端知识大餐,当敏捷遇上UML,会发生怎样的化学作用呢?首席专家张老师将会为您分享需求分析及软件设计方面的最佳实践,帮助您掌握敏捷.UML及两者相结合的实战技巧. 时间:2 ...

随机推荐

  1. AngularJS进阶(九)控制器controller之间如何通信

    AngularJS控制器controller之间如何通信 注:请点击此处进行充电! angular控制器通信的方式有三种: 1,利用作用域继承的方式.即子控制器继承父控制器中的内容 2,基于事件的方式 ...

  2. C++之多继承

    #include <iostream> using namespace std ; class AA { public: int a ; void Say_hello(void) { co ...

  3. 设置UIButton中的文字和图片,设置UILabel的文在显示不同颜色

    UIButton: UIEdgeInsets 在UIButton中有三个对EdgeInsets的设置:ContentEdgeInsets.titleEdgeInsets.imageEdgeInsets ...

  4. java并发之可见性与原子性:Syncronized和volatile

    转载:http://blog.csdn.net/guyuealian/article/details/52525724 在说明Java多线程内存可见性之前,先来简单了解一下Java内存模型.     ...

  5. 第15章-输入/输出 --- File类

    (一) Java的IO通过java.io包下的类和接口来支持,在java.io包下主要包括输入.输出两种IO流. 每种输入.输出流又分为字节流和字符流两大类: (1)字节流以字节为单位来处理输入.输出 ...

  6. 用js来实现那些数据结构15(图01)

    其实在上一篇介绍树结构的时候,已经有了一些算法的相关内容介入.而在图这种数据结构下,会有更多有关图的算法,比如广度优先搜索,深度优先搜索最短路径算法等等.这是我们要介绍的最后一个数据结构.同时也是本系 ...

  7. SpringBoot使用Maven插件打包部署

    [问题] 之前一直用SpringBoot做一些小项目,想打包部署在环境上,总是少依赖包jar.百度下可以通过Spring Boot Maven plugin插件,把Maven配置的依赖包都打到项目包里 ...

  8. 常常搞不清楚SQLServer中的sp_columns来看一看

    The sp_columns catalog stored procedure is equivalent to SQLColumns in ODBC. The results returned ar ...

  9. tar结果find打包指定后缀的文件

    find 目录名 -name "*.ini" | xargs tar czvf tarch.tar.gz  tar czf tmp.tar.gz tmp/ --exclude=&q ...

  10. Spring Security简明实践及相关国际化处理

    别人的都是最佳实践,因为我目前的设置没有按照参考文档推荐,还是采用DelegatingFilterProxy,所以我只能说简明实践.先贴我的applicationContext-security.xm ...