Blog

不用 Maven 的 Java 包、模块与 JAR

只用 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.textcom.example.text.internal 是两个毫无关系的包,只是碰巧前缀相同。导入其中一个不会导入另一个,谁也不能特殊访问对方的代码。

importimport static

import 让你写类的短名,而不是全限定名。它不加载任何东西,也不复制任何代码。编译器只是替你把 List 改写成 java.util.Listimport 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.maxComparator.reverseOrder()。静态导入要少用,不然读代码的人得到处找每个光秃秃的名字是从哪来的。

java.lang 里的东西都不需要导入。所以 StringMathIO 总是能直接用。

通配符导入 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.utiljava.iojava.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 文件的列表,javacjava 在其中查找已编译的类。先编译库,再把库放到类路径上编译应用,最后把两者都放到类路径上运行应用:

$ 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.basejavac 可以按 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 告诉 javacsrc 下的每个目录都是一个模块,模块名就是目录名。-m 选择要编译的模块。javac 根据 requires 行自己算出了编译顺序。

--module-path out,或者 -p out,就是模块路径。javaout 下的每个目录当作一个命名模块。-m module/class 指定启动哪个模块,以及哪个类里有 main。同一个 Main.class 现在报告的是 module: com.example.app

module com.example.app com.example.app class Main 不导出任何包 module com.example.text com.example.text WordCounter, WordCount exports com.example.text com.example.text.internal public class Tokenizer 未导出 requires 可使用 不可见 java.base 模块,每个模块都读取它

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.basecontains 列出在 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-pjavac,还有 jar,和本文做的差不多。

要点

  • 包是一个命名空间,也是一个目录。import 只是全限定名的简写,import static 对静态成员做同样的事,单类导入优先于通配符导入。
  • 紧凑源文件位于默认包,不能声明包。import module java.base; 在 Java 25 成为正式特性,紧凑源文件会自动获得它。
  • 类路径是目录和 JAR 的列表。找不到启动类会得到 ClassNotFoundException,而之后才缺失的类只有在执行到那一行时才报 NoClassDefFoundError
  • JAR 是带清单的 zip。--main-class 设置 Main-Classjava -jar 总是在类路径上运行它,哪怕它是模块化 JAR。
  • module-info.javarequires 声明模块依赖,用 exports 控制包的编译期访问,用 opens 允许深度反射。未导出包里的 public 类在模块外不可见。
  • 封装和启动检查只存在于模块路径上。在类路径上,一切都属于同一个未命名模块。
  • 大多数应用放在类路径上就很好。模块最有价值的场合是库和 jlink

包给代码命名,JAR 把代码送出去,模块决定外面谁能用它。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。