在项目开发过程中遇到一个类加载过程的异常,引发一些列思考和基础知识的回顾,记录如下。
1 背景
本地同时依赖athena-jdbc和aws-java-sdk-core
...
<dependency>
<groupId>com.amazon.athena</groupId>
<artifactId>athena-jdbc</artifactId>
<version>42-2.0.35.1000</version>
</dependency>
...
<!-- s3依赖 -->
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-s3</artifactId>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-core</artifactId>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-kms</artifactId>
</dependency>
1.1 项目启动和跑单测的时候报错:
...
Caused by: java.lang.SecurityException: class "com.amazonaws.auth.AWSCredentialsProviderChain"'s signer information does not match signer information of other classes in the same package
at java.lang.ClassLoader.checkCerts(ClassLoader.java:891)
at java.lang.ClassLoader.preDefineClass(ClassLoader.java:661)
at java.lang.ClassLoader.defineClass(ClassLoader.java:754)
at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:468)
at java.net.URLClassLoader.access$100(URLClassLoader.java:74)
at java.net.URLClassLoader$1.run(URLClassLoader.java:369)
at java.net.URLClassLoader$1.run(URLClassLoader.java:363)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:362)
at java.lang.ClassLoader.loadClass(ClassLoader.java:418)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:352)
at java.lang.ClassLoader.loadClass(ClassLoader.java:351)
at com.amazonaws.regions.AwsRegionProviderChain.(AwsRegionProviderChain.java:33)
... 72 more
1.2 预演环境不报错
预演使用的是springboot jar。
2基础回顾
根据错误信息,知道这个错误是类加载过程中包签名机制导致的问题,先回顾一下类加载和包签名机制。
2.1类加载
简单来说:类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。
经过类加载这个过程后,我们才能在程序中构建这个类的实例对象,并完成对象的方法调用和操作。
2.1.2 基本的工作原理
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。它们的顺序如下图所示:
2.1.2 类加载时机
- 创建类的实例时(使用
new关键字) - 访问类的静态成员(字段或方法)时
- 使用反射(Reflection)时
- 初始化子类时,父类也会被加载
- JVM启动时,指定的主类会被加载
使用动态代理或某些框架(如Spring)时
2.1.3双亲委派机制
2.1.3 classpath 后的jar顺序对类加载的影响
JVM 会按照 classpath 中指定的路径顺序查找和加载类,如果多个 jar 包中包含同名的类(即全限定类名相同),JVM 会优先加载第一个遇到的类,后续同名类会被忽略。
例如,假设 classpath 顺序为 lib/a.jar:lib/b.jar,且 a.jar 和 b.jar都包含 com.example.MyClass 类,那么 JVM 会加载 a.jar 中的 MyClass,而 b.jar 中的版本不会被使用。
2.2包签名
包签名机制是为了保证jar包的安全和完整性,是java安全模型的实践之一,它和三个文件有关
MAINFEST.MF:清单文件,定义了JAR包的元信息,包括主类、包名和签名相关属性
签名文件(.SF文件):是MANIFEST.MF的摘要文件,其内容基于清单计算得出
签名块文件(.DSA或.RSA文件)是数字签名的核心载体,存储了签名者的证书和加密后的摘要
这里只做简单介绍,知道包签名是做什么的就行。
3 本地报错处理
3.1问题分析
错误说的是AWSCredentialsProviderChain类和同包下其它类的签名不一样,看起来像是jar冲突(jar冲突一般报错java.lang.ClassNotFoundException,java.lang.NoSuchMethodError, java.lang.NoClassDefFoundError,java.lang.LinkageError等, SecurityException是第一次遇到)
全局搜索发现确实是athena-jdbc和aws-java-sdk下有相同的AWSCredentialsProviderChain类,但是在不同的包下:
/Users/pb/work/repo/com/amazonaws/aws-java-sdk-core/1.12.261/aws-java-sdk-core-1.12.261.jar!/com/amazonaws/auth/AWSCredentialsProviderChain.class
/Users/pb/work/repo/com/amazon/athena/athena-jdbc/42-2.0.35.1000/athena-jdbc-42-2.0.35.1000.jar!/com/simba/athena/amazonaws/auth/AWSCredentialsProviderChain.class
根据错误提示可以排除 jdbc的AWSCredentialsProviderChain影响 core的AWSCredentialsProviderChain。
接着,发现athena-jdbc下也有com/amazonaws/auth包,并且包下的类都有签名,而core的相同包下类是没有签名的。
直接定位报错的到java.lang.ClassLoader.checkCerts方法,发现在校验证书的时候会把这个包名下的所有证书存到Map缓存中,如果存在相同包名,但是证书或签名不同就会抛异常
class ""+ name +
""'s signer information does not match signer information of other classes in the same package
断点调试后,确实是jdbc的com/amazonaws/auth包的签名先加载到Map中了,然后在验证sdk的AWSCredentialsProviderChain的签名时抛出异常,类加载失败。\
简单总结如下:
JVM在类加载时会检查证书签名。
类加载时的签名检查机制
证书验证:ClassLoader 在定义类时会调用 checkCerts 方法验证类的签名证书
包一致性检查:确保同一包内的所有类都来自相同的代码源和签名者,注意检查维度是包
安全策略执行:这是JVM安全模型的一部分,防止恶意代码混入受信任的包中
检查时机:
类定义阶段:当 ClassLoader.defineClass 被调用时
预定义检查:在 preDefineClass 方法中进行初步验证
证书匹配:确保新加载的类所在包的签名与已加载的同名包具有相同的签名信息
异常情况:
当出现签名不匹配时,会抛出 java.lang.SecurityException。这种机制保护了Java运行时环境的安全性,防止不同来源的代码在同一个包命名空间内产生冲突。
3.2问题解决
问题解决其实很简单,但是有误打误撞的成分...
在maven依赖中把athena-jdbc和aws-java-sdk的依赖位置换了一下
...
<!-- s3依赖 -->
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-s3</artifactId>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-core</artifactId>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-kms</artifactId>
</dependency>
<dependency>
<groupId>com.amazon.athena</groupId>
<artifactId>athena-jdbc</artifactId>
<version>42-2.0.35.1000</version>
</dependency>
...
问题解决了,条件断点调试,在验证sdk的AWSCredentialsProviderChain签名时,sdk的证书为空,缓存中同包下的证书也为空,通过校验。
3.3 依赖声明顺序带来的影响
为什么换一下依赖申明的顺序就可以解决问题,改变顺序会带来哪些影响?
3.3.1 maven依赖原则
我们知道maven依赖有两个基本原则:
(1)最短路径原则
如下图,项目同时依赖C、A,C依赖B,B又依赖A,那么根据最短路径原则,项目最终依赖A-api-2.1
(2) 最先声明原则
如下图,项目同时依赖C、B,C依赖A,B也依赖A,那么根据最先声明原则,项目最终依赖A-api-2.1
显然,这两个原则都跟错误无关,因为出问题的类是jdbc和sdk包下的核心类,不是它们的依赖包的类。
3.3.2 classpath的顺序
还会影响本地idea运行时classpath的顺序
如下图,左图是pom依赖顺序改变前的classpath相对顺序,右图是改变后的顺序。
sdk core刚好有jdbc com/amazonaws/auth包下的同名类,sdk core在前,全限定类名相同的先加载,所以根本不会加载jdbc该包下的接口,刚好解决了签名问题。
4 预演环境不报错
预演环境是用的springboot jar包,springboot包是有自己的包结构,和普通的jar不一样,打开springboot jar看看,也可以直接uzip解压,第三方依赖是放在BOOT-INF下的lib下面,解压后发现jdbc的顺序是在前面的(文件名顺序),如果是按照文件名的顺序那么也应该出现问题的。
那现在有两种可能:
(1)springboot实际加载的顺序不是文件名的顺序,并且sdk先加载
(2)sdk是先加载,但是签名机制被破坏了
4.1springboot加载器
因为springboot的jar包结构特殊,所以springboot实现了自己的加载器,加载器是org.springframework.boot.loader.LaunchedURLClassLoader,它继承了URLClassLoader类。
另外,我们可以打开MANIFEST.MF文件,能看到springboot jar的实际启动类是org.springframework.boot.loader.JarLauncher。
当我们java -jar 启动springboot jar时首先进入这个启动类,完成类加载器初始化、资源的加载等准备工作后再反射调用我们的项目启动类。
4.2springboot对第三方依赖的加载顺序
直接调试springboot的加载过程能最直观看到第三方依赖的加载。
4.2.1 springboot加载器调试
springboot加载器调试需要使用idea的远程调试功能。
(1)在项目中添加springboot加载器的依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-loader</artifactId>
<version>2.2.0.RELEASE</version>
</dependency>
(2)idea开启远程调试
运行/调试配置-》编辑配置-〉添加新配置-》远程JVM调试
(3)本地打出项目的jar包,可以是(1)之前的jar包
(4)执行下面命令
java -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005 -jar your-project.jar
(5)点击idea的调试按钮
可以看到,加载的顺序和文件名顺序是不一样的。最后通过查找资料,springboot第三方依赖的加载顺序和maven的打包顺序一致,但由于不是官方的资料,我们验证一下。
验证方法很多,最简单可以打包的时候加载-X参数,mvn -X package -DskipTests 就可以看到打包的顺序了,麻烦一点可以直接调试打包代码,打包插件调试方法如下
4.2.2 maven打包插件调试
(1)添加打包插件依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.1.18.RELEASE</version>
<scope>provided</scope>
</dependency>
(2)idea package-》调试xxx
可以验证springboot第三方依赖的加载顺序和maven的打包顺序一致。
maven打包的顺序和pom依赖申明的顺序有关,但是具体如何对应的,笔者没做跟深入的研究,有兴趣的读者可以自己研究。
4.2.3 总结
言归正传,通过前面的调试,可以得出结论:springboot加载第三方依赖的顺序和springboot maven打包插件打包顺序一致,而这个顺序和pom依赖的声明顺序有关,并且调试结果也可以看出先加载的是jdbc。
第一点猜想(springboot实际加载的顺序不是文件名的顺序,并且sdk先加载)可以否决了。
另外 其实有更加简单的方法证明预演环境虚拟机中加载的是jdbc,直接通过arthas查看线上这四个接口的加载信息即可,当然前提是线上已经安装了arthas工具。
4.3 签名被破坏了
现在验证第二点猜想。
同样开启远程调试,断点最开始报错checkCerts方法,发现jdbc的证书为空了,确实被破坏了。。。
springboot是如何破坏这个证书的,自顶向下根据源码分析。
(1)CodeSource对象里记录了证书信息,首先找到它的来源。
本地报错和远程调试两个场景下,CodeSource来源是URLClassLoader#findClass下的ucp.getResource方法
(2)进入getResource方法
报错场景进入的是URLClassPath**JarLoader**#getResource,返回checkResource方法是URLClassPathJarLoader#getResource->checkResource,这个方法返回Resource对象有get证书和签名的方法
远程jar包调试进入的是URLClassPath$Loader#getResource,并且直接返回new Resource,没有证书和签名返回方法。
(3)URLClassPath#getLoader
两种方式URLClassPath给jdbc的loader类型不同
报错场景:
jar调试场景:
URLClassPath是根据URL#getFile()来设置loader类型的,这里的调试可以到jdbc的URL file属性赋值是以 !/ 结尾,导致URLClassPath给它设置成Loader类型,而不是JarLoader类型,最终导致证书失效。
(4)springboot Archive
我们再看看springboot 是怎么给jdbc的URL file属性赋值的。
Archive是springboot抽象的资源接口,jar包资源类型就是JarFileArchive,getUrl方法如下,最终得到上面(3)jar调试的jdbc的url。
5 总结
1.jar包签名是为了保护jar包的完整性和安全性,是java安全模型实践之一
2.包的签名是会在第一个该包下的类触发加载时存到缓存,后续记载同包名下其它类时会做校验
3.不同jar有相同包名时可能会导致包签名问题
4.两个jar有同限定名的类,以先加载的为主
5.maven依赖管理有两个原则,根据最先声明原则,pom的依赖申明顺序会影响同名jar包的依赖
6.pom的声明顺序会影响idea调试classpath jar包顺序,也会影响打包的顺序
7.spring-boot-maven-plugin打包的顺序决定springboot加载jar的顺序
8.spring-boot-maven-plugin打成的fat jar会让原来第三方依赖的jar包的数字签名"失效"
6其它问题
签名校验属于类加载的哪个阶段?
属于类加载的第一步,加载阶段,加载阶段的类加载器是jvm设计的时候特意留给开发人员“可见的”,方便开发者实现不同的加载方式。