Springboot上运行javaagent时出现NoClassDefFoundError错误的分析和解决

avatar
SE

一、问题背景

详情见-> springboot中拦截并替换token来简化身份验证

二、问题概述

1.报错信息

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'methodValidationPostProcessor' defined in class path resource [org/springframework/boot/autoconfigure/validation/ValidationAutoConfiguration.class]: Unsatisfied dependency expressed through method 'methodValidationPostProcessor' parameter 0; nested exception is org.springframework.beans.factory.CannotLoadBeanClassException: 
Error loading class [com.wingli.agent.helper.util.SpringContextHolder] for bean with name 'com.wingli.agent.helper.util.SpringContextHolder': problem with class file or dependent class; nested exception is 
java.lang.NoClassDefFoundError: org/springframework/context/ApplicationContextAware
        at ......
Caused by: java.lang.ClassNotFoundException: org.springframework.context.ApplicationContextAware
        at java.net.URLClassLoader.findClass(URLClassLoader.java:382) ~[?:1.8.0_251]
        at java.lang.ClassLoader.loadClass(ClassLoader.java:418) ~[?:1.8.0_251]
        at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:355) ~[?:1.8.0_251]
        at java.lang.ClassLoader.loadClass(ClassLoader.java:351) ~[?:1.8.0_251]
        at java.lang.ClassLoader.defineClass1(Native Method) ~[?:1.8.0_251]
        at java.lang.ClassLoader.defineClass(ClassLoader.java:756) ~[?:1.8.0_251]
        at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142) ~[?:1.8.0_251]
        at java.net.URLClassLoader.defineClass(URLClassLoader.java:468) ~[?:1.8.0_251]
        at java.net.URLClassLoader.access$100(URLClassLoader.java:74) ~[?:1.8.0_251]
        at java.net.URLClassLoader$1.run(URLClassLoader.java:369) ~[?:1.8.0_251]
        at java.net.URLClassLoader$1.run(URLClassLoader.java:363) ~[?:1.8.0_251]
        at java.security.AccessController.doPrivileged(Native Method) ~[?:1.8.0_251]
        at java.net.URLClassLoader.findClass(URLClassLoader.java:362) ~[?:1.8.0_251]
        at java.lang.ClassLoader.loadClass(ClassLoader.java:418) ~[?:1.8.0_251]
        at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:355) ~[?:1.8.0_251]
        at java.lang.ClassLoader.loadClass(ClassLoader.java:405) ~[?:1.8.0_251]
        at org.springframework.boot.loader.LaunchedURLClassLoader.loadClass(LaunchedURLClassLoader.java:94) ~[study-minder.jar:?]
        at java.lang.ClassLoader.loadClass(ClassLoader.java:351) ~[?:1.8.0_251]
        at org.springframework.util.ClassUtils.forName(ClassUtils.java:251) ~[spring-core-4.3.20.RELEASE.jar!/:4.3.20.RELEASE]
        at ......

2.依赖关系

I.背景知识

  • java会默认把javaagent的jar添加到AppClassLoader的搜索路径中
  • Springboot插件打包的jar的结构,即jar-in-jar/nested-jars
  • Springboot插件打包的jar启动的大致流程及其使用的LaunchedURLClassLoader

详见:java中常见jar包形式的联系和结构解析

II.报错的类关系及其ClassLoader分析

image.png

为了方便区分描述:
javaagent本身的jar包称为 agent jar
Springboot打包的jar包称为springboot-application jar

三、问题排查

1.NoClassDefFoundErrorClassNotFoundException 是什么错误?

Tips:这里特别注意一下 ClassNotFoundExceptionNoClassDefFoundError 区别,前者是找不到类,后者是类加载过程出现错误(如静态代码块执行失败),导致加载失败。

所以翻译过来报错的原因是: 在加载 com.wingli.agent.helper.util.SpringContextHolder 的时候,找不到 org.springframework.context.ApplicationContextAware 类。

2.org.springframework.context.ApplicationContextAware 真的不在吗?

这时候就要祭出Arthas了,一看便知: image.png 可以清晰看到,这个类是有加载了的。
那为啥在加载 com.wingli.agent.helper.util.SpringContextHolder 的时候会报 ClassNotFoundException 呢?

3.既然类加载过程报错,那类加载时候干了啥?

Tips:因为jar-in-jar/nested-jars使用的是自定义的classloader并对启动过程有包装,所以要加下 spring-boot-loader 的 pom, 可以方便debug

从上边的报错caused可以看到,org.springframework.util.ClassUtils#forName 处进入了类加载的逻辑,那就在这里开始打条件断点:

a.入口类加载器:LanuchedURLClassLoader

image.png

b.委托父加载器:AppClassLoader

image.png

c.委托父加载器:ExtClassLoader

image.png

d.委托父加载器:BoostrapClassLoader

image.png

e.BoostrapClassLoader失败,ExtClassLoader自行加载

image.png

f.ExtClassLoader失败,AppClassLoader自行加载

image.png

Tips:AppClassLoader默认会将agentjar添加到类搜索路径 image.png

g.AppClassLoader能搜索到类,但是defineClass失败了

image.png defineClass抛出了上边最开始的caused:ClassNotFoundException: org.springframework.context.ApplicationContextAware

4.整理分析

由上边类加载过程可以知道,错误抛出的原因是:AppClassLoader尝试define(区别于Not Found)类com.wingli.agent.helper.util.SpringContextHolder的时候,找不到 org.springframework.context.ApplicationContextAware类。

5.为什么AppClassLoader找不到org.springframework.context.ApplicationContextAware呢?

org.springframework.context.ApplicationContextAware是spring的依赖包中的类,通过上边的分析可以看到 AppClassLoader 的搜索路径只有 springboot-application jaragent jar,从类关系图里边可以看到,org.springframework.context.ApplicationContextAware是在spring-context-4.3.20.RELEASE.jar中,而该jar则是以jar in jar的方式包含在springboot-application jar 里边,而AppClassLoader没有将jar in jar的URL添加到自己的类搜索路径中,因此找不到该类。

6.Arthas看到的是什么?

在前面我们使用Arthas的sc命令看到了 org.springframework.context.ApplicationContextAware ,难道是假的?
当然不是,只是它不是 AppClassLoader 加载的,而是 LanuchedURLClassLoader 加载的,该类加载器是 AppClassLoader 的子类加载器,所以AppClassLoader无法直接使用该类。 使用 sc -d org.springframework.context.ApplicationContextAware 可以看到详细的加载信息 image.png

7.idea run为何没有问题?

因为idea默认不会以springboot-application jar的方式启动应用,所以它没有使用LanuchedURLClassLoader,也没有对启动过程进行包装,而应用本身的依赖(如spring)和agent jar都在AppClassLoader的路径中,不存在jar in jar的依赖形式,所以不会出现父子类加载器的问题。 image.png

四、问题分析

一图胜千言: image.png

五、解决方案

既然成因已明了,那么解决方法也呼之欲出!
要避免这个问题,就要区分好各层级类加载器需要加载的类,由于 org.springframework.context.ApplicationContextAware 存在于springboot-application jar中,并且只能由 LanuchedURLClassLoader 进行加载,所以解决这个问题的关键就是让 LanuchedURLClassLoader 去加载com.wingli.agent.helper.util.SpringContextHolder

1.分离依赖【污染代码】

把 javaagent 中的需要spring加载的类分离出来,独立成一个pom依赖,然后让项目加上这个依赖,这样这个依赖就会只被打包到springboot-application jar中,而且javaagent中只做字节码修改的操作,在触发bean加载的时候,就能全部由 LanuchedURLClassLoader 进行加载,不会出现类加载问题。

Tips:实际上如果需要加载的bean需要在线上使用,那么这样做是非常必要且合理的!

2.拦截部分类的加载过程并打破双亲委派机制 【不彻底】

既然双亲委派机制会让 AppClassLoader 尝试加载 com.wingli.agent.helper.util.SpringContextHolder 类,那针对这个类打破这个规则就好了!将agent jar添加到LanuchedURLClassLoader的搜索路径, 当 LanuchedURLClassLoader 遇到 com.wingli.agent.helper.util.SpringContextHolder 类时,直接由自身进行类加载,不走双亲委派。 示意: image.png

Tips: com.wingli.agent.helper.util.SpringContextHolderAppClassLoader仍然是可见的!

3.javaagent中强制使用LanuchedURLClassLoader提前加载com.wingli.agent.helper.util.SpringContextHolder【不彻底】

与上一种方法类似,只是加载的地方放到的javaagent的逻辑里边,并且不需要修改classloader的类搜索路径,只需要使用javassist.CtClass#toClass()来强制打破双亲委派机制,让LanuchedURLClassLoader加载com.wingli.agent.helper.util.SpringContextHolder类,由于已经加载过的类会被缓存起来,下次触发加载的时候会直接读取缓存,不会再触发类搜索,自然也不会走双亲委派。但是 com.wingli.agent.helper.util.SpringContextHolderAppClassLoader也仍然是可见的!

4.分离依赖并利用jar in jar特性控制classloader对依赖的可见性【就你了】

充分利用AppClassLoader不读取jar in jar中的类,而LanuchedURLClassLoader可以读取jar in jar中的类的这个特性,加上合理分离依赖,就能更优雅地解决这个问题。

六、实现步骤

由于javaagent打包出来肯定是一个jar包,所以我们期待加入到项目中的依赖也必须放到该jar包中,但是可以有两种方式,一种是jar-with-dependencies方式(旧实现),只要Classloader有添加该jar路径,就能读取依赖,一种是jar in jar的方式(将要实现的方式),Classloader需要指定路径(特殊获取的URL)才能读取依赖。

1.修改javaagent中逻辑和依赖关系

I.旧实现的示意图(所有类放到一起)

image.png

II.新实现示意图(按照Classloader期待的可见性对类和依赖进行分包)

image.png

Tips:Transform 中类是由AppClassLoader加载的,所以它的依赖也会由AppClassLoader加载(即使当前线程的ContextLoader是LaunchedURLClassLoader),因此 transform 模块不能直接依赖 helper 模块,只能用反射,或者在插桩的时候使用。

2.修改javaagent打包逻辑

I.旧jar包示意图(所有类和依赖都解压到jar的顶级目录中)

image.png

II.新jar包示意图(分包并采用jar in jar组合到一起)

将transform模块以jar-with-dependencies方式进行打包,而 将 helper模块 以jar in jar形式打包进transform.jar中。

image.png

3.让transorm.jar中的helper.jar对LaunchedURLClassLoader可见

将helper.jar以jar in jar形式放入tramsform.jar中使得AppClassLoaer不可加载helper的类,成功了一半,还需要让LaunchedURLClassLoader可以加载helper的类,为此,需要在代码的某处生成helper.jar的特殊URL,并添加到LaunchedURLClassLoader的类加载路径中。

I.拦截点的选择

  • 必须在使用到helper.jar中的类之前进行添加
  • 仅添加一次
  • 触发类加载时ClassLoader必须为LaunchedURLClassLoader 本文选择的是:org.springframework.boot.SpringApplication,该类是Springboot应用启动类 image.png

II.代码实现

在加载org.springframework.boot.SpringApplication时,tranform中调用该方法即可

private void appendAgentNestedJars(ClassLoader classLoader) {
    String agentJarPath = getAgentJarPath();
    if (agentJarPath == null) return;

    //LaunchedURLClassLoader 是属于 springboot-loader 的类,没有放到jar in jar里边,所以它是被AppClassLoader加载的
    if (classLoader instanceof LaunchedURLClassLoader) {
        LaunchedURLClassLoader launchedURLClassLoader = (LaunchedURLClassLoader) classLoader;
        try {
            Method method = URLClassLoader.class.getDeclaredMethod("addURL", URL.class);
            method.setAccessible(true);
            //遍历 agent jar,处理所有对应目录下的jar包,使用 JarFileArchive 获取到的url才可以处理jar in jar
            JarFileArchive jarFileArchive = new JarFileArchive(new File(agentJarPath));
            List<Archive> archiveList = jarFileArchive.getNestedArchives(new Archive.EntryFilter() {
                @Override
                public boolean matches(Archive.Entry entry) {
                    if (entry.isDirectory()) {
                        return false;
                    }
                    return entry.getName().startsWith("BOOT-INF/lib/") && entry.getName().endsWith(".jar");
                }
            });
            for (Archive archive : archiveList) {
                method.invoke(launchedURLClassLoader, archive.getUrl());
                System.out.println("add url to classloader. url:" + archive.getUrl());
            }
        } catch (Throwable t) {
            t.printStackTrace();
        }
    }


    System.out.println("trigger add urls to classLoader:" + classLoader.getClass().getName() + " agentJarPath:" + agentJarPath);

}

七、结果验证

1.最终打包的jar结构

image.png

2.插桩情况

image.png

3.ClassLoader的类搜索路径

image.png

4.ClassLoader对类的可见性

  • LaunchedURLClassLoader可以加载helper中的类 image.png

  • AppClassLoader不可以加载helper中的类 image.png

5.jar in jar中Bean加载情况

image.png

八、代码

1.github仓库

github.com/isadliliyin…

2.分支说明:

  • default-dependency 为旧的实现方式
  • split-dependency 为新的实现方式

END:不正之处,欢迎交流!