入门并理解Java模块化系统(JPMS)
前言
你是否在面试中被问过JDK9的新特性?你是否好奇Java8以后的Java世界?或许你已经开始使用了Java17+SpringBoot3的组合,却从来没有使用过模块化编程,现在一起了解一下这个技术吧。
本文将简单介绍JPMS,并通过简单的案例进行模块化开发,希望读者已经具有一定的Java开发经验。
简单回忆Jar文件
在Java 9模块化系统(JPMS)诞生之前,Java Archive (JAR) 文件是Java生态系统中代码组织、部署和分发的基石。在Java 8及更早的版本中,每一个可复用的代码单元,无论是核心库还是第三方依赖,几乎都以JAR的形式存在。
一个JAR文件本质上是一个标准的 ZIP 压缩包格式,它将编译后的 字节码文件(.class)、资源文件、配置文件等打包在一起。开发者只需将这些JAR文件放置在应用程序的 Classpath(类路径) 中,Java虚拟机(JVM)就能通过查找机制,定位并加载所需的任何类。这种机制极大地简化了项目的构建和分享流程,使得共享和复用代码变得非常方便快捷。
然而,传统的Classpath模型是一个“大锅饭”机制:一旦一个JAR被放入Classpath,理论上其中的所有 public 类都对所有其他JAR开放,没有任何限制,下面是一个例子:
开发者 A 编写了一个核心库,提供公有的 sort() 方法,其内部逻辑依赖于一个名为 QuickSortUtil 的类,该类实现了快速排序。当 A 将库打包发布后,开发者 B 直接使用了QuickSortUtil的快排。几个月后,A 经过测试发现堆排序在该公司数据集下性能更优,于是悄悄地将 QuickSortUtil 类修改为 HeapSortUtil,并更新了库的 JAR 包。结果是灾难性的:尽管 A 认为自己只是修改了内部实现细节,但由于 B 依赖了原本不打算公开的 QuickSortUtil,B 的代码遭遇运行错误或编译失败。
传统的JAR机制无法在编译期或运行时有效区分哪些类是公共API,哪些类是内部实现。很容易能够想到在打包好的jar包中,可以引入一个特殊的声明文件来限制对某些类的访问,这就是JPMS的核心思想:我们作为开发者提前限制使用人员的访问,这也是叫模块化编程的原因。
模块化入门
Hello World开始
我将用一个打印hello world的程序展示模块化编程,大致的步骤:
1)创建两个文件夹,src用于存放模块源码,out用于存放编译后的代码。
2)为你的模块取名,这里我们叫com.helloWorld,并在src中创建这个文件夹
3)创建com/test文件夹,添加对应的java程序:
package com.test;
public class Main {
public static void main(String[] args) {
System.out.println("hello,World");
}
}
4)在模块文件夹下,创建module-info.java,这个文件用来标识模块的依赖关系,创建后目录应该是:
5)我们执行命令javac -d out --module-source-path src --module com.helloWorld 进行编译,其中-d表示输出目录,--module-source-path表示原目录,--module表示要编译的模块,会将该模块的所有java文件都编译
6)out目录的到的结果如图,可以看到和原来的结构也是一一对应的。
7)运行,使用java --module-path out --module com.helloWorld/com.test.Main
处理模块的依赖关系
之前我们提到了一个案例,我们现在来使用模块化编程进行简单的实现,体会模块化编程是如何处理依赖关系的。
1)同样创建文件夹、模块,名称分别为client.app和core.library
2)提供简单的代码,两个模块的代码也不过40行
//client.app
package com.b.app;
import com.a.api.Sorter;
import com.a.impl.QuickSortUtil;
public class Main {
public static void main(String[] args) {
int[] data = {3, 1, 4, 1, 5, 9, 2, 6};
Sorter.sort(data);
System.out.println("\n--------------------------");
QuickSortUtil.quickSort(data);
}
}
module client.app {
// 声明依赖 core.library
requires core.library;
}
//----------------------------以下是core.library-------------------------------------------
package com.a.api;
import com.a.impl.QuickSortUtil;
public class Sorter {
public static void sort(int[] data) {
System.out.println("--- 调用公共 sort() 方法 ---");
// 内部依赖于 QuickSortUtil 或 HeapSortUtil
QuickSortUtil.quickSort(data);
}
}
package com.a.impl;
public class QuickSortUtil {
public static void quickSort(int[] data) {
System.out.println("使用 QuickSort 算法进行排序...");
}
}
module core.library {
exports com.a.api;
// com.a.impl 包未被导出,这意味着外部模块无法访问其中的 QuickSortUtil
}
3)这里core.library模块的api就是我们可以提供的包,impl这个具体实现的包我们就不提供,这点在两个module-info文件可以看出,也引入了exports、requires这两个关键字。我们也在Main.java中尝试直接调用QuickSortUtil。完整的文件架构如图:
4)编译:javac -d out --module-source-path src --module core.library,client.app,此时会报错,即使QuickSortUtil是public的,报错信息如下:
5)去除掉Main中对QuickSortUtil的使用,编译成功。执行命令:java --module-path out --module client.app/com.b.app.Main
模块化的JDK
我们编码的方式发生了改变,JDK本身当然也进行了模块化的调整。原本jdk中最核心的是rt.jar这个包(runtime),包含了我们常见的大部分类。问题也出现在这个大包,在一些客户端例如移动设备等,这个包就显得有点太大,而且我们通常使用java进行后端开发,也不需要rt里面的swing等可视化开发工具。Java8尝试解决这个问题,引入了紧凑配置文件,有兴趣的小伙伴可以自行了解。
平台模块化,所有类都被隔离划分到了模块中,例如与JDBC 和 SQL 相关的类都进入了一个名为java.sql的新模块、与 XML 相关的类进入了java.xml模块等,而java.lang、java.io、java.util等基本 API 和类进入java.base模块。java.base是隐式引用,所有模块都依赖了他。
下图是JDK9和JDK8的文件对比结构。可以看到JDK 9 放弃了 JRE 和 JDK 之间的区别,引入了jmod文件代替jar文件,刚刚提到的那几个模块对应的就是这几个jmod文件。jmod文件是一种新的打包格式,可以包含Java类、资源文件和本地代码等内容。它不能像JAR文件那样直接运行,但可以作为编译和链接时的模块依赖,并且是jlink工具用来创建自定义运行时镜像的基础。
jlink生成的结果,包括你编写应用程序和库模块的最小集(见模块化解析)和正常运行所需的最小平台模块集。我们还是以上面的那个简单案例来实践,我们已经编译了两个模块,现在使用命令:jlink --module-path out --add-modules client.app --output image ,这样就得到一个获得镜像,可以看到和jdk的文件结构非常像,其中lib/modules就是我们打包之后的模块,也是“新的rt.jar”。
我们生成的这个,既是一个jre(可以运行其他字节码文件),也是运行我们程序的镜像,有点类似docker镜像,可以直接运行:bin/java --module client.app/com.b.app.Main。
模块化原理
可读性和可访问性
我们说一个模块requires另外一个模块时,第一个模块就是读取第二个模块。每个模块都自动读取了java.base模块,同时也会读取自身。可访问性就是exports声明的公共类型才能访问。
隐式可读性就是依赖的传递性,需要用transitive,例如java.sql,我们使用java -d java.sql来查看,会得到如下图所示的结果,也就是说读取java.sql模块,也会读取java.logging、java.xml等模块。
所以,如果我们创建一个模块,使用transitive关键字引入其他基本模块,就和java8的rt.jar没有太大区别了。这种模块也被称为聚合模块,可以表示完整的JRE,例如java.se模块,感兴趣可以自行使用命令查看。
我们可以使用to关键字来控制访问。如果恰好有几个包需要对某个模块A公开,但是对其他的模块应该要隐藏,可以写成:exports moduleb.privateA to A
模块解析
模块与模块之间,存在依赖关系,形成了一个有向无环图(DAG)。所以模块的解析,实际上也是DAG遍历的问题,如果我们要执行的main文件在C中,那就需要C、B、A、D这几个模块,如果是E,就只用E、D这两个模块。
所以这就是一个递归图的操作,最终的找到依赖的最小模块集。这个步骤可能产生很多问题:例如找不到模块、模块重复、循环依赖、分割包(同一个包的代码分别存在于两个或更多的命名模块)。
模块的兼容性
目前IDEA创建SpringBoot,只能选择springboot3+JDK17。笔者在学习后端技术栈的时候,也没有没有进行模块化编程,直接使用JDK8的编程方式也能直接运行。同时众所周知,Java是向后兼容的语言,Java8的项目也能够不经过修改就运行在JDK9上。这些多亏了模块系统的设计,提出了模块路径、保留类路径机制来兼容以前的JDK:某个类库到底是模块还是传统的JAR包,只取决于它存放在哪种路径上。
首先,在类路径下的Jar文件以及其他文件,会被打包为一个匿名模块,匿名模块没有任何隔离,可以使用直接使用JDK的平台模块的导出包(例如java.sql)、其他模块的导出包等。而且真正的模块,还是遵循JDK9的规范,不能访问匿名模块的所有内容,也就是看不见传统Jar包的内容。
然后,如果当前这个模块不包含module-info文件,例如传统的Jar包,放置在模块路径就会变成一个自动模块,默认依赖模块路径的所有模块,也会自动导出自己的所有包。我们将创建一个SpringBoot3项目来简单说明上述两点:
1)创建项目:直接在IDEA中创建。这里笔者选择maven的构建方式、JDK17,勾选web和lombok这两个依赖方便展示。
2)写一个简单的controller:
3)运行,并访问。可以看到程序正确执行了,而且我们也没有编写module-info文件。这是因为他将所有的Jar包、class文件都默认通过类路径进行加载,成为匿名模块。
4)我们加上一个空的module-info文件,会发现直接报错,因为这个时候项目会从传统的类路径模式强制转换到了模块路径模式,需要我们添加requires依赖
5)添加完毕之后,点击编译运行,会发现仍然运行失败。这是因为Spring框架是充满反射的框架,实现了AOP、依赖注入等功能。也就是说Spring需要反射访问我们的应用代码。这里就介绍一下opens关键字,声明本模块的哪些包允许其他模块通过反射访问其 非公共成员(如私有字段、私有方法)。如果只使用 exports,其他模块只能访问公共成员。完整的module-info参考:
module com.testjpms {
requires spring.boot;
requires spring.boot.autoconfigure;
requires spring.core;
requires spring.beans;
requires spring.context;
requires spring.web;
requires spring.webmvc;
requires java.logging;
requires java.sql;
opens com.vox.testjpms;
opens com.vox.testjpms.controller;
}
6)这里引入Springboot框架,读者会发现项目整体的复杂度变高了,而实际上,使用SpringBoot3进行开发的项目,大概率还是会选择类路径的方式(这里的模块化也要和maven模块化区分开,后者是开发阶段管理大型项目的编译、依赖的方式)
类加载器
简单回顾一下Java8及以前的类加载机制,采用三层类加载、双亲委派的机制,主要是BootClassLoader、ExtensionClassLoader、ApplicationClassLoader这三个加载器。加载一个类时,会依次向上委托父类进行加载,其中BootClassLoader是C++写的。
首先BootClassLoader的变化,BosstClassLoader原本用来加载rt.jar,现在rt.jar被分为了数十个JMOD文件,所以BootClassLoader现在用于加载java.base等少数关键模块。同时也使用java重新实现了这个BootClassLoader
ExtensionClassLoader的改动最大,直接被PlatformClassLoader取代。原本是加载jre/lib/ext目录的jar包,现在如其名,加载其他的平台模块,也就是java.sql、java.xml等。原本他是URLClassLoader的实现类,现在改为一个内部类。
ApplicationClassLoader也不再是URLClassLoader的实例,而是一个内部类。加载我们写的模块以及类路径的类,也会加载原本的模块,例如jkd.aot等。
接下来简单的介绍一下类的加载。首先根据前文,所有类都属于一个模块,可以是显示模块,也可以是匿名模块。当ApplicationClassLoader和PlatformClassLoader收到类加载的请求时,会先判断是否能归宿到某一个模块,如果能够找到,就会委派给负责这个模块的类加载器加载,所以也算是打破了双亲委派。

浙公网安备 33010602011771号