第3章 从单体到模块化Java:回顾与持续演进

引言

在前面的章节中,我们探索了Java语言及其执行环境的重要进步,见证了这些基础元素的显著成长和转型。然而,Java演进中一个对生态系统具有深远影响的关键方面,是Java开发工具包(JDK)自身的转型。随着Java的成熟,它引入了大量特性和语言级增强,每一项都增加了JDK的复杂性和复杂性。例如,J2SE 5.0中引入枚举类型,需要添加java.lang.Enum基类、java.lang.Class.getEnumConstants()方法、java.util包中的EnumSetEnumMap,以及对序列化形式(Serialized Form)的更新。每个新特性或语法添加都需要细致的集成和强大的支持,以确保无缝功能。

随着Java的每一次扩展,JDK开始显现出笨重的迹象。其单体结构带来了诸如内存占用增加、启动时间变慢以及维护和更新困难等挑战。JDK 9的发布标志着Java历史上的一个转折点,因为它引入了Java平台模块系统(JPMS),并使Java从单体结构转变为更可管理的模块化结构。这一演进而继续,在JDK 11和JDK 17中,每个版本都为模块化Java生态系统带来了进一步的增强和完善。

本章深入探讨这一转型的具体细节。我们将探索单体JDK固有的挑战,并详细说明走向模块化的历程。我们的讨论将扩展到模块化对开发者的益处,特别关注那些已经采用JDK 11或JDK 17的开发者。此外,我们将考虑这些变化对JVM性能工程的影响,提供帮助开发者优化应用程序并利用最新JDK创新的见解。通过这一探索,目标是展示Java应用程序如何从模块化中显著受益。

理解Java平台模块系统

如前所述,JPMS是对单体JDK日益增长的复杂性和笨重性的一项战略响应。开发它的主要目标是创建一个可扩展的平台,能够有效管理API级别的安全风险,同时提升性能。Java生态系统中模块化的出现,赋予开发者根据其应用程序的具体需求选择和缩放模块的灵活性。这一转型使Java平台开发者能够在使用模块化布局管理Java API时,培育出一个不仅更易维护而且更高效的系统。这种模块化方法的一个显著优势是,开发者可以只使用其应用程序所需的JDK部分;这种选择性使用减少了应用程序的大小并改善了加载时间,从而带来更高效和性能卓越的应用程序。

揭秘模块

在Java中,模块是一个由包、资源和模块描述符(module-info.java)组成的 cohesive 单元,该描述符提供关于模块的信息。模块充当这些元素的容器。因此,一个模块:

  • 封装其包:模块可以声明其哪些包应对其他模块可访问,哪些应隐藏。这种封装通过允许开发者清晰地表达其代码的预期用途,提高了代码的可维护性和安全性。
  • 表达依赖关系:模块可以声明对其他模块的依赖,从而明确该模块正常运行需要哪些模块。这种显式依赖管理简化了部署过程,并帮助开发者在开发周期早期识别问题。
  • 强制执行强封装:模块系统在编译时和运行时都强制执行强封装,使得意外或恶意破坏封装变得困难。这种执行带来了更好的安全性和可维护性。
  • 提升性能:模块系统允许JVM优化代码的加载和执行,从而改善启动时间、降低内存消耗并加快执行速度。

模块系统的采用极大地提高了Java平台的可维护性、安全性和性能。

模块示例

让我们通过两个示例模块来探索模块系统:com.house.brickhousecom.house.brickscom.house.brickhouse 模块包含两个类,House1House2,它们计算不同楼层房屋所需的砖块数量。com.house.bricks 模块包含一个 Story 类,该类提供一个基于楼层数计算砖块的方法。以下是 com.house.brickhouse 的目录结构:

src
── com.house.brickhouse
        ├── com
        │   └── house
        │       └── brickhouse
        │           ├── House1.java
        │           └── House2.java
        └── module-info.java

com.house.brickhouse:

module-info.java:

module com.house.brickhouse {
    requires com.house.bricks;
    exports com.house.brickhouse;
}

com/house/brickhouse/House1.java:

package com.house.brickhouse;
import com.house.bricks.Story;
public class House1 {
    public static void main(String[] args) {
        System.out.println("My single-level house will need " + Story.count(1) + " bricks");
    }
}

com/house/brickhouse/House2.java:

package com.house.brickhouse;
import com.house.bricks.Story;
 
public class House2 {
    public static void main(String[] args) {
        System.out.println("My two-level house will need " + Story.count(2) + " bricks");
    }
}

现在来看 com.house.bricks 的目录结构:

src
└── com.house.bricks
    ├── com
    │   └── house
    │       └── bricks
    │           └── Story.java
    └── module-info.java

com.house.bricks:

module-info.java:

module com.house.bricks {
    exports com.house.bricks;
}

com/house/bricks/Story.java:

package com.house.bricks;
 
public class Story {
    public static int count(int level) {
        return level * 18000;
    }
}

编译与运行细节

我们先编译 com.house.bricks 模块:

$ javac -d mods/com.house.bricks src/com.house.bricks/module-info.java src/com.house.bricks/com/house/bricks/Story.java

接着编译 com.house.brickhouse 模块:

$ javac --module-path mods -d mods/com.house.brickhouse \
src/com.house.brickhouse/module-info.java \
src/com.house.brickhouse/com/house/brickhouse/House1.java \
src/com.house.brickhouse/com/house/brickhouse/House2.java

现在运行 House1 示例:

$ java --module-path mods -m com.house.brickhouse/com.house.brickhouse.House1

输出:

My single-level house will need 18000 bricks

然后运行 House2 示例:

$ java --module-path mods -m com.house.brickhouse/com.house.brickhouse.House2

输出:

My two-level house will need 36000 bricks

引入一个新模块

现在,通过引入一个提供各种砖块类型的新模块来扩展我们的项目。我们将这个模块命名为 com.house.bricktypes,它将包含不同砖块类型的不同类。以下是 com.house.bricktypes 模块的新目录结构:

src
└── com.house.bricktypes
    ├── com
    │   └── house
    │       └── bricktypes
    │           ├── ClayBrick.java
    │           └── ConcreteBrick.java
    └── module-info.java

com.house.bricktypes:

module-info.java:

module com.house.bricktypes {
    exports com.house.bricktypes;
}

ClayBrick.javaConcreteBrick.java 类将定义各自砖块类型的属性和方法。

ClayBrick.java:

package com.house.bricktypes;
 
public class ClayBrick {
    public static int getBricksPerSquareMeter() {
        return 60;
    }
}

ConcreteBrick.java:

package com.house.bricktypes;
 
public class ConcreteBrick {
    public static int getBricksPerSquareMeter() {
        return 50;
    }
}

有了新模块后,我们需要更新现有模块以利用这些新的砖块类型。首先更新 com.house.brickhouse 模块中的 module-info.java 文件:

module com.house.brickhouse {
    requires com.house.bricks;
    requires com.house.bricktypes;
    exports com.house.brickhouse;
}

我们修改 House1.javaHouse2.java 文件以使用新的砖块类型。

House1.java:

package com.house.brickhouse;
import com.house.bricks.Story;
import com.house.bricktypes.ClayBrick;
 
public class House1 {
    public static void main(String[] args) {
        int bricksPerSquareMeter = ClayBrick.getBricksPerSquareMeter();
        System.out.println("My single-level house will need " 
+ Story.count(1, bricksPerSquareMeter) + " clay bricks");
    }
}

House2.java:

package com.house.brickhouse;
import com.house.bricks.Story;
import com.house.bricktypes.ConcreteBrick;
 
public class House2 {
    public static void main(String[] args) {
        int bricksPerSquareMeter = ConcreteBrick.getBricksPerSquareMeter();
        System.out.println("My two-level house will need " 
+ Story.count(2, bricksPerSquareMeter) + " concrete bricks");
    }
}

通过这些更改,我们允许 House1House2 类使用不同类型的砖块,这为程序增加了更多灵活性。现在更新 com.house.bricks 模块中的 Story.java 类以接受每平方米砖块数:

package com.house.bricks; 
 
public class Story {
    public static int count(int level, int bricksPerSquareMeter) {
        return level * bricksPerSquareMeter * 300;
    }
}

现在我们已经更新了模块,让我们编译并运行它们以查看更改的效果:

  • com.house.bricktypes 模块创建新的 mods 目录:
$ mkdir mods/com.house.bricktypes
  • 编译 com.house.bricktypes 模块:
$ javac -d mods/com.house.bricktypes \
src/com.house.bricktypes/module-info.java \
src/com.house.bricktypes/com/house/bricktypes/*.java
  • 重新编译 com.house.brickscom.house.brickhouse 模块:
$ javac --module-path mods -d mods/com.house.bricks \
src/com.house.bricks/module-info.java src/com.house.bricks/com/house/bricks/Story.java
 
$ javac --module-path mods -d mods/com.house.brickhouse \
src/com.house.brickhouse/module-info.java \
src/com.house.brickhouse/com/house/brickhouse/House1.java \
src/com.house.brickhouse/com/house/brickhouse/House2.java

通过这些更新,我们的程序现在更加通用,可以处理不同类型的砖块。这只是Java模块系统如何使我们的代码更加灵活和可维护的一个例子。

图3.1  展示模块间关系的类图

现在让我们用类图来可视化这些关系。图3.1包含了新模块com.house.bricktypes,箭头表示“使用”关系。House1使用Story和ClayBrick,而House2使用Story和ConcreteBrick。因此,House1和House2的实例将分别包含对Story实例以及ClayBrick或ConcreteBrick实例的引用。它们利用这些引用来与Story、ClayBrick和ConcreteBrick类的方法和属性进行交互。以下是更多细节:

  • House1和House2:这两个类代表两种不同类型的房屋。两个类都有以下属性:
    • name:一个字符串,表示房屋名称。
    • levels:一个整数,表示房屋的层数。
    • story:一个Story类的实例,表示房屋的一个楼层。
    • main(String[] args):类的入口方法,作为应用程序执行的初始启动点。
  • Story:这个类表示房屋的一个楼层。它具有以下属性:
    • level:一个整数,表示楼层编号。
    • bricksPerSquareMeter:一个整数,表示该楼层每平方米的砖块数量。
    • count(int level, int bricksPerSquareMeter):一个方法,计算给定楼层和每平方米砖块数所需的总砖块数。
  • ClayBrick和ConcreteBrick:这两个类代表两种不同类型的砖块。两个类都有以下属性:
    • getBricksPerSquareMeter():一个静态方法,返回每平方米的砖块数量。房屋调用该方法以获取Story类计算所需的值。

理解Java平台模块系统

接下来,让我们看一下砖房建筑系统的用例图,其中房屋所有者作为参与者,黏土砖和混凝土砖作为系统(图3.2)。此图展示了房屋所有者如何与系统交互,计算不同类型房屋所需的砖块数量,并为施工选择砖块类型。

以下是关于用例图元素的更多信息:

  • 房屋所有者:这是想要建造房屋的参与者。房屋所有者通过以下方式与砖房建筑系统交互:
    • 计算房屋1的砖块:房屋所有者使用系统计算建造房屋1所需的砖块数量。
    • 计算房屋2的砖块:房屋所有者使用系统计算建造房屋2所需的砖块数量。
    • 选择砖块类型:房屋所有者使用系统选择施工中使用的砖块类型。
  • 砖房建筑系统:该系统帮助房屋所有者完成施工过程。它提供以下用例:
    • 计算房屋1的砖块:该用例计算房屋1所需的砖块数量。它与黏土砖和混凝土砖系统交互以获取必要数据。
    • 计算房屋2的砖块:该用例计算房屋2所需的砖块数量。它与黏土砖和混凝土砖系统交互以获取必要数据。
    • 选择砖块类型:该用例允许房屋所有者选择施工的砖块类型。
  • 黏土砖和混凝土砖系统:这些系统向砖房建筑系统提供计算房屋施工所需砖块数量所需的数据(例如,尺寸、成本)。

图3.2  砖房建筑系统的用例图


从单体到模块化:JDK的演进

在引入模块化JDK之前,JDK的臃肿导致了过于复杂且难以阅读的应用程序。特别是,复杂的依赖关系和交叉依赖关系使得维护和扩展应用程序变得困难。由于缺乏简单性以及JAR(Java存档)对其所含类缺乏感知,导致了JAR地狱(即与Java中类加载相关的问题)。

JDK庞大的体积也带来了挑战,特别是在小型设备或不需要整个单体JDK的其他场景下。模块化JDK提供了解决方案,改变了JDK的格局。


持续演进:JDK 11及更高版本中的模块化JDK

Java平台模块系统(JPMS)最初在JDK 9中引入,并在后续版本中持续演进。JDK 11是JDK 8之后的第一个长期支持(LTS)版本,进一步优化了模块化Java平台。以下总结了JDK 11中一些显著的改进和变更:

  • 移除已弃用的模块:JDK 9中已弃用的某些Java企业版(EE)和通用对象请求代理架构(CORBA)模块最终在JDK 11中被移除。这一变更促进了更精简的Java平台,并减轻了维护负担。
  • 模块系统成熟:JPMS随着时间的推移而成熟,得益于开发者的反馈和实际使用。较新的JDK版本解决了问题,改进了性能,并优化了模块系统的能力。
  • API优化:在后续版本中对API和特性进行了优化,为使用模块系统的开发者提供了更一致、更连贯的体验。
  • 持续增强:JDK 11及后续版本继续增强模块系统——例如,提供更好的诊断消息和错误报告、改进JVM性能以及其他有利于开发者的渐进式改进。

使用JDK 17实现模块化服务

借助JDK的模块化方法,我们可以通过将提供服务接口的模块与其提供者模块解耦,从而增强服务(自Java 1.6引入)的概念,最终创建完全解耦的消费者。为了使用服务,类型通常声明为接口或抽象类,服务提供者需要在其模块中被明确标识,从而使其被识别为提供者。最后,消费者模块需要使用这些提供者。

为了更好地解释所发生的解耦,我们将使用一个逐步示例来构建一个BricksProvider及其提供者和消费者。

服务提供者

服务提供者是一个实现服务接口并将其提供给其他模块使用的模块。它负责实现服务接口中定义的功能。在我们的示例中,我们将创建一个名为com.example.bricksprovider的模块,该模块将实现BrickHouse接口并提供服务。

创建com.example.bricksprovider模块

首先,创建一个名为bricksprovider的新目录;在该目录内,创建com/example/bricksprovider的目录结构。接下来,在bricksprovider目录中创建一个module-info.java文件,内容如下:

module com.example.bricksprovider {
    requires com.example.brickhouse;
    provides com.example.brickhouse.BrickHouse with com.example.bricksprovider.BricksProvider;
}

这个module-info.java文件声明了我们的模块需要com.example.brickhouse模块,并通过com.example.bricksprovider.BricksProvider类提供了BrickHouse接口的实现。

现在,在com/example/bricksprovider目录内创建BricksProvider.java文件,内容如下:

package com.example.bricksprovider;
 
import com.example.brickhouse.BrickHouse;
 
public class BricksProvider implements BrickHouse {
    @Override
    public void build() {
        System.out.println("Building a house with bricks...");
    }
}

服务消费者

服务消费者是一个使用其他模块提供的服务的模块。它在module-info.java文件中使用uses关键字声明所需的服务。然后,服务消费者可以使用ServiceLoader API来发现并实例化所需服务的实现。

创建com.example.builder模块

首先,创建一个名为builder的新目录;在该目录内,创建com/example/builder的目录结构。接下来,在builder目录中创建一个module-info.java文件,内容如下:

module com.example.builder {
    requires com.example.brickhouse;
    uses com.example.brickhouse.BrickHouse;
}

这个module-info.java文件声明了我们的模块需要com.example.brickhouse模块,并使用BrickHouse服务。

现在,在com/example/builder目录内创建Builder.java文件,内容如下:

package com.example.builder;
 
import com.example.brickhouse.BrickHouse;
import java.util.ServiceLoader;
 
public class Builder {
    public static void main(String[] args) {
        ServiceLoader<BrickHouse> loader = ServiceLoader.load(BrickHouse.class);
        loader.forEach(BrickHouse::build);
    }
}

一个工作示例

让我们考虑一个使用服务的模块化Java应用程序的简单示例:

  • com.example.brickhouse:一个模块,定义了其他模块可以实现的BrickHouse服务接口。
  • com.example.bricksprovider:一个模块,提供了BrickHouse服务的实现,并在其module-info.java文件中使用provides关键字声明了该实现。
  • com.example.builder:一个模块,消费BrickHouse服务,并在其module-info.java文件中使用uses关键字声明了所需的服务。

然后,构建者可以使用ServiceLoader API来发现并实例化由com.example.bricksprovider模块提供的BrickHouse实现。

图3.3  模块化服务

图3.3以模块图形式展示了模块和类之间的关系。该模块图表示了模块和类之间的依赖关系及关系:

  • com.example.builder模块包含Builder.java类,该类使用来自com.example.brickhouse模块的BrickHouse接口。
  • com.example.bricksprovider模块包含BricksProvider.java类,该类实现并提供BrickHouse接口。

实现细节

ServiceLoader API是一种强大的机制,它允许com.example.builder模块在运行时发现并实例化由com.example.bricksprovider模块提供的BrickHouse实现。这提供了更高的灵活性以及模块之间更好的关注点分离。以下小节将重点介绍一些实现细节,帮助我们更好地理解模块之间的交互以及ServiceLoader API的角色。

发现服务实现

ServiceLoader.load()方法接受一个服务接口作为参数——在我们的例子中是BrickHouse.class——并返回一个ServiceLoader实例。该实例是一个可迭代对象,包含所有可用的服务实现。ServiceLoader依赖于module-info.java文件中提供的信息来发现服务实现。

实例化服务实现

在迭代 ServiceLoader 实例时,API 会自动实例化由服务提供者提供的服务实现。在我们的示例中,迭代 ServiceLoader 实例时会实例化 BricksProvider 类,并调用其 build() 方法。

封装实现细节

通过使用 JPMS(Java 平台模块系统),com.example.bricksprovider 模块可以封装其实现细节,仅暴露它提供的 BrickHouse 服务。这使得 com.example.builder 模块能够消费该服务,而无需依赖具体实现,从而构建出更健壮、更易维护的系统。

添加更多服务提供者

我们的示例可以轻松扩展,通过添加更多实现 BrickHouse 接口的服务提供者。只要新的服务提供者在各自的 module-info.java 文件中正确声明,com.example.builder 模块就能通过 ServiceLoader API 自动发现并使用它们。这使得系统更具模块化和可扩展性,能够适应需求变化或新的实现。

图 3.4 是一个用例图,描述了服务消费者与服务提供者之间的交互。它包含两个参与者:服务消费者服务提供者

  • 服务消费者:使用服务提供者提供的服务。服务消费者通过以下方式与模块化 JDK 交互:

    • 发现服务实现:服务消费者使用模块化 JDK 查找可用的服务实现。
    • 实例化服务实现:发现服务实现后,服务消费者使用模块化 JDK 创建这些服务的实例。
    • 封装实现细节:服务消费者受益于模块化 JDK 提供的封装,无需了解服务的底层实现即可使用它们。
  • 服务提供者:实现并提供服务。服务提供者通过以下方式与模块化 JDK 交互:

    • 实现服务接口:服务提供者使用模块化 JDK 实现服务接口,该接口定义了服务的契约。
    • 封装实现细节:服务提供者使用模块化 JDK 隐藏其服务实现的细节,仅暴露服务接口。
    • 添加更多服务提供者:服务提供者可以使用模块化 JDK 为服务添加更多提供者,从而增强系统的模块化和可扩展性。

JAR 地狱版本问题与 Jigsaw 层级

图 3.4  突出服务消费者和服务提供者的用例图

模块化 JDK 作为这些交互的稳健促进者,建立了一个综合平台,服务提供者可以在其中有效提供服务,同时服务消费者也可以发现并高效利用这些服务。这一动态生态系统促进了服务的无缝交换,增强了模块化 Java 应用程序的整体功能和互操作性。


JAR 地狱版本问题与 Jigsaw 层级

在深入探讨 JAR 地狱版本问题和 Jigsaw 层级之前,我想介绍 Nikita Lipski,一位 JVM 工程师和 Java 模块化领域的专家。Nikita 为该主题提供了宝贵的见解和全面的阐述,我们将在本节中讨论这些内容。他的工作将帮助我们更好地理解 JAR 地狱版本问题,以及如何在 JDK 11 和 JDK 17 中利用 Jigsaw 层级解决该问题。

Java 的向后兼容性是其关键特性之一。该兼容性确保当新版本 Java 发布时,为旧版本构建的应用程序可以在新版本上运行,无需修改源代码,甚至通常无需重新编译。同样的原则也适用于第三方库——应用程序可以在不修改源代码的情况下使用这些库的更新版本。

然而,这种兼容性并不扩展到源代码级别的版本管理,JPMS 也没有引入这一级别的版本管理。相反,版本管理是在工件级别进行的,使用如 Maven 或 Gradle 等工件管理系统。这些系统处理 Java 项目中所用库和框架的版本及依赖管理,确保在构建过程中包含正确版本的依赖。但是,当 Java 应用依赖多个第三方库,而这些库又可能依赖另一个库的不同版本时,会发生什么?这可能导致冲突和运行时错误,如果同一库的多个版本同时存在于类路径中。

因此,尽管 JPMS 无疑改善了 Java 的模块化和代码组织,但在处理工件级别的版本问题时,“JAR 地狱”问题仍然可能相关。让我们看一个示例(如图 3.5 所示),其中应用依赖两个第三方库(Foo 和 Bar),而这两个库又分别依赖另一个库(Baz)的不同版本。

如果两个版本的 Baz 库都放在类路径上,那么在运行时将无法确定使用哪个版本,导致不可避免的版本冲突。为解决这个问题,JPMS 通过检测不允许在 JPMS 中存在的分裂包来禁止这种情况,以支持其“可靠配置”目标(图 3.6)。

虽然早期检测版本问题很有用,但 JPMS 并未提供推荐的方法来解决这些问题。一种解决方法是假设冲突库的最新版本是向后兼容的,从而使用该版本。但由于可能引入不兼容性,这并不总是可行。

针对这种情况,JPMS 提供了 ModuleLayer 特性,允许以隔离方式将模块子图安装到模块系统中。当冲突库的不同版本被放置到单独的层级中时,JPMS 可以同时加载这两个版本。虽然无法直接从父层级访问子层级的模块,但可以通过在子层级模块中实现服务提供者,然后由父层级模块使用的间接方式来实现这一目的。(详见前文“使用 JDK 17 实现模块化服务”的讨论。)

图 3.5  模块化与版本冲突

图 3.6  JPMS 的可靠配置


工作示例:JAR 地狱

本节提供了一个工作示例,演示在 JDK 17 上下文中如何使用模块层级解决 JAR 地狱问题(该策略也适用于 JDK 11 用户)。该示例基于 Nikita 的解释和我们之前讨论的房屋服务提供者实现,展示了如何在模块化应用中使用同一库的不同版本(称为基础实现高质量实现)。

首先,我们看一下 Java SE 9 文档提供的示例代码:¹

1   ModuleFinder finder = ModuleFinder.of(dir1, dir2, dir3);
2   ModuleLayer parent = ModuleLayer.boot();
3   Configuration cf = parent.configuration().resolve(finder, ModuleFinder.of(), Set.of("myapp"));
4   ClassLoader scl = ClassLoader.getSystemClassLoader();
5   ModuleLayer layer = parent.defineModulesWithOneLoader(cf, scl);

在此示例中:

  • 第 1 行:设置一个 ModuleFinder 用于从特定目录(dir1dir2dir3)定位模块。
  • 第 2 行:将引导层建立为父层。
  • 第 3 行:解析引导层的配置,作为在第 1 行指定目录中找到的模块的父配置。
  • 第 5 行:使用解析后的配置创建一个新层,并使用单一类加载器,其父类加载器为系统类加载器。

图 3.7  JPMS 示例版本与层级

图 3.8  JPMS 示例版本层级扁平化

现在,扩展我们的房屋服务提供者实现。我们将在 com.codekaram.provider 模块中提供基础实现和高质量实现。您可以将“基础实现”视为房屋库的第 1 版,将“高质量实现”视为房屋库的第 2 版(图 3.7)。

对于每个层级,我们都会同时联系这两个库。因此,我们的组合将是:层级 1 + 基础实现提供者、层级 1 + 高质量实现提供者、层级 2 + 基础实现提供者、层级 2 + 高质量实现提供者。为简单起见,我们将这些组合分别记为 house ver1.bhouse ver1.hqhouse ver2.bhouse ver2.hq(图 3.8)。

实现细节

基于 Nikita 在前一节中介绍的概念,让我们深入探讨实现细节,了解层级结构和程序流在实际中的运作方式。首先,查看源代码树:

ModuleLayer
├── basic
│   └── src
│       └── com.codekaram.provider
│           ├── classes
│           │   ├── com
│           │   │   └── codekaram
│           │   │       └── provider
│           │   │           └── House.java
│           │   └── module-info.java
│           └── tests
├── high-quality
│   └── src
│       └── com.codekaram.provider
│           ├── classes
│           │   ├── com
│           │   │   └── codekaram
│           │   │       └── provider
│           │   │           └── House.java
│           │   └── module-info.java
│           └── tests
└── src
    └── com.codekaram.brickhouse
        ├── classes
        │   ├── com
        │   │   └── codekaram
        │   │       └── brickhouse
        │   │           ├── loadLayers.java
        │   │           └── spi
        │   │               └── BricksProvider.java
        │   └── module-info.java
        └── tests

以下是 com.codekaram.provider 的模块文件信息和模块图。注意,基础实现和高质量实现的这些信息完全相同。

module com.codekaram.provider {
    requires com.codekaram.brickhouse;
    uses com.codekaram.brickhouse.spi.BricksProvider;
    provides com.codekaram.brickhouse.spi.BricksProvider with com.codekaram.provider.House;
}

模块图(如图 3.9 所示)有助于可视化模块之间的依赖关系以及它们提供的服务,这对于理解模块化 Java 应用的结构非常有用:

  • com.codekaram.provider 模块依赖于 com.codekaram.brickhouse 模块,并隐式依赖于 java.base 模块(每个 Java 应用的基础模块)。图中箭头从 com.codekaram.provider 指向 com.codekaram.brickhousejava.base

图 3.9 描述了模块依赖关系: com.codekaram.provider 依赖 com.codekaram.brickhouse,并隐式依赖 java.base

¹ https://docs.oracle.com/javase/9/docs/api/java/lang/ModuleLayer.html

前文描述了以下依赖关系:

  • com.codekaram.provider 模块依赖于 com.codekaram.brickhouse 模块,并隐式依赖于 java.base 模块(所有Java应用程序的基础模块)。这由从 com.codekaram.provider 指向 com.codekaram.brickhouse 的箭头以及指向 java.base 的隐含箭头表示。

  • com.codekaram.brickhouse 模块也隐式依赖于 java.base 模块,如同所有Java模块一样。 图3.9 包含服务与层的实际示例

  • java.base 模块不依赖于任何其他模块,是所有其他模块依赖的核心模块。

  • com.codekaram.provider 模块提供服务 com.codekaram.brickhouse.spi.BricksProvider,其实现为 com.codekaram.provider.House。这种关系在图中由从 com.codekaram.provider 指向 com.codekaram.brickhouse.spi.BricksProvider 的虚线箭头表示。

在深入这些提供程序的代码之前,先查看 com.codekaram.brickhouse 模块的模块文件信息:

module com.codekaram.brickhouse {
    uses com.codekaram.brickhouse.spi.BricksProvider;
    exports com.codekaram.brickhouse.spi;
}

loadLayers 类不仅处理层的形成,还能加载每个层次的服务提供程序。这是一种简化,但有助于我们更好地理解流程。现在,让我们分析 loadLayers 实现。以下是根据“工作示例:JAR地狱”部分示例代码创建层的代码:

static ModuleLayer getProviderLayer(String getCustomDir) {
   ModuleFinder finder = ModuleFinder.of(Paths.get(getCustomDir));
   ModuleLayer parent = ModuleLayer.boot();
   Configuration cf = parent.configuration().resolve(finder, 
     ModuleFinder.of(), Set.of("com.codekaram.provider"));
   ClassLoader scl = ClassLoader.getSystemClassLoader();
   ModuleLayer layer = parent.defineModulesWithOneLoader(cf, scl);
	
   System.out.println("Created a new layer for " + layer);
   return layer;
}

JAR地狱版本问题与Jigsaw层

如果我们只想创建两个层——一个用于房屋版本 basic,另一个用于房屋版本 high-quality——我们只需从 main 方法调用 getProviderLayer()

doWork(Stream.of(args) 
    .map(getCustomDir -> getProviderLayer(getCustomDir)));

如果将两个目录 basichigh-quality 作为运行时参数传递,getProviderLayer() 方法将在这些目录中查找 com.codekaram.provider,然后为每个目录创建一个层。让我们检查输出(为清晰和解释添加了行号):

1   $ java --module-path mods -m com.codekaram.brickhouse/ 
    com.codekaram.brickhouse.loadLayers basic high-quality
2   Created a new layer for com.codekaram.provider
3   I am the basic provider
4   Created a new layer for com.codekaram.provider
5   I am the high-quality provider
  • 第1行:我们的命令行参数,其中 basichigh-quality 是提供 BricksProvider 服务实现的目录。
  • 第2行和第4行:输出,表明在两个目录中都找到了 com.codekaram.provider,并为每个目录创建了一个新层。
  • 第3行和第5行provider.getName() 的输出,该输出由 doWork() 代码实现:
private static void doWork(Stream<ModuleLayer> myLayers){  
    myLayers.flatMap(moduleLayer -> ServiceLoader 
        .load(moduleLayer, BricksProvider.class)
        .stream().map(ServiceLoader.Provider::get))
    .forEach(eachSLProvider -> System.out.println("I am the " 
        + eachSLProvider.getName() + " provider"));
}

doWork() 中,我们首先为 BricksProvider 服务创建一个服务加载器,并从模块层加载提供程序。然后打印该提供程序的 getName() 方法返回的字符串。如输出所示,我们有两个模块层,并成功打印了 I am the basic providerI am the high-quality provider,其中 basichigh-qualitygetName() 方法返回的字符串。

扩展到四层示例

现在,让我们可视化前面讨论的四层的工作方式。为此,我们将创建一个简单的问题陈述,为房屋的两个层级计算基础和高品质砖块的报价。首先,在 main() 方法中添加以下代码:

int[] level = {1,2};
IntStream levels = Arrays.stream(level);

然后,按如下方式流式调用 doWork()

levels.forEach(levelcount -> loadLayers
    .doWork(...));

现在我们有了四个层,类似于前面提到的(house ver1.b、house ver1.hq、house ver2.b、house ver2.hq)。以下是更新后的输出:

Created a new layer for com.codekaram.provider
My basic 1 level house will need 18000 bricks and those will cost me $6120
Created a new layer for com.codekaram.provider
My high-quality 1 level house will need 18000 bricks and those will cost me $9000
Created a new layer for com.codekaram.provider
My basic 2 level house will need 36000 bricks and those will cost me $12240
Created a new layer for com.codekaram.provider
My high-quality 2 level house will need 36000 bricks and those will be over my budget of $15000

NOTE

我们的提供程序中 getName() 方法的返回字符串已更改为仅返回 "basic""high-quality" 字符串,而不是完整句子。

更新后输出的最后一行变化展示了如何对服务提供程序应用附加条件。这里,在两层房屋的高品质提供程序实现中集成了预算约束检查。当然,您可以根据需求自定义输出和条件。

以下是更新后的 doWork() 方法和 main 方法中的相关代码,用于同时处理层级和提供程序:

private static void doWork(int level, Stream<ModuleLayer> myLayers){
    myLayers.flatMap(moduleLayer -> ServiceLoader
        .load(moduleLayer, BricksProvider.class)
        .stream().map(ServiceLoader.Provider::get))
    .forEach(eachSLProvider -> System.out.println("My " 
        + eachSLProvider.getName() + " " + level 
        + " level house will need " 
        + eachSLProvider.getBricksQuote(level)));
}
 
public static void main(String[] args) {
    int[] levels = {1, 2};
    IntStream levelStream = Arrays.stream(levels);
 
    levelStream.forEach(levelcount -> doWork(levelcount, Stream.of(args)
        .map(getCustomDir -> getProviderLayer(getCustomDir))));
}

现在,我们可以使用基础和高品质实现计算不同层级房屋的砖块数量和成本,每个实现都分配了一个单独的模块层。这展示了模块层提供的强大功能和灵活性:它使您能够动态加载和卸载同一服务的不同实现,而不影响应用程序的其他部分。

TIP

请根据您的具体用例和需求调整服务提供程序的代码。此处提供的示例只是一个起点,供您在此基础上构建和适应。

总结:模块层的实用性

总之,这个示例说明了Java模块层在创建既适应性强又可扩展的应用程序中的实用性。通过使用模块层和Java ServiceLoader 的概念,您可以创建可扩展的应用程序,从而能够适应不同的需求和条件,而不影响代码库的其余部分。


Open Services Gateway Initiative (OSGi)

Open Services Gateway Initiative(OSGi)自2000年以来一直是Java开发者可用的替代模块系统,远早于Jigsaw和Java模块层的引入。由于在OSGi出现时Java中没有内置的标准模块系统,它以与Project Jigsaw不同的方式解决了许多模块化问题。在本节中,我们将借助Nikita(他在Java模块化方面具有OSGi专业知识)的见解,比较Java模块层和OSGi,突出它们的相似点和不同点。

OSGi概述

OSGi是一个成熟且广泛使用的框架,为Java应用程序提供模块化和可扩展性。它提供了一个动态组件模型,允许开发者在不重启应用程序的情况下,在运行时创建、更新和删除模块(称为bundle)。

相似之处

  • 模块化:Java模块层和OSGi都通过强制组件之间的清晰分离来促进模块化,从而更易于维护、扩展和重用代码。
  • 动态加载:两种技术都支持模块或bundle的动态加载和卸载,允许开发者在运行时更新、扩展或删除组件,而不影响应用程序的其余部分。

共同点

  • 模块化与封装:两种技术都支持将代码组织成模块或捆绑包(bundle),强制组件之间的显式边界,使维护、扩展和代码复用更容易。
  • 动态加载:两种技术都支持模块或捆绑包的动态加载和卸载,允许开发者在不影响应用其他部分的情况下,在运行时更新、扩展或移除组件。

差异

  • 成熟度:OSGi是一种更成熟、经受过实战考验的技术,拥有丰富的生态系统和工具支持。Java模块层(在JDK 9中引入)相对较新,可能不如OSGi拥有同等水平的工具和库支持。
  • 与Java平台的集成:Java模块层是Java平台的一部分,提供原生的模块化和可扩展性解决方案。相比之下,OSGi是一个构建在Java平台之上的独立框架。
  • 复杂性:OSGi可能比Java模块层更复杂,学习曲线更陡峭,拥有更多高级特性。Java模块层虽然也提供强大功能,但对于不熟悉模块化概念的开发者来说,可能更直截了当、更易用。
  • 运行时环境:OSGi应用运行在OSGi容器内,容器管理捆绑包的生命周期并强制模块化规则。Java模块层直接运行在Java平台上,由模块系统处理模块的加载和卸载。
  • 版本管理:OSGi内置支持一个模块或捆绑包的多个版本,允许开发者同时部署和运行同一组件的不同版本。这是通过给模块加上版本限定符,并应用“用途约束”(uses constraints)来确保每个模块拥有安全的类命名空间实现的。然而,在OSGi中处理版本可能会给模块解析和最终用户引入不必要的复杂性。相比之下,Java模块层原生不支持一个模块的多个版本,但你可以通过为每个版本创建独立的模块层来实现类似功能。
  • 强封装:Java模块层作为JDK中的一等公民,提供强封装:当未经授权访问未导出的功能时(即使通过反射),会发出错误信息。在OSGi中,未导出的功能可以通过类加载器“隐藏”,但模块内部仍然可以通过反射访问,除非设置了特殊的安全管理器。OSGi受限于Java SE在JPMS之前的特性,无法提供与Java模块层相同级别的强封装。

选择Java模块层还是OSGi

在Java应用中实现模块化和可扩展性时,开发者通常有两个主要选择:Java模块层和OSGi。请记住,Java模块层与OSGi之间的选择并非总是非此即彼的,它可能取决于多种因素,包括项目的具体需求、现有技术栈以及团队对技术的熟悉程度。另外,值得注意的是,Java模块层和OSGi并非在Java应用中实现模块化的唯一选择。根据你的具体需求和上下文,其他解决方案可能更合适。在决定项目方案之前,彻底评估所有可用选项的利弊至关重要。你的选择应基于项目的具体需求和约束,以确保最佳结果。

一方面,如果你需要多版本支持、动态组件模型等高级特性,OSGi可能是更好的选择。该技术非常适合需要灵活性和可扩展性的复杂应用。然而,它比Java模块层更难学习和实现,因此可能不是刚接触模块化的开发者的最佳选择。

另一方面,Java模块层为在Java应用中实现模块化和可扩展性提供了更直接的解决方案。该技术内置于Java平台本身,这意味着已经熟悉Java的开发者应该会感觉相对易用。此外,Java模块层提供强封装特性,有助于防止依赖关系在不同模块之间泄露。

Jdeps、Jlink、Jdeprscan和Jmod简介

本节介绍四个有助于开发和部署模块化应用的工具:jdeps、jlink、jdeprscan 和 jmod。每个工具在构建、分析和部署Java应用的过程中都有其独特的用途。

Jdeps

Jdeps 是一个分析Java类或包依赖关系的工具。当你试图为JAR文件创建模块文件时,它特别有用。使用 jdeps,你可以通过正则表达式(regex)创建各种过滤器;正则表达式是一个字符序列,用于形成搜索模式。下面是如何在 loadLayers 类上使用 jdeps:

$ jdeps mods/com.codekaram.brickhouse/com/codekaram/brickhouse/loadLayers.class 
loadLayers.class -> java.base
loadLayers.class -> not found
   com.codekaram.brickhouse -> com.codekaram.brickhouse.spi   not found
   com.codekaram.brickhouse -> java.io                        java.base
   com.codekaram.brickhouse -> java.lang                      java.base
   com.codekaram.brickhouse -> java.lang.invoke               java.base
   com.codekaram.brickhouse -> java.lang.module               java.base
   com.codekaram.brickhouse -> java.nio.file                  java.base
   com.codekaram.brickhouse -> java.util                      java.base
   com.codekaram.brickhouse -> java.util.function             java.base
   com.codekaram.brickhouse -> java.util.stream               java.base

上述命令与向 jdeps 传递选项 -verbose:package 效果相同。-verbose 选项本身会列出所有依赖关系:

$ jdeps -v mods/com.codekaram.brickhouse/com/codekaram/brickhouse/loadLayers.class 
loadLayers.class -> java.base
loadLayers.class -> not found
   com.codekaram.brickhouse.loadLayers -> com.codekaram.brickhouse.spi.BricksProvider  not found
   com.codekaram.brickhouse.loadLayers -> java.io.PrintStream                   java.base
   com.codekaram.brickhouse.loadLayers -> java.lang.Class                       java.base
   com.codekaram.brickhouse.loadLayers -> java.lang.ClassLoader                 java.base
   com.codekaram.brickhouse.loadLayers -> java.lang.ModuleLayer                 java.base
   com.codekaram.brickhouse.loadLayers -> java.lang.NoSuchMethodException       java.base
   com.codekaram.brickhouse.loadLayers -> java.lang.Object                      java.base
   com.codekaram.brickhouse.loadLayers -> java.lang.String                      java.base
   com.codekaram.brickhouse.loadLayers -> java.lang.System                      java.base
   com.codekaram.brickhouse.loadLayers -> java.lang.invoke.CallSite             java.base
   com.codekaram.brickhouse.loadLayers -> java.lang.invoke.LambdaMetafactory    java.base
   com.codekaram.brickhouse.loadLayers -> java.lang.invoke.MethodHandle         java.base
   com.codekaram.brickhouse.loadLayers -> java.lang.invoke.MethodHandles        java.base
   com.codekaram.brickhouse.loadLayers -> java.lang.invoke.MethodHandles$Lookup java.base
   com.codekaram.brickhouse.loadLayers -> java.lang.invoke.MethodType           java.base
   com.codekaram.brickhouse.loadLayers -> java.lang.invoke.StringConcatFactory  java.base
   com.codekaram.brickhouse.loadLayers -> java.lang.module.Configuration        java.base
   com.codekaram.brickhouse.loadLayers -> java.lang.module.ModuleFinder         java.base
   com.codekaram.brickhouse.loadLayers -> java.nio.file.Path                    java.base
   com.codekaram.brickhouse.loadLayers -> java.nio.file.Paths                   java.base
   com.codekaram.brickhouse.loadLayers -> java.util.Arrays                      java.base
   com.codekaram.brickhouse.loadLayers -> java.util.Collection                  java.base
   com.codekaram.brickhouse.loadLayers -> java.util.ServiceLoader               java.base
   com.codekaram.brickhouse.loadLayers -> java.util.Set                         java.base
   com.codekaram.brickhouse.loadLayers -> java.util.Spliterator                 java.base
   com.codekaram.brickhouse.loadLayers -> java.util.function.Consumer           java.base
   com.codekaram.brickhouse.loadLayers -> java.util.function.Function           java.base
   com.codekaram.brickhouse.loadLayers -> java.util.function.IntConsumer        java.base
   com.codekaram.brickhouse.loadLayers -> java.util.function.Predicate          java.base
   com.codekaram.brickhouse.loadLayers -> java.util.stream.IntStream            java.base
   com.codekaram.brickhouse.loadLayers -> java.util.stream.Stream               java.base
   com.codekaram.brickhouse.loadLayers -> java.util.stream.StreamSupport        java.base

Jdeprscan

Jdeprscan 是一个分析模块中已弃用API使用情况的工具。已弃用API是Java社区已用新API替换的旧API。这些旧API仍受支持,但已标记为在未来的版本中移除。Jdeprscan 帮助开发者维护代码,为这些已弃用的API提供替代方案建议,帮助他们过渡到新的、受支持的API。

下面是如何在 com.codekaram.brickhouse 模块上使用 jdeprscan:

$ jdeprscan --for-removal mods/com.codekaram.brickhouse
No deprecated API marked for removal found.

在这个例子中,jdeprscan 用于扫描 com.codekaram.brickhouse 模块中标记为移除的已弃用API。输出表明未找到此类API。

你也可以使用 --list 来查看模块中的所有已弃用API:

$ jdeprscan --list mods/com.codekaram.brickhouse
No deprecated API found.

在这种情况下,com.codekaram.brickhouse 模块中未发现已弃用的API。

Jmod

Jmod 是一个用于创建、描述和列出 JMOD 文件的工具。JMOD 文件是 JAR 文件的替代品,用于打包模块化Java应用,提供了额外特性,如原生代码和配置文件。这些文件可用于分发,或与 jlink 一起创建自定义运行时镜像。

下面是如何使用 jmod 为 brickhouse 示例创建 JMOD 文件。首先编译并打包本示例专用的模块:

$ javac --module-source-path src -d build/modules $(find src -name "*.java")
$ jmod create --class-path build/modules/com.codekaram.brickhouse com.codekaram.brickhouse.jmod

这里,jmod create 命令用于从位于 build/modules 目录下的 com.codekaram.brickhouse 模块创建一个名为 com.codekaram.brickhouse.jmod 的 JMOD 文件。然后你可以使用 jmod describe 命令显示关于该 JMOD 文件的信息:

$ jmod describe com.codekaram.brickhouse.jmod

此命令将输出模块描述符以及关于 JMOD 文件的任何附加信息。

此外,你可以使用 jmod list 命令显示所创建的 JMOD 文件的内容:

$ jmod list com.codekaram.brickhouse.jmod
com/codekaram/brickhouse/
com/codekaram/brickhouse/loadLayers.class
com/codekaram/brickhouse/loadLayers$1.class
...

输出列出了 com.codekaram.brickhouse.jmod 文件的内容,显示包结构和类文件。

通过使用 jmod 创建 JMOD 文件、描述其内容以及列出其中的各个文件,你可以更好地理解模块化应用的结构,并简化使用 jlink 创建自定义运行时镜像的过程。

Jlink 是一款帮助链接模块及其传递依赖关系,以创建自定义模块化运行时镜像的工具。这些自定义镜像可以被打包和部署,而无需整个Java运行时环境(JRE),这使得应用程序更轻量级且启动速度更快。

要使用 jlink 命令,需要将该工具添加到你的路径中。首先,确保 $JAVA_HOME/bin 在路径中。然后,在命令行输入 jlink

$ jlink
Error: --module-path must be specified
Usage: jlink <options> --module-path <modulepath> --add-modules <module>[,<module>...]
Use --help for a list of possible options

以下是如何针对“使用JDK 17实现模块化服务”一节中所示的代码使用 jlink

$ jlink --module-path $JAVA_HOME/jmods:build/modules --add-modules com.example.builder --output consumer.services --bind-services

关于此示例的几点说明:

  • 该命令在模块路径中包含了一个名为 $JAVA_HOME/jmods 的目录。该目录包含所有应用模块所需的 java.base.jmod
  • 因为该模块是服务的消费者,所以有必要链接服务提供者(及其依赖关系)。因此,使用了 --bind-services 选项。
  • 运行时镜像将位于 consumer.services 目录中,如下所示:
$ ls consumer.services/
bin	conf
include
legal
lib
release

现在运行该镜像:

$ consumer.services/bin/java -m com.example.builder/com.example.builder.Builder
Building a house with bricks...

借助 jlink,你可以创建专为模块化Java应用程序量身定制的轻量级、自定义、独立的运行时镜像,从而简化部署并减小分发的应用程序的大小。

结论

本章全面探索了Java模块、工具和技术,用于创建和管理模块化应用程序。我们深入研究了Java平台模块系统(JPMS),强调了其优势,如可靠的配置和强封装。这些特性有助于构建更易维护和更具可扩展性的应用程序。

我们细致地探讨了创建、打包和管理模块的复杂性,以及利用模块层增强应用程序灵活性的方法。这些实践有助于应对迁移到较新JDK版本(如JDK 11或JDK 17)时常见的挑战,包括更新项目结构和确保依赖兼容性。

性能影响

使用模块化Java会带来显著的性能影响。通过在应用程序中只包含必要的模块,JVM加载的类更少,从而提高了启动性能并减少了内存占用。这在资源受限的环境(如运行在容器中的微服务)中尤为有益。然而,需要注意的是,虽然模块化可以提升性能,但它也引入了一定程度的复杂性。例如,不当的模块设计可能导致循环依赖,1 进而对性能产生负面影响。因此,精心设计和充分理解模块是充分获得性能收益的关键。

工具与未来发展

我们考察了诸如 jdepsjdeprscanjmodjlink 等强大工具的使用,这些工具在识别和解决兼容性问题、创建自定义运行时镜像以及简化模块化应用程序的部署方面发挥着重要作用。展望未来,我们可以预期 jlink 会有更多高级选项用于创建自定义运行时镜像,jdeps 会提供更详细、更准确的依赖分析。

随着越来越多的开发者采用模块化Java,新的最佳实践和模式将会涌现,同时也会出现新的工具和库来配合JPMS。Java社区正在持续改进JPMS,未来的Java版本有望进一步优化和扩展其能力。

拥抱模块化编程范式

向模块化Java的过渡可能会带来独特的挑战,尤其是在大型应用程序中理解和实施模块化结构方面。与可能不完全兼容JPMS的第三方库或框架之间可能会出现兼容性问题。这些挑战虽然是现代化进程的一部分,但通常会被模块化Java的优势(如改进的性能、增强的可扩展性和更好的可维护性)所抵消。

总之,通过运用本章获得的知识,你可以自信地迁移你的项目,并充分挖掘模块化Java应用程序的潜力。模块化Java的未来令人兴奋,拥抱这一范式将使你能够满足软件开发领域不断变化的需求。现在正是与模块化Java共同工作的激动时刻,我们期待看到它将如何演进,并塑造健壮、高效的Java应用程序的未来。


Footnotes

  1. https://openjdk.org/projects/jigsaw/spec/issues/#CyclicDependences