一、引言
最近作者在做性能分析服务的agent,有个功能是在代理启动的时候加载配置中心,拿到具体哪些目录下的类需要增强,这里碰到了类加载失败的问题。
二、类加载
1、问题
这里使用了pom的设置,在class文件被拉进去,但是对象加载之前会执行premain(String agentArgs, Instrumentation instrumentation),这时候主要就是把拦截增强的类和方法设置好。
<Premain-Class>com.profiler.agent</Premain-Class>
出现的问题是ClassNotFoundException,也就是编译时加载不到类,因为在agent里面指定了扫描路径和类类加载器
JarFile jarFile = new JarFile(LogAgent.getBootstrapPath());instrumentation.appendToBootstrapClassLoaderSearch(jarFile);
2、双亲委派
根据《深入理解java虚拟机》,类加载通过双亲委派机制,避免类的重复加载,而BootstrapClassLoader是其中最顶级的类加载器,他只会加载放在《JAVA_HOME》\lib下面的文件,或者被-Xbootclasspath指定的路径,比如rt.jar、tools.jar。
比如Object就是在rt.jar里面,保证在各种类加载器环境中他都是同一个类。这里就产生了一些问题:
1、为什么不通过maven引入配置中心包
2、agent引入的包是和main之后的maven引入的包产生互斥还是一样的,毕竟两个阶段的类加载器明显不一样
3、为什么 Bootstrap ClassLoader 只加载 JVM 核心类库中的类,但是在maven里面引入之后他就可以加载了,难道是maven通过-Xbootclasspath将引入的包放到了对应的路径给Bootstrap加载吗
带着问题去思考才会理解原理,这些我们在下一章解决方案里面再去分析
3、打破双亲委派
既然提到了双亲委派,就要说说我们经典的打破模型了,尽管双亲委派模型是推荐的类加载机制,但在某些情况下,为了满足特定的需求,Java 中的一些类加载器实现选择了打破这一模型
在《深入理解java虚拟机》中有叙说原因
看看一些经典的框架
-
Tomcat 的 WebAppClassLoader: Tomcat 容器为每个部署的 web 应用程序提供了一个独立的类加载器(WebAppClassLoader)。为了允许每个 web 应用使用自己的类库版本,而不是容器级别或者系统级别的类库,Tomcat 的类加载器会首先尝试加载本地的类和资源,然后才委托给父类加载器。这样做可以避免类库版本冲突。
-
OSGi(Open Service Gateway initiative)框架: OSGi 是一个用于模块化开发的 Java 框架,它允许应用程序由多个模块(称为 bundles)组成。OSGi 框架使用自定义的类加载器来加载每个 bundle,这些类加载器会根据 bundle 的依赖关系来加载类,而不是简单地遵循双亲委派模型。这允许不同的 bundle 使用不同版本的同一个库而不会发生冲突。
-
JDBC 4.0 的 Service Provider Mechanism: 在 JDBC 4.0 中,引入了一种服务提供者机制,允许 JDBC 驱动程序通过在 JAR 文件的
META-INF/services
目录下提供一个服务配置文件来自动被发现和加载。这个机制使用了 Java 的ServiceLoader
类,它不完全遵循双亲委派模型,因为它会查找所有可用的类加载器,包括当前线程的上下文类加载器。 -
热部署(Hot Deployment): 在一些应用服务器和开发工具中,为了支持热部署(即在不重启服务器的情况下替换或更新应用程序组件),可能会使用自定义的类加载器来加载和卸载类。这些类加载器通常会在加载类之前检查是否有更新的版本,从而打破了传统的双亲委派模型。
这里有了一个新的问题,为了他们要这样做?以tomcat举例,在 Java Web 应用程序中,通常会有多个应用程序部署在同一个应用服务器(如 Tomcat)上。每个应用程序可能需要使用特定版本的第三方库(例如,日志库、JSON 处理库等)。如果所有应用程序都共享同一个类库版本,那么可能会出现以下问题:
-
版本冲突:不同的应用程序可能需要不同版本的同一个类库。如果应用服务器只提供了一个版本,那么可能会导致某些应用程序无法正常工作。
-
安全和隔离:如果所有应用程序共享相同的类库,那么一个应用程序中的问题可能会影响到其他应用程序。例如,一个应用程序中的安全漏洞可能会被其他应用程序利用。
-
升级和维护:当共享的类库需要升级时,可能需要同时升级所有依赖该类库的应用程序。这会增加维护的复杂性和风险。
所以tomcat会按照以下顺序尝试加载类和资源:
-
Web 应用程序的类库:首先尝试从部署在
/WEB-INF/lib
目录下的 JAR 文件和/WEB-INF/classes
目录中加载类。 -
容器级别的类库:如果在 Web 应用程序的类库中找不到所需的类,类加载器会委托给容器级别的类加载器。
-
系统级别的类库:如果容器级别的类加载器也无法加载所需的类,最后会尝试使用系统级别的类加载器。
这种调整看起来似乎是颠倒了双亲委派模型的顺序,但实际上,Tomcat 仍然保留了双亲委派的基本原则,即在尝试自己加载类之前,会先询问父类加载器。只不过在这种情况下,Tomcat 为了支持每个 Web 应用程序使用独立的类库,允许 WebAppClassLoader 有条件地优先加载特定的类路径。
这种做法的目的是为了解决 Web 应用程序可能遇到的类库冲突问题,同时保持了双亲委派模型的好处,如避免类的重复加载和保证 Java 核心库的安全性
三、解决
现在再来看一下怎么解决这个问题,方案有三种
1、自定义类加载器扫描配置中心所需要的类
但是这有很大的隐患,因为配置中心所需要的包和加载模式可以说是非常繁琐多样的,自定义类加载器,看起来从底层上隔离了(因为类的重复加载是由类和类加载器确定唯一的),但是会导致许多问题,比如:
-
类型不兼容: 当两个相同全名的类由不同的类加载器加载时,尽管它们的名称相同,但它们在 JVM 中是不同的类型。这意味着,即使它们的字段和方法签名完全相同,它们也不兼容。例如,如果你尝试将一个类的实例赋值给另一个类加载器加载的同名类的引用,会抛出
ClassCastException
。 -
静态成员冲突: 如果两个相同全名的类由不同的类加载器加载,它们的静态成员(字段和方法)也是独立的。这可能会导致预期之外的行为,因为每个类版本都有自己的静态状态。
-
单例模式破坏: 如果你的应用程序依赖于单例模式,并且该单例类被不同的类加载器加载了多次,那么每个类加载器都会创建该类的一个实例,这违反了单例模式的原则。
-
服务提供者冲突: 在使用服务提供者接口(SPI)时,如果不同的类加载器加载了相同的服务提供者,可能会导致服务加载重复或不一致。
-
类加载器泄漏: 在复杂的类加载器层次结构中,如果不同的类加载器加载了相同的类,可能会导致类加载器无法被垃圾回收,从而导致内存泄漏。
-
资源管理问题: 如果不同的类加载器加载了相同的类,它们可能会尝试独立管理相同的资源(如文件句柄、数据库连接等),这可能导致资源冲突或泄漏。
还有其他的不是类型问题
-
内存泄漏: 类加载器和它加载的类之间存在引用关系。如果自定义类加载器的实例被长期保持引用,那么它加载的所有类也无法被垃圾回收,这可能导致内存泄漏。
-
违反双亲委派模型: 如果自定义类加载器没有正确实现双亲委派模型,可能会导致一些问题,比如 Java 核心库的类被错误地重载,或者类的不一致性(比如同一个类被不同的类加载器加载)。
-
安全问题: 类加载器是 Java 安全模型的一部分。自定义类加载器如果没有正确地实现安全检查,可能会导致安全漏洞,比如加载未经验证的字节码。
-
性能问题: 不正确或者低效的类加载策略可能会导致性能问题。例如,频繁地创建类加载器实例和加载类可能会增加应用程序的启动时间和运行时的内存消耗。
-
兼容性问题: 自定义类加载器可能会与某些框架或库不兼容,因为这些框架或库可能依赖于特定的类加载机制。
这里又引入了新的问题,为什么tomcat之类的就敢打破双亲委派,自定义类加载器?
一方面这是他们投入了巨大的资源,做各种排障、迭代兼容,另外一方面人家牌子够大,如果跟他有冲突,其他的框架愿意花时间精力去适配他,所以正常情况下,这种方式我们玩不起,还是别想了
2、配置中心使用OpenApi替代SDK
这其实是绕过类加载的问题,既然这个包加载不了,干脆不加载了,直接通过调用接口的方式去获取配置。
这个其实无非就是配置中心的框架组把他的服务端数据开个接口出来,正常框架都支持的。
但是这里还会引入新的问题,发送网络请求倒是没什么,jdk的原生包就支持了,只是写起来比较丑而已。但是问题在于返回数据的解析,返回的是一串复杂json,你要怎么解析,还是得引入包。
3、maven引入
通过maven引入就要注意之前说的几个问题了
1、为什么不通过maven引入配置中心包?
这个看完下面的分析就知道了
2、agent引入的包是和main之后的maven引入的包产生互斥还是一样的,毕竟两个阶段的类加载器明显不一样
按原理说是不一样的,毕竟类重复是通过类加载器和类确定的,但是之前打破双亲委派也提到了
-
类型不兼容: 当两个相同全名的类由不同的类加载器加载时,尽管它们的名称相同,但它们在 JVM 中是不同的类型。这意味着,即使它们的字段和方法签名完全相同,它们也不兼容。例如,如果你尝试将一个类的实例赋值给另一个类加载器加载的同名类的引用,会抛出
ClassCastException
。 -
静态成员冲突: 如果两个相同全名的类由不同的类加载器加载,它们的静态成员(字段和方法)也是独立的。这可能会导致预期之外的行为,因为每个类版本都有自己的静态状态。
-
单例模式破坏: 如果你的应用程序依赖于单例模式,并且该单例类被不同的类加载器加载了多次,那么每个类加载器都会创建该类的一个实例,这违反了单例模式的原则。
-
服务提供者冲突: 在使用服务提供者接口(SPI)时,如果不同的类加载器加载了相同的服务提供者,可能会导致服务加载重复或不一致。
-
类加载器泄漏: 在复杂的类加载器层次结构中,如果不同的类加载器加载了相同的类,可能会导致类加载器无法被垃圾回收,从而导致内存泄漏。
-
资源管理问题: 如果不同的类加载器加载了相同的类,它们可能会尝试独立管理相同的资源(如文件句柄、数据库连接等),这可能导致资源冲突或泄漏。
所以说引入可以,怎么解决呢,那就是把包打成完全不一样的,这样类的路径都不一样了。
怎么实现呢?通过maven里面的relocation声明,他是通过转换路径名实现的,打成的包路径就变了(ps:在编码的时候看到的路径还是没有改变的,只在于运行打包替换)
这样就可以解决以上的问题,但是有一个新的问题,配置中心太大了,配置中心的生态体系非常复杂,依赖的库非常多,所以说要relocation的非常多,对于加载启动也不友好
所以relocation只适用于bytebuddy、Gson之类的没有其他依赖的库
<configuration><relocations><relocation><pattern>net.bytebuddy</pattern><shadedPattern>shaded.profiler.net.bytebuddy</shadedPattern></relocation></relocations></configuration>
3、为什么 Bootstrap ClassLoader 只加载 JVM 核心类库中的类,但是在maven里面引入之后他就可以加载了,难道是maven通过-Xbootclasspath将引入的包放到了对应的路径给Bootstrap加载吗
这里其实是第二个问题的延伸思考了,这时候的agent还是Bootstrap ClassLoader ,maven为什么可以加载?
Maven 并不会通过 -Xbootclasspath
将引入的包放到对应的路径给 Bootstrap ClassLoader 加载。实际上,Maven 管理的依赖并不是由 Bootstrap ClassLoader 加载的,而是由系统类加载器(System ClassLoader)或者应用类加载器(Application ClassLoader)加载的。
在 Maven 项目中添加依赖时,Maven 会处理这个依赖并将其放入项目构建的类路径(classpath)中,在运行时,这些依赖是由系统类加载器或应用类加载器加载的,而不是由 Bootstrap ClassLoader 加载的。
4、解决方案
经过上面的分析,可以确定方案了,首先配置中心通过接口获取数据,其次在agent的maven里面引入Gson并且进行relocation。解决配置数据和json解析的问题。
四、总结
以上分析除了作者的理解和资料研究,还有ld和同事cbc的讨论支持,有疑问可以沟通交流。