到现在这个时间点,JDK 19已经发布,但是从JDK 9开始出现的、可以说是Java平台破坏性最大的一次改动——模块化(Project Jigsaw),说实话直到这几天之前,我依然不是特别清楚。曾经也有尝试去了解过这个东西,但都不了了之,这可能与我主要是做后端开发有关,确实是没有什么接触这个东西的机会,在Maven、IDE、DevOps的加持下,真的是干就完了。但是看下来它是个值得了解学习的“没用“的知识,感觉应该还是有不少同志有相同的情况,便在此说它一说。

模块化的目的

这里抄一段官网的内容:

  • Make it easier for developers to construct and maintain libraries and large applications;
  • Improve the security and maintainability of Java SE Platform Implementations in general, and the JDK in particular;
  • Enable improved application performance; and
  • Enable the Java SE Platform, and the JDK, to scale down for use in small computing devices and dense cloud deployments.

第一点,在我看来不明显;第二点,安全性、可维护性,源自它提供的更细致的访问控制能力;第三点,比较虚,当然我相信它是有的;第四点,JDK裁切,为存储资源受限的小型设备和云服务赋能。所以我们记住第二点和第四点,这是模块化最核心的内容和目标。

推荐看看大佬对这几个目标的解释:https://www.zhihu.com/question/39112373/answer/79768859

Module Info文件

那什么是模块呢,它可以是说是Java包(Package)的基础上的又一层新的抽象,由Java平台模块系统(JPMS)管理处理。通俗来讲,可以就把它当成一个带有描述文件的Java包,当然了,模块可以由多个Package组成。所以模块的重点就在它的描述文件module-info.java文件中。示例和解释如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 模块名称声明
module sample.module {

// 表明需要依赖、访问某个模块,在模块化的情况下如果要使用其他模块的内容
// 除了java.base这个模块之外都需要显式声明
// 同时,被使用的包也需要在模块中显式声明导出
requires com.another.module;

// 如果依赖只需要在编译期使用,比如测试代码或者一些代码生成库,那么可以选择静态依赖
// 这就和maven的scope provided比较像
requires static com.abc.module;

// 传递型依赖,显然有依赖就会有依赖链,默认情况下依赖不传递,需要显式使用 transitive
requires transitive com.sample.jkl;

// 公开模块下的某个包。默认情况下,模块不会向其他模块公开其任何内容
// 这种强力的封装是模块化能够做到更细的访问控制的关键
exports com.sample.package.abc;

// 限制某个包只开放给指定的其他模块
exports com.sample.package.def to specific.module;

// 为了解耦服务提供方和使用方,模块化提供了use和provides两个关键字
// 这就和SPI机制非常的相似,理解SPI就能理解这个,我觉得也不需要多解释
use com.sample.abcinterface;

// 服务提供方的声明方式,当然你只能with本模块下的内容
provides com.specific.defInterface with com.sample.defImpl;

// 反射API的安全性得到了改进,在模块化下,默认不允许进行反射,需要显式打开反射许可
// 这里我们显式开放了某个包的反射许可
// 如果整个模块都允许反射,那么可以文件开头直接使用 open module sample.module { ... } 的形式
opens com.sample.reflect;

// 同样的,开放反射也可以指定只给到特定的模块
opens com.sample.more to specific.module;

}

从中我们看到,模块描述文件指明了依赖关系、对外暴露控制、反射控制、类SPI服务解耦,public不再意味着能够直接访问,反射也进入了可控范畴。正是这些特质,实现了模块化所带来的更加细致的访问控制和安全性的提升。至于裁切、缩小体积,那是因为JDK本身也做了模块化处理,做到了按需取用,显著减小了Runtime基座的物理体积。

Hello World

说了这么多,还是得要动手试一试才能更好的理解,就以官网最简单的例子来做个简单说明。

首先在IDEA创建一个纯Java项目,SDK选择9以上的版本。

然后我们创建主类,写点Hello world代码,添加module-info文件。可以试试引用java.base模块之外的内容,idea会提示你进行依赖处理。注意在这种情况下,module-info只能放在那个位置。

然后我们手动进行编译和打包,打开命令行工具,执行如下命令。注意win下命令会有所不同,以及java版本是否正确。

1
2
3
4
5
6
7
8
9
10
11
12
// 编译处理
$ mkdir -p mods/com.greetings

$ javac -d mods/com.greetings \
src/module-info.java \
src/com/greetings/Main.java

// 打包处理
$ mkdir mlib

$ jar --create --file=mlib/com.greetings.jar \
--main-class=com.greetings.Main -C mods/com.greetings .

此时我们可以尝试执行如下命令,都可以得到打印输出:

1
2
3
4
5
// 执行编译完class文件
$ java --module-path mods -m com.greetings/com.greetings.Main

// 执行jar包
$ java -p mlib -m com.greetings

对比一下java8和java9 java命令的help说明,--module-path-m都是新出现的选项,可以看到module path和class path是两条路,完全遵从模块化就没有class path老的那一套了。

最后尝试一下JLink,有了这个指令,前文所述的JDK裁切、缩减Java运行时大小才能够达成。JLink是将Module进行打包的工具,创建定制的模块化运行时映像。

1
$ jlink --module-path $JAVA_HOME/jmods:mlib --add-modules com.greetings --output greetingsapp

此时我们可以尝试执行如下命令,成功得到打印输出:

1
$ ./greetingsapp/bin/java -m com.greetings

基本上相当于这个命令把用户模块和需要的JRE模块打包成了一个自定义的JRE,基础库和用户库融合成一体了,这样出来的java命令可以直接执行用户代码。也可以看到打包出来的文件夹大小在40mb左右(根据环境有所不同)。

兼容性

那为什么说这是“没用”的知识呢,一是因为兼容性足以无缝迁移旧项目,二是在服务端开发场景上用处不多,原来惯有的思维没有改变的动力。虽然说模块化是一个破坏性很“大”的改变,但是Java并没有也不敢让迭代出现太大的裂痕。这就不得不重点说明一下模块化的兼容性,而且理解兼容性可能是一件更重要的事情。

为了保证兼容性,除了正规的module(带有module-info且位于module path下)之外,还有两种特殊的module来为向后兼容或者说辅助迁移提供帮助。

Unnamed Module

每个classloader在classpath下加载的所有JAR(不管是否模块化)共同组成一个unnamed module(未命名模块),未命名模块自动声明依赖所有的显式模块,同时exports自己的所有包,而一个显式模块并不能声明依赖未命名模块。

存在JVM默认选项--illegal-access=permit,即允许unnamed modules反射(java.lang.reflect / java.lang.invoke)使用所有显式模块中的类,但这个不确定java 9之后是不是移除了。

所以,如果我们继续使用传统的classpath方式运行,那就和之前在使用上不会有任何差别,还是public / protected / private那一套,该反射还是能反射。

Automatic Module

模块系统会为在module path上找到的每个JAR包创建一个内部模块,对于模块化的JAR包来说,因为包含了module-info文件,它的模块名、依赖、导出等都是有明确描述的,所以没有什么问题。但是迁移的过程中无法避免非模块化的JAR包依赖。这种情况下模块系统会为它自动创建一个模块,即Automatic module(自动模块),并且对该模块的属性进行最安全的补全。

  • Name:对于模块的名称,如果在MANIFEST文件中定义了Automatic-module-name这个header则以此值为准,否则使用JAR包文件名。
  • Requires:模块系统允许自动模块读取所有其他模块,也就是说自动模块依赖其他所有模块。与其他显式定义的正规模块不同,自动模块可以读取未命名模块。
  • Exports / Opens:模块系统Export、Open Jar包内的所有package

引入模块的原因之一是为了使编译和启动应用程序相比较于classpath形式来说更加可靠、更快地发现错误。为了保持可靠性,模块没有任何办法声明require除了标准模块之外的内容,这其中就包含了从classpath加载的所有东西。如果保持这种状态,那么模块化的JAR只能依赖其他模块化的JAR,这将迫使整个生态系统自底向上全部模块化。很明显这样是不可接受的,因此自动模块作为模块JAR依赖于非模块JAR的一种手段被引入,只要将普通JAR放在模块路径上,并且按照模块系统赋予它的名称进行require就可以正常运转。
另外可以看到,由于自动模块可以读取未命名模块,因此将它的依赖项保留在类路径上是可行的。这样,自动模块就充当了从module到classpath的桥梁。

总结

目前看来模块化的核心作用还是在裁剪JRE体积上,对于客户端开发比较有用,对服务端影响有限。之前没能解决的JAR Hell问题模块化也解决不了,也会有Module Hell。Maven / Gradle这些工具已经能帮助解决绝大多数问题了,在服务端开发上,一来硬件存储基本不在乎这减少的100mb,二来微服务的情况下每个JVM的功能范围都是往小了走的,更加精细的权限控制对编码没有什么正向效果,主要是安全方面。

所以先天动力缺失,在能不动就不懂的“铁原则”下,就算不了解这个东西,照样继续敲代码。本人工作上的服务也基本已经迁移到了Java 11,迁移过程中在代码上基本没什么大的改动。

当然了,这仅是皮毛上的感觉和简介,了解熟悉这个东西对于Java开发人员来说还是很有必要的。