本节目标:能独立解释从
.java到可运行程序的每一步,知道javac -d、java -cp、--release各自改变了什么,并能读懂最常见的三类启动错误。
适用版本:JDK 25 LTS(Temurin 25.0.5);对照 JDK 21。
一个文件,两条命令
第 1 章用 LibraryApp 打印过版本和参数,第 2 章把它放进了 Maven 目录。现在把中间那层「Maven 自动完成的事」拆开,手工走一遍。先写一个最小的 LibraryApp.java:
public class LibraryApp {
public static void main(String[] args) {
System.out.println("Hello, Library");
}
}
编译:
javac LibraryApp.java
运行:
java LibraryApp
本机 JDK 25 输出:
Hello, Library
三行代码、两条命令,背后其实经过了五个阶段:读取源码、语法分析、生成字节码、写 class 文件、启动 JVM 加载并执行。理解这五个阶段,比记住「Java 是编译型语言」有用得多。
javac 到底做了什么
javac 是编译器,不是解释器。它把 .java 源码翻译成 JVM 能读的 .class 文件。执行 javac LibraryApp.java 后,当前目录多了一个文件:
ls -l LibraryApp.class
-rw-r--r-- 1 user staff 429 Oct 10 14:00 LibraryApp.class
上面是示意输出,文件大小、所有者和时间会随环境变化。结构是固定的:魔数 CAFEBABE、版本号、常量池、方法表、属性表。用 javap 可以看反汇编结果:
javap -c LibraryApp
Compiled from "LibraryApp.java"
public class LibraryApp {
public LibraryApp();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello, Library
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}
两个方法:一个你没写的构造器 <init>,一个你写的 main。构造器调用 Object.<init>,main 把字符串常量压栈、调用 PrintStream.println。字节码里没有「Mac 怎么打印」「Linux 怎么打印」的信息,只有「调用哪个方法」。平台差异留给 JVM。
javac 还负责语法检查、类型检查、访问控制检查。下面这些错误都发生在编译期,不会生成 class 文件:
- 括号不匹配、缺少分号。
- 变量未声明就使用。
- 把
String赋给int。 - 调用了不存在的方法,或者参数类型不匹配。
编译期能发现的问题越多,运行期越安全。这也是 Java 相比动态语言的一个实际差异:很多错误在写代码时就被拦住,而不是等到某条路径被执行。
java 启动时发生了什么
java LibraryApp 做了四件事:
- 启动 JVM,初始化运行时。
- 用系统类加载器找到
LibraryApp.class。 - 链接、初始化这个类。
- 找到
public static void main(String[]),调用它。
java 命令后面的 LibraryApp 是类名,不是文件名。如果源码里有包声明,类名必须写全限定名。比如 package com.plume.library; 的类,运行时要写 java com.plume.library.LibraryApp,而不是 java LibraryApp。
java 默认到当前目录找类。当前目录不在类路径里时,就会失败。可以用 -cp 显式指定:
java -cp . LibraryApp
. 表示当前目录。-cp 和 -classpath 是同一个参数的两个写法,-cp 更常用。
JVM 启动本身有成本:加载核心类、初始化运行时、启动 JIT 线程。一个 Hello World 也要经历这些步骤。这个成本在小工具里不明显,在高频短任务和 Serverless 冷启动里就会被放大。第 1 章提过这个取舍,这里看到的是它在命令层面的表现。
main 签名为什么长这样
main 的签名是固定的:
public static void main(String[] args)
四个部分各有原因:
public:JVM 从类外部调用它,必须可访问。static:JVM 不需要先创建对象就能调用。void:进程退出码由System.exit或异常决定,不靠返回值。String[] args:命令行参数。java LibraryApp list会把"list"放进args[0]。
参数从 args[0] 开始,不含程序名。这一点和 C 的 argv[0] 不同。java LibraryApp add "Effective Java" 会得到两个参数:args[0] 是 "add",args[1] 是 "Effective Java"。shell 负责去掉引号,Java 看到的是已经分好组的字符串。
args 永远不会是 null。没有参数时它是长度为 0 的数组,访问 args[0] 会抛 ArrayIndexOutOfBoundsException。所以入口处要先判断长度:
if (args.length == 0) {
System.out.println("usage: library <command>");
return;
}
这个判断在第 5 章会变成完整的命令分发,现在先把「参数怎么进来」跑通。
用 –release 控制目标版本
JDK 25 的 javac 默认生成 class 文件版本 69。如果目标环境只有 JDK 21,就要降版本:
javac --release 21 -d out21 LibraryApp.java
javap -verbose out21/LibraryApp.class | grep 'major version'
major version: 65
不加 --release 时:
javac -d out25 LibraryApp.java
javap -verbose out25/LibraryApp.class | grep 'major version'
major version: 69
--release 21 不只是把版本号改成 65。它会用 JDK 21 的 API 快照来编译,如果你调用了 JDK 22–25 才有的方法,编译期就会报错。-source 21 -target 21 只改语法检查和版本号,仍然链接当前 JDK 的 API,可能产出「版本号是 21、却调用了 25 方法」的 class 文件,运行时抛 NoSuchMethodError。兼容低版本,用 --release,不要用 -source/-target。
在 library 里落地
把上面的最小版本放进 library 的包结构。先只打印问候语:
package com.plume.library;
public final class LibraryApp {
public static void main(String[] args) {
System.out.println("Hello, Library");
}
}
编译到 out,再运行:
javac -d out src/main/java/com/plume/library/LibraryApp.java
java -cp out com.plume.library.LibraryApp
本机输出:
Hello, Library
-d out 会按包名创建目录:
out/
└── com/
└── plume/
└── library/
└── LibraryApp.class
java -cp out com.plume.library.LibraryApp 里的 out 是类路径根目录。JVM 用全限定名拼出 out/com/plume/library/LibraryApp.class,加载后找到 main。如果写成 java -cp out/com/plume/library LibraryApp,类路径根目录就变成了包目录,JVM 会去找 out/com/plume/library/LibraryApp.class 之外的错误位置。
接着加上参数和用法提示,形成第 3 章的完整版本:
package com.plume.library;
public final class LibraryApp {
public static void main(String[] args) {
System.out.println("Hello, Library");
if (args.length == 0) {
System.out.println("usage: library <add|list|borrow|return|search|quit>");
return;
}
System.out.println("command = " + args[0]);
}
}
重新编译并运行:
javac -d out src/main/java/com/plume/library/LibraryApp.java
java -cp out com.plume.library.LibraryApp
java -cp out com.plume.library.LibraryApp list
本机输出:
Hello, Library
usage: library <add|list|borrow|return|search|quit>
Hello, Library
command = list
这段代码仍然很笨:它不校验命令、不处理参数、不碰业务。但它已经把四个基础约定定下来了:入口类、包名、main 签名、参数协议。第 5 章会把 if 换成分发逻辑,第 18 章会让它长成完整程序。
常见错误与边界
「public 类名和文件名不一致。」 Java 要求 public 类的文件名与类名完全一致(区分大小写)。把 public class LibraryApp 写进 WrongName.java,编译时报:
WrongName.java:1: 错误: 类 LibraryApp 是公共的, 应在名为 LibraryApp.java 的文件中声明
public class LibraryApp {
^
1 个错误
非 public 类可以放在任意文件名里,但一个文件只能有一个 public 类。初学阶段最简单规则:一个文件一个 public 类,文件名与类名一致。
「找不到 main 方法。」 类能加载,但签名不对时:
错误: 在类 NoMain 中找不到 main 方法, 请将 main 方法定义为:
public static void main(String[] args)
常见原因:写成 public void main(少了 static)、参数写成 String args(少了 [])、返回类型写成 int。JVM 只认 public static void main(String[]) 这一个入口。
「类路径写错。」 编译成功但运行时报:
错误: 找不到或无法加载主类 com.plume.library.LibraryAp
原因: java.lang.ClassNotFoundException: com.plume.library.LibraryAp
先检查类名拼写,再检查 -cp 是否指向包目录的上一级。-cp 指向 out,不是 out/com/plume/library。如果类在 jar 里,-cp 要指向 jar 文件本身。
「用 java LibraryApp.java 直接跑源码。」 JDK 11 起支持单文件源码启动,但它只适合没有包声明、没有多文件依赖的小例子。带 package 的源码用这种方式运行,类名解析会和正常编译不同。学习阶段可以试,项目里不要依赖它。
小结
javac把.java翻译成.class,同时做语法、类型和访问控制检查;编译失败不会留下可运行的 class。java启动 JVM,按类名加载 class,找到public static void main(String[])并调用。args从命令行参数的第一项开始,不含程序名;无参数时是长度为 0 的数组,不是null。-d out指定输出根目录,包名会变成子目录;-cp out指定类路径根目录。--release 21同时切换 class 文件版本和 API 快照,比-source/-target可靠。- 三类高频错误分别是文件名不匹配、main 签名不对、类路径指向错误。
单个类的链路已经打通。接下来 library 会长出多个包,public、包私有、protected、private 会决定谁能看见谁;module-info.java 又会在这之上再加一层边界。下一节把包、模块与可见性放在一起讲。
阅读导航:上一节:2.3 IDE、JShell 与命令行 · 下一节:3.2 包、模块与可见性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。