Java 25 只需一条命令和一个光秃秃的 void main 就能运行单个源文件,不用先写类。了解 JDK 里有什么、为什么选 Java 25,以及怎样读懂编译错误和异常。
以前,一个 Java 程序要先写五行样板代码,才能打印出一个词。Java 25 把它减到了三行。你写一个 void main(),调用 IO.println,再用一条命令运行这个文件。老写法仍然有效,而且几乎所有现有代码库里都能看到它,所以本文两种写法都讲,并说明被去掉的每一部分原本是做什么的。
本文先讲 JDK 是什么、该装哪个版本。然后写第一个程序,看看 java Main.java 做了什么,读取一行输入,试用 jshell,再读懂一个编译错误和一个运行时异常。下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行,就把代码保存为 Main.java,然后执行 java Main.java。
JDK:编译器、运行时和一套工具
JDK,全称 Java Development Kit(Java 开发工具包),是编写和运行 Java 唯一需要下载的东西。它包含三类内容:
- 编译器
javac。 它把.java源文件变成.class文件,里面装满字节码。字节码是一套紧凑的指令,不绑定任何一种处理器。 - 运行时,用
java启动。 它就是 Java 虚拟机(JVM)加上标准库。JVM 加载字节码并运行它,还会在程序运行期间把频繁执行的部分编译成机器码。 - 工具。
jshell用来逐行试代码,jar用来打包,javadoc用来生成文档,jlink用来构建精简的运行时,还有更多。
老教程会告诉你:运行程序要装 JRE(Java 运行时环境),只有写程序才要装 JDK。如今这种区分基本上已经不再是两个单独的下载了。从 Java 11 起,OpenJDK 项目就不再发布独立的 JRE。有些厂商和 Linux 发行版仍然会单独打包,但 JDK 包含了 JRE 的全部内容,所以装 JDK 就行。
安装与检查
用操作系统的包管理器安装 JDK 包,比如 Ubuntu 上的 openjdk-25-jdk;也可以从 adoptium.net 下载 Eclipse Temurin 25,它会持续为长期支持版发布更新。装好后,用两条命令查看路径上是哪个运行时、哪个编译器:
$ java -version
openjdk version "25.0.4" 2026-07-21
$ javac -version
javac 25.0.4
java -version 还会多打印两行,标明具体的构建版本,各厂商不一样,所以这里只显示第一行。两个版本号都应该以 25 开头。如果对不上,说明你装了不止一个 JDK,路径把它们混在了一起。先解决这个问题,否则你会遇到莫名其妙的错误。
为什么本系列用 Java 25
Java 每六个月发布一个新版本,分别在三月和九月;每两年会有一个版本被标为 LTS(长期支持版)。LTS 版本能获得多年的安全和 bug 修复。其他版本只有六个月的修复,直到下一个版本取代它们。
最近的 LTS 版本是 17、21 和 25,更早的是 11 和 8。大多数公司运行的是 LTS 版本,所以你在工作中遇到的也是它们。
Java 25 是当前的 LTS 版本,于 2025 年 9 月发布。它也是第一个支持本文这种简短程序写法的 LTS 版本。本系列的所有内容在 Java 25 中都是正式特性,只有讲虚拟线程的那一部分用到一个预览特性,届时会特别说明。如果你用的是 Java 21,本系列大部分内容仍然适用,但下面的第一个程序无法编译。
你的第一个 Java 程序
一个 Java 25 程序可以只有三行。把它保存为 Main.java:
void main() {
IO.println("Hello, Java 25");
}
输出:
Hello, Java 25
void main() 是程序开始的地方,void 表示它不返回任何东西。IO.println 打印一行文字。花括号 { } 包住方法体。
这叫紧凑源文件。紧凑源文件和 IO 类在 Java 25 中成为正式特性。让编译器把同一个文件当作 Java 24 来处理,它就会拒绝:
$ javac --release 24 Main.java
Main.java:1: error: implicitly declared classes are not supported in -source 24
void main() {
^
(use -source 25 or higher to enable implicitly declared classes)
1 error
报错信息里说的是“implicitly declared class”(隐式声明的类),这是该特性还处于预览阶段时的旧名字。意思是一样的:你没有写类,编译器替你写了一个。
Java 25 之前的同一个程序
在 Java 的大部分历史里,最小的程序长这样:
public class Main {
public static void main(String[] args) {
System.out.println("Hello, Java 25");
}
}
输出:
Hello, Java 25
Java 25 依然能运行这种长写法,你在书里、网上的回答里和大多数现有项目里都会读到它。下面是每一部分的作用:
public class Main { ... }:所有 Java 代码都放在类里面。紧凑写法其实也有一个类,因为编译器会用一个以文件名命名的类把你的文件包起来。- 类和方法上的
public:让它们对外可见,这样启动器才能调用main。从 Java 25 起,启动器不再强制要求public,所以紧凑写法把它省掉了。 static:方法属于类,而不属于某个对象,所以启动器不用先创建Main就能调用它。从 Java 25 起,main可以是普通方法,启动器会替你创建对象。String[] args:在命令行上跟在程序名后面输入的那些词。你仍然可以用void main(String[] args)来获取它们,但如果用不到,就不必声明。System.out.println:System是一个类,out是它的标准输出流,println往这个流里打印一行。IO.println做同样的事,只是少打几个字。
紧凑源文件还会自动导入整个 java.base 模块,所以 List、Map 这些类不写 import 就能直接用:
void main() {
List<String> names = List.of("Ada", "Grace");
IO.println(names);
}
输出:
[Ada, Grace]
同样几行代码放进 public class Main 里,就会报 cannot find symbol,直到你加上 import java.util.List;。本系列全程使用紧凑写法。
java Main.java 到底做了什么
运行 java Main.java 会在内存里编译你的源文件,然后运行结果,一步完成。为了看清这个过程,我们用一个在 main 旁边声明了一个小 record(记录类)的程序。record 有专门的一部分来讲。现在你可以先把它理解成一个带方法的具名值:
record Greeting(String name) {
String text() {
return "Hello, " + name;
}
}
void main() {
var g = new Greeting("Ada");
IO.println(g.text());
}
输出:
Hello, Ada
在一个空文件夹里运行它,并在运行前后各列一次文件。磁盘上没有出现任何新文件:
$ ls
Main.java
$ java Main.java
Hello, Ada
$ ls
Main.java
另一种运行方式是 Java 一直以来的做法:先用 javac 编译,它会写出 .class 文件,然后让 java 指向编译好的类:
$ javac -d classes Main.java
$ ls classes
Main$Greeting.class Main.class
$ java -cp classes Main
Hello, Ada
-d classes 告诉 javac 把输出放到哪里。-cp classes 设置类路径,也就是 java 查找已编译类的位置列表。注意,最后一条命令给的是类名 Main,而不是文件名。
看看这些文件名。javac 为每个类生成一个 .class 文件,record 变成了 Main$Greeting.class。$ 表示它是嵌套的:在紧凑源文件里,你声明的所有东西都位于编译器替你写的那个类里面。如果去掉 -cp classes,java Main 会在当前文件夹里找,找不到 Main.class,于是报错 Could not find or load main class Main。
那什么时候还需要 javac?当你想保留编译好的文件时。真正的应用只编译一次,把类打包成 JAR,然后发布这个 JAR。java Main.java 每次运行都要重新编译,适合学习和写小脚本。讲包、模块和 JAR 的那一部分会用完整的方式构建一个项目。
用十岁孩子能懂的话说
想象有一份用法语写的菜谱,还有一位只看得懂一种特殊厨房暗号的厨师。
javac 是一位翻译,他一次性把整份菜谱翻译成厨房暗号,写在一张卡片上。卡片归你保管,任何厨房里的任何厨师都可以反复使用它。
java Main.java 是另一位翻译,他读着法语菜谱,在脑子里翻译,直接念给厨师听。蛋糕做出来了,但没有留下卡片。下一次,翻译又得从头开始。
准确的说法
两种情况下,都是同一个编译器在做同样的工作。java Main.java 在启动器内部运行编译器,把生成的类留在内存里,加载进 JVM,然后调用 main。javac 这条路则把同样的类写到磁盘上,之后再由一条 java 命令从磁盘加载。
“厨房暗号”就是字节码,“厨师”就是 JVM。字节码不依赖你的处理器或操作系统。在 Linux 上编译出的 .class 文件,可以在同版本或更新版本的 Mac 或 Windows JVM 上运行。
这个比喻的局限:厨师会一直照着卡片一步一步做。JVM 不是这样。它一开始解释执行字节码,观察哪些方法运行得最多,然后在程序运行期间把这些方法编译成真正的机器码。所以其实有两个编译器:程序运行前的 javac,以及运行期间 JVM 的即时(JIT)编译器。讲 JVM 的那一部分会介绍后者。
由多个文件组成的程序
启动器也能运行分散在多个源文件里的程序。你仍然只需指定包含 main 的那个文件,它会从同一个文件夹里编译所需的其他文件。直接运行单个源文件是 Java 11 引入的,运行多个源文件是 Java 22 引入的。
$ cat Main.java
void main() {
IO.println(Greeter.greet("Ada"));
}
$ cat Greeter.java
class Greeter {
static String greet(String name) {
return "Hello, " + name + ", from another file";
}
}
$ java Main.java
Hello, Ada, from another file
$ ls
Greeter.java Main.java
Main.java 用到了 Greeter,所以启动器找到了 Greeter.java,在内存里把两个文件都编译好,然后运行程序。同样,没有留下任何 .class 文件。
用 IO.readln 读取一行输入
IO.readln 读取用户输入的一行文字,并以 String 返回。如果你给它传一个字符串,它会先把这个字符串打印出来作为提示:
void main() {
String name = IO.readln("What's your name? ");
IO.println("Hello, " + name);
}
这个程序要等人输入,所以它的输出没法自动检查。下面是真实的运行结果,输入是从 shell 通过管道传进去的,而不是手动敲的:
$ echo Ada | java Main.java
What's your name? Hello, Ada
$ java Main.java < /dev/null
What's your name? Hello, null
提示语和问候语挤在同一行。在键盘上输入时,你按下的回车会被回显,光标随之下移。通过管道传入的输入不会回显,所以没有东西让它换行。程序本身的输出在两种情况下是一样的。
第二次运行完全没有输入,readln 返回了 null,这是 Java 表示“没有对象”的值。把 null 拼接到字符串上会得到文字 null,所以程序打印了 Hello, null,而没有崩溃。真正的程序应该检查这种情况。
在 jshell 里试代码
jshell 是 Java 的交互式环境。你输入一个表达式或一条语句,按回车,它马上显示结果,不需要文件,也不需要 main:
$ jshell
| Welcome to JShell -- Version 25.0.4
| For an introduction type: /help intro
jshell> 2 + 3
$1 ==> 5
jshell> 10 / 4
$2 ==> 2
jshell> 10 / 4.0
$3 ==> 2.5
jshell> var name = "Ada"
name ==> "Ada"
jshell> "Hello, " + name
$5 ==> "Hello, Ada"
jshell> name.toUpperCase()
$6 ==> "ADA"
jshell> /exit
| Goodbye
每个结果都有一个名字。如果你没给它起名,jshell 会自动起一个,比如 $1,你可以在后面的行里使用这个名字。以斜杠开头的命令是发给 jshell 本身的:/vars 列出你的变量,/help 列出其余命令,/exit 退出。
jshell 不要求每行以分号结尾,但源文件要求。再注意一下 10 / 4。两个整数相除得到的是整数,余数被丢掉了。从 JavaScript 或 Python 转过来的人常常对此感到意外,而这正是 jshell 能快速解答的那种小问题。讲值和类型的那一部分会解释它。
编译错误:程序还没运行,构建就停了
编译错误意味着 javac 没法把你的源代码变成字节码,所以程序连一行都不会运行。漏写分号是最经典的例子:
void main() {
IO.println("Hello")
IO.println("Goodbye");
}
构建失败,报错:
Main.java:2: error: ';' expected
IO.println("Hello")
^
javac 的报错要从左往右读。Main.java:2 是文件名和行号。error: 后面是出了什么问题。下一行重复你的那行源代码,下面的插入符 ^ 指向编译器放弃的确切位置。这里它就在右括号后面,那个位置本该有一个分号。
“Hello”从来没有被打印出来,尽管那一行在前面。用 java Main.java 运行时,启动器还会在最后加一行 error: compilation failed,然后停止。
名字拼错会引发最常见的错误:
void main() {
String name = "Ada";
IO.printn("Hello, " + name);
}
构建失败,报错:
Main.java:3: error: cannot find symbol
IO.printn("Hello, " + name);
^
javac 还会在插入符下面多打印两行:symbol: method printn(String) 和 location: class IO。合起来的意思是:“我在 IO 里找一个名叫 printn、接收一个 String 的方法,但没有找到。”插入符指向那个点号,就在它找不到的名字前面。“Symbol”(符号)是编译器对任何名字的叫法:变量、方法或类都算。
当屏幕上满是错误时,先修第一个,再重新编译。少一个花括号就可能让编译器把后面的一切都理解错。
运行时异常:程序启动了,然后停了
运行时异常发生在一个编译通过的程序里。程序启动、运行,然后遇到了它做不到的事。下面的程序试图把一段不是数字的文字转换成 int:
void main() {
String input = "forty-two";
IO.println("Parsing " + input);
int age = Integer.parseInt(input);
IO.println("Next year you'll be " + (age + 1));
}
输出后停止:
Parsing forty-two
Exception in thread "main" java.lang.NumberFormatException: For input string: "forty-two"
和上面的编译错误对比一下。这次第一行确实打印出来了,因为程序已经在运行。接着 Integer.parseInt 抛出了 NumberFormatException,没有代码捕获它,程序以非零退出码结束。最后一行从未执行。
异常这一行给出了线程名(main)、异常的类和一条消息。在它下面,Java 会打印栈跟踪,也就是一串以 at 开头的行,显示哪个方法调用了哪个方法。要找的是第一行提到你自己文件的那一行,这里是 at Main.main(Main.java:4)。那就是你的代码里出问题的那一行。
编译器检查的是它能从源代码里知道的东西:语法、名字和类型。它没法知道 "forty-two" 不是数字,因为在真实程序里,这段文字是在程序运行时才传进来的。异常处理有专门的一部分来讲。
语法基础:注释、语句、花括号和大小写
Java 的语法看起来像 C、C#、JavaScript 和 Go,所以如果你会其中任何一门,这一节就是快速核对一下。下面这个程序用到了所有这些部分:
// A line comment runs to the end of the line.
/*
* A block comment can span lines.
*/
void main() {
int apples = 3; int pears = 4; // two statements, one line: legal, but hard to read
int total = apples
+ pears; // one statement, two lines: the semicolon ends it
IO.println("total = " + total);
{
int inner = total * 2;
IO.println("inner = " + inner);
}
}
输出:
total = 7
inner = 14
- 注释。
//一直到行尾,/* ... */可以跨越多行。写在类或方法上方的/** ... */是文档注释,由javadoc读取。 - 语句和分号。 一条语句就是一条指令,由分号结束。换行对编译器没有意义,所以
total是一条跨两行的语句,而apples那一行包含两条语句。和 JavaScript、Go 不同,Java 不会自动插入分号。 - 花括号。
{ }把语句组合成一个代码块:方法体、循环体,或者像包住inner的那样单独的一个块。在块内声明的变量只在块内存在。缩进是给人看的,编译器会忽略它。 - 大小写敏感。
total、Total和TOTAL是三个不同的名字。按照惯例,变量和方法以小写字母开头,类以大写字母开头,比如String和IO。
main 的大小写同样要紧。把它首字母大写,启动器就找不到可以启动的东西:
void Main() {
IO.println("Hello");
}
构建失败,报错:
Main.java:1: error: compact source file does not have main method in the form of void main() or void main(String[] args)
这条消息列出了它本可以接受的两种写法。把 String 写成 string 会报 cannot find symbol,因为不存在名叫 string 的类。
网上一些较老的示例有时会直接调用 println("Hello"),前面没有 IO.。在紧凑源文件还是预览特性的 Java 23 和 24 里,这样写可以。在正式版 Java 25 里,它会报 cannot find symbol,所以要写 IO.println。
要点
- JDK 包含编译器(
javac)、运行时(java和 JVM)以及jshell等工具。安装 JDK,不用再去找单独的 JRE。 - Java 25 是当前的 LTS 版本。用
java -version和javac -version检查,确保两者都显示 25。 - 一个 Java 25 程序可以就是
Main.java里的void main() { IO.println("Hello"); }。编译器会在外面替你写好类,List这类java.base里的类不需要导入。 java Main.java在内存里编译并运行,不留下任何文件。javac -d classes Main.java写出.class文件,java -cp classes Main运行它们。IO.readln读取一行输入,没有更多输入时返回null。- 编译错误会在任何一行运行之前就让构建停下。依次读文件名、行号、消息和插入符。运行时异常发生在正在运行的程序里,此时前面的行已经执行过了。
- 语句以分号结尾,花括号组成代码块,名字区分大小写。
Java 25 让你从三行代码开始,而编译器仍会在程序运行之前检查其中的每一行。