记一次java.lang.ClassNotFoundException问题排查过程

 同事提供一个or-simulation-engine.jar包(非maven项目,内部依赖很多其他jar,这个包是手动打出来的)给我,我集成到我的springboot项目中,在本地IDEA启动Springboot后,相关功能都是正常的;但是将Springboot项目打成app.jar后,使用java -jar app.jar方式启动后,运行时爆出java.lang.ClassNotFoundException: com.anylogic.libraries.modules.markup_descriptors.DescriptorFactory

为什么IDEA可以执行,打成jar包使用java -jar就执行不了呢?

以下内容使用java -jar形式测试。

一、代码定位

jar包依赖关系:我的app.jar依赖第二方or-simulation-engine.jar,而or-simulation-engine.jar依赖第三方com.anylogic.engine.jar

通过分析代码得知,app.jar包在运行时,调用了如下代码:

String name = "com.anylogic.libraries.modules.markup_descriptors.DescriptorFactory";
ClassLoader systemCL = ClassLoader.getSystemClassLoader();
Class clazz = systemCL.loadClass(name);

这个代码是anylogic的jar包:com.anylogic.engine.jar中的内容。

具体异常信息:

java.lang.ClassNotFoundException: com.anylogic.libraries.modules.markup_descriptors.DescriptorFactory
        at java.net.URLClassLoader.findClass(URLClassLoader.java:382)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
        at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:349)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
        at com.yonghui.or.simulation.controller.CoreController.test(CoreController.java:99)
        at com.yonghui.or.simulation.controller.CoreController$$FastClassBySpringCGLIB$$6a496143.invoke(<generated>)
        at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218)
        at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.invokeJoinpoint(CglibAopProxy.java:749)

通过异常信息可以看出:ClassLoader.getSystemClassLoader()获得的是AppClassLoader,但是AppClassLoader并没有在对应的路径下加载到该类。但是该类确实是存在的,而且通过new或者Class.forname()都是可以找到该类的。

可以看到,or-simulation-engine.jar手动打完包后,内部依赖的jar都被放到了一起,不是以jar包的方式存在的。

这可能是非maven项目or-simulation-engine手动打包有问题,导致集成到springboot项目打成app.jar后找不到该类了。

二、确定使用的ClassLoader

那么异常中的这个类com.anylogic.libraries.modules.markup_descriptors.DescriptorFactory应该使用哪个classloader加载呢?

可以使用jvm调优工具arthas,找到app.jar进程后,输入sc -d com.anylogic.libraries.modules.markup_descriptors.DescriptorFactory命令,查看该类使用的类加载的情况:

可以看到这里使用的是LaunchedURLClassLoader。而该加载器的上级才是AppClassLoader。

三、spring-boot-maven项目类加载原理

app.jar是通过spring-boot-maven-plugin这个插件生成的, app.jar中依赖的各个jar文件其实并不在运行时应用的classpath下(实际在app.jar/BOOT-INF/lib下存放所有依赖的jar包),也就是根据类加载的双亲委派机制,这些依赖没办法被默认的任何一个classloader加载,Springboot为了解决这个问题,自定义了类加载机制,LaunchedURLClassLoader就是Springboot自定义的类加载器。

app.jar解压后可以看到内部结构:

其中,app.jar/BOOT-INF/classes下放我们自己写的代码编译的class;app.jar/BOOT-INF/lib - 存放所有依赖的jar包;

查看app.jar/META-INFO/MANIFEST.MF的内容,这个文件有app.jar这个包加载过程需要的相关信息,包括Main-class、Start-calss、Spring-Boot-Classes、Spring-Boot-Lib等相关信息。可以看到主类class是org.springframework.boot.loader.JarLauncher。这个主类是spring-boot-loader包中,这个类最后创建的就是LaunchedURLClassLoader。

关于Springboot的类加载可以查看spring-boot-loader包:

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-loader</artifactId>
            <scope>Provided</scope>
        </dependency>

找到spring-boot-loader包中的org.springframework.boot.loader.JarLauncher。可以看到有两个静态变量BOOT_INF_CLASSES和BOOT_INF_LIB,表示class和库的路径;这里有个main方法入口:

打开launch方法:

可以看到最终创建的就是LaunchedURLClassLoader。

通过launch方法把LaunchedURLClassLoader加入到当前线程的上下文中Thread.currentThread().setContextClassLoader(classLoader)

Thread.currentThread().setContextClassLoader(loader)方法用于设置当前线程的上下文类加载器,即把给定的ClassLoader设置为当前线程的上下文类加载器。上下文类加载器是一个ClassLoader,它是由线程的创建者设置的,并且在线程中保持不变。如果没有显式地设置上下文类加载器,那么线程将继承其创建者的上下文类加载器。

LaunchedURLClassLoader的作用是加载app.jar这样的目录结构中的class,能够找到要加载的class(比如BOOT-INF目录中的class或jar),并且加载他们。

LaunchedURLClassLoader继承了java.net.URLClassLoader,自己实现了loadClass方法,并且在自己的loadClass方法中还是调了super.loadClass,也就是java.lang.ClassLoader#loadClass,这其实又回到了双亲委派机制:

LaunchedURLClassLoader是怎么加载我们在springboot应用中定义的mainclass,也就是app.jar应用的入口程序呢? 在org.springframework.boot.loader.MainMethodRunner中,通过LaunchedURLClassLoader load并且通过反射调用了main 方法。

其中mainClassName就是从MANIFEST.MF中找到的Start-Class对应的类名:

四、IDEA和java -jar运行Springboot项目的区别

Java -Jar是用LaunchedURLClassLoader来加载class和依赖jar包的。而在IDE中则是直接以ApplicationClassLoader来加载的。

通过IDEA和java -jar运行分别运行下面代码,可以看到application加载路径是不一样的,IDEA的路径包括jre/lib、jre/lib/ext、本地maven仓库、本地项目路径等,这些路径正好能被IDEA的默认类加载器ApplicationClassLoader加载到。

而使用LaunchedURLClassLoader加载器的java -jar app.jar方式启动,是能够加载读取app.jar/BOOT-INF/classes和app.jar/BOOT-INF/lib路径的class的;但是app.jar内部的class如果再自定义使用ApplicationClassLoader加载app.jar/BOOT-INF内的class就会出现找不到class的情况;这时ApplicationClassLoader的application加载路径只有app.jar。

        System.out.println("不同类加载器加载的路径");
        System.out.println("bootstap加载路径: " + System.getProperty("sun.boot.class.path"));
        System.out.println("extantion加载路径: " + System.getProperty("java.ext.dirs"));
        System.out.println("application加载路径: " + System.getProperty("java.class.path"));

五、问题确认

通过上面的分析,可以确认问题的原因: app.jar依赖第二方or-simulation-engine.jar,而or-simulation-engine.jar依赖第三方com.anylogic.engine.jar。第三方的jar包为了代码安全,给代码做了相关的混淆等操作后,在代码运行时,使用动态加载ClassLoader.getSystemClassLoader()动态加载自己的一个类;当所有的代码集成到springboot项目并用springboot-maven插件打包后,ClassLoader.getSystemClassLoader()就找不到对应的类了。

六、解决方法

找到问题后,就可以针对性解决问题了。有两种方式:

1.改变打包方式,让打包后的代码能被ClassLoader.getSystemClassLoader()这个classLoader找到;

2.修改第三方com.anylogic.engine.jar,将类加载器改成LaunchedURLClassLoader。

这两种方式的目的都是class文件放到所使用的类加载器对应的路径下。

方式一

将打包方式改成:

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>2.6</version>
                <configuration>
                    <archive>
                        <manifest>
                            <addClasspath>true</addClasspath>
                            <classpathPrefix>lib/</classpathPrefix>
                            <mainClass>com.example.helloloader.HelloLoaderApplication</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-dependency-plugin</artifactId>
                <version>2.10</version>
                <executions>
                    <execution>
                        <id>copy</id>
                        <phase>package</phase>
                        <goals>
                            <goal>copy-dependencies</goal>
                        </goals>
                        <configuration>
                            <outputDirectory>
                                ${project.build.directory}/lib
                            </outputDirectory>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

方式二

修改第三方com.anylogic.engine.jar,将类加载器改成LaunchedURLClassLoader。其实就是修改一行代码:

将:

ClassLoader systemCL = ClassLoader.getSystemClassLoader();

修改成:

 ClassLoader systemCL = Thread.currentThread().getContextClassLoader();

Thread.currentThread().getContextClassLoader()的意义是:父Classloader可以使用当前线程上下文中指定的classloader中加载的类。颠覆了父ClassLoader不能使用子Classloader或者是其它没有直接父子关系的Classloader中加载的类这种情况。它是由线程的创建者设置的,并且在线程中保持不变。如果没有显式地设置上下文类加载器,那么线程将继承其创建者的上下文类加载器。

这样app.jar在运行时就可以获得LaunchedURLClassLoader。

这种方式需要反编译,如果将第三方com.anylogic.engine.jar整体反编译,部分class会编译失败,修改代码后也很难再编译成功。

这里有个简单的方式:新建一个空maven项目,在pom中引入本地的com.anylogic.engine.jar。然后按照要修改的class文件在第三方jar包内的包名,在该空项目中建相同的包和类名(com.anylogic.engine.markup.descriptors.IDescriptorFactory),并将反编译后的内容放入这个类中,再修改掉对应的一行代码。通过mvn clean packge重新打包,在target目录下找到这个IDescriptorFactory.class文件。

然后用这个IDescriptorFactory.class替换掉第三方com.anylogic.engine.jar所对应的IDescriptorFactory.class.

如何替换?

首先解压:

 jar -xvf com.anylogic.engine.jar

然后找到并替换掉IDescriptorFactory.class

最后成jar包

 jar cvfM  com.anylogic.engine.jar ./

将新的jar包集成到项目中后就可以启动了。

参考:

The Executable Jar Format

使用spring-boot-maven-plugin插件将本地jar包打入包中_诸葛小猿的博客-CSDN博客

记一次springboot项目结合arthas排查ClassNotFoundException问题_spring boot arthas_linyb极客之路的博客-CSDN博客

聊一聊Springboot的类加载机制 - 简书

  • 3
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值