谁还没遇上过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. sql记录查询重复注意事项(经验提升),in的用法和效率

    sql查询重复记录,使用: select * from dimappnamenew as appn where id in (   select id   from dimappnamenew gro ...

  2. 初探linux子系统集之写在前言

    毕业两周年,进入嵌入式linux这个行业也已两个年头有余,从开始的linux驱动,android的framework,到现在的linux应用,android的app以及产品的零零总总,其实很想把这些都 ...

  3. Java Class Version 研究

    一:要解决的问题 我们在尝鲜 JDK1.5 的时候,相信不少人遇到过 Unsupported major.minor version 49.0 错误,当时定会茫然不知所措.因为刚开始那会儿,网上与此相 ...

  4. 服务器:SATA、PATA及IDE的比较

    SATA SATA全称是Serial Advanced Technology Attachment(串行高级技术附件,一种基于行业标准的串行硬件驱动器接口),是由Intel.IBM.Dell.APT. ...

  5. windows from 手风琴

    public class OutlookBar : Panel { private int SelectedBandHeight { get; set; } public int ButtonHeig ...

  6. 解决ecshop3.6 H5版本公告页面为空的修改办法

    ecshop3.6公告页面打开如下,页面完全无效果,如下图. 经过简单美化后,有返回按钮,页面加以美化.如下图. 是不是要好看多了.简单修改几步即可. 修改文件 \appserver\resource ...

  7. iframe之局部刷新

      例如: <iframe src="1.htm" name="ifrmname" id="ifrmid"></ifram ...

  8. 2.3MySQL 自带工具使用介绍

    1.mysql 首先看看“-e, --execute=name”参数,这个参数是告诉mysql,我只要执行“-e”后面的某个命令,而不是要通过mysql 连接登录到MySQL Server 上面.此参 ...

  9. H5之postMessage 。实现跨域

    对于跨域我们有很多的解决方案,今天我来分享一下postMessage的那点事,postMessage是html5新增的一个解决跨域的一个方法,不过很可惜万恶的ie6,7不支持 postMessage( ...

  10. 摘抄详细的VUE生命周期

     Vue所有的生命周期钩子自动绑定在this上下文到实例中,因此你可以访问数据,对属性和方法进行运算.这意味着你不能使用箭头函数来定义一个生命周期方法.这是因为箭头函数绑定了父上下文,因此this与你 ...