本节目标:说清 Java 的跨平台能力到底发生在哪一层、边界在哪,以及为这些能力付出了什么代价。读完之后,你应该能判断一个项目该不该用 Java,而不是只记住「一次编译,到处运行」这句口号。
适用版本:JDK 25 LTS(Temurin 25.0.5);涉及版本差异时对照 JDK 21。
从 library 的第一个需求说起
我们要写的项目叫 library,一个图书借阅系统。第一版的需求很小:在命令行里录入图书、列出图书、借书、还书。开发者在 Mac 上写代码,最终要放到公司的 Linux 服务器上跑,同事的 Windows 笔记本上也要能运行。
这件事听起来平平无奇,但它恰好是 Java 最擅长的问题。假设用 C 写,同一份源码在三个平台上通常要分别编译:Mac 上产出 Mach-O,Linux 上产出 ELF,Windows 上产出 PE;链接的系统库、路径分隔符、换行符、字符编码都可能不同。Java 换了一个思路:源码先编译成一种与平台无关的中间格式,再由每个平台上的运行时负责执行。
这个中间格式就是字节码(bytecode),负责执行它的运行时就是 JVM(Java Virtual Machine)。理解这两件事,比背「Java 是面向对象语言」有用得多。
字节码:跨平台发生在哪一层
先从最小的 LibraryApp 开始。它只做一件事:打印一行版本信息。
public class LibraryApp {
public static void main(String[] args) {
System.out.println("library: " + Runtime.version());
}
}
在 JDK 25 下编译并运行:
javac LibraryApp.java
java LibraryApp
本机输出:
library: 25.0.5+8-LTS
命令执行完,目录里多了一个 LibraryApp.class。用 file 看它的类型:
file LibraryApp.class
LibraryApp.class: compiled Java class data, version 69.0
这个 69.0 是 class 文件格式的版本号,不是 Java 的版本号。它对应 JDK 25。javac 把 .java 源码翻译成 JVM 能读的指令,java 启动 JVM,JVM 加载 .class 并解释/编译执行。用 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: invokestatic #13 // Method java/lang/Runtime.version:()Ljava/lang/Runtime$Version;
6: invokestatic #19 // Method java/lang/String.valueOf:(Ljava/lang/Object;)Ljava/lang/String;
9: invokedynamic #25, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
14: invokevirtual #29 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
17: return
}
关键点在于:这份字节码不包含任何「Mac 上怎么调用系统调用」或「Linux 上怎么链接 libc」的信息。 它只描述「把哪个字段压栈、调用哪个方法、返回什么」。真正与操作系统打交道的是 JVM:Mac 上有 Mac 的 JVM,Linux 上有 Linux 的 JVM,Windows 上有 Windows 的 JVM。同一份 .class 可以在三者上运行,因为差异被下沉到了 JVM 这一层。
所以「一次编译,到处运行」的准确说法是:一次编译成字节码,到处由各自的 JVM 执行。 JVM 本身是平台相关的,字节码才是平台无关的。
「一次编译,到处运行」的三条边界
这句话成立,但只在特定条件下成立。实际项目里最容易踩的是下面三类边界。
边界一:class 文件版本
JDK 25 编译出的 class 文件版本是 69.0,JDK 21 只认到 65.0。把高版本编译的 class 直接丢给低版本 JVM,会得到一个非常明确的错误。本机用 JDK 8 运行上面那份 .class:
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" java.lang.UnsupportedClassVersionError: LibraryApp has been compiled by a more recent version of the Java Runtime (class file version 69.0), this version of the Java Runtime only recognizes class file versions up to 52.0
这不是 JVM 的 bug,而是二进制兼容的硬边界。反过来,如果你希望产物能在 JDK 21 上运行,就要用 --release 把目标版本降下来:
javac --release 21 -d out21 LibraryApp.java
javap -verbose out21/LibraryApp.class | grep 'major version'
major version: 65
--release 21 不只是改版本号,它还会用 JDK 21 的 API 快照来编译,防止你误用了 22–25 才有的方法。给库或服务定最低 JDK 版本时,用 --release,不要用 -source/-target 拼凑。
边界二:原生依赖与操作系统接口
标准库能抹平大部分差异,但一旦用到 JNI、JNA、数据库驱动里的本地库、文件锁、进程信号、Unix domain socket,跨平台的「免费午餐」就结束了。这些能力最终要落到某个平台的原生实现上,而不同平台的行为并不一致。
例如,文件路径分隔符在 Windows 上是 \,在 Unix 上是 /。写死字符串的代码在 Mac 上跑得好好的,到 Windows 上就会把 data/books.csv 当成一个文件名,而不是两级目录。正确做法是用 Path.of("data", "books.csv"),让 java.nio.file 去处理平台差异。
再比如,Linux 上可以给进程发 SIGTERM 做优雅退出,Windows 上没有同名的信号语义。涉及这类能力时,必须做平台分支,不能假设「字节码跨平台 = 行为跨平台」。
边界三:编码、时区与文件系统
JDK 18 起,file.encoding 默认是 UTF-8(JEP 400),这解决了很多历史问题,但不代表所有环境都一致。容器里没挂时区、数据库连接串没指定字符集、CSV 从 Excel 导出时带了 BOM,这些都会让中文变成乱码或时间偏移八小时。
一个具体建议:凡是跨进程边界的数据,显式指定编码和时区。 读文件用 Files.newBufferedReader(path, StandardCharsets.UTF_8),时间用 ZonedDateTime 并写明 ZoneId,不要依赖 FileReader 或 new Date() 的默认行为。
Java 给你的四样东西
跨平台只是入口。Java 在企业后端长期占主流,还因为它同时提供了下面四样东西——它们单独看都不稀奇,合在一起才构成工程上的稳定性。
| 能力 | 具体表现 | 对 library 的意义 |
|---|---|---|
| 标准库 | java.base 提供集合、IO、时间、HTTP、并发、加密 | 卷一可以只用 JDK 写完整个 CLI,不必先学框架 |
| 工具链 | javac、jshell、jlink、jpackage、jcmd、jfr | 从编译、试跑、打包到诊断,不依赖第三方 |
| 向后兼容 | 旧字节码通常能在新 JVM 上运行;API 极少删除 | 升级 JDK 的迁移成本相对可控 |
| 可观测性 | JFR、JMX、线程转储、堆转储是 JDK 自带能力 | 卷四会用同一套工具分析 libraryd |
向后兼容这一条值得展开一点。Java 的承诺主要是二进制兼容:用 JDK 8 编译的 jar,通常可以在 JDK 25 上直接运行。但它不是「源码兼容」的无限承诺,更不是「行为兼容」——某些默认值、废弃 API、内部实现会在版本升级时变化。所以「Java 升级很稳」这句话,要加一个限定:稳在二进制,不稳在每一个默认行为。
代价也要摆上桌
如果只讲优点,这一节就变成了宣传材料。Java 的代价同样具体:
- 启动慢、内存占用高。 一个 Hello World 也要启动 JVM、加载类、初始化运行时。JDK 25 已经比早期版本快很多,但和 Go、Rust 的原生二进制仍不在一个量级。对 CLI 小工具、Serverless 冷启动敏感的场景,这是实打实的成本。
- 样板代码多。 一个简单的数据载体,在 Java 8 时代要写字段、构造器、getter、
equals、hashCode、toString。record缓解了这个问题,但注解、配置、分层带来的仪式感仍然存在。 - 抽象层多。 从源码到字节码,再到 JIT 编译的机器码,中间隔了多层。出问题时,栈追踪、GC 日志、JIT 日志都需要单独学。卷四会系统讲这些,但你要先接受「多一层就多一层排查成本」。
- 版本升级有摩擦。 大版本每两年一个 LTS,框架、构建插件、容器镜像、CI 工具链都要跟着对齐。升级不是改一行
JAVA_HOME那么简单。
所以选 Java 的合理理由通常是:团队需要长期维护、生态里有成熟的库、部署环境以 JVM 为主、可观测性和稳定性优先。 如果你只是写一个一次性脚本,或者对启动时间和内存极其敏感,Python、Go、Rust 可能更合适。这不是贬低 Java,而是把工具放回它擅长的场景。
在 library 里落地
回到项目。library 的第一版确定为:
- 命令行程序,不引入 Web 框架,先把领域模型和业务规则写对。
- 只用 JDK 标准库,构建工具用 Maven,测试用 JUnit 5。
- 包名
com.plume.library,源码放在src/main/java下。 - JDK 25 为开发与运行版本,如果公司服务器暂时只有 JDK 21,用
--release 21产出兼容字节码。
第一版骨架只做命令分发,先把「程序怎么启动、参数怎么进来」跑通:
package com.plume.library;
public final class LibraryApp {
public static void main(String[] args) {
if (args.length == 0) {
System.out.println("usage: library <add|list|borrow|return|search>");
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 list
command = list
不带参数时:
usage: library <add|list|borrow|return|search>
这段代码故意很笨:它不解析参数、不校验命令、不碰任何业务。但它已经把后面要用的三件事定了下来——入口类、包结构、命令协议。第 3 章会把它换成 Maven 工程,第 18 章会让它长成一个能处理借还书的完整程序。
三个常见误解
「Java 就是 JavaScript。」 两者名字像,血统完全不同。JavaScript 最初叫 LiveScript,改名是为了蹭 Java 的热度;它的运行时是浏览器或 Node.js,语言模型是原型链,和 JVM 没有关系。
「JVM 只能跑 Java。」 JVM 执行的是字节码,任何能编译到合法 class 文件的语言都可以跑,Kotlin、Scala、Groovy、Clojure 都是例子。反过来说,Java 也不一定跑在 JVM 上——GraalVM Native Image 会把程序提前编译成原生二进制,卷四会讲它的取舍。
「新 JDK 一定更好,生产应该立刻升。」 新版本带来新特性,也带来新的默认行为和生态兼容问题。企业环境通常跟 LTS 走,并且等框架、驱动、构建插件都声明支持后再升级。本书以 JDK 25 LTS 为主线,正是这个考虑。
一张判断清单
如果你正在决定一个新项目要不要用 Java,可以按下面五个问题过一遍。五个里有两个以上答「否」,就应该认真考虑别的语言。
- 是否需要长期维护(通常指两年以上)? 长期维护的项目更看重生态稳定、人员可替换和向后兼容,Java 的优势会放大。
- 团队里是否已经有人能读懂栈追踪和 GC 日志? Java 的上手门槛不高,但排障门槛不低;没有这类人,出问题时会很被动。
- 部署环境是否以 JVM 为主? 如果公司已有成熟的 JVM 监控、发布、日志体系,接入成本最低。
- 启动时间和内存是否敏感? 如果目标是毫秒级冷启动、几十 MB 内存的边缘服务,Java 不是首选。
- 关键依赖是否有成熟的 Java 实现? 数据库驱动、消息队列客户端、协议库的成熟度,往往比语言本身更决定项目成败。
这五个问题没有标准答案,但它们能把「我听说 Java 好」变成「我的场景需要 Java 的哪些能力」。本书后续每一卷都会围绕 library 的具体决策展开,而不是抽象地讲语言特性。
举个例子:一个内部报表服务,预计维护三年,团队有两人熟悉 JVM,部署在现有的 Kubernetes JVM 运行时上,启动时间不敏感,还要接 Oracle 驱动——五个问题里前四个都是「是」,Java 是合理选择。
反过来,一个边缘函数要求 50 ms 内冷启动、只做 JSON 转发,即使团队会 Java,也应该优先考虑 Go 或 Rust。语言选型不是站队,而是把项目的约束逐条对上。
小结
- Java 的跨平台能力来自字节码 + 平台相关的 JVM,不是源码直接跨平台。
- class 文件版本是硬边界:JDK 25 是 69.0,JDK 21 是 65.0;要兼容低版本,用
javac --release。 - 原生库、路径分隔符、编码、时区这些地方,字节码跨平台不等于行为跨平台,必须显式处理。
- Java 的真正优势是标准库、工具链、二进制向后兼容和自带可观测性四件事的组合。
- 代价是启动、内存、样板代码和多层抽象;它适合长期维护的服务端项目,不适合所有场景。
library从命令行 + 标准库起步,包名com.plume.library,先把领域规则写对,再谈框架。
知道了 Java 能做什么,下一个问题很自然:版本那么多,为什么本书选 JDK 25,而不是 21 或最新的 27?下一节把 Java 的发布节奏和 LTS 口径讲清楚。
阅读导航:上一节:《Java 编程入门》目录 · 下一节:1.2 JDK 25 LTS 与版本节奏 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。