只用 javac、jar 和 java 搭一个双模块 Java 项目。看清包、导入、类路径、JAR 文件和 module-info.java 怎样配合,以及 Maven 和 Gradle 在此之上又多做了什么。
本系列到目前为止,每个程序都放在一个叫 Main.java 的文件里。真实的 Java 代码会拆分成多个包,编译成一个装满 .class 文件的目录,打包成 JAR 文件,再从类路径或模块路径启动。构建工具替你包办了这一切,所以大多数开发者从没亲手做过一遍。
本文就亲手做一遍。内容包括包和导入、类路径和它的两个经典错误、JAR 文件,以及用 module-info.java 定义的模块系统。下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行其中的单文件程序,就把代码存成 Main.java,再执行 java Main.java。终端会话来自一个小小的双模块项目,由一个只用 JDK 的脚本检查。
包就是一个名字加一个目录
包给类起一个更长、唯一的名字,所以 java.util 里的 List 永远不会和别人写的 List 类冲突。包名加类名,就是全限定名,你随时都可以把它完整写出来。下面这个程序一个导入都没有。它用的是经典的 public class Main,因为紧凑源文件会自动替你导入 java.base,反而会掩盖要说明的问题:
public class Main {
public static void main(String[] args) {
java.util.List<String> words = java.util.List.of("cat", "sat", "mat");
IO.println(words.getFirst() + " is one of " + words.size() + " words");
IO.println(java.util.List.class.getName());
IO.println(java.util.List.class.getPackageName());
IO.println("[" + Main.class.getPackageName() + "]");
}
}
输出:
cat is one of 3 words
java.util.List
java.util
[]
java.util.List 才是这个类的真名,单独的 List 只是简写。最后一行说明 Main 的包名是空的。它位于默认包,也叫无名包,因为这个文件没有 package 行。
在项目里,源文件的第一行写明它所属的包,磁盘上的目录也按同样的点号分层。package com.example.text; 里的类放在 com/example/text/ 下。包名通常以你掌控的域名开头,倒过来写,这样两家公司就不会选中同一个名字。
包名看起来像一棵树,但 Java 并不把它当树。com.example.text 和 com.example.text.internal 是两个毫无关系的包,只是碰巧前缀相同。导入其中一个不会导入另一个,谁也不能特殊访问对方的代码。
import 和 import static
import 让你写类的短名,而不是全限定名。它不加载任何东西,也不复制任何代码。编译器只是替你把 List 改写成 java.util.List。import static 对静态成员做同样的事,比如方法和常量:
import java.util.ArrayList;
import java.util.List;
import static java.lang.Math.max;
import static java.util.Comparator.reverseOrder;
public class Main {
public static void main(String[] args) {
List<Integer> scores = new ArrayList<>(List.of(4, 9, 2));
scores.sort(reverseOrder());
IO.println(scores);
IO.println(max(scores.getFirst(), 10));
}
}
输出:
[9, 4, 2]
10
没有静态导入,你就得写 Math.max 和 Comparator.reverseOrder()。静态导入要少用,不然读代码的人得到处找每个光秃秃的名字是从哪来的。
java.lang 里的东西都不需要导入。所以 String、Math 和 IO 总是能直接用。
通配符导入 import java.util.*; 会导入一个包里的所有类。当两个包里有同名的类时,它就会给你惹麻烦:
import java.util.*;
import java.sql.*;
public class Main {
public static void main(String[] args) {
Date today = new Date(0);
IO.println(today);
}
}
构建失败,报错:
Main.java:6: error: reference to Date is ambiguous
Date today = new Date(0);
^
both class java.sql.Date in java.sql and class java.util.Date in java.util match
修复方法是加一条单类导入 import java.util.Date;。单类导入总是优先于通配符导入。或者在用到的地方写全限定名。
默认包,以及紧凑源文件为什么没有包
默认包适合单文件程序,放到更大的程序里就不对了。命名包里的类根本无法导入默认包里的类,因为 import 后面没有名字可写。
紧凑源文件,也就是本系列一直用的 void main() 写法,总是位于默认包。编译器把它包进一个你从没起过名字的类里,其他代码无从引用它,而且 package 行会被拒绝:
package com.example;
void main() {
IO.println("hello");
}
构建失败,报错:
Main.java:1: error: compact source file should not have package declaration
所以紧凑源文件适合写脚本和学习。给别人用的代码要放进命名包。
import module:一次导入所有导出的包
模块导入 import module java.base; 会导入该模块导出的每个包里的每个类。它在 Java 25 成为正式特性,javac --release 24 会拒绝它,报 module imports are not supported in -source 24。
import module java.base;
public class Main {
public static void main(String[] args) {
List<String> words = List.of("b", "a");
IO.println(words);
}
}
输出:
[b, a]
这一行就涵盖了 java.util、java.io、java.time 以及 java.base 里的其余部分。紧凑源文件不用开口就能得到的,正是这些。名字冲突的处理和通配符导入一样:用一条单类导入解决。
示例项目:一个分成两个模块的单词计数器
本文剩下的部分都用同一个小项目:一个统计单词的库,加一个打印出现次数最多的三个单词的命令。下面是整个项目,只省略了 .gitignore:
14-wordcount/
├── run-checks.sh
├── sample.txt
├── expected-output.txt
├── checks/
│ └── Peek.java
└── src/
├── com.example.text/
│ ├── module-info.java
│ └── com/example/text/
│ ├── WordCount.java
│ ├── WordCounter.java
│ └── internal/
│ └── Tokenizer.java
└── com.example.app/
├── module-info.java
└── com/example/app/
└── Main.java
src 下面一层的目录以模块命名,模块在本文后面再讲。每个模块目录下面,目录结构和包名一致。
库把分词逻辑放在一个叫 internal 的包里:
package com.example.text.internal;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/** Splits text into lower-case words. Public, but its package is not exported. */
public final class Tokenizer {
private Tokenizer() {
}
public static List<String> words(String text) {
var words = new ArrayList<String>();
for (String part : text.split("[^\\p{L}\\p{N}']+")) {
String word = part.replaceAll("^'+|'+$", "");
if (!word.isEmpty()) {
words.add(word.toLowerCase(Locale.ROOT));
}
}
return words;
}
}
Tokenizer 必须是 public,因为 WordCounter 在另一个包里,需要调用它。在有模块之前,这就意味着谁都能调用它。先记住这一点。
库真正的 API 是一个 record 和一个类:
package com.example.text;
/** One word and how many times it appeared. */
public record WordCount(String word, int count) {
}
package com.example.text;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import com.example.text.internal.Tokenizer;
/** Counts how often each word appears in a piece of text. */
public final class WordCounter {
private WordCounter() {
}
/** Returns each word and its count, sorted by word. */
public static Map<String, Integer> count(String text) {
var counts = new TreeMap<String, Integer>();
for (String word : Tokenizer.words(text)) {
counts.merge(word, 1, Integer::sum);
}
return counts;
}
/** Returns the n most frequent words. Ties are broken alphabetically. */
public static List<WordCount> top(Map<String, Integer> counts, int n) {
return counts.entrySet().stream()
.map(e -> new WordCount(e.getKey(), e.getValue()))
.sorted(Comparator.comparingInt(WordCount::count).reversed()
.thenComparing(WordCount::word))
.limit(n)
.toList();
}
}
命令从标准输入读取文本,打印前三个单词,然后说明自己运行在哪个模块里:
package com.example.app;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import com.example.text.WordCount;
import com.example.text.WordCounter;
public class Main {
public static void main(String[] args) throws IOException {
String text = new String(System.in.readAllBytes(), StandardCharsets.UTF_8);
var counts = WordCounter.count(text);
for (WordCount wc : WordCounter.top(counts, 3)) {
IO.println(wc.word() + " " + wc.count());
}
Module module = Main.class.getModule();
IO.println("module: " + (module.isNamed() ? module.getName() : "unnamed"));
}
}
最后那一行的输出会随启动程序的方式而变,这正是它存在的意义。
类路径:java 去哪里找类
类路径是一个目录和 JAR 文件的列表,javac 和 java 在其中查找已编译的类。先编译库,再把库放到类路径上编译应用,最后把两者都放到类路径上运行应用:
$ javac -d classes/lib src/com.example.text/com/example/text/*.java src/com.example.text/com/example/text/internal/*.java
$ javac -cp classes/lib -d classes/app src/com.example.app/com/example/app/Main.java
$ find classes -name "*.class" | sort
classes/app/com/example/app/Main.class
classes/lib/com/example/text/WordCount.class
classes/lib/com/example/text/WordCounter.class
classes/lib/com/example/text/internal/Tokenizer.class
$ java -cp classes/app:classes/lib com.example.app.Main < sample.txt
the 3
cat 2
and 1
module: unnamed
sample.txt 里只有一行:the cat sat on the mat and the cat slept。-d 指定输出目录,javac 会在里面替你建好包目录。-cp 接受一个列表,在 Linux 和 macOS 上用 : 分隔,在 Windows 上用 ; 分隔。java 命令用全限定名指定类,而不是文件。
这几条 javac 命令跳过了 module-info.java,所以这里没有模块。类路径上的所有东西都落进同一个大的未命名模块,最后一行说的就是这个。
类路径出错有两种情况,看起来很像。如果 java 找不到你要它启动的类,会得到这个:
$ java -cp classes/lib com.example.app.Main < sample.txt
Error: Could not find or load main class com.example.app.Main
Caused by: java.lang.ClassNotFoundException: com.example.app.Main
如果它找到了 Main,却找不到 Main 需要的某个类,程序会先启动,然后在第一次用到缺失类的那一行失败。这里的栈跟踪做了删减:
$ java -cp classes/app com.example.app.Main < sample.txt
Exception in thread "main" java.lang.NoClassDefFoundError: com/example/text/WordCounter
at com.example.app.Main.main(Main.java:12)
Caused by: java.lang.ClassNotFoundException: com.example.text.WordCounter
ClassNotFoundException 表示按名字查找一个类,但它不在。NoClassDefFoundError 表示代码编译时存在的某个类,现在不见了。两者的修复方法一样:把缺失的目录或 JAR 放到类路径上。
注意第二个错误是什么时候出现的。启动时没有任何东西检查类路径。main 照常运行,到第 12 行 JVM 第一次需要 WordCounter 时才出错。
JAR 文件:带清单的 zip
JAR 文件就是一个装着 .class 文件的 zip 文件,META-INF/ 里还有一个小文本文件,叫清单(manifest)。jar 工具负责构建它,--main-class 把要启动的类写进清单:
$ jar --create --file wordcount.jar --main-class com.example.app.Main -C classes/app . -C classes/lib .
$ jar --list --file wordcount.jar
META-INF/
META-INF/MANIFEST.MF
com/
com/example/
com/example/app/
com/example/app/Main.class
com/example/text/
com/example/text/WordCount.class
com/example/text/WordCounter.class
com/example/text/internal/
com/example/text/internal/Tokenizer.class
$ unzip -p wordcount.jar META-INF/MANIFEST.MF
Manifest-Version: 1.0
Created-By: 25.0.4 (Ubuntu)
Main-Class: com.example.app.Main
$ java -jar wordcount.jar < sample.txt
the 3
cat 2
and 1
module: unnamed
-C classes/app . 的意思是“进入 classes/app,把里面的所有东西都加进来”。JAR 里的路径就是包目录,布局和磁盘上一样。java -jar 从清单里读取 Main-Class,所以你不用指定类名。它还会忽略你传入的任何 -cp:JAR 本身,加上清单里 Class-Path 行列出的内容,就是全部类路径。
这个 JAR 同时装着应用和库,所以能独立运行。真实的应用要依赖几十个库 JAR,这时就轮到构建工具出场了。
模块:module-info.java
模块是一组有名字的包,配一个描述符 module-info.java,写明它需要哪些模块,以及其他模块可以使用它的哪些包。模块从 Java 9 开始引入。库的描述符导出一个包,对 internal 只字不提:
/** Counts words in text. */
module com.example.text {
exports com.example.text;
}
应用什么都不导出,只依赖这个库:
/** A command that prints the most frequent words from standard input. */
module com.example.app {
requires com.example.text;
}
requires 针对的是模块,exports 针对的是包。每个模块都会自动读取 java.base。javac 可以按 src 下的目录布局,一条命令编译两个模块:
$ javac -Xlint:all -Werror --release 25 -d out --module-source-path src -m com.example.text,com.example.app
$ find out -type f | sort
out/com.example.app/com/example/app/Main.class
out/com.example.app/module-info.class
out/com.example.text/com/example/text/WordCount.class
out/com.example.text/com/example/text/WordCounter.class
out/com.example.text/com/example/text/internal/Tokenizer.class
out/com.example.text/module-info.class
$ java --module-path out -m com.example.app/com.example.app.Main < sample.txt
the 3
cat 2
and 1
module: com.example.app
--module-source-path src 告诉 javac:src 下的每个目录都是一个模块,模块名就是目录名。-m 选择要编译的模块。javac 根据 requires 行自己算出了编译顺序。
--module-path out,或者 -p out,就是模块路径。java 把 out 下的每个目录当作一个命名模块。-m module/class 指定启动哪个模块,以及哪个类里有 main。同一个 Main.class 现在报告的是 module: com.example.app。
com.example.app 依赖 com.example.text,所以 Main 可以使用导出的包。internal 包在同一个模块里,它的类也是公共的,但这个包没有导出,所以模块外的任何代码都用不了它。
强封装:光有 public 已经不够了
如果一个包没有被所在模块导出,那么其中的 public 类在模块外无法使用。这叫强封装。checks/Peek.java 偏要试一试:
import com.example.text.internal.Tokenizer;
public class Peek {
public static void main(String[] args) {
IO.println(Tokenizer.words("reaching inside"));
}
}
对着模块路径编译它。--add-modules 把库加入这次构建,效果和一行 requires 一样:
$ javac -p out --add-modules com.example.text -d peek checks/Peek.java
checks/Peek.java:1: error: package com.example.text.internal is not visible
import com.example.text.internal.Tokenizer;
^
(package com.example.text.internal is declared in module com.example.text, which does not export it)
1 error
错误信息既点出了规则,也给出了原因。Tokenizer 是公共的,但这不重要。它所在的模块没有导出这个包。
再把同一个文件对着前面类路径方式的构建结果编译一次:
$ javac -cp classes/lib -d peek checks/Peek.java
$ java -cp peek:classes/lib Peek
[reaching, inside]
编译通过,也运行了。在类路径上,module-info.class 不起作用,所以没有什么需要强制执行。只有当库在模块路径上时,强封装才存在。我们第一次运行时也吃了一惊。以后有人说某个库的内部实现受模块保护,就值得想起这一点。
用十岁孩子能懂的话说
包就是文件柜里的文件夹。每个文件夹贴着标签,比如 com.example.text,标签相同的文件放进同一个文件夹。你靠标签来要一份文件:“com.example.text 文件夹里的 WordCounter 那张。”
模块是一个带锁的文件柜,抽屉里装着文件夹。柜子的主人在一些抽屉上贴了“已导出”的贴纸,别的柜子的人可以打开这些抽屉。没贴贴纸的抽屉从外面打不开,哪怕里面的文件顶上写着“公开”。在这个柜子旁边工作的人,仍然能打开每个抽屉。
类路径就像把所有柜子里的东西全倒在一张大桌子上。每份文件都摊在那里,谁都能随手拿起任何一份。
准确的说法
模块是一组有名字的包,由 module-info.class 描述。一行 requires 让一个模块读取另一个模块。模块 A 的代码要使用模块 B 里的类型,必须同时满足三个条件:A 读取 B,B 把该类型所在的包导出给 A,并且这个类型本身是 public。缺一个就是编译错误,JVM 在运行时也执行同样的规则。要通过反射访问私有成员,还需要把包开放出来。
类路径上的所有东西都进入未命名模块。未命名模块会读取 JVM 或编译器解析到的每个模块,所以 Peek 需要 --add-modules,但它仍然只能看到这些模块导出的包。类路径上各个 JAR 里的类之间没有模块边界,所以老规则仍然适用:public 就是谁都能用。
这个比喻的局限:上锁的抽屉听起来像是安全措施,其实不是。任何启动 JVM 的人都能在命令行上传入 --add-exports 或 --add-opens 来解锁一个包,或者像刚才的 Peek 那样,把 JAR 挪到类路径上。封装保护的是库的作者,免得别人意外依赖他们的内部实现。它保护不了秘密,挡不住掌控命令行的人。
opens:让反射进来
exports 控制编译期访问和普通调用,opens 控制深度反射,也就是读取私有字段的那种反射。从 JSON 填充对象或者注入依赖的框架需要它。像 opens com.example.text.model; 这样一行,能让运行时的反射访问该包的所有成员,包括私有成员,而不必为了编译导出这个包。
JDK 自己的模块不会开放内部实现,用一个单文件程序就能看到它被拒绝:
void main() {
try {
var field = String.class.getDeclaredField("value");
field.setAccessible(true);
IO.println("opened");
} catch (NoSuchFieldException | InaccessibleObjectException e) {
IO.println(e.getClass().getSimpleName());
String message = e.getMessage();
// The message ends with " @" and a hash code that changes on every run.
IO.println(message.substring(0, message.lastIndexOf(" @")));
}
}
输出:
InaccessibleObjectException
Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module
getDeclaredField 成功了,说明这个字段存在。失败的是 setAccessible(true) 这一步。从 Java 9 到 15,同样的调用会成功,只给出一条警告,不少老库就依赖这一点。Java 16 默认让它失败,Java 17 又删掉了恢复旧行为的开关。
模块化 JAR 和模块路径
模块化 JAR 就是根目录下有 module-info.class 的普通 JAR。每个模块打一个 JAR,应用 JAR 上的 --main-class 会把要启动的类记录在它的模块描述符里:
$ mkdir mods
$ jar --create --file mods/com.example.text.jar -C out/com.example.text .
$ jar --create --file mods/com.example.app.jar --main-class com.example.app.Main -C out/com.example.app .
$ java -p mods -m com.example.app < sample.txt
the 3
cat 2
and 1
module: com.example.app
这次 -m 只写模块名,因为 JAR 已经知道自己的主类。jar --describe-module 会打印描述符。第一行是模块名和 JAR 的完整路径,之后它为每个 JAR 打印了这些:
$ jar --describe-module --file mods/com.example.text.jar
exports com.example.text
requires java.base mandated
contains com.example.text.internal
$ jar --describe-module --file mods/com.example.app.jar
requires com.example.text
requires java.base mandated
contains com.example.app
main-class com.example.app.Main
mandated 标出的是你从没写过的那行 requires java.base。contains 列出在 JAR 里但没有导出的包。
模块路径还会检查类路径不检查的东西。删掉库的 JAR,再运行一次应用:
$ rm mods/com.example.text.jar
$ java -p mods -m com.example.app < sample.txt
Error occurred during initialization of boot layer
java.lang.module.FindException: Module com.example.text not found, required by com.example.app
main 一行都没运行。JVM 在启动前读取了每一行 requires,发现有一个满足不了,就停下了。对比一下前面在第 12 行才出现的 NoClassDefFoundError。
还有一个意外。把两个 JAR 都放回 mods 后,对模块化的应用 JAR 执行 java -jar 会失败:
$ java -jar mods/com.example.app.jar < sample.txt
Exception in thread "main" java.lang.NoClassDefFoundError: com/example/text/WordCounter
at com.example.app.Main.main(Main.java:12)
Caused by: java.lang.ClassNotFoundException: com.example.text.WordCounter
java -jar 总是把 JAR 放到类路径上。里面的 module-info.class 被忽略,所以 requires 行毫无作用,而库又不在类路径上。要把模块化 JAR 当作模块运行,请用 -p 和 -m。
java --list-modules,以及什么时候值得用模块
JDK 本身就拆分成了模块,java --list-modules 会连同版本号把它们打印出来。在这台机器上它列出了 69 个,开头是这样的:
$ java --list-modules | head -4
java.base@25.0.4
java.compiler@25.0.4
java.datatransfer@25.0.4
java.desktop@25.0.4
加上 -p mods,你自己的模块就会出现在列表末尾,每个都附带它来自的文件。
说句实在话。如今大多数 Java 应用仍然跑在类路径上,而且跑得好好的。许多流行的框架和库诞生于模块出现之前,有些依赖反射的方式让模块路径用起来很别扭。如果你写的是应用,完全可以不管 module-info.java,损失很小。
模块真正有用的地方有两个:
- 库。不导出的包让你可以随意修改内部实现,而不会破坏使用你的库的人,前提是他们在模块路径上。这就是
internal目录的思路,只不过由编译器强制执行。 - 定制运行时。
jlink能构建一个精简的 JDK,只包含你的程序需要的模块。它必须知道是哪些模块,所以你的代码必须模块化。讲不用构建工具进行测试和发布的那一篇会用到它。
Maven 和 Gradle 多做了什么
上面的一切只用了三个 JDK 工具,对于两个没有依赖的模块,这就够了。检查这个项目的脚本是手工列出源代码目录和构建步骤的。Maven 和 Gradle 用声明式的项目取而代之。你按名字和版本列出依赖,工具就从 Maven Central 这样的仓库下载它们,连同这些依赖自己需要的一切,并解决它们之间的版本冲突。然后它替你构建类路径或模块路径,按正确的顺序编译,运行测试,打包 JAR。同样声明的版本让每台机器上的构建结果都一样,而手写脚本最先出错的恰恰是这一点。这里面没有魔法:在底层,这些工具仍然调用带 -cp 或 -p 的 javac,还有 jar,和本文做的差不多。
要点
- 包是一个命名空间,也是一个目录。
import只是全限定名的简写,import static对静态成员做同样的事,单类导入优先于通配符导入。 - 紧凑源文件位于默认包,不能声明包。
import module java.base;在 Java 25 成为正式特性,紧凑源文件会自动获得它。 - 类路径是目录和 JAR 的列表。找不到启动类会得到
ClassNotFoundException,而之后才缺失的类只有在执行到那一行时才报NoClassDefFoundError。 - JAR 是带清单的 zip。
--main-class设置Main-Class,java -jar总是在类路径上运行它,哪怕它是模块化 JAR。 module-info.java用requires声明模块依赖,用exports控制包的编译期访问,用opens允许深度反射。未导出包里的public类在模块外不可见。- 封装和启动检查只存在于模块路径上。在类路径上,一切都属于同一个未命名模块。
- 大多数应用放在类路径上就很好。模块最有价值的场合是库和
jlink。
包给代码命名,JAR 把代码送出去,模块决定外面谁能用它。