Java模块化

为什么要模块化?

  1. 精简运行时

在JDK 9之前,Java的运行时库(Runtime Library,JRE)包含一个庞大的rt.jar,其大小超过60MB,包含了Java大部分运行时类。随着JDK的发展,JDK增加了许多API,但几乎没有删除任何API,有些类现在己经很少用了,但仍然保留在JRE中,保留它们的唯一目的就是为了保证兼容性。再比如javafx相关的类,即使应用程序用不到,但是还是被包含在最终的生产jar包中。模块化的作用就是将JDK进行模块拆分,让应用程序选择依赖的模块,打包时只打包这些模块,从而可以起到精简运行时的目的。

  1. 保持封装性

模块强调的是封装性,针对于库的开发者来说,经常有这种需求:类A被当前项目中其他包的其他类使用,但是不希望类A被使用这个库的其他项目使用,也就是保持一些API只在项目内部可见。虽然java提供了访问权限,但是只能做到包级别的访问控制。同时为了使代码组织看上去更清晰,私有的API也是需要跨包互相引用的。

模块化基础

JPMS:JPMS 全称Java Platform Module System,Java模块化系统。由JEP261引入(https://openjdk.org/jeps/261
Jigsaw项目:https://openjdk.org/projects/jigsaw/

模块化的JDK

如果你下载JDK8之后版本的JDK,你会发现安装之后里面少了很多在JDK8存在的jar包,这一点就是模块化带来的变化,因为JDK9之后JDK不再以jar包形式发布,而是以模块的形式进行发布,这种JDK就是经过模块化的JDK。

下面分别展示oracle发布的jdk8和jdk11安装后的样子:

[root@localhost java]# ll
total 353836
drwxr-xr-x. 9 root root       126 Jul  7 12:56 jdk-11.0.27
drwxr-xr-x. 8 root root       190 Apr 15 21:37 jdk8u452-b09

[root@localhost java]# find ./jdk-11.0.27/ -type f -name "*.jar"
./jdk-11.0.27/lib/jrt-fs.jar
[root@localhost java]# find ./jdk8u452-b09/ -type f -name "*.jar"
./jdk8u452-b09/lib/dt.jar
./jdk8u452-b09/lib/tools.jar
./jdk8u452-b09/lib/sa-jdi.jar
./jdk8u452-b09/lib/jconsole.jar
./jdk8u452-b09/jre/lib/rt.jar
./jdk8u452-b09/jre/lib/jsse.jar
./jdk8u452-b09/jre/lib/resources.jar
./jdk8u452-b09/jre/lib/ext/sunjce_provider.jar
./jdk8u452-b09/jre/lib/ext/nashorn.jar
./jdk8u452-b09/jre/lib/ext/zipfs.jar
./jdk8u452-b09/jre/lib/ext/sunpkcs11.jar
./jdk8u452-b09/jre/lib/ext/sunec.jar
./jdk8u452-b09/jre/lib/ext/cldrdata.jar
./jdk8u452-b09/jre/lib/ext/jaccess.jar
./jdk8u452-b09/jre/lib/ext/localedata.jar
./jdk8u452-b09/jre/lib/ext/dnsns.jar
./jdk8u452-b09/jre/lib/management-agent.jar
./jdk8u452-b09/jre/lib/security/policy/unlimited/US_export_policy.jar
./jdk8u452-b09/jre/lib/security/policy/unlimited/local_policy.jar
./jdk8u452-b09/jre/lib/security/policy/limited/US_export_policy.jar
./jdk8u452-b09/jre/lib/security/policy/limited/local_policy.jar
./jdk8u452-b09/jre/lib/jce.jar
./jdk8u452-b09/jre/lib/jfr.jar
./jdk8u452-b09/jre/lib/charsets.jar

JDK9之后安装完成后会有一个jmods文件夹,里面包容了很多*.jmod文件,这些就是模块化后的JDK

[root@localhost java]# tree ./jdk-11.0.27 -L 1
./jdk-11.0.27
├── bin
├── conf
├── include
├── jmods
├── legal
├── lib
├── man
├── README.html
└── release

可以通过/path/to/java.exe --list-modules命令列出JDK的模块清单

➜ $JAVA17_HOME/java --list-modules
java.base@17.0.7
java.compiler@17.0.7
java.datatransfer@17.0.7
java.desktop@17.0.7
......

模块描述符 module-info.java

任意一个 jar 文件,只要加上一个合法的模块描述符 (module descriptor),就可以变成为一个模块。

这个模块描述符就是位于 jar 包根路径下的 module-info.class 文件,此文件是由位于根目录下的module-info.java文件编译而来的,是 Java 模块化系统的核心文件。它用于定义模块的元数据和配置,明确模块的边界、依赖关系和访问权限

module-info.java 文件是应该位于源文件的根目录(也就是默认包名所在的目录)。主要内容如下:

  1. 定义模块名称:每个模块都有一个唯一的名称,通过 module 关键字定义。模块名称是模块的唯一标识符,不同的两个模块不能有相同的包名。通常采用反向域名命名法(如 com.example.mymodule)。
  2. 声明模块依赖:使用 requires 关键字声明模块所依赖的其他模块。依赖的模块可以是标准库模块(如 java.base)或其他自定义模块。
  3. 导出包:使用 exports 关键字声明模块对外公开的包。只有导出的包才能被其他模块访问,未导出的包是模块私有的。
  4. 提供Java的SPI服务:使用 provides 关键字声明模块提供的 SPI 服务实现。
  5. 使用 SPI 服务:使用 uses 关键字声明模块使用的服务接口。模块可以通过 SPI 机制动态获取服务实现。
  6. 开放反射访问:使用 opens 关键字开放某些包,允许其他模块通过反射访问这些包中的类和成员。默认情况下,模块中的类不允许通过反射访问。

通过/path/to/java --describe-module 模块名命令可以查看模块的信息,这个信息实际上就是 module-info.java 文件中的内容

➜ .\bin\java --describe-module java.base
java.base@11.0.18
exports java.io
exports java.lang
exports java.lang.annotation
exports java.lang.invoke
exports java.lang.module
......
exports java.nio.charset.spi
exports java.nio.file
exports java.nio.file.attribute
......

完整的内容如下:

module com.example.mymodule {
    // 依赖其他模块
    requires java.base;

    // 导出包,允许其他模块访问
    exports com.example.mymodule.api;
    exports com.example.mymodule.util;

    // 提供 SPI 服务
    provides com.example.mymodule.spi.MyService with com.example.mymodule.impl.MyServiceImpl;

    // 使用服务
    uses com.example.mymodule.spi.MyService;

    // 开放包,允许其他模块通过反射访问
    opens com.example.mymodule.internal;
}

requires

不同模块之前的类要互相使用,需要先通过 require 声明需要的模块

module com.example.mymodule {
    requires java.base;
	requires com.example.othermodule;
    // 传递依赖
    requires transitive com.example.othermodule;
}

exports

模块 A 要使用模块 B 中包 com.example.moduleb.api 下的类,首先需要 require 该模块,然后需要在模块 B 的模块描述符中声明导出该包,然后模块 A 中才能 import com.example.moduleb.api,否则,IDE 也不会提示 com.example.moduleb.api 包下的类

module moduleb {
    exports com.example.moduleb.api;
}

opens

允许其他模块通过反射访问指定包下的类,如果未指定,则会在运行时进行反射操作时报错

module com.example.mymodule {

    opens com.example.mymodule.internal;
}

provides/uses

provide 声明提供的 SPI 服务实现,use 声明此模块使用的 SPI 服务

module mymodule {
    // 依赖其他模块
    requires java.base;

    // 提供 SPI 服务
    provides com.example.mymodule.spi.MyService with com.example.mymodule.impl.MyServiceImpl;

    // 使用服务
    uses com.example.mymodule.spi.MyService;
}

Classpath和Modulepath

module-path是和classpath类似的一个概念,如同只会加载classpath下的类一样,模块系统使用它来定位平台模块中找不到的所需模块,只有在module-path路径下的才会在启动时被识别为模块。所有 modulepath 下的元素都会被视为模块,即使是普通的 jar 包。

模块路径是一个列表,每个元素是一个模块的路径或者包含模块的路径。根据操作系统的不同,模块路径元素可以用以下方式分隔:

Windows下是;,Linux下是:,这一点和 classpath 也是一样的。

JDK9及以后,javac 和 java 以及其他与模块相关的命令都可以处理 modulepath:

  1. --module-path 参数 或者 -p:此参数用于指定模块路径,模块位置
  2. --add-modules 参数

例如java --add-modules my.module.name -jar myapp.jar,这个参数主要有2个作用

  1. 添加模块:允许在启动 Java 应用程序时指定要加载的模块,特别是对于未在模块路径中列出的模块。
  2. 解决依赖问题:在某些情况下,应用程序可能依赖于特定的模块,而这些模块未被自动加载。使用 --add-modules 可以明确指定这些模块,确保它们在运行时可用。

在很多方面,可以将Modulepath视为Classpath的替代者,但是并不是说Classpath这个概念就被移除了,因为要保证兼容性。

不使用模块路径,而是通过类路径来构建和运行应用程序时,只是没有在应用中使用新的模块特性而已。这对现有代码的改动需求极小,甚至无需改动。大致来说,只要应用程序及其依赖项仅使用 JDK 中官方认可的 API,它在JDK9及以上版本就应该能顺利编译和运行。

从 Java 9 开始,无论你的应用程序是否使用模块,它所运行的 JDK 始终由模块组成。在多数情况下,模块化的 JDK 不会给基于类路径的应用程序带来问题。由于 JDK 本身已经模块化了,因此某些场景下不可避免地需要做一些改动,这些场景大多与一些三方库相关。尽管在这些场景下,应用程序层面几乎无需关注模块系统,但 JDK 结构的变化是客观存在的。

模块化迁移-不模块化

Java一直保持了很好的向后兼容性,因此即使有了模块系统,但是对于应用来说,模块化升级不是必须的。我们可以只使用高版本的JDK,不使用模块化,这样和JDK8的代码在很多场景下没有区别。但是毕竟JDK底层是做了改动了,所以在一些场景下还是有一些问题的

下面将描述JDK为模块化做了哪些兼容性工作,以及迁移过程中的一些常见场景及解决方案

反射操作

某些库使用了平台模块中一些私有的API,通过反射强行暴露出来,这个操作与模块化所提倡的封装理念是相违背的

以javassist这个库为例:

<dependency>
    <groupId>org.javassist</groupId>
    <artifactId>javassist</artifactId>
    <version>3.28.0-GA</version>
</dependency>

下面是一个示例:

import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import java.lang.reflect.Method;

public class CreateClassExample {
  public static void main(String[] args) throws Exception {
    ClassPool pool = ClassPool.getDefault();
    CtClass newClass = pool.makeClass("com.example.MyClass");
    String method = "public void sayHello() { System.out.println(\"Hello, World!\"); }";
    CtMethod method = CtMethod.make(method, newClass);
    newClass.addMethod(method);
    Class<?> clazz = newClass.toClass(); 				// 将类加载到 JVM 中
    Object obj = clazz.getConstructor().newInstance();  // 实例化并调用方法
    Method sayHello = obj.getClass().getDeclaredMethod("sayHello");
    sayHello.invoke(obj);
  }
}

JDK8下运行这段代码没有问题

JDK11下这段代码执行会报警告

image-20250726235213598

除了这条警告外,应用程序仍会照常运行。正如警告信息所提示的,这一行为将在 Java 的后续版本中发生改变。未来,JDK 会对平台模块强制执行严格的封装,即便对于类路径上的代码也是如此。在未来的 Java 版本中,相同的应用程序在默认设置下将无法运行。例如:JDK17运行这段代码直接报错

image-20250726235359722

这个错误表示javaassist正在使用java.lang.ClassLoader#defineClass(byte[], int, int)这个非公开的API

默认情况下,首次发生非法访问尝试时只会生成一条警告,后续的尝试不会产生额外的错误或警告。如果我们想进一步排查问题的原因,可以通过为 --illegal-access 命令行标志设置不同的值来调整其行为:

--illegal-access=permit

  • 行为:这是默认行为。允许对封装类型进行非法访问,但在首次通过反射进行非法访问时会生成一条警告信息。
  • 说明:此模式下,程序可以正常执行非法访问操作,仅在第一次出现时提醒开发者存在不合规的访问行为,后续访问不再警告。

--illegal-access=warn

  • 行为:与permit类似,允许非法访问,但每次非法访问尝试都会生成错误信息(注意这里的 “error” 更偏向于警告级别,不会直接终止程序执行)。
  • 说明:相比permit,此模式更严格,通过反复提醒来强调非法访问的频繁性,促使开发者修复问题。

--illegal-access=debug

  • 行为:在warn模式的基础上,额外显示非法访问尝试的堆栈跟踪(stack traces)
  • 说明:堆栈跟踪能精确显示非法访问发生的代码位置(类、方法、行号等),方便开发者定位问题根源,常用于调试阶段。

--illegal-access=deny

  • 行为不允许任何非法访问尝试,一旦发生非法访问,会直接阻止操作(可能导致程序抛出异常或终止)。
  • 说明:这是 Java 未来计划采用的默认模式,旨在强化封装性和安全性,符合模块化系统(如 Java 9 引入的模块系统)的设计理念,强制开发者通过合规方式访问类成员。

注意,在设计上是禁止消除这个打印的警告的,并且--illegal-access=deny 是未来版本的默认设置,所以必须从根本上解决非法访问警告

--add-opens / --add-exports

对于一些无法控制的模块,例如系统模块,可以手动使反射合法化,添加启动参数:

--add-opens java.base/java.lang=ALL-UNNAMED

这个的意思是ALL-UNNAMED访问java.base/java.lang,而ALL-UNNAMED代表整个类路径,因为目前迁移是未添加模块化特性的,所以是ALL-UNNAMED

同样的,对于某些未被导出的API,如果要使用它,可以通过--add-exports来强行导出,例如

--add-exports java.base/sun.security.x509=ALL-UNNAMED 

命令行参数过长

一些操作系统对命令的长度有限制,所以如果使用--add-opens和--add-exports时很容易超过限制。这时可以将参数放到文件里面,然后执行java或javac:java @arguments.txt

-cp application.jar:javassist.jar 
--add-opens java.base/java.lang=ALL-UNNAMED 
--add-exports java.base/sun.security.x509=ALL-UNNAMED 
-jar application.jar

sun.misc.Unsafe使用

JDK中有些API只应该在JDK内部使用,常见的比如下面这些:

sun.misc.Signal
sun.misc.SignalHandler
sun.misc.Unsafe
sun.reflect.Reflection::getCallerClass(int)
sun.reflect.ReflectionFactory::newConstructorForSerialization

对于模块化系统来说,这些API应该是要封装起来的,但是一些库广泛使用了这些API,因此JDK9做了一个妥协:这些API被放在了jdk.unsupported这个模块中,并且这个模块export了这些应该被封装的API

./jdk-11.0.27/bin/java  --describe-module jdk.unsupported
[root@localhost java]# ./jdk-11.0.27/bin/java  --describe-module jdk.unsupported
jdk.unsupported@11.0.27
exports com.sun.nio.file
exports sun.misc
exports sun.reflect
requires java.base mandated
opens sun.reflect
opens sun.misc

Unnamed Module

UNNAMED-MODULE 是一种特殊的模块,用于兼容非模块化的代码(即未明确声明模块的代码)。它是 Java 模块化系统的一部分,旨在确保在模块化环境中仍然能够运行传统的、非模块化的 JAR 包和类。

当类路径(--class-path)上的 JAR 包或类没有被明确声明为模块时,Java 会将它们放入 UNNAMED-MODULE 中,即类路径下所有未模块化的类和jar包在一个叫做UNNAMED-MODULE 的模块下

这样可以确保传统的、非模块化的代码仍然可以在模块化系统中运行。

默认访问权限:UNNAMED-MODULE 默认可以读取所有其他模块(包括命名模块和自动模块)。但是,命名模块不能读取 UNNAMED-MODULE 中的类,除非显式地开放包或使用反射。

自动模块的依赖:自动模块(Automatic Module)可以读取 UNNAMED-MODULE 中的类。

UNNAMED-MODULE 的特点

  1. 没有模块名称:或者说模块名称就是 UNNAMED-MODULE
  2. 默认导出所有包:默认导出所有包,但这些包只能被自动模块访问,而不能被命名模块访问。
  3. 无法显式依赖:命名模块无法在 module-info.java 中声明对 UNNAMED-MODULE 的依赖(例如 requires UNNAMED-MODULE; 是无效的)。
  4. 反射访问:通过反射可以访问 UNNAMED-MODULE 中的类,但需要显式地开放包或使用 --add-opens 参数。例如
--add-opens javafx.controls/javafx.scene.control.skin=ALL-UNNAMED

在这个例子中,将 javafx.controls 中的 javafx.scene.control.skin 包被开放给所有匿名模块(ALL-UNNAMED),这样所有匿名模块就可以使用反射访问该包中的私有类或成员。

当运行一个非模块化的应用程序时,所有的类都会被加载到 UNNAMED-MODULE 中。
在模块化应用程序中,如果需要使用非模块化的 JAR 包,可以将这些 JAR 包放在类路径上,它们会被加载到 UNNAMED-MODULE 中。

模块化迁移-支持模块化

参考自:https://www.youtube.com/watch?v=p7jCvbzqnS0

如果想创建一个完全模块化的应用,但是依赖的某些第三方jar包还未完成模块化,等待这些jar包的维护者完成模块化是不现实的,所以我们需要手动迁移这些未模块化的jar包。下面将以实际案例来完成这一过程:

环境准备

操作环境Windows或者Linux都可以,不使用IDE,只需要安装java即可,同时安装jdk8和jdk11

首先准备一个简单的项目,结构如下所示

[root@localhost java]# tree ./tweet/
./tweet/
├── lib
│   ├── jackson-annotations-2.6.7.jar
│   ├── jackson-core-2.6.7.jar
│   └── jackson-databind-2.6.7.jar
└── src
    ├── MyApp.java
    └── Tweet.java

下面是源码文件的内容:

Tweet.java
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.sql.Timestamp;

public class Tweet {
    String text;
    @JsonProperty("create_at")
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "EEE MMM d HH:mm:ss Z yyy", timezone = "GMT")
    Timestamp time;

    public String getText() { return text; }
    public void setText(String text) { this.text = text; }
    public Timestamp getTime() {return time;}
    public void setTime(Timestamp time) { this.time = time; }
    @Override
    public String toString() { return time + ": " + text; }
}
MyApp.java
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.util.List;

public class MyApp {
    public static void main(String[] args) throws IOException {
        ObjectMapper mapper = new ObjectMapper();
        mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
        List<Tweet> tweets = mapper.readValue(System.in, new TypeReference<List<Tweet>>() {
        });
        tweets.forEach(t -> System.out.format("%n%s: %s%n", t.getTime(), t.getText()));
    }
}

接下来编译项目

[root@localhost tweet]# javac -d classes -cp lib/jackson-annotations-2.6.7.jar:lib/jackson-core-2.6.7.jar:lib/jackson-databind-2.6.7.jar $(find src/ -name '*.java')
[root@localhost tweet]# tree ./classes/
./classes/
├── MyApp$1.class
├── MyApp.class
└── Tweet.class

# PowerShell 使用下面这个命令
javac -d classes -cp "lib/jackson-annotations-2.6.7.jar;lib/jackson-core-2.6.7.jar;lib/jackson-databind-2.6.7.jar" (Get-ChildItem -Recurse -Path "src/" -Filter "*.java").FullName

通过下面的命令将项目打成 jar 包

jar --create --file ./lib/tweet.jar -C ./classes .

下面是测试数据

[ {
  "text" : "AI won't replace you. But someone using AI might. Time to level up!",
  "create_at" : "2025-08-12 14:27:37"
}, {
  "text" : "Building a startup is like jumping off a cliff and assembling the plane on the way down.",
  "create_at" : "2016-08-12 14:27:37"
} ]

执行程序,验证程序功能正常。

# Linux
java -cp "./lib/jackson-core-2.6.7.jar;./lib/jackson-annotations-2.6.7.jar;./lib/jackson-databind-2.6.7.jar;./lib/tweet.jar" MyApp <tweet.json
# PowerShell
Get-Content "tweet.json" -Encoding UTF8 | java -cp "./lib/jackson-core-2.6.7.jar;./lib/jackson-annotations-2.6.7.jar;./lib/jackson-databind-2.6.7.jar;./lib/tweet.jar" MyApp

jdeps 工具

jdeps 是 Java 8 及更高版本中提供的工具,用于分析 Java 类文件的依赖关系。java8 就提供了这个工具

常用参数

  1. 输入控制参数
参数 描述
--class-path <path> 指定查找用户类文件的位置
--module-path <path> 指定模块路径
--upgrade-module-path <path> 指定升级模块路径
--system <jdk> 指定系统 JDK 路径
--multi-release <version> 指定多版本 JAR 文件的版本 (如 9, 11)
  1. 依赖分析参数
参数 描述
-v-verbose 显示详细依赖信息
-s-summary 显示依赖摘要
-dot-output <dir> 生成 DOT 格式的依赖图文件
--package <pkg> 仅分析指定包的依赖
--regex <regex> 仅分析与正则表达式匹配的依赖
--require <module> 仅分析指定模块的依赖
-jdkinternals 分析使用 JDK 内部 API 的情况
-apionly 仅分析从API导出的依赖
  1. 输出控制参数
参数 描述
-q-quiet 静默模式,不显示类级依赖
-p <pkg> 仅显示指定包的依赖
-e <regex> 仅显示与正则表达式匹配的依赖
--filter <regex> 过滤掉匹配的依赖
--filter:archive 过滤掉归档内部的依赖
--filter:package 过滤掉同一包内的依赖
--filter:module 过滤掉同一模块内的依赖
  1. 模块化分析参数
参数 描述
--module <module> 仅分析指定模块
--generate-module-info <dir> 生成模块描述符 (module-info.java)
--generate-open-module <dir> 生成开放模块描述符
--check <module> 检查模块的依赖
--list-deps 列出依赖的模块和 JDK 内部 API
--list-reduced-deps 列出简化后的依赖关系
--print-module-deps 打印模块依赖关系
  1. 其他参数
参数 描述
-h-help 显示帮助信息
--version 显示版本信息
-cp-classpath --class-path
-m--module --module

实际案例

基本依赖分析

# 分析单个JAR文件的依赖
jdeps ./jackson-core-2.9.0.jar

# 显示详细依赖信息: 具体到一个jar包中的每个类依赖的哪些类,以及依赖的这些类是在哪个jar包或是哪个模块
➜ jdeps -v ./jackson-core-2.9.0.jar
jackson-core-2.9.0.jar -> java.base
   com.fasterxml.jackson.core.Base64Variant           -> com.fasterxml.jackson.core.Base64Variants          jackson-core-2.9.0.jar
   com.fasterxml.jackson.core.Base64Variant           -> com.fasterxml.jackson.core.util.ByteArrayBuilder   jackson-core-2.9.0.jar
   com.fasterxml.jackson.core.Base64Variant           -> java.io.Serializable                               java.base
   ......
   com.fasterxml.jackson.core.Base64Variant           -> java.lang.Character                                java.base
   com.fasterxml.jackson.core.Base64Variant           -> java.lang.IllegalArgumentException                 java.base
   com.fasterxml.jackson.core.Base64Variant           -> java.lang.Integer                                  java.base
   com.fasterxml.jackson.core.Base64Variant           -> java.lang.Object                                   java.base
   com.fasterxml.jackson.core.Base64Variant           -> java.lang.String                                   java.base
   com.fasterxml.jackson.core.Base64Variant           -> java.lang.StringBuilder                            java.base

# 显示依赖摘要: 这个信息就比较简单了,只会列出依赖哪个jar包或者模块
> jdeps -s ./jackson-core-2.9.0.jar
jackson-core-2.9.0.jar -> java.base



列出模块依赖

列出jar包依赖的jar包或者模块,例如:jdeps --list-deps myapp.jar

# 列出了jar包依赖的 jar 包
[root@localhost tweet]# $JAVA8_HOME/bin/jdeps -s ./lib/jackson-*.jar
jackson-annotations-2.6.7.jar -> /root/develop/java/jdk8u452-b09/jre/lib/rt.jar
jackson-core-2.6.7.jar -> /root/develop/java/jdk8u452-b09/jre/lib/rt.jar
jackson-databind-2.6.7.jar -> ./lib/jackson-annotations-2.6.7.jar
jackson-databind-2.6.7.jar -> ./lib/jackson-core-2.6.7.jar
jackson-databind-2.6.7.jar -> /root/develop/java/jdk8u452-b09/jre/lib/rt.jar

# 使用JDK11执行是下面这样:列出了 jar 包依赖的模块
[root@localhost tweet]# $JAVA11_HOME/bin/jdeps -s ./lib/jackson-*.jar
jackson-annotations-2.6.7.jar -> java.base
jackson-core-2.6.7.jar -> java.base
jackson-databind-2.6.7.jar -> ./lib/jackson-annotations-2.6.7.jar
jackson-databind-2.6.7.jar -> ./lib/jackson-core-2.6.7.jar
jackson-databind-2.6.7.jar -> java.base
jackson-databind-2.6.7.jar -> java.sql
jackson-databind-2.6.7.jar -> java.xml

生成.dot文件保存的依赖图

dot 是一个绘图语言,可以表达有向图和无向图,用dot 语言描述的图可以render 成png、jpeg、pdf、svg 等等各种格式,非常方便。可视化dot文件,参考:https://blog.csdn.net/qq_44846964/article/details/134888257

> jdeps --dot-output ./ ./jackson-core-2.6.7.jar
> cat .\summary.dot
digraph "summary" {
  "jackson-core-2.6.7.jar"                           -> "java.base (java.base)";
}

生成module-info.java

jdeps可以直接基于没有模块化的jar包生成module-info.java文件,利用这个我们可以制作模块化的 jar 包。使用方法如下:

jdeps --generate-module-info <output_directory> <path_to_your_class_files>

  1. <output_directory>:指定生成的 module-info.java 文件的输出目录。
  2. <path_to_your_class_files>:指定你的 .class 文件的路径。可以是jar包

举个例子:以下的命令会在<output_directory>下输出一个模块名称对应的目录,目录下就是生成的 module-info.java 文件,这个模块名称是根据前面所说针对Auto-Module的命名策略进行生成的

[root@localhost tweet]# $JAVA11_HOME/bin/jdeps --generate-module-info src ./lib/jackson-core-2.6.7.jar
writing to src/jackson.core/module-info.java

[root@localhost tweet]# cat ./src/jackson.core/module-info.java
module jackson.core {
    exports com.fasterxml.jackson.core;
    exports com.fasterxml.jackson.core.base;
    exports com.fasterxml.jackson.core.filter;
    exports com.fasterxml.jackson.core.format;
    exports com.fasterxml.jackson.core.io;
    exports com.fasterxml.jackson.core.json;
    exports com.fasterxml.jackson.core.sym;
    exports com.fasterxml.jackson.core.type;
    exports com.fasterxml.jackson.core.util;

    provides com.fasterxml.jackson.core.JsonFactory with
        com.fasterxml.jackson.core.JsonFactory;

}

还可以一次性生成所有jar包的module-info.java文件

[root@localhost tweet]# $JAVA11_HOME/bin/jdeps --generate-module-info src ./lib/jackson*
writing to src/jackson.annotations/module-info.java
writing to src/jackson.core/module-info.java
writing to src/jackson.databind/module-info.java

自动生成的 module-info.java 文件如果有需要修改的内容,需要进行适当调整。

有了 module-info.java 文件这个模块描述符以后,我们需要将其编译写入 jar 包中。而编译 module-info.java 则需要该 jar 包的源码文件

检查是否使用了JDK内部API

使用下面的方式检查是否使用了 jdk.internals 相关 API

在 JDK 内部 API 上查找类级别的被依赖对象。除非指定了 -include 选项,否则默认情况下, 它会分析 --class-path 上的所有类和输入文件。此选项不能与 -p, -e 和 -s 选项一起使用。警告: 无法访问 JDK 内部 API。

jdeps -jdkinternals ./jackson-core-2.6.7.jar
# 或者
jdeps --jdk-internals ./jackson-core-2.6.7.jar

多版本JAR分析

分析多版本JAR的特定版本,指定处理多发行版 jar 文件时的版本。<版本> 应为大于等于 9 的整数或基数。例如

jdeps --multi-release 11 ./jackson-core-2.6.7.jar

过滤输出

# 只显示com.example包的依赖
jdeps -p com.example ./jackson-core-2.6.7.jar

# 过滤掉java.base模块的依赖
jdeps --filter:module java.base ./jackson-core-2.6.7.jar

Automatic Modules

jackson是开源的,我们可以基于它的源码构建一个模块化的版本,但是在依赖很多的情况下, 一个一个进行模块化显然不太现实。因此JPMS提供了一个特性:automatic modules(自动模块)

可以通过将现有 JAR 文件从类路径移动到模块路径,而不更改其内容。这会将 JAR 转换为带有模块描述符的模块,由模块系统动态生成。相比之下,显式模块总是有一个用户定义的模块描述符。到目前为止,我们看到的所有模块,包括平台模块,都是显式模块。
自动模块的行为与显式模块不同。自动模块具有以下内容

自动模块的特性:

  1. 不包含module-info.class文件
  2. 模块名基于META-INF/MANIFEST.MF文件Automatic-Module-Name的这个属性指定或者从文件名进行推断
  3. requires transitive了模块关系图所有其他模块,也就是说require了一个自动模块,那么就自动所有其他模块
  4. 导出自身的所有包
  5. 会读取classpath
  6. 不能与其他模块进行拆分包

对于普通的jar包,没有module-info.class文件,如果要参与模块化系统的构建中,那么需要为其指定模块信息。此时JPMS只能根据一定的规则来推测生成模块信息

对于一个模块重点需要关注的有3点:

  1. 模块名称

确定模块名称有2种策略

  1. 在签名文件中定义了Automatic-Module-Name这个头,那么该属性的值就是模块名称
  2. 取该jar包的名称作为模块名称:这种方式是不可控的,因为jar包名称就是普通的文件名称,可以随便指定

模块名称,模块取名算法如下:dash(-)替代为dot(.);省略版本号

  1. 该模块依赖哪些东西

简单来说,就是确定module-info.java中的requires部分,由于未显示指定,JPMS也不知道这个jar包依赖哪些东西

  1. Exports/Opens

导出所有的公共内容,并隐式从其他模块导入所有公共内容。

模块化 jar 包

将一个非模块化的 jar 包转化为模块化的 jar 包要经过以下几个步骤:

  1. 使用 jdeps 生成 module-info.java

    jdeps --generate-module-info <output_dir> <jar_path>
    
  2. 使用 javac编译 module-info.java 文件

    javac --patch-module <module_name>=<jar_path> <module_name>/module-info.java
    

    使用--patch-module这个选项用来在编译时用指定的 JAR 文件或目录中的类和资源覆盖或者扩展模块,本文后面会详细介绍这个参数。

  3. 使用 jar 将编译后的 module-info.class 文件添加到 jar 包中

    jar uf <jar_path> -C <module_name> module-info.class
    

以前面的 jackson 这些 jar 包为例,目录结构如下:

├─lib
│      jackson-annotations-2.6.7.jar
│      jackson-core-2.6.7.jar
│      jackson-databind-2.6.7.jar
├─src
│      MyApp.java
│      Tweet.java
└─target
    └─classes
            MyApp.class

第一步:生成 module-info.java 文件

jdeps --generate-module-info ./mods ./lib/*.jar

第二步:使用下面的命令进行编译

javac -d ./mods/jackson.core --patch-module jackson.core=./lib/jackson-core-2.6.7.jar `
	./mods/jackson.core/module-info.java
javac -d ./mods/jackson.annotations --patch-module jackson.annotations=./lib/jackson-annotations-2.6.7.jar `
	./mods/jackson.annotations/module-info.java
javac -d ./mods/jackson.databind --patch-module jackson.databind=./lib/jackson-databind-2.6.7.jar ` 
	./mods/jackson.databind/module-info.java

注意:上面的命令添加了换行符(Windows是 `, Linux 是 \),使用时有问题的话注意去掉

当处理 jackson.databind 时报下面的错误:

.\mods\jackson.databind\module-info.java:2: 错误: 找不到模块: jackson.annotations
    requires transitive jackson.annotations;
                               ^
.\mods\jackson.databind\module-info.java:3: 错误: 找不到模块: jackson.core
    requires transitive jackson.core;

这是因为前面为 jackson.databind 的 jar 包生成的 module-info.java 里 require 了这两个模块,而我们现在根本没有这两个模块,所以肯定会报错。前面我们提到了自动模块,所以我们可以将 jackson.core 和 jackson.annotations 这两个 jar 包添加到 module-path 下,这样在编译时就能找到了。注意:根据自动模块的命名规则,需要确保使用的 jar 包对应的模块名称符合要求。

javac -d ./mods/jackson.databind --patch-module jackson.databind=./lib/jackson-databind-2.6.7.jar `
	--module-path ./lib ./mods/jackson.databind/module-info.java

这样编译仍然会有警告,警告可以先不管

➜ javac -d ./mods/jackson.databind --patch-module jackson.databind=./lib/jackson-databind-2.6.7.jar `
			--module-path ./lib ./mods/jackson.databind/module-info.java
.\mods\jackson.databind\module-info.java:2: 警告: 需要自动模块的过渡指令
    requires transitive jackson.annotations;
                               ^
.\mods\jackson.databind\module-info.java:3: 警告: 需要自动模块的过渡指令
    requires transitive jackson.core;
                               ^
2 个警告

➜ tree /f
├─lib
│      jackson-annotations-2.6.7.jar
│      jackson-core-2.6.7.jar
│      jackson-databind-2.6.7.jar
├─mods
│  ├─jackson.annotations
│  │      module-info.class
│  │      module-info.java
│  ├─jackson.core
│  │      module-info.class
│  │      module-info.java
│  └─jackson.databind
│          module-info.class
│          module-info.java
├─src
│      MyApp.java
│      Tweet.java
└─target
    └─classes
            MyApp.class

接下来是第三步,将 module-info.class 插入到 jar 包中

jar uf ./lib/jackson-annotations-2.6.7.jar -C ./mods/jackson.annotations ./module-info.class
jar uf ./lib/jackson-core-2.6.7.jar -C ./mods/jackson.core ./module-info.class
jar uf ./lib/jackson-databind-2.6.7.jar -C ./mods/jackson.databind ./module-info.class

经过这些步骤,jackson 这 3 个普通的 jar 包变成了一个模块化的 jar 包。除上面这种方式之外,还有另一种方式:将 jar 包解压成目录,然后混合生成的 module-info.java 一起编译。

模块化普通 jar 包这一步遇到问题的话可以参考以下资料:

  1. https://stackoverflow.com/questions/47222226/how-to-inject-module-declaration-into-jar

  2. https://stackoverflow.com/questions/77634820/how-to-create-a-module-info-class-file-and-add-it-to-the-jar

分包问题 Split Package

简单说就是:JPMS 不允许不同模块导出相同的包名

为了确保一致性,模块系统不允许同一个包从两个不同的模块中被读取。任何两个命名模块都不允许包含相同的包(无论是否导出这个包)。模块系统基于这个假设运行,每当需要加载类时,它会先查找包含该包的模块,然后在该模块中寻找类(这有助于提升类加载性能)。为了维护这个假设,模块系统会检查所有命名模块,确保没有包拆分现象,一旦发现就会报错。但在迁移过程中,旧的代码在类路径下,这些代码会被放入未命名模块中。为了最大限度保持兼容性,不会对这些代码严格应用模块相关的检查规则。这意味着命名模块(例如JDK中的模块)与未命名模块之间的包拆分不会被检测出来。

这种机制会给类加载带来一个问题:如果一个包在模块和类路径之间被拆分,对于该包中的类,类加载器将始终且仅会在模块中查找。当应用程序依赖的第三方库包含与JDK模块相同包名的类时,这意味着位于类路径部分的包中的类实际上是不可见的。这些类将无法被加载,或者两个第三方库包含有同名的类,其中一个库的类无法被加载,这两种情况都会引发 ClassNotFoundExceptionNoClassDefFoundError。这是Java模块化系统设计中为了保持模块封闭性而做出的权衡,可以通过 --patch-module 等机制显式解决这类包冲突问题。

遇到有这种问题的三方库,一般有以下几种解决办法:

  1. 使用一些maven或者gradle插件合并Jar

  2. 使用--path-module参数,见本文后面--patch-module参数的使用

  3. 放弃模块化

模块化相关命令行参数

--module-source-path

--module-source-path 是 Java 命令行工具的一部分,尤其在使用 javac 编译模块化的 Java 应用程序时非常重要。这个参数用于指定模块的源代码路径,帮助编译器找到模块的源代码。

--module-source-path 可以指定一个或多个目录,这些目录中包含模块的源代码。编译器将根据这些路径来查找和编译模块。

--module-source-path 参数使得编译模块化代码变得更容易。

假设有一个模块化的项目结构如下:

myproject/
├── moduleA/
│   ├── src/
│   │   └── module-info.java
│   │   └── com/example/moduleA/
│   │       └── MyClassA.java
└── moduleB/
    ├── src/
    │   └── module-info.java
    │   └── com/example/moduleB/
    │       └── MyClassB.java

可以使用以下命令编译这两个模块:

javac --module-source-path myproject -d out $(find myproject -name "*.java")

参数说明

  1. --module-source-path myproject:指定根目录 myproject 作为模块源路径。
  2. -d out:指定输出目录为 out,编译后的文件将放在这个目录中。
  3. $(find myproject -name "*.java"):查找所有 Java 源文件。

--add-exports

此参数主要用于访问内部 API,可以在javajavac中使用

一般使用格式为

java --add-exports $module/$package=$readingmodule

表示将$module这个模块下的$package这个包导出给 $readingmodule进行读取,从而使得$readingmodule模块中的代码可以读取$module 这个模块下的 $package 这个包下的所有 public 元素

注意--add-exports应该只是个临时方案,如果依赖的API有可选的替代品,并且是public的,或者随着一些库的升级,提供了一些public的相同功能的API,应该尽量修改代码进行适配

--add-opens

开放反射权限

Java 的 --add-opens $module/$package=$reflectingmodule 选项可用于将 $module 模块的 $package 包开放给 $reflectingmodule 模块进行反射操作。这样,$reflectingmodule 中的代码就能通过反射访问 $package 中的所有类型和成员,而其他模块则无法访问。

当将 $reflectingmodule 设置为 ALL-UNNAMED 时,类路径上的所有代码都可以通过反射访问该包。在迁移到 Java 9 的过程中,访问内部 API 时通常会使用这个占位符——只有当您自己的代码在模块中运行时,将导出限制到特定模块才真正有意义。

举个例子,像 Guice 这样的依赖注入库使用了类加载器的内部 API,这会导致如下错误:

Caused by: java.lang.reflect.InaccessibleObjectException:
Unable to make ClassLoader.defineClass accessible:
module java.base does not "opens java.lang" to unnamed module

请注意,错误消息指出了具体的问题,包括包含该类的模块。为了解决这个问题,我们只需要开放包含该类的包即可:

java --add-opens java.base/java.lang=ALL-UNNAMED --class-path $dependencies -jar $appjar

注意:--add-opens 是引入模块系统后,为了允许对特定模块的包进行反射访问而设计的命令行参数。它主要用于解决应用程序或第三方库(如 Guice、Hibernate 等)在尝试通过反射访问 JDK 内部 API 时遇到的 java.lang.reflect.InaccessibleObjectException 异常。虽然这个参数在迁移阶段很有用,但过度使用可能会带来安全风险和稳定性问题,因此建议仅在必要时使用,并考虑在长期通过其他方式(如使用官方API)避免对内部API的依赖。

--add-reads

编译期及运行时的 --add-reads $module=$targets 选项会在 $module 与逗号分隔列表 $targets 中的所有模块之间建立可读性连接。这使得 $module 能够访问这些模块所导出包中的所有public类型,即使 $module 没有通过 requires 子句声明对这些模块的依赖。如果将 $targets 设置为 ALL-UNNAMED$module 就可以读取未命名模块中的内容。

--add-reads 主要应用于以下场景:

  1. 解决模块间非声明性依赖的需求
  2. 在迁移期间临时处理模块依赖问题
  3. 允许模块访问未命名模块中的类(通过 ALL-UNNAMED 参数)
  4. 实现某些框架所需的动态依赖解析

需要注意的是,虽然这个选项提供了灵活性,但过度使用可能会破坏模块系统的封装性设计原则,因此在生产环境中应谨慎使用,优先考虑通过正式的模块声明(requires)来建立依赖关系。

--add-modules

https://dev.java/learn/modules/add-modules-reads/

模块系统解析的根模块,从根模块解析(在编译时期,而不是运行时)模块依赖,通过--add-modules mod1,mod2可以将除 JDK 默认 root modules 外的模块添加到模块依赖解析中,可以通过扫描模块描述符把相关依赖的模块也同时解析了。

javacjava都可以用这个选项

--module-path

module-path可以分为三类

  • application module path,通过--module-path指定
  • compilation module path,通过--module-source-path指定,配合javac使用
  • upgrade module path,通过--upgrade-module-path指定

--module-path 会将没有模块声明的jar变为automatic module;module-path可以是class/jar目录,jar,jmod 目录

--patch-module

关于这个选项的使用参考:https://openjdk.org/projects/jigsaw/quick-start#xoverride

--patch-module $module=$artifact可以在编译期及运行时将 $artifact 中的所有类合并到 $module 模块中。

此参数可以向指定的模块添加类,用来在将目录或jar包中的class文件添加/覆盖到指定module,通常在测试环节使用

java --patch-module <模块名>=<路径1> --patch-module <模块名>=<路径2> ... <主类>

同时 patch 多个模块

java --patch-module java.base=patches/base --patch-module jdk.compiler=patches/compiler MainClass

如果你需要为多个模块指定多个修补路径,可以结合使用 --patch-module 和模块路径分隔符(:;,取决于操作系统):

# Linux/macOS(使用 : 分隔)
java --patch-module module1=patch1:patch2 --patch-module module2=patchA:patchB MainClass

# Windows(使用 ; 分隔)
java --patch-module module1=patch1;patch2 --patch-module module2=patchA;patchB MainClass

注意事项:

  1. 每个模块只能修补一次:不能对同一个模块多次使用 --patch-module(后面的会覆盖前面的)。
  2. 路径可以是目录或 JAR 文件
    • 如果是目录,会加载该目录下的所有类文件。
    • 如果是 JAR 文件,会加载该 JAR 中的类。
  3. 模块化兼容性:修补的类必须与目标模块的包结构一致,否则可能引发 IllegalAccessError

jmod

jmod 是模块化系统推出的一种新的应用打包形式。大多数开发场景下,打包自己的应用模块,或者将其发布到 Maven 仓库,只需要打包成模块化的 jar 包就行了,不用将其打包为 jmod 模块。jmod 文件与 jar 不同,jmod 是不能单独运行的,它主要供 jlink 使用,用于构建可独立运行的运行时环境。主要适用场景:

  1. 模块包含本地库文件
  2. 模块包含其他配置文件
  3. 模块需要给其他模块使用,进行链接

JMOD 文件可以包含多个 class 文件、元数据和资源以外的文件。这种格式是可传输的,但不可执行,也就是说可以在编译时或链接时使用它,但不能在运行时使用。为了保证兼容性,高版本的JDK,首先保证始终支持直接运行 jar 包中的代码,然后在相应的命令行工具如 javac 和 java 中,添加对于模块化 jar 包的支持。

具体地说,就是 jlink 会将特定程序中用到的 jmod 模块抽取出来,构建出一个可独立运行的文件集合,这个文件集合仅包容它所用到的模块,移除了无关的模块,最终结果会比标准的 JRE 要小,并且不强制要求目标计算机上预先安装有特定版本的 JRE。

jmod文件

一个 .jmod 文件通常包含以下内容:

  1. 模块元数据module-info.class:这是模块的核心文件,定义了模块的名称、依赖关系(requires)、导出的包(exports)、提供的服务(provides)等信息。该文件是编译后的字节码文件,由 module-info.java 编译生成。

  2. 类文件(Class Files):模块中包含的所有 Java 类文件(.class 文件),这些文件是模块的功能实现。这些类文件通常位于模块的包路径下。

  3. 资源文件(Resources)
    模块中使用的静态资源文件,例如配置文件、图片、文本文件等。这些文件通常位于模块的资源目录中。

  4. 本地库文件(Native Libraries):如果模块依赖于本地库(如 JNI 库),这些库文件(如 .dll、.so 或 .dylib)可以包含在 .jmod 文件中。

  5. 命令文件(Commands):模块中定义的可执行命令(如脚本或二进制文件),通常用于命令行工具。这些文件位于 bin 目录下。

  6. 配置文件(Configuration Files):模块可能包含一些配置文件,例如服务配置文件(META-INF/services/ 目录下的文件),用于定义服务提供者。

  7. 其他文件:模块可能包含其他辅助文件,例如文档(doc 目录)、头文件(include 目录)等。

一个 .jmod 文件实际上是一个类似于 .zip 文件的压缩文件,例如,可以将java.base.jmod文件直接修改为zip压缩文件,然后就可以进行解压,解压之后是下面这个样子:

在这里插入图片描述

也可以使用 jmod 工具创建、查看或提取其内容。其内部结构通常如下:

  1. classes/ :类文件
  2. bin/ :可执行命令
  3. conf/ :配置文件
  4. include/ :头文件
  5. legal/ :法律声明
  6. lib/ :本地库文件
  7. man/ :手册页
  8. module-info.class :模块元数据文件

jmod命令行工具

jmod工具是java9之后随jdk安装的一个命令行工具,和java命令在同一个位置,用来创建、查看jmod文件

  1. 查看 .jmod 文件内容:使用 jmod list 命令,该命令的作用就是列出jmod文件中每个文件的相对路径
/path/to/jmod list /path/to/<module-name>.jmod
  1. 提取 .jmod 文件:使用 jmod extract 命令,该命令作用就相当于解压缩文件,--dir参数指定解压后输出的位置
/path/to/jmod extract /path/to/<module-name>.jmod --dir <output-directory>

创建jmod文件

使用jmod create命令,支持3个参数

  • <path>:指定模块的类文件所在路径
  • <version>:模块的版本号。版本号通常遵循语义化版本规范,例如 1.0.0、2.3.4 等
  • <module-name>.jmod:生成的 .jmod 文件名。

此命令必须要求目录下存在module-info.class文件,一般形式如下:

jmod create --class-path <path> --module-version <version> <module-name>.jmod

我们还是使用前面的 tweet 项目,在 src 目录下创建 module-info.java 文件,内容如下:

module tweet {
    requires jackson.core;
    requires jackson.databind;
    requires jackson.annotations;
    requires java.sql;
    
    exports org.tweet;
}

由于不允许默认包名,因此我们需要为 java 文件添加包名 org.tweet。

https://stackoverflow.com/questions/52469803/error-main-class-found-in-top-level-directory-unnamed-package-not-allowed-in-m

接下来编译项目

➜ javac -d ./target/tweet --module-path ./lib $(find src/ -name '*.java')
# Windows PowerShell 使用下面这个命令
➜ javac -d ./target/tweet --module-path ./lib (Get-ChildItem -Recurse -Path "src/" -Filter "*.java").FullName

结果如下:

├─lib
│      jackson-annotations-2.6.7.jar
│      jackson-core-2.6.7.jar
│      jackson-databind-2.6.7.jar
├─target
│  └─tweet
│      │  module-info.class
│      └─org
│          └─tweet
│                  MyApp$1.class
│                  MyApp.class
│                  Tweet.class
└─src
    │  module-info.java
    └─org
        └─tweet
                MyApp.java
                Tweet.java

下面制作 jmod :

jmod create --class-path .\target\tweet\ tweet.jmod

根目录下会生成 tweet.jmod 文件,文件内容很简单,就是将指定的编译结果进行打包而已

➜ jmod extract ./tweet.jmod --dir ./tweet  
➜ tree /f
D:/Test
│  tweet.jmod
│ ...... 省略部分
└─tweet
    └─classes
        │  module-info.class
        │
        └─org
            └─tweet
                    MyApp$1.class
                    MyApp.class
                    Tweet.class

maven-jmod-plugin

此插件可以直接在 maven 的构建过程中,将应用打包为 jmod 文件。但是此项目似乎还处于 alpha 版本。

https://github.com/apache/maven-jmod-plugin

https://maven.apache.org/plugins/maven-jmod-plugin/usage.html

jlink

https://docs.oracle.com/en/java/javase/11/tools/jlink.html

使用jlink工具可以定制化运行时,即将一组模块及其依赖关系组装并优化到自定义运行时镜像中。

  • 传统方式:部署应用需要完整JRE(约200-300MB)
  • JLink方式:只包含应用实际需要的模块(可小至40-50MB)

jlink 基于Java模块(module-info.java),静态分析模块依赖关系,只打包必要的模块到运行时镜像。一般使用方式如下:

jlink [options] --module-path modulepath --add-modules module[,module...] --output output_path

下面解释这些参数的含义

  1. modulepath:参数指定查找模块的位置,可以是模块化的 jar 包,jmod 文件或者暴露的模块,暴露的模块就是指将 jmod 提取出来的根目录下的文件集合

  2. module:指定需要加到运行时镜像中的模块,jlink 工具会添加这些模块以及这些模块的传递依赖到

  3. output_path:指定运行时镜像的输出位置

主要优势

  • ✅ 显著减小部署包体积:去除未使用的JDK模块
  • ✅ 提升启动性能:精简的运行时加载更快
  • ✅ 增强安全性:减少潜在攻击面
  • ✅ 简化部署:自带JRE,无需目标机器预装Java
  • ✅ 支持交叉编译:可为不同平台构建运行时

制作运行时镜像

参考:https://dev.java/learn/jlink/

还是基于我们前面的 tweet 项目,先编译项目,这次我们输出到 mods/tweet 目录下:

➜ javac -d mods/tweet --module-path ./lib $(find src/ -name '*.java')
# Windows PowerShell 使用下面这个命令
➜ javac -d mods/tweet --module-path ./lib (Get-ChildItem -Recurse -Path "src/" -Filter "*.java").FullName

由于自动模块不能参与 jlink ,所以我们使用到的 jackson 需要是模块化的,前面我们已经演示过了如何将普通的 jar 转换为模块化的 jar,下面我们就来使用这些模块化的 jackson 来进行 jlink。

# 注意:Windows 上;是表示多条命令,需要使用引号括起来
➜ jlink --module-path "./lib/;./mods/tweet" --add-modules tweet --output ./image/tweet
# 结果如下,文件比较多,因此省略了文件,只显示文件夹
➜ tree .\image\tweet\
.\IMAGE\TWEET
├─bin
│  └─server
├─conf
│  └─security
│      └─policy
│          ├─limited
│          └─unlimited
├─include
│  └─win32
├─legal
│  ├─java.base
│  ├─java.logging
│  ├─java.sql
│  ├─java.transaction.xa
│  └─java.xml
└─lib
    └─security

# 查看镜像的模块,可以看到比JDK少了很多
➜ .\image\tweet\bin\java --list-modules
jackson.annotations
jackson.core
jackson.databind
java.base@17.0.7
java.logging@17.0.7
java.sql@17.0.7
java.transaction.xa@17.0.7
java.xml@17.0.7
tweet

也可以将我们的应用打成 jar 包再进行 jlink

jar --create --file ./lib/tweet.jar -C ./mods/tweet .
jlink --module-path "./lib/" --add-modules tweet --output ./image/tweet

制作的镜像包含了全部程序,我们可以直接运行它

➜ Get-Content "tweet.json" -Encoding UTF8 | .\image\tweet\bin\java --module tweet/org.tweet.MyApp

--launcher

应用程序模块可以包括一个自定义启动器,它是镜像中bin文件夹中的可执行脚本,预先配置为使用具体模块和主类启动JVM。需要使用到 --launcher 这个参数,格式如下:

--launcher $NAME=$MODULE/$MAIN-CLASS
# $NAME:为可执行文件选择的文件名
# $MODULE:要启动的模块的名称
# $MAIN-CLASS:模块主类的名称

如果模块定义了一个主类,则可以省略$MAIN-CLASS

➜ jlink --module-path "./lib/;./mods/tweet" --add-modules tweet `
	--launcher app=tweet/org.tweet.MyApp --output ./image/tweet

效果就是生成的镜像包含一个可执行的启动脚本 app.bat,将此文件添加快捷方式即可双击启动我们的程序

https://maven.apache.org/plugins/maven-jlink-plugin/jlink-mojo.html

maven-jlink-plugin 是 Apache Maven 的一个插件,封装了 jlink 工具,用于创建定制的 Java 运行时镜像(JRE)。

一般配置如下,详细使用见官网:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-jlink-plugin</artifactId>
    <version>3.1.0</version>
    <executions>
        <execution>
            <id>create-runtime</id>
            <goals><goal>jlink</goal></goals>
            <phase>package</phase>
            <configuration>
                <jlinkImageName>custom-runtime</jlinkImageName>
                <launcher>
                    name=myapp
                    module=com.myapp/com.myapp.Main
                </launcher>
                <compression>2</compression>
                <stripDebug>true</stripDebug>
                <noManPages>true</noManPages>
                <addModules>
                    java.base,
                    java.logging,
                    jdk.httpserver
                </addModules>
            </configuration>
        </execution>
    </executions>
</plugin>

注意事项

  1. 模块化要求:应用及其依赖必须支持JPMS
  2. 反射问题:需要为反射访问的包添加opens语句
  3. 服务加载:使用bindServices选项处理ServiceLoader
  4. 多平台构建:建议在CI中为各目标平台分别构建

jpackage

jpackage 是 Java 提供的一个跨平台的打包工具,自 JDK 14 开始引入。通过 jpackage,可以将 Java 应用程序和所需的 JRE 打包成一个独立的、可执行的安装包,方便分发和部署。

jpackage 会使用 jlink 工具创建一个包含仅应用程序所需模块的精简 JRE。这个 JRE 将被打包进最终的应用程序包中。

使用方法参考:

  1. https://openjdk.org/jeps/392

  2. https://docs.oracle.com/en/java/javase/17/docs/specs/man/jpackage.html

常用命令集合

Unix/Linux:

# 判断jar包是否模块化
jar tf /path/to/xxx.jar | grep "moudle-info.class"
# 解压jar包
jar xf /path/to/xxx.jar ./

# 将把当前目录下的所有文件和子目录压缩到 newfile.jar 中
jar cf xxx.jar *
# 压缩多个目录
jar cf xxx.jar src/ lib/
# 压缩某些特定类型的文件,可以使用通配符
jar cf xxx.jar src/*.java
# 压缩多个目录和文件
jar cf xxx.jar src/ lib/ README.md

Windows:

# 判断jar包是否模块化, 是否包含module-info.class文件
jar tf yourfile.jar | findstr "module-info.class"

执行:

java -Xdiag:resolver --module-path mlib --add-modules jackson.databind 
	\ -cp tweetsum.jar org.tweetsum.Main <tweet.json

相关资料

  1. https://docs.oracle.com/en/java/javase/17/docs/specs/man/javac.html
  2. https://dev.java/learn/modules/building/
  3. https://docs.oracle.com/en/java/javase/11/tools/tools-and-command-reference.html
  4. https://openjdk.org/projects/jigsaw/quick-start
  5. https://nipafx.dev/java-9-migration-guide
posted @ 2025-07-07 20:38  vonlinee  阅读(102)  评论(0)    收藏  举报