得物技术 2024年09月25日
浅析Java类隔离规避依赖冲突的实现原理|得物技术
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文深入探讨了 Java 类隔离容器的实现原理和应用场景,并分析了其优缺点。类隔离容器通过类加载劫持和编排,实现了多版本类并存,解决了依赖冲突问题,但同时也带来了排障心智、迁移成本和元信息维护等挑战。作者认为,类隔离容器更适合特定领域内部使用的 JVM 租户,例如内部特定场景下、特定开发人员使用的应用引擎。

😄 **类隔离容器的原理**: 类隔离容器利用 Java 类加载机制,通过类加载劫持和编排,在同一个 JVM 中创建多个隔离的依赖空间,每个空间可以包含相同组件的不同版本,从而解决依赖冲突问题。 - **类加载劫持**: 通过自定义类加载器,拦截 Java 运行时加载类的过程,并根据预设的规则,将类加载到指定的依赖空间。 - **类加载器编排**: 设计不同的类加载器层次结构,并通过委托机制,将类加载请求传递到不同的类加载器,实现类隔离和共享。 - **Bundle**: 类隔离容器将每个需要隔离的组件打包成 Bundle,Bundle 包含自身代码和依赖,每个 Bundle 拥有独立的依赖空间。 - **Bundle 元信息**: Bundle 中包含元信息,用于描述 Bundle 导出的类和导入的类,以及其他用于控制类加载过程的信息。

🥳 **类隔离容器的应用场景**: 类隔离容器主要应用于解决依赖冲突问题,特别适合于以下场景: - **大型项目**: 大型项目中,依赖关系复杂,不同组件可能依赖相同组件的不同版本,使用类隔离容器可以避免版本冲突。 - **插件系统**: 插件系统中,不同插件可能依赖相同组件的不同版本,使用类隔离容器可以确保插件之间互不影响。 - **微服务架构**: 微服务架构中,不同服务可能依赖相同组件的不同版本,使用类隔离容器可以确保服务之间互不影响。

😥 **类隔离容器的挑战**: 类隔离容器虽然能解决依赖冲突问题,但也带来了一些挑战: - **排障心智**: 类隔离机制构造了一个复杂的类加载器拓扑,当出现类加载异常时,排查难度会增加。 - **迁移成本**: 将现有项目迁移到类隔离容器模式,需要对代码进行调整,并重新打包,迁移成本较高。 - **元信息维护**: Bundle 的元信息需要人工维护,维护成本较高,并且可能存在错误,导致类加载异常。

🤔 **类隔离容器的未来**: 作者认为,类隔离容器更适合特定领域内部使用的 JVM 租户,例如内部特定场景下、特定开发人员使用的应用引擎。在这些场景下,资源隔离性要求不高,类隔离容器可以有效地解决依赖冲突问题。

🚀 **总结**: 类隔离容器是一种解决依赖冲突的有效方案,但其复杂性也带来了一些挑战。在选择使用类隔离容器时,需要权衡其优缺点,并根据实际情况进行选择。

原创 羊羽 2024-09-25 18:31 上海

Java类隔离容器的思路是在Java语言既有特性的基础上,利用类加载劫持、类加载器编排实现了一套多版本类并存的机制,确实可以减少某些场景下的类版本冲突的问题。

目录

一、导语

二、类隔离容器

三、类加载API

    1. 装载类

    2. 定义类

    3. JNI方法实现

四、类的相等性:ClassCastException

五、类加载编排、委托

六、类加载劫持

七、类隔离模块:Bundle

    1. 解压FatJar

    2. 文件切片

八、类导入/导出:Bundle元信息

九、Bundle依赖隔离

    1. 业务类加载器

    2. Bundle类加载器

    3. 类加载器管理器

    4. 类隔离容器底座

    5. 使用姿势

十、没有银弹

十一、总结


导语

随着业务规模增长、业务逻辑演进,项目工程的依赖树(二方依赖、三方依赖)变得愈发复杂。随之而来的便是【依赖冲突】问题。


当几个软件包对相同的共享包或库有依赖性,但它们依赖于不同的、不兼容的共享包版本时,就会出现依赖性问题。如果共享包或库只能安装一个版本,用户可能需要通过获得较新或较旧版本的依赖包来解决这个问题。反过来,这可能会破坏其他的依赖关系。


【依赖冲突】问题是软件工程广泛存在的问题,换句话说,各语言生态如Python、Golang、Nodejs、Java等都存在类似问题。但是由于Java语言的特殊机制,【依赖冲突】问题在Java中似乎有完美的解决方案,那就是【类隔离容器】。


从2000年的开源规范OSGI,到阿里巴巴自研Pandora容器,再到蚂蚁金服开源sofa-ark,业界在【类隔离容器】这个领域的实践方兴未艾。那到底什么是类隔离容器?怎么实现类隔离容器?为什么它听起来很完美但是却没有成为主流实践?


本文代码均为示意的伪代码。


类隔离容器

当项目依赖树变得复杂时,不可避免的会出现不同的组件依赖同一个组件的不同版本的问题。如下图,3个组件分别依赖了 maven-settings 组件的2个版本:3.03.3.9plexus-interpolation组件同理。




当项目中只有一个依赖空间时,项目需求的多个版本的组件最终只会有一个版本进入项目依赖空间,极易因为上层组件对版本需求的众口难调而出现ClassNotFoundExceptionNoSuchMethodException等版本兼容性问题。


为解决这个问题,业界开始考虑通过Java类加载隔离来在项目运行时创建多个隔离的依赖空间。每个依赖空间中可以各自使用相同组件的不同版本,这种隔离的依赖空间即为:类隔离容器。


如下图,项目中存在3个类隔离容器,maven-settings组件在两个容器中分别存在3个版本。

这里的maven-*只是Jar包名称,和mvn工具无关,只是笔者手上恰好有这个案例。


类隔离容器劫持、干预了Java类加载流程,让同一个组件的多个版本可以在同一个项目中并存。


类加载API

Java是一种强类型的动态语言,其代码符号(类名、方法名、字段名)都在运行时动态链接,通过【类加载器】来实现运行时的类搜索和代码装载。这种动态特性赋予了框架开发者极大的便利性,支撑了大量企业级开发框架的实现,提高了上层业务代码的迭代效率。这也是Java语言二十几年如一日占据编程语言排行榜前列的一个重要原因。

TIOBE编程社区指数-2024(https://www.tiobe.com/tiobe-index/)


为支撑上述类加载能力,同时赋予开发者自定义类加载流程的能力,Java Runtime定义了ClassLoader这一API。抽象的API如下:

ClassLoader的实现者负责根据【位置无关】的类标识,定位、装载类。所谓【位置无关】说的是,JVM不关心这个类文件的物理位置是在网络上、磁盘里、内存里。


由于Classs类型的返回值无法由开发者自行构造,涉及JVM内部的状态联动,因此JVM会暴露一个构造Class对象的工具API。抽象的API如下:

parseAndLinkClass方法由JVM实现,JVM内部会进行我们八股文都背过的类验证、类解析、类初始化等标准动作。


因此,开发者自定义类加载流程的样板代码如下:

Java提供了类似上述样板代码的具体实现,即:java.lang.ClassLoader,其实就是大家都熟悉的【模板方法设计模式】


上述通俗的、抽象的API能力,映射到Java的具体实现分别为:

装载类

java.lang.ClassLoader#loadClass(java.lang.String)


定义类

java.lang.ClassLoader#defineClass0


JNI方法实现

jdk/src/share/native/java/lang/ClassLoader.c


类的相等性:

ClassCastException

尽管在源代码层面,我们用【类的全限定名】作为编码时定位类的标识,但是在JVM内部,类的标识是一个联合索引。


JVM内部使用 <ClassLoader,className> 二元组来索引、标识一个类。通俗来说就是,两个不同的 ClassLoader使用相同的类名和字节码 defineClass得到的是两个不同的Class对象。

通俗的伪代码来表达的话,上述ClassLoadUtil#parseAndLinkClass方法的实现如下:

defineClass时,创建的Class对象上会关联Loader。


具体到Java中的java.lang.Class类,我们可以看到如下字段:

java.lang.Class#classLoader


上述类加载特性,在复杂的类加载逻辑下如果没有处理好的话极易产生类型转换异常:ClassCastException。如下示例:

如果Type类同时被两个类加载器加载在JVM内部产生了Type_1Type_2两个版本的类型(Class对象)。


LoadTest类中的Type符号链接到了Type_1


TypeUtil类中的Type符号链接到了Type_2


那么,当LoadTest.main方法执行时即会产生ClassCastException异常。


因为TypeUtil.newType方法返回的Type_2类型的对象,和LoadTest.mian方法中声明的Type_1类型的typeVar变量的类型不兼容,无法进行隐式的类型转换。


类隔离容器的需求天然需要同名类存在多个版本,因此类隔离容器的实现和使用时需要极小心的设计、处理该问题。这种问题排查起来非常费劲。


类加载编排、委托

综上分析,我们发现在Java层面实现load一个类并不复杂,只需要根据类名拿到二进制的字节码,然后调用JVM提供的工具方法就行了。


到这里,事情已经回到我们最熟悉不过的CRUD主场,我们可以用各种我们熟悉的设计模式来实现特定的类加载业务需求,其中最重要的设计模式即为:委托模式。


第一个业务需求是类加载的安全性。Java标准库自带了大量易用的工具和数据结构,这部分代码的物理位置和业务代码不在一起。为避免项目中的恶意代码使用标准库同名的类来干坏事,我们需要实现类加载优先级,即加载一个类时优先从JRE目录加载,JRE目录中加载不到时再从项目中加载。


第二个业务需求是类的复用。如Tomcat场景,一个Tomcat进程可以托管多个Web服务(war包)。每个Web服务自身的业务代码和依赖是不同的,但是各个Web服务依赖的Servlet APITomcat API是相同的,因为这是Tomcat容器提供的公共的Runtime。考虑到上述【类的相等性】,我们希望这些Runtime类只有一个版本,以避免访问Runtime API时出现ClassCastException


那么我们重新实现上述AbstractClassLoader如下:

继而我们可以基于上述模板类,构造、编排我们的自定义类加载逻辑:

上述代码通过编排类加载器,实现了如下项目依赖空间拓扑:

综上,我们在Java Runtime的基础ClassLoader机制上,通过非常熟悉的业务编排实现了类加载的安全性需求、共享复用需求,最终呈现了一个树形的类加载器拓扑。


不同的类加载需求需要编排出不同的类加载器拓扑,比如我们讨论的【类隔离容器】需求,需要编排出更复杂的类加载器拓扑。但是其核心的编排思路都是相似的~


类加载劫持

到这里,我们已经有足够的技术储备来根据业务需求编排类加载器拓扑以达成目的。但是遗留了一个关键的问题:


怎么样才能让Java Runtime在加载、链接代码符号时,使用我们构造出来的自定义类加载器呢?


因为如果我们构造出来的类加载器不能参与到类加载流程,那其实就是一个普通的Java对象,没啥用。


要解决这个问题,我们需要参考JVM规范明确类加载器会被如何获取和使用,因为类加载器本质上是供Java Runtime使用的SPI。


JVM规范对这一块的阐述是严谨但抽象的,但通俗来说就一个原则:如果一个类C1是由CL加载器加载(defineClass)的,那么,C1触发的的其他类如Cn的加载和链接,也会委托给CL。示例如下:

因为app1.Main类是由app1Loader加载,那么app1.Main依赖的App1Service类也会隐式的交给app1Loader加载。这个过程是JVM在解析、链接app1.Main类的时候自动进行的。


也就是说,当我们指定某个类加载器CL加载项目的EntryPoint并执行后,后续触发的类加载动作都会交给指定的类加载器CL或者CL委托的其他类加载器。Java项目中的EntryPoint往往是项目中的main方法。


这里有点绕。换句话说,某个Class类对象C1依赖的其他类的加载都会交给C1.classLoader来进行。注意,上面【类的相似性】一节说过,每个Class对象上都持有加载它的ClassLoader的引用。


那么,想让3个WebApp在各自类空间中运行的方式就很简单了:

上述流程还遗留一个问题,那就是ServiceLoader场景。


为打破呆板的双亲委派机制实现某种意义上的IOC,Java提供了contextClassLoader机制。contextClassLoader关联在Thread对象上,并且会在父子线程中复制、传播。

java.lang.Thread#getContextClassLoader


为了让上述3个WebApp中正常使用ServiceLoaderAPI或类似的SPI框架,我们需要做如下特别处理:

至此,我们就实现了Tomcat场景下的类加载劫持、类隔离、类共享。


到这里,我们已经掌握了实现类隔离容器的核心基础。


总结来说,只要我们能在应用的EntryPoint(main方法)中合理的介入、干预,就能实现灵活的类加载业务。


类隔离模块:Bundle

回到最开始的需求,我们希望可以在项目中达成如下依赖结构:

为了实现版本隔离、共存,上述mave-coremaven-compatmaven-xxx组件会将其依赖的maven-settings Jar文件按特定布局打包到自身的jar包中,形成各自独立的依赖空间,供运行时提取、加载。


OSGI中将上述隔离的依赖空间或者类隔离容器称为bundle。需要进行类隔离的组件按bundle文件布局来交付自己的代码和依赖。

每个bundle是一个FatJar,通俗来说是一个包含自身依赖的Jar文件的Jar文件。类似如下Jar文件:

可以把上述dubbo-demo jar文件想象成我们熟悉的mybatis框架。该模块把mybaits框架自身的代码和它依赖的三方包按设计的布局打包到同一个Jar中。


我们知道,Java自带的URLClassLoader天然支持从Jar文件中搜索、读取class文件,但是不支持上述嵌套Jar。


解决这个问题有两个方案:


解压FatJar

在进程启动时,类隔离容器底座识别出ClassPatch中存在上述类型的bundle Jar后,提前将上述FatJar解压到本地磁盘。后续就简单了,无非就是在指定目录搜索类和Jar。


文件切片

我们知道,Jar文件本质上就是ZIP格式的文件,而ZIP文件的逻辑结构是一个Map。

如上图,test.jar中有两个文件,一个是a.b.C.class文件,另一个是dep.jar。该文件在磁盘上的抽象布局如下:

ZIP文件除文件元数据外,整体分为两部分。


    数据区:存放文件的内容。

    索引区:存放文件名称和文件内容的偏移量和长度。


因此,在技术上我们可以对FatJar文件做切片。即在不解压FatJar的前提下,将其中一个区间当成jar文件读取。如上图,我们解析test.jar索引区得到dep.jar文件的长度为4000字节,在外层jar文件的1000偏移处,那么我们读取它内部嵌套的Jar文件的伪代码如下:

这一块说来话长,全是花活。spring-boot就是使用类似方式来拍平嵌套的Jar文件。


可以参考相关资料:

【SpringBoot】服务 Jar 包的启动过程原理(https://www.cnblogs.com/kukuxjx/p/18207068)

类导入/导出:Bundle元信息

到这里,我们可以初步勾勒类隔离容器的代码蓝图了。

    【底座】需要在执行流进入业务main方法前,提前执行。

    【底座】扫描项目中的依赖,区分Jar依赖和Bundle依赖。

    【底座】为每个Bundle依赖创建独立的Bundle类加载器(N个)。

    【底座】为Bundle以外的业务代码和普通Jar创建类加载器(1个)。

    【底座】将上述N+1个类加载器状态编排到一起,拼凑成完整的依赖视图。

    【底座】初始化当前线程contextClassLoader。

    【底座】使用业务类加载器搜索、加载main方法所在类(EntryPoint)。

    【底座】调用业务代码的main方法。


这里存在两个问题:


com.alipay.sofa.ark.spi.constant.Constants#ARK_PLUGIN_MARK_ENTRY

上述信息表明:



是不是有点像 JDK9 的新特性:模块化?


除此之外,一般还会提供优先级等其他用于控制类加载过程的元信息,毕竟可能有多个bundle暴露相同的类。相关的细节信息很多,在各个具体的实现(开源的OSGI、阿里巴巴的Pandora、蚂蚁金服的sofa-ark)上可能有差异,但是大同小异。


Bundle依赖隔离

到这里,我们终于可以看下怎么实现一个类隔离容器了。


业务类加载器

Bundle类加载器

类加载器管理器

类隔离容器底座

使用姿势

上述代码仅为理论示意,并不能直接运行,读者会意即可。


以上,我们就实现了一个简单的类隔离容器,最终形成的类加载器拓扑如下:

最终实现了每个Bundle优先使用自身内部嵌入的Jar依赖,从而实现每个Bundle Jar有一个独立的依赖空间,避免了依赖冲突。


没有银弹

当bundle jar中的嵌套依赖不向外逃逸时,一切都工作的很好。但是如果嵌套依赖中的API被跨bundle耦合、交互,那事情就变得棘手起来。


考虑如下的场景:


十一

总结

刚进入一个陌生领域就陷入代码细节并不是一个高效的方式,所以本文中笔者尽可能的使用伪代码、示意代码来进行论述。


一路梳理下来,我们最终通过类加载器编排,实现了一个理论上的类隔离容器。尽管没有具体的代码实现,但是相信看到这里,读者们已经对类隔离机制有了一个较为系统的认识。


总的来说,Java类隔离容器的思路是在Java语言既有特性的基础上,利用类加载劫持、类加载器编排实现了一套多版本类并存的机制,确实可以减少某些场景下的类版本冲突的问题。但是它解决了一些问题,但是同样的也带来了新的问题。


    排障心智:类隔离机制构造了一个复杂的类加载器拓扑,当因为cornor case出现了类加载异常时,bundle组件的使用者是一脸懵逼的。本来遇到类似ClassNotFoundExceptionNoSuchMethodException问题时,组件使用者可以根据项目依赖树所见即所得的按沉淀的经验排查、处置。但是当你用【嵌套Jar+编排加载机制】交付组件后,之前沉淀的相关排障心智都没用了。

    迁移成本:在组织从0到1起步阶段介入进行上述改造是合适的、成本极低的,但是没人能顾得上这个。在组织从80到100的阶段发现类隔离机制能解决一些问题,但是这个时期各个业务项目的代码结构、组件版本、组件使用姿势百花齐放。想要技改、收敛到bundle jar模式,成本比较大且客观上存在一个研发效率、业务稳定性的阵痛期。

    元信息维护:如上梳理,bundle jar交付时,bundle维护者需要梳理其导出的类、导入的类。这个只能人肉梳理,可能会漏、可能会错;且因为多个bundle在运行期的化学反应,漏、错的异常表现很不直观,难以诊断、排查。如果各个bundle维护者都在一个部门下那沟通、处理起来还好,如果是跨部门的多个bundle互相打架,事情就比较麻烦。


笔者以为,类隔离机制的高价值场景应该是特定领域内部使用的JVM租户。由于JVM比较吃资源,某些轻量逻辑(FAAS)如果单独启动一个进程来执行,有点类似于用集装箱运一只篮球,性价比很低,那干脆大家一起众筹拼集装箱得了。

又回到了十年前用Tomcat托管多个WebApp的模式...


如上图,在JVM进程上构建一个应用引擎,可以根据JVM资源情况动态的将包含代码和依赖的bundle jar调度到JVM上运行。JVM租户的主要问题是资源隔离性不够,比如CPU、MEM和IO。但是如果这个平台只是内部特定场景下、特定开发人员使用问题倒也不大。


以上均为笔者一家之言,欢迎指正~


参考





往期回顾


1. 包材推荐中的算法应用|得物技术

2. 得物自建 Redis 无人值守资源均衡调度设计与实现

3. 暗水印显隐术助力生产排障提效|得物技术

4. 深入理解 Babel - 微内核架构与 ECMAScript 标准化|得物技术

5. 浅析JVM invokedynamic指令和Java Lambda语法|得物技术


文 / 羊羽


关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。


扫码添加小助手微信

如有任何疑问,或想要了解更多技术资讯,请添加小助手微信:

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

Java 类隔离容器 依赖冲突 Bundle 类加载器 JVM
相关文章