Skip to content

Java 9 新特性-模块化

本文主要翻译自Baeldung中的文章:https://www.baeldung.com/java-modularity,并附上相关代码案例。案例在Windows和macOS系统下均有,但问题不大。

1. 概述

Java 9 在包(package)之上引入了新一层的抽象,即Java Platform Module System (JPMS),简称为模块化(modules)。

在本文中,我们会介绍模块化以及它的各方面内容,也会通过一个个简单的案例演示介绍的概念。

2. 什么是模块化

首先,在明白如何使用模块化之前,我们应该明白什么是模块化。

模块是一组紧密相关的包和资源,以及一份模块描述文件。换句话说,模块就是Java包的包,使得我们的代码复用性更强。

2.1 包

在模块中的包与我们之前使用的包一样。当我们创建模块时,我们将代码放在包中,就像我们之前在其他项目(非模块化项目)中一样。

除了组织代码,包也可以用于决定哪些代码可以从模块外部访问。

2.2 资源

每个模块有责任管理自己的资源,例如媒体资源或配置文件。

在模块化之前,我们把所有资源放在项目根路径下,并且手动管理哪个资源属于项目哪个部分。在模块化之后,我们可以把模块需要的资源文件(例如图片、XML文件)和模块放在一起,使得我们的项目更容易管理。

2.3 模块描述文件

当我们创建一个模块时,我们会创建一个模块描述文件,这个文件定义了模块的不同方面内容:

  • Name:该模块的名称;模块的名称规则与包名规则类似,我们可以使用.,但是不允许使用-;通常有两种方式命名模块名:项目风格(my.module)和反转域名风格(com.baeldung.mymodule)。
  • Dependencies:模块的依赖,即该模块依赖哪些模块;
  • Public Packages:定义了一组开放包,其他模块可以访问开放包;默认情况下,该模块内的所有包都是私有的,即其他模块不允许访问。
  • Services Offered:定义了该模块提供的服务;
  • Services Consumed:定义了该模块要消费(使用)哪些服务;
  • Reflection Permissions:定义了其他模块可以通过反射访问该模块哪些私有成员;默认情况下,该模块的所有类都是反射禁止的,即其他模块不允许通过反射使用该模块内的类。

2.4 模块类型

在介绍模块类型之前,我们先介绍一些关于各种路径的前置知识:

  • 类路径-ClassPath:类路径指定了Java虚拟机(JVM)或编译器(javac)查找用户定义的类和包的位置。具体来说,类路径告诉JVM在哪里可以找到需要加载的类文件和其他资源。 类路径的常见组成部分我们可以使用命令行参数指定类路径 使用 -cp-classpath 参数指定类路径:
bash
java -cp /path/to/classes:/path/to/libs/* com.example.Main

多个路径之间使用特定的分隔符分隔: * 在Windows上使用 ; * 在Unix/Linux/Mac OS上使用 : 1. 编译后的类文件:Java源代码编译后生成的 .class 文件通常放在某个目录结构中,该结构反映了包层次结构。例如,对于包 com.example.app,类文件可能位于 /home/user/projects/myapp/bin/com/example/app/MyClass.class。 2. JAR文件:JAR(Java Archive)文件是一种将多个类文件和其他资源打包在一起的格式。许多库和框架都以JAR文件的形式发布。例如,Apache Commons库可能包含在一个名为 commons-lang3-3.9.jar 的文件中。 3. 资源文件:配置文件、属性文件、图像、文本文件等资源文件也可以放在类路径中,以便通过 ClassLoader.getResource()ClassLoader.getResourceAsStream() 方法访问。

  • 系统模块路径-system module path:在Java 9 及之后,我们下载的JDK结构有了变化,之前的Java官方提供的运行时jar包被拆分成了模块,放在了jmods目录: image-20250307142713020 我们将JDK下的jmods路径称为系统模块路径(我自己取的名字😄)。
  • 自定义模块路径-module path:当我们写程序时,我们肯定还会依赖其他第三方模块,比如从Java 11之后,JavaFX从JDK中脱离出来成为独立的项目,并且支持模块化,假如我们现在要编写一个基于JavaFX的程序,那么就需要指定JavaFX模块所在路径,这就需要为程序指定其他模块路径,这称为自定义模块路径。我们可以使用--module-path指定模块路径。

有了上面的认识,我们可以介绍模块类型了。在模块化系统中存在四种模块类型:

  • System Modules(系统模块):在系统模块路径中的模块,我们可以使用java --list-modules列出所有的模块: image-20250307143943765
  • Application Modules(应用模块):这是我们自己创建的模块,当我们开发一个程序并且使用模块化技术时,就会创建属于我们的应用模块。我们创建的应用模块有自己的名称,并且包含模块描述文件。
  • Automatic Modules(自动模块):当我们把非模块化的jar包(不包含模块描述文件)放在模块路径后,这些模块会在被依赖时转为自动模块,并且jar包的名称会转为模块的名称。自动模块可以访问该模块路径中的其他模块。
  • Unnamed Module(无名模块):无名模块是类路径(--class-path)上的所有 JAR 文件和类文件的集合。这是为了兼容传统的非模块化应用程序。处在类路径中的jar文件或类文件,由于属于无名模块,所以无法在模块描述文件中引入,如果是模块化项目,需要以反射的方式使用。

2.5 分发方式

模块可以通过两种方式分发:

  1. 作为JAR文件:将模块打包成一个JAR文件进行分发。
  2. 作为编译项目:即直接以编译后的文件目录结构形式分发(未打包成JAR文件)。

这两种分发方式与传统的Java项目相同,并不新鲜。

我们可以创建一个多模块项目,包含一个“主应用程序”和多个库模块。例如,一个大型的应用程序可能由多个模块组成,每个模块负责不同的功能。

需要注意的是,每个JAR文件只能包含一个模块。这意味着如果有多个模块,不能将它们打包到同一个JAR文件中,每个模块需要单独打包为一个JAR文件。

3. 系统模块

在Java 9 及之后,我们下载的JDK结构有了变化,之前的Java官方提供的运行时jar包被拆分成了模块,放在了jmods目录:

image-20250307142713020

我们可以使用java --list-modules列出所有的系统模块:

image-20250307143943765

在系统模块中,模块分为四组:java, javafx, jdk, and Oracle

  • java : 以java为前缀的模块是Java SE核心规范的实现类;
  • javafx:JavaFX实现,在Java 11之后移除;
  • jdk:被JDK所需要的模块以jdk为前缀;
  • oracle:oracle 特定的模块(按照不同的JDK版本,有可能没有);

4. 模块描述文件

为了声明模块,我们需要在包的根路径下创建一个特殊文件:module-info.java,这个文件就是模块描述文件,其中包含了所有关于这个模块的信息。

文件合适如下:

txt
module moduleName{
    // 模块命令,可选的
}

首先使用关键字module,然后紧跟模块名称,之后再花括号中指定模块相关命令,详细命令如下:

4.1 依赖声明

我们可以使用requires相关命令指定该模块依赖哪些模块。

例如:

txt
module my.module {
    requires com.module;
}

上面的模块描述文件表示my.module模块依赖com.module模块。依赖有两个含义:

  • 编译时依赖:在将源码文件编译为字节码文件时,所依赖的模块必须存在;
  • 运行时依赖:在运行字节码文件时,所依赖的模块必须存在;

我们可以进一步指定依赖时期,使用requires static,它的作用是允许模块在编译时依赖某个模块,但在运行时该依赖可以不存在。

txt
module my.module {
    requires static com.module;
}

传递性依赖:我们可以使用requires transitive指定传递性依赖。例如:

txt
module my.module {
    requires transitive com.module;
}

之后,如果有另外一个模块org.app.one依赖my.module

txt
module org.app.one{
    requires my.module;
}

那么模块org.app.one自动依赖com.module

4.2 导出

默认情况下,模块中的类不会被导出,即其他模块无法访问本模块不导出的类。我们可以使用exports相关命令导出指定包:

txt
module my.module {
    exports com.my.package.name;
}

上面的命令表示模块my.module导出了com.my.package.name包下面的公共类。只要其他模块引入模块my.module,那么就可以访问com.my.package.name包下面的公共类。注意:子包不会自动导出,即com.my.package.name.sub包不会被自动导出,如果我们需要导出子包,需要显式声明。

txt
module my.module {
    exports com.my.package.name;
    exports com.my.package.name.sub;
}

上面是将指定的包导出给所有模块,我们也可以指定导出的范围,即只有指定的模块才能访问导出的包,其他模块不允许访问,使用exports...to命令:

txt
module my.module {
    exports com.my.package.name to org.app.one;
}

4.3 服务的提供与消费

本小节参考博文:https://jenkov.com/tutorials/java/modules.html

服务的提供与消费与SPI有关。在模块化环境下,我们可以使用uses声明要使用的服务,使用provides...with声明提供的服务。

首先定义服务接口(或服务抽象类)在一个模块中:

java
package com.lee.api;

public interface HelloService {
    void hello();
}
txt
module service.api {
    exports com.lee.api;
}

然后创建服务实现模块(注意:需要将服务接口模块作为该模块依赖):

java
package com.lee.impl;

import com.lee.api.HelloService;

public class HelloServiceImpl implements HelloService {
    @Override
    public void hello() {
        System.out.println("hello");
    }
}
txt
module service.impl {
    requires service.api;

    provides com.lee.api.HelloService with com.lee.impl.HelloServiceImpl;
}

然后创建服务消费者模块,可以使用服务发现模式:

txt
module service.consumer {
    requires service.api;

    uses com.lee.api.HelloService;
}

注意,上面的模块描述文件中,并没有依赖服务实现模块service.api

然后使用服务发现模式寻找服务实现者:

java
package com.lee.consumer;

import com.lee.api.HelloService;

import java.util.Iterator;
import java.util.ServiceLoader;

public class ConsumerDemo {
    public static void main(String[] args) {
        Iterator<HelloService> iterator = ServiceLoader.load(HelloService.class).iterator();
        while (iterator.hasNext()){
            HelloService helloService = iterator.next();

            helloService.hello();
        }
    }
}

最后,在服务发现模式运行命令中加上模块路径,以便我们可以在其中找到模块service.consumer所依赖的各个模块service.api

java
--module-path /Users/lhb/my_java_modules

等等,这个路径是什么路径?模块service.api是怎么放进那个路径里的?

其实就是将模块service.api打成jar包放进自定义模块路径中,还记得之前介绍的模块类型吗。

在IDEA中可以通过File -> Project Structure -> Artifacts将指定的模块打成jar包,例如:

image-20250307194433202

注意:在第三点中,不要把该模块的依赖也一起打进jar包了,即不要打成fat jar。例如上面的例子中,service.impl依赖service.api,但是在jar包内容中并没有service.apiservice.consumerjar包同理。

然后在IDEA运行时,修改配置,在虚拟机参数(VM Options)中添加模块路径:

image-20250307194708927

最后点击运行,结果正常:

image-20250307194843898

为什么我们的模块service.consumer没有依赖service.impl,也可以正常输出结果呢?这是因为服务实现者模块描述文件中声明了该模块提供哪些服务,那么在运行时,由服务发现模式自动扫描模块路径下的模块,寻找服务提供者,然后自动引入。

现在我们再创建一个模块,并将其打成jar包放进模块路径中:

txt
module service.another.impl {
    requires service.api;

    provides com.lee.api.HelloService with org.lee.impl.ChineseHelloService;
}
java
package org.lee.impl;

import com.lee.api.HelloService;

public class ChineseHelloService implements HelloService {
    @Override
    public void hello() {
        System.out.println("你好");
    }
}

image-20250307195702336

再次运行ConsumerDemo,结果如下:

image-20250307195753190

4.4 反射相关

在Java 9之前,我们可以使用反射访问私有的类及其成员变量、成员方法,没有什么内容是真正封装了的。但是在Java 9之后,由于引入了模块化系统,我们可以显式地指明哪些模块可以利用反射访问我们的私有类及其成员。默认情况下,其他模块是不允许利用反射访问私有类的。

如果我们想像以前一样,把整个模块的反射都放开,可以开放整个模块:

txt
open module com.module.one {
}

但如果只想开放某些包中的类,则需要指明:

txt
module com.module.one {
  opens com.module.one.util;
}

上面的命令将指定的包的反射限制开放给所有其他模块,如果我们只想把指定包的反射限制开放给指定的模块,可以使用opens...to命令:

txt
module com.module.one {
    opens com.module.one.util to my.module, moduleTwo;
}

案例演示:

首先创建com.module.one模块,并提供NumberUtil类:

txt
module com.module.one {

}
java
package com.module.one.util;

public class NumberUtil {
    public static int add(int a, int b) {
        return a+b;
    }
}

然后创建my.module模块,并依赖于com.module.one模块,利用反射获取NumberUtil.add()方法:

txt
module my.module {
    requires com.module.one;
}
java
package my.module;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class Demo {
    public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException {
        Class<?> clazz = Class.forName("com.module.one.util.NumberUtil");
        System.out.println(clazz);

        Method add = clazz.getMethod("add", int.class, int.class);
        int sum = (int)add.invoke(null, 1, 2);
        System.out.println(sum);
    }
}

运行结果如下:

txt
class com.module.one.util.NumberUtil
Exception in thread "main" java.lang.IllegalAccessException: class my.module.Demo (in module my.module) cannot access class com.module.one.util.NumberUtil (in module com.module.one) because module com.module.one does not export com.module.one.util to module my.module
    at java.base/jdk.internal.reflect.Reflection.newIllegalAccessException(Reflection.java:394)
    at java.base/java.lang.reflect.AccessibleObject.checkAccess(AccessibleObject.java:714)
    at java.base/java.lang.reflect.Method.invoke(Method.java:571)
    at my.module/my.module.Demo.main(Demo.java:12)

我们可以在com.module.one模块描述文件中加上open命令,以解决该问题:

txt
open module com.module.one {

}
txt
module com.module.one {
    opens com.module.one.util;
}
txt
module com.module.one {
    opens com.module.one.util to my.module;
}

5. 案例演示

在讲解案例之前,我们先来回顾一下java.exe的运行参数:

  • --class-path classpath, -classpath classpath, 或 -cp classpath:指定类路径,类路径可以是目录、jar包或zip文件。在Windows系统下,多个类路径用;分隔,在macOS或Linux系统下,多个路径用:分隔。
  • --module-path modulepath... 或 -p modulepath:指定模块路径,模块路径可以是一个指向模块的路径,也可以是一个包含模块的文件夹。一个模块可以是一个jar包,也可以是一个包含模块内容的普通文件夹(注意,此时必须要有module-info.java编译后的module-info.class文件,该文件夹才能被称为模块)。
  • -m--module module[/mainclass]:指定模块中的主类。

5.1 模块化程序引入非模块化依赖

这个案例基于IDEA,非maven环境。

首先创建一个非模块化程序,程序目录结构如下:

image-20250308135753398

java
package util;

public class StringUtil {
    public static String reverse(String str) {
        return new StringBuilder(str).reverse().toString();
    }
}

然后创建一个模块化程序,程序目录如下:

image-20250308140009083

java
package my.module;

import util.StringUtil;

public class Demo {
    public static void main(String[] args) {
        String reversed = StringUtil.reverse("hello");
        System.out.println(reversed);
    }
}

由于my.module还没有引入non.module依赖,所以会报错,首先在Flile -> Project Structure -> modules -> my.module中添加依赖:

image-20250308140327240

但此时仍然报错:

txt
Package 'util' is declared in the unnamed module, but module 'my.module' does not read it

我们需要在模块描述文件中声明需要使用的模块:

txt
module my.module {
    requires non.module;
}

此时运行程序仍然会报错:

txt
Error occurred during initialization of boot layer
java.lang.module.FindException: Module non.module not found, required by my.module

这是为什么呢?我们看看运行命令(只看关键参数):

bash
xxx/bin/java -p xxx/out/production/my.module:xxx/out/production/non.module -m my.module/my.module.Demo

可以看到使用-p指定了模块路径xxx/out/production/non.module,但是在该路径下并没有non.module.jar文件,所以我们还需要将非模块化项目打成jar包,放在模块路径下:

image-20250308143535062

non.module打成jar包后再次运行程序不报错。

最后总结一下,在模块化程序中引入非模块化jar,就是把非模块化jar包放入模块路径中,将其变为自动模块,模块名称就是jar包名称,然后在模块化程序中引入。

5.2 非模块化程序引入模块化依赖

在非模块化程序中引入模块化依赖,我们不用考虑模块化的影响,直接引入作为依赖即可。

5.3 模块化程序引入模块化依赖

在模块化程序中引入模块化依赖,首先在模块化依赖的module-info.java文件中声明要导出的包:

txt
module com.module.one {
    exports com.module.one.service;
}

然后在模块化程序中引入依赖:

image-20250308145846313

最后在模块描述文件中引入依赖:

txt
module my.module {
    requires com.module.one;
}

之后就可以正常使用模块化依赖中导出的类了:

java
package my.module;

import com.module.one.service.HelloService;

public class Demo {
    public static void main(String[] args) {
        HelloService helloService = new HelloService();
        helloService.hello();
    }
}

6. 模块化的好处

本小节翻译自:https://jenkov.com/tutorials/java/modules.html#java-module-benefits

6.1 更小的分发文件

在Java 9 之前,如果我们要将我们的程序打包分发,那么需要包含JDK自带的所有类,但实际上,我们的程序可能用不到这么多类。在Java 9 之后,模块系统将JDK自带的类分成了一个个模块,我们可以在自己的项目中显式声明需要使用的模块,然后在打包分发时,只会将用到的模块打包,不会将其他没有用到的模块一起打包,所以分发内容会变得更小。

以下内容在Windows平台下验证,分别在JDK 8和JDK 17下打包JavaFX程序,查看打包后的程序大小。

Java 8 分发内容 首先在IDEA中创建JDK 8程序,编写代码如下:

java
package com.lee;

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.Pane;
import javafx.stage.Stage;

public class App8 extends Application {
    @Override
    public void start(Stage primaryStage) throws Exception {
        Label label = new Label("hello world");
        Pane pane = new Pane(label);
        Scene scene = new Scene(pane, 300, 200);

        primaryStage.setScene(scene);
        primaryStage.setTitle("jfx_8");
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

然后将该程序打成jar包: a 最后使用java工具javapackager将jar包打成可运行程序:

bash
D:\JDK\JDK-8\bin\javapackager -deploy -native image -srcdir . -srcfiles java8_jfx.jar -appclass com.lee.App8 -outdir .\dest  -
outfile launcher

最后生成的大小为200M:

并且我们也可以执行dest\bundles\App8\App8.exe程序:

Java 17分发内容

同理首先在IDEA中创建程序,代码如下:

java
package com.lee;

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.Pane;
import javafx.stage.Stage;

public class App17 extends Application {
    @Override
    public void start(Stage primaryStage) throws Exception {
        Label label = new Label("hello world");
        Pane pane = new Pane(label);
        Scene scene = new Scene(pane, 300, 200);

        primaryStage.setScene(scene);
        primaryStage.setTitle("jfx_17");
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

模块描述文件内容如下:

txt
module java17.jfx {
    requires javafx.controls;

    exports com.lee;
}

所以需要在程序依赖中添加JavaFX的依赖,在JavaFX - Gluon上下载:

然后将程序打成jar包:

注意,该jar包不包含JavaFX依赖。

之后我们就可以使用Java工具来打包分发了。

首先使用java.exe来验证我们生成的jar包是否正常:

bash
D:\JDK\JDK-17\bin\java -p .\java17_jfx.jar;D:\JDK\javafx-sdk-21.0.6\lib -m java17.jfx/com.lee.App17

可以正常调用程序。

然后使用jdeps.exe查看程序依赖的模块:

bash
D:\JDK\JDK-17\bin\jdeps.exe --ignore-missing-deps --print-module-deps --module-path .\java17_jfx.jar;D:\JDK\javafx-sdk-21.0.6
\lib .\java17_jfx.jar

结果如下:

txt
java.base,javafx.controls

可以看到我们的程序依赖的模块有java.basejavafx.controls

然后我们使用jlink.exe自定义运行时环境:

bash
D:\JDK\JDK-17\bin\jlink.exe --module-path .\java17_jfx.jar;D:\JDK\javafx-jmods-21.0.6 --add-modules java17.jfx --output .\myj
re

这样会在当前目录下生成myjre的运行时环境。

然后使用jpackage.exe打包:

bash
D:\JDK\JDK-17\bin\jpackage.exe --type app-image -m java17.jfx/com.lee.App17 --runtime-image .\myjre --name App17

最后在当前目录下生成App17的目录,里面包含exe文件,可以双击运行:

并且App17大小只有90M+,所以模块化减少了分发文件的大小:

备注:

6.2 内部包的封装

一个Java模块必须明确指定该模块中的哪些Java包需要被导出(即对其他使用该模块的Java模块可见)。一个Java模块可以包含不被导出的Java包。未导出包中的类不能被其他Java模块使用,这些包只能在定义它们的Java模块内部使用。

未被导出的包也被称为隐藏包或封装包。

6.3 程序启动时模块缺失检测

从Java 9开始,Java应用程序也必须打包为Java模块。因此,一个应用模块需要指定它使用了哪些其他模块(包括Java API模块或第三方模块)。这样,在Java虚拟机启动时,它可以检查整个从应用模块开始的模块依赖图。如果在启动时发现有任何必需的模块缺失,Java虚拟机会报告缺少的模块并关闭。

在Java 9之前,缺失的类(例如,缺少的JAR文件中的类)只有在应用程序实际尝试使用这些缺失的类时才会被检测到。这会在运行时的某个时刻发生——具体取决于应用程序何时尝试使用缺失的类。

相比于在运行时尝试使用缺失的模块/JAR/类时才发现问题,在应用程序启动时就能报告缺失模块是一个巨大的优势。这意味着:

  • 可以更早地发现问题,减少调试难度。
  • 提高了系统的可靠性和稳定性,因为可以在部署阶段就确保所有依赖项都已正确配置。
  • 减少了由于依赖项缺失而导致的应用程序崩溃风险,使得开发和维护过程更加顺畅。

参考资料

[1] https://www.baeldung.com/java-modularity

[2] https://jenkov.com/tutorials/java/modules.html

[3] java相关的工具文档:https://docs.oracle.com/en/java/javase/23/docs/specs/man/index.html