谁还没遇上过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. ASP.NET MVC不可或缺的部分——DI(IOC)容器及控制器重构的剖析(DI的实现原理)

    IoC框架最本质的东西:反射或者EMIT来实例化对象.然后我们可以加上缓存,或者一些策略来控制对象的生命周期,比如是否是单例对象还是每次都生成一个新的对象. DI实现其实很简单,首先设计类来实现接口, ...

  2. 剑指offer面试题48: 最长不含重复字符的子字符串

    Given a string, find the length of the longest substring without repeating characters.(请从子字符串中找出一个最长 ...

  3. sql语句查询表中重复字段以及显示字段重复条数

    今天跟大家分享两条SQL语句,是关于查询某表中重复字段以及显示该字段的重复条数. 1.select * from 表名 where 列名 in (select 列名 from 表名 group by ...

  4. JavaScript中对象数组 作业 题目如下

    var BaiduUsers = [], WechatUsers = []; var User = function(id, name, phone, gender, age, salary) { t ...

  5. Day17 Django的基础使用和结构

    整个Django的访问流程: 浏览器 urls: http://127.0.0.1:8000/timer url.py: 1, http://127.0.0.1:8000/timer GET 无请求数 ...

  6. python---购物车---更新

    购物车程序更新: 更新商家入口,实现以下功能: 1. 商家能够修改商品价格: 2. 商家能够下线商品: 3. 商家能够增加商品: 4. 商品信息存在文件中 # -*- coding:utf-8 -*- ...

  7. IEEE发布2017年编程语言排行榜:Python高居首位

    https://news.cnblogs.com/n/574248 编者按:本文由微信公众号“机器之心”(ID:almosthuman2014)编译,机器之心专注生产 AI 领域专业性内容.本文作者: ...

  8. eclipse web开发Server配置

    用 Tomcat 和 Eclipse 开发 Web 应用程序:http://www.ibm.com/developerworks/cn/opensource/os-eclipse-tomcat/ Ec ...

  9. Grunt的配置和使用

    Grunt和Grunt插件是通过NodeJs的包管理工具npm安装并进行管理的. Grunt 0.4.x必须配合NodeJs=>0.8.0版本使用(奇数版本的NodeJs不是稳定的开发版本)   ...

  10. 微信小程序入门一

    基本的准备工作 -知识储备 --基础:HTML+JS+CSS --进阶:React.Vue -工具安装 --工具由微信官方提供 ---下载地址:https://github.com/zce/weapp ...