Blog

你好,Java 25:不写样板代码直接运行 Java 文件

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.printlnSystem 是一个类,out 是它的标准输出流,println 往这个流里打印一行。IO.println 做同样的事,只是少打几个字。

紧凑源文件还会自动导入整个 java.base 模块,所以 ListMap 这些类不写 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 classesjava 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,然后调用 mainjavac 这条路则把同样的类写到磁盘上,之后再由一条 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 的那样单独的一个块。在块内声明的变量只在块内存在。缩进是给人看的,编译器会忽略它。
  • 大小写敏感。 totalTotalTOTAL 是三个不同的名字。按照惯例,变量和方法以小写字母开头,类以大写字母开头,比如 StringIO

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 -versionjavac -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 让你从三行代码开始,而编译器仍会在程序运行之前检查其中的每一行。

这篇文章对你有帮助吗?

点一颗爱心来评分!

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

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