20

ClassLoader踩坑实例现场

 4 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzA5NTE1ODQyOQ%3D%3D&%3Bmid=2247483938&%3Bidx=1&%3Bsn=9a1464c90b2290cb226024b3fb5aa890
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
NzeqaeJ.gif

在本篇文章中,作者介绍了classloader的定义和核心api,以及内部的一些实现细节,并结合实例进行了分析;

一. ClassLoader 是什么

ClassLoader顾名思义就是类加载器,负责将字节码形式的Class数据流解析成内存形式的Class对象,加载到JVM中。而这样一个很核心很底层的重要组件,Java语言设计者却并没有将其完全放置于JVM内部实现,而是直接暴露在Java语言层面(java.lang.ClassLoader),这无疑使Java更具灵活性和扩展性。

所以ClassLoader在很多底层框架领域可谓是大放异彩,比如类隔离,OSGI,热部署,类字节码加密等。但同时,在ClassLoader看似简单的API下,同样也是危机四伏,稍有不慎,便会陷入荆棘遍布的陷阱中。

二. ClassLoader 核心API

ClassLoader 类核心API如下图所示

eU7r2a6.png!web
  • defineClass

    • 将byte字节流解析成Class对象

  • findClass

    • 在当前ClassLoader负责的层级内查找对应Class对象,而不会委托给父ClassLoader

//以URLClassLoader实现为例,其主要是在其加载的所有URL Jar包内(URLClassPath)内,查找是否存在对应的class文件

//如果存在,则调用defineClass进行字节码解析

protected Class<?> findClass(final String name)

throws ClassNotFoundException

{

final Class<?> result;

String path = name.replace('.', '/').concat(".class");

Resource res = ucp.getResource(path, false);

if (res != null) {

try {

return defineClass(name, res);

} catch (IOException e) {

throw new ClassNotFoundException(name, e);

}

} else {

return null;

}

}

  • loadClass

    • loadClass是Class加载的入口,负责在运行时加载指定的Class对象,而通过 ClassLoader#loadClass 或者 Class#forName 我们可以显示调用加载Class

//loadClass 的默认实现是一个典型的双亲委派模型实现,其先会尝试让父ClassLoader加载

//如果父ClassLoader加载不到,才会调用findClass在本ClassLoader进行加载

protected Class<?> loadClass(String name, boolean resolve)

throws ClassNotFoundException

{

synchronized (getClassLoadingLock(name)) {

// First, check if the class has already been loaded

Class<?> c = findLoadedClass(name);

if (c == null) {

try {

if (parent != null) {

c = parent.loadClass(name, false);

} else {

c = findBootstrapClassOrNull(name);

}

} catch (ClassNotFoundException e) {

// ClassNotFoundException thrown if class not found

}


if (c == null) {

// If still not found, then invoke findClass in order to find the class.

c = findClass(name);

}

}

if (resolve) {

resolveClass(c);

}

return c;

}

  • findResource

    • 作用类似于findClass,该方法主要处理资源的搜索加载,并返回完整的URL

//以URLClassLoader实现为例,其主要是在其加载的所有URLClassPath内

// 搜索对应的资源URL

public URL findResource(final String name) {

/*

* The same restriction to finding classes applies to resources

*/

URL url = AccessController.doPrivileged(

new PrivilegedAction<URL>() {

public URL run() {

return ucp.findResource(name, true);

}

}, acc);


return url != null ? ucp.checkURL(url) : null;

}

  • getResource

    • findResource实现逻辑可类比findClass,显然getResource同样可类比loadClass,getResource主要用于在整个ClassPath内加载某个资源文件,其默认实现同样遵循双亲委派模型

public URL getResource(String name) {

URL url;

if (parent != null) {

url = parent.getResource(name);

} else {

url = getBootstrapResource(name);

}

if (url == null) {

url = findResource(name);

}

return url;

}

三. 双亲委派模型

通过上面的代码,我们其实已经基本对双亲委派模型(Parents Delegation Model)有了认识(但其实明明是个单亲委派模型啊,每个ClassLoader最多只有一个parent)。双亲委派模型要求除了顶层的启动类加载器(Bootstrap ClassLoader)外,其余的类加载器都应当有自己的父类加载器。
双亲委派模型的工作原理是: 如果一个ClassLoader开始加载某个类(loadClass),它会首先委托给父ClassLoader去加载,这个过程是一个递归的过程,每个层级的ClassLoader都会逐级委托给其父 ClassLoader,直至Bootstrap ClassLoader。而只有当父ClassLoader加载不到该类时,才会交给子ClassLoader进行加载。
双亲委派模型的ClassLoader遵循以下三个准则:

  • Delegation ,委托性,即逐级委托给父ClassLoader

  • Visibility ,可见性,子ClassLoader可以感知到所有父ClassLoader加载的类,但是父ClassLoader感知不到子ClassLoader加载的类

  • Uniqueness , 唯一性,唯一性保证了一个Class最多只会被Load一次,如果父ClassLoader加载了该Class,子ClassLoader不会再尝试加载。

如下图是一个典型的双亲委派模型类加载层次关系图

3uEBNj7.png!web

四. java agent 类隔离机制

4.0 为什么需要类隔离

假设不进行类隔离,java agent依赖了apolloY-client,而应用层同样依赖了apolloY-Client。按照双亲委派模型,agent会直接使用AppClassLoader加载的apolloY相关类,而这个类来自于应用层classpath。当两个jar包版本完全一致时,肯定是相安无事,和平共处的。而一旦版本不一致,api不能够完全兼容时,agent直接使用应用层面的apolloY-client,则会使agent发生未知的错误。

4.1 如何进行类隔离

  • Step0. 自定义ClassLoader, 配置其负责的jar包URL

  • Step1. 注册其负责加载的Class, 将注册Class的类加载拦截在自定义ClassLoader,破坏双亲委派模型

  • 示例代码如下:

public class RouterClassLoader extends URLClassLoader {

private final ClassLoader parent;

private final RouterLibClass libClass;

//urls,设置为自定义ClassLoader所负责加载的JAR包URL

//parent,为系统CLassLoader,即AppClassLoader

//libClass,主要用于判断一个类是否属于该ClassLoader进行加载

public RouterClassLoader(URL[] urls, ClassLoader parent, RouterLibClass libClass) {

super(urls, parent);

if (parent == null) {

throw new NullPointerException("parent must not be null");

}

if (libClass == null) {

throw new NullPointerException("libClass must not be null");

}

this.parent = parent;

}


@Override

protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {

// First, check if the class has already been loaded

Class clazz = findLoadedClass(name);

if (clazz == null) {

if (libClass.hasClass(name)) {

//!!! 这里破坏了双亲委派模型,该ClassLoader不会先委派给父ClassLoder进行加载

//而是直接进行拦截判断,如果属于当前ClassLoader加载范围内,则直接findClass,进行加载

// load a class By itself

clazz = findClass(name);

} else {

try {

// load a class by parent ClassLoader

clazz = parent.loadClass(name);

} catch (ClassNotFoundException ignore) {

}

if (clazz == null) {

// if not found, try to load a class by itself

clazz = findClass(name);

}

}

}

if (resolve) {

resolveClass(clazz);

}

return clazz;

}

}

4.2 caesar-agent的两级ClassLoader

在说明caesar-agent ClassLoader机制前,首先先说明一下caesar-agent-router是什么。caesar-agent-router是caesar-agent的版本路由器,router本质上也是一个JavaAgent,通过动态配置来路由到真正的caesar-agent上。这也是为什么JVM参数中统一配置的是 -javaagent:/home/caesar-agent/caesar-agent-router-1.0.0.jar, 而不是真正的agent包。

6Rf2E32.png!web

所以再使用caesar-agent时,会存在两个自定义ClassLoader: RouterClassLoader和AgentClassLoader。两个ClassLoader由于都存在于javaAgent阶段,所以二者都直接破坏了双亲委派模型。

这里留个疑问,为什么RouterClassLoader/AgentClasaLoader一定要继承AppClassLoader ?不继承行不行?

五. 双亲委派模型对破坏者的惩戒

上文,我们已经了解到RouterClassLoader/AgentClassLoader如何对双亲委派模型进行破坏,实现类隔离。但生活往往不会一直按照剧本发展,我们还是时不时的感受到了双亲委派模型的反击。

案例1:双亲委派模型的惩戒

背景

Caesar Agent的第一次失利来自于log4j的同步锁的坑,于是我们毅然决定换成号称独霸于Slf4j实现类天下,使用Disruptor+AsyncLogger实现的log4j2。但是也就是在这里埋下了隐患。

uyQviee.png!web

案例分析

NoClassDefFoundError? 难道不是老相识ClassNotFoundException?二者看似相似,其实南辕北辙,相差甚远。ClassNotFoundException一般发生在编译时,而NoClassDefFoundError发生在运行时,当引用某个类或者继承某个类,依赖某个类时,会触发隐式类加载,当发现这个类不存在或者不可用时,JVM就会抛出NoClassDefFoundError错误。

仔细分析这个异常栈后,还是发现了一些猫腻。org.apache.logging.log4j.core.pattern.ThrowablePatternConverter 这个类理应由AgentClassLoader加载,却还是被AppClassLoader抛出了NoClassDefFoundError。

Rfi2EfN.png!web

所以问题就一目了然了,当AppClassLoader加载ExtendedWhitespaceThrowablePatternConverter时,触发了父ClassThrowablePatternConverter的加载,而ThrowablePatternConverter 类被AgentClassLoader拦截加载,由ClassLoader可见性原则可知,子ClassLoader中的Class对于父ClassLoader是不可见的,所以对于AppClassLoader来说ThrowablePatternConverter不可见,故而抛出了NoClassDefFoundError。
问题是梳理清楚了,但是问题的根源是为什么会触发ExtendedWhitespaceThrowablePatternConverter的类加载呢?根本原因在于log4j2的插件扩展注册器PluginRegistry#decodeCacheFiles 方法会通过Classloader#getResources(PluginProcessor.PLUGIN_CACHE_FILE)
加载出所有的插件class。而正是基于此,扫描出了应用层的SpringBoot Log4j2插件。

nY3euaI.jpg!web

解决方案

public Enumeration<URL> getResources(String name) throws IOException {

try {

//libClass.hasResource limit the resourceName,

// eg : "META-INF/org/apache/logging/log4j/core/config/plugins/Log4j2Plugins.dat",

// "org/slf4j/impl/StaticLoggerBinder.class"

if (libClass.hasResource(name)) {

return findResources(name);

}

} catch (IOException e) {

//Ignore

}

return super.getResources(name);

}

我们把AgentClassLoader#getResources方法的双亲委派模型同样进行破坏,只在Agent的Lib目录下进行资源搜索,从而可以避免应用层的Resource被搜索到,规避掉 ExtendedWhitespaceThrowablePatternConverter 的类加载。

案例2:SPI的惩戒

背景

上述Bug修复后,完美运行了一段时间,又是一个风和日丽的下午,我们吹着空调吃着火锅,突然Bug就又从天而降了。

fQBn63z.jpg!web

案例分析

又是log4j2哈,看起来还是熟悉的味道,那我们就用熟悉的配方吧?仔细分析,发现似乎这个异常又大不相同,这里真正报错的地方是在 java.util.ServiceLoader.LazyIterator#nextService
VvEJ7zZ.png!web

java.lang.Class#isAssignableFrom  是用来判断某个类是否是当前类的子类(或者同类)。而判别两个类关系或者对象与类关系,包括 isAssignableFrom() isInstance() instance of

等方法,都有一个前提,二者必须是被同一个ClassLoader加载,或者父子Class(实例)分别被父子ClassLoader加载。没有这个前提,那结果一定是false。

ProviderUtil 的构造器方法
63eIn2y.png!web

而这里有个致命的点 LoaderUtil.getClassLoaders() , 该方法会迭代把各个祖先ClassLoader都捞出来,甚至如果没有祖先,会主动捞ClassLoader#getSystemClassLoader 启动类ClassLoader。

ErQBr2q.png!web

然后拿着每个层级的ClassLoader去进行SPI加载,LoadProvider

aaENNve.png!web所以问题就是这里了, Provider.Class 是被当前上下文的ClassLoader(即AgentClassLoader)所加载的,而在SPI阶段主动进行类加载时(  c = Class.forName(cn, false, loader) ),却是拿着其他的ClassLoader(AppClassLoader)进行类加载。所以进一步在进行 isAssignableFrom 判断时,自然返回了false,从而抛出来 “not a subtype”

的错误。而触发这个Bug的一个前提是应用层使用了2.9.1以上版本的log4j2,会启用这个SPI特性。

解决方案

这里可以发现这是一个明显的Log4j2官方Bug,所以还是追溯一下官方的解决方案

  • https://github.com/powermock/powermock/issues/861(powermock较为详细的Bug记录)

  • https://issues.apache.org/jira/browse/LOG4J2-2055(尝试修复该Bug, 但是显然失败了)

  • https://jira.apache.org/jira/browse/LOG4J2-2327 (关于该Bug的讨论,且类似问题不止一处,涉及SPI的地方都触发了该Bug)

  • https://issues.apache.org/jira/browse/LOG4J2-2266(经OSGI资深玩家反馈,终于将此Bug修复)

可以看出出现这个问题的,都不是安静的玩家,比如OSGI用户,大家基本都对ClassLoader双亲委派模型进行了破坏。但是官方经过 四个版本 的解决方案似乎也残暴了一些,竟然只是try-catch了该异常,不过终归是规避了该Bug。所以我们的方案也是直接升级log4j2到2.11.2版本。

riyIJri.png!web

六. 总结

总的来看,驾驭ClassLoader似乎没有那么容易,我们在破坏双亲委派模型获取定制化自由的同时,仍然要看其眼色行事,不可肆意而为。而Log4j2的踩坑过程,更是给了我很大的启示,当我们设计一个底层服务和框架时,往往想要将其设计的更具扩展性和开放定制化能力,这件事本无可厚非,甚至值得嘉奖,但是你可能永远不够完全了解用户的使用方式,更多的自由意味着更大的风险。

期待下一个吹着空调,吃着火锅,从天而降的Bug吧。

作者简介

立源,2017年硕士研究生毕业于北京邮电大学,后加入网易严选。曾负责严选小程序服务端开发工作,见证了严选小程序的发展。2018年开始参与严选中间件体系和基础设施建设,参与负责了严选分布式配置中心,分布式多级缓存中间件,CI/CD体系,数据库数据迁移切换,服务端APM监控等多个中间件和基础设施项目。当前主要负责严选全链路大APM体系建设,完成了严选从大前端到服务端的全链路监控体系建设。

本文由作者授权严选技术团队发布

AzURRnZ.png!web

jyAn6vA.jpg!web


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK