ClassLoader踩坑实例现场

在本篇文章中,作者介绍了classloader的定义和核心api,以及内部的一些实现细节,并结合实例进行了分析;
一. ClassLoader 是什么
ClassLoader顾名思义就是类加载器,负责将字节码形式的Class数据流解析成内存形式的Class对象,加载到JVM中。而这样一个很核心很底层的重要组件,Java语言设计者却并没有将其完全放置于JVM内部实现,而是直接暴露在Java语言层面(java.lang.ClassLoader),这无疑使Java更具灵活性和扩展性。
所以ClassLoader在很多底层框架领域可谓是大放异彩,比如类隔离,OSGI,热部署,类字节码加密等。但同时,在ClassLoader看似简单的API下,同样也是危机四伏,稍有不慎,便会陷入荆棘遍布的陷阱中。
二. ClassLoader 核心API

- 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() { 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不会再尝试加载。

四. 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包。
所以再使用caesar-agent时,会存在两个自定义ClassLoader: RouterClassLoader和AgentClassLoader。两个ClassLoader由于都存在于javaAgent阶段,所以二者都直接破坏了双亲委派模型。
这里留个疑问,为什么RouterClassLoader/AgentClasaLoader一定要继承AppClassLoader ?不继承行不行?
五. 双亲委派模型对破坏者的惩戒
上文,我们已经了解到RouterClassLoader/AgentClassLoader如何对双亲委派模型进行破坏,实现类隔离。但生活往往不会一直按照剧本发展,我们还是时不时的感受到了双亲委派模型的反击。
案例1:双亲委派模型的惩戒
背景
Caesar Agent的第一次失利来自于log4j的同步锁的坑,于是我们毅然决定换成号称独霸于Slf4j实现类天下,使用Disruptor+AsyncLogger实现的log4j2。但是也就是在这里埋下了隐患。
案例分析
NoClassDefFoundError? 难道不是老相识ClassNotFoundException?二者看似相似,其实南辕北辙,相差甚远。ClassNotFoundException一般发生在编译时,而NoClassDefFoundError发生在运行时,当引用某个类或者继承某个类,依赖某个类时,会触发隐式类加载,当发现这个类不存在或者不可用时,JVM就会抛出NoClassDefFoundError错误。
仔细分析这个异常栈后,还是发现了一些猫腻。org.apache.logging.log4j.core.pattern.ThrowablePatternConverter 这个类理应由AgentClassLoader加载,却还是被AppClassLoader抛出了NoClassDefFoundError。
所以问题就一目了然了,当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插件。
解决方案
public Enumeration 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的惩戒
背景

案例分析
又是log4j2哈,看起来还是熟悉的味道,那我们就用熟悉的配方吧?仔细分析,发现似乎这个异常又大不相同,这里真正报错的地方是在
java.util.ServiceLoader.LazyIterator#nextService
java.lang.Class#isAssignableFrom
是用来判断某个类是否是当前类的子类(或者同类)。而判别两个类关系或者对象与类关系,包括
isAssignableFrom()
,
isInstance()
,
instance of
等方法,都有一个前提,二者必须是被同一个ClassLoader加载,或者父子Class(实例)分别被父子ClassLoader加载。没有这个前提,那结果一定是false。
ProviderUtil
的构造器方法
而这里有个致命的点
LoaderUtil.getClassLoaders()
, 该方法会迭代把各个祖先ClassLoader都捞出来,甚至如果没有祖先,会主动捞ClassLoader#getSystemClassLoader 启动类ClassLoader。
然后拿着每个层级的ClassLoader去进行SPI加载,LoadProvider
所以问题就是这里了,
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版本。
六. 总结
总的来看,驾驭ClassLoader似乎没有那么容易,我们在破坏双亲委派模型获取定制化自由的同时,仍然要看其眼色行事,不可肆意而为。而Log4j2的踩坑过程,更是给了我很大的启示,当我们设计一个底层服务和框架时,往往想要将其设计的更具扩展性和开放定制化能力,这件事本无可厚非,甚至值得嘉奖,但是你可能永远不够完全了解用户的使用方式,更多的自由意味着更大的风险。
期待下一个吹着空调,吃着火锅,从天而降的Bug吧。
作者简介
立源,2017年硕士研究生毕业于北京邮电大学,后加入网易严选。曾负责严选小程序服务端开发工作,见证了严选小程序的发展。2018年开始参与严选中间件体系和基础设施建设,参与负责了严选分布式配置中心,分布式多级缓存中间件,CI/CD体系,数据库数据迁移切换,服务端APM监控等多个中间件和基础设施项目。当前主要负责严选全链路大APM体系建设,完成了严选从大前端到服务端的全链路监控体系建设。
本文由作者授权严选技术团队发布