You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

12 KiB

17 | 模块系统为什么Java需要模块化

你好我是范学雷。今天我们一起来讨论Java平台模块系统Java Platform Module SystemJPMS

Java平台模块系统是在JDK 9正式发布的。为了沟通起来方便我们有时候就直接简称为Java模块。Java平台模块系统可以说是自Java诞生以来最重要的新软件工程技术了。模块化可以帮助各级开发人员在构建、维护和演进软件系统时提高工作效率。软件系统规模越大我们越需要这样的工程技术。

实现Java平台的模块化是具有挑战性的Java模块系统Module System的最初设想可以追溯到2005年的Java 7但是最后的发布是在2017年的 JDK 9。它的设计和实现花了十多年时间我们可以想象它的复杂性。

令人满意的是Java平台模块系统最终呈现的结果是简单、直观的。我们并不需要太长的时间就能快速掌握这一技术。

我们先来了解Java模块化背后的动力和它能够带来的工程效率提升。除非特别说明这一次的讨论说的都是JDK 8及以前的版本的事情。下一次我们再来讨论JDK 9之后我们应该怎么使用Java平台模块系统。

缺失的访问控制

我们都清楚并且能够熟练地使用Java的访问修饰符。这样的访问修饰符一共有三个public、protected以及private。如果什么修饰符都不使用那就是缺省的访问修饰符这也算是一种访问控制。所以Java语言一共定义了四种类型的访问控制。

图片

private访问修饰符修饰的对象在同一个类里是可见的缺省访问修饰符修饰的对象在同一个Java包里是可见的pubic访问修饰符修饰的对象在不同的Java包里也是可见的。有了private、public和缺省的访问修饰符看起来我们已经能解决大部分的问题了。不过这里还欠缺了重要的一环。

当我们设计对象的扩展能力的时候我们可能期待扩展的子类处于不同的Java包里。但是其中的一些数据信息子类需要访问但又因为它们是接口实现的细节不应该对外公开。所以这时候就需要一个能够穿越Java包传递到子类的访问修饰符。这个访问修饰符就是protected。protected访问修饰符在Java包之间打通了一条继承类之间的私密通道。

图片

我们可以用下面这张表来总结Java语言访问修饰符的控制区域。

图片

从这个列表看Java语言访问修饰符似乎覆盖了所有的可能性这好像是一个完备的定义。遗憾的是Java语言访问修饰符遗漏了很重要的一种情况那就是Java包之间的关系。Java包之间的关系并不是要么全开放要么全封闭这么简单。

类似于继承类之间的私密通道Java包之间也有这种类似私密通道的需求。比如说我们在JDK的标准类库里可以看到像java.net这样的放置公开接口的包也可以看到像sun.net这样的放置实现代码的包。

公开接口当然需要定义能够广泛使用的类比如public修饰的Socket类。

package java.net;

public class Socket implements java.io.Closeable {
    // snipped
}

让人遗憾的是放置公开接口实现代码的包里也需要定义public的类。这就让本来只应该由某个公开接口独立使用的代码变得所有人都可以使用了。

比如说用来实现公开接口Socket类的PlatformSocketImpl类就是一个使用public修饰的类。

package sun.net;

public interface PlatformSocketImpl {
    // snipped
}

虽然PlatformSocketImpl是一个public修饰的类但是我们并不期望所有的开发者都能够使用它。这是一个用来支持公开接口Socket实现的类。除了实现公开接口Socket的代码之外它不应该被任何其他的代码和开发者调用。

然而PlatformSocketImpl是一个public修饰的类。这也就意味着任何代码和开发者都可以使用它。这显然是不符合设计者的预期的。

在JDK 8及以前的版本里一个对象在两个包之间的访问控制要么是全封闭的要么是全开放的。所以JDK 9之前的Java世界里它的设计者没有办法强制性地设定PlatformSocketImpl给出一个恰当的访问控制范围。

两个包之间没有一个定向的私密通道。换句话说JDK 9之前的Java语言没有描述和定义包之间的依赖关系也没有描述和定义基于包的依赖关系的访问控制规则。 这是一个缺失的访问控制。

这种缺失的关系,带来了严重的后果。

松散的使用合约

按照JDK的期望一个开发者应该只使用公开接口比如上面提到的Socket类而不能使用实现细节的内部接口比如上面提到的PlatformSocketImpl接口。无论是公开接口还是内部接口都可以使用public修饰符。那么该怎么判断一个接口是公开接口还是内部接口呢

解决的办法是依靠Java接口的使用规范这样的纪律性合约而不是依靠编译器强制性的检查。**在JDK里以java或者javax命名开头的Java包是公开的接口其他的包是内部的接口。按照Java接口的使用规范一个开发者应该只使用公开的接口而不能使用内部的接口。**不过这是一个完全依靠自觉的纪律性约束Java的编译器和解释器并不会禁止开发者使用内部接口。

内部接口的维护者可能会随时修改甚至删除内部的接口。使用内部接口的代码,它的兼容性是不受保护的。这是一个危险的依赖,应该被禁止。

遗憾的是这种纪律性合约是松散的它很难禁止开发者使用内部接口。我们能够看到大量的、没有遵守内部接口使用合约的应用程序。内部接口的不合规使用也成了Java版本升级的重要障碍之一。松散的纪律性合约既伤害了内部接口的设计者也伤害了它的使用者和最终用户。

我们前面提到过Java平台模块化的设计和实现花了十多年时间。而内部接口的不合规使用就是这项工作复杂性的主要来源。

我们认为,如果一件事情应该禁止,那么最好的办法就是让这件事情没有办法发生;而不是警告发生以后的的后果,或者依靠事后的惩罚。

那怎么能够更有效的限制内部接口的使用提高Java语言的可维护能力呢这是Java语言要解决的一个重要问题。

手工的依赖管理

Java语言没有描述和定义包之间的依赖关系这就直接增加了应用程序部署的复杂性。

公开接口的定义和实现并不一定是放置在同一个Java包。比如上面我们提到的Socket类和PlatformSocketImpl类就位于不同的Java包。

因为通常情况下我们使用Jar文件来分发和部署Java应用所以公开接口的定义和实现也不一定是放置在同一个Jar文件里。比如一个加密算法的实现它的公开接口一般是由JDK定义的但是它的实现可能是由一个第三方的类库完成的。

图片

Java的编译器只需要知道公开接口的规范并不会去检查实现的代码也不会去链接实现的代码。可是Java在运行时不仅需要知道公开接口的字节码还需要知道实现的字节码。这就导致了编译和运行的脱节。一个能通过编译的代码运行时可能也会遇到找不到实现代码的错误。

而且Java的编译器不会在字节码里添加类和包的依赖关系。我们在编译期设置的依赖类库在运行期还需要重新设置。编译器环节和运行环节是由两个独立的Java命令执行的所以这种依赖关系也不会从编译期传导到运行期。

由于依赖关系的缺失Java运行的时候可能不会完全按照它的设计者的意图工作。这就给Java应用的部署带来很多问题。这一类的问题如此让人讨厌以至于它还有一个让人亲切不起来的外号Jar地狱。

为了解决依赖关系的缺失带来的种种问题业界现在也有了一些解决方案比如使用Maven和Gradle来管理项目。然而由于Java没有内在的依赖关系规范现有的解决方案也就只能依赖人工。依赖人工的手段也就意味着效率和质量上的潜在风险。

缓慢的实现加载

Java语言没有描述和定义包之间的依赖关系还直接影响了Java应用程序的启动效率。

我们都知道像Spring这样的框架它缓慢的启动一直都是一个大问题。影响Java应用启动速度的最主要原因就是类的加载。导致类加载缓慢的一个重要原因就是很难查找到要加载的类的实现代码。

假设我们设置的class path里有很多Jar文件对于一个给定名称的classJava怎么才能找到实现这个类的字节码呢由于Jar文件里没有描述类的依赖关系的内容Java的类加载器只能线性地搜索class path下的Jar文件直到发现了给定的类和方法。这种线性搜索方法当然不是高效的。class path下的Jar文件越多类加载得就越慢。

更糟糕的是这种线性搜索的方式还带来了不可预测的副作用。其中影子类Shadowing classes和版本冲突是最常见的两个副作用。

因为在不同的Jar文件里可能会存在两个有着相同命名但是行为不同的类。给定了类的名称哪一个Jar文件里的类会被首先加载呢这依赖于Jar文件被检索的顺序。在不同的运行空间class path的设置可能是不同的Jar文件被检索的顺序可能也是不同的所以实际加载的类就有可能是不同的最终的运行结果当然也是不同的。这样的问题可能会导致难以预料的结果而且非常难以排查。

如果一个类的不同版本的实现都出现在了 class path 里,也会出现类似的问题。

新的思路

我们可以看到这些问题的根源都来自于Java语言没有描述和定义包之间的依赖关系。那么我们能不能通过扩展访问修饰符来解决这些问题呢

答案可能没有这么简单。多个节点之间的依赖关系描述,需要使用的是数学逻辑图。而单个的修饰符,不足以表达复杂的图的逻辑。

另外Jar文件虽然是Java语言的一种必不可少的代码组织方式但是它却不是由我们编写的代码直接控制的。我们编写的代码可以控制Java包可以控制Java类但是管不了Jar文件的内容和形式。

所以要解决这些问题需要新的思路。而JDK 9发布的Java平台模块系统就是解决这些问题的一个尝试。

总结

到这里我来做个小结。前面我们讨论了JDK 8及其以前版本的访问控制缺陷以及由此带来的种种问题。

总体来说Java语言没有描述和定义包之间的依赖关系。这个缺失导致了无法有效地封闭实现的细节无法有效地管理应用的部署无法精准地控制类的检索和加载也影响了应用启动的效率。

那能不能在Java语言里添加进来这个缺失的关系呢该怎么做这是我们下一次要讨论的话题。

如果面试的时候讨论到了Java的访问修饰符你不妨聊一聊这个缺失的环节以及Jar地狱这样的问题。我相信这是一个有意思、有深度的话题。

思考题

在前面的讨论中我们提到了使用Maven或者Gradle来管理项目以此解决依赖关系的缺失。但是我们并没有展开讨论这些问题是怎么解决的。

如果熟悉Maven、Gradle或者类似的工具的话你能不能聊一聊这样的工具是怎么解决依赖关系缺失这样的问题的它们哪些地方做得比较好哪些地方还有待改进这样的讨论也许有助于我们深入了解我们这一次讨论到的问题。

欢迎你在留言区留言、讨论分享你的阅读体验以及你对Maven或者Gradle的了解。我们下节课见