假设这是你接触 Java 的第一天。你照着教程敲下这样一段代码:

1public class HelloWorld {
2    public static void main(String[] args) {
3        System.out.println("你好,Java!");
4    }
5}

把它存成 HelloWorld.java,然后在命令行里敲两个命令:

1javac HelloWorld.java
2java HelloWorld

屏幕就打印出“你好,Java!”。大多数初学者在这里就满足了,但这一瞬间其实发生了很多事:你的代码先是变成了一种你看不见的中间形态,再被一台“软件造出来的计算机”逐条执行,最终才变成 CPU 上的指令。本篇就把这趟旅程完整走一遍。至于 publicstaticmain 这些词为什么必须这么写,是下一篇的事,今天只回答一个问题:程序是怎么跑起来的。

一趟需要“两次翻译”的旅程

先说一个绕不开的事实:你的 CPU 听不懂 Java。CPU 只认识机器指令——由 0 和 1 组成的、针对特定芯片型号(如 Intel、ARM)的指令。不同平台的指令体系还互不相同。

所以任何高级语言写出的程序,想被 CPU 执行,都得经过“翻译”。C 语言的做法是一次翻译到底:编译器直接产出针对某个平台的机器码,换一个平台就得重新编译一次。Java 选择了另一条路:翻译两次

正在绘制图表

第一次翻译由 javac 完成,把源码变成字节码;第二次翻译发生在你运行程序的那一刻,由 JVM 完成,把字节码变成当前这台机器、这块 CPU 真正执行的指令。整个 Java 技术体系,就是围绕“中间那层字节码”设计的。下面一站一站看。

第一站:javac 把源码变成字节码

你敲下的第一个命令 javac HelloWorld.java,调用的是 JDK 里自带的编译器 javac。它逐行检查你的源码,如果没有语法错误,就生成一个新的文件 HelloWorld.class

这个 .class 文件里装的不是机器码,而是 字节码(bytecode):一套给 JVM 看的指令。JVM 是 Java 设计者定义的一台“想象中的计算机”,它有自己的指令集——就像真实的 CPU 有它的指令集一样,只是这套指令不属于任何一家芯片厂商,也不依赖任何操作系统。指令之所以叫“字节码”,是因为每条指令的操作码只占一个字节 4

你可以用 JDK 自带的 javap 工具偷看一眼 .class 内部长什么样:

1javap -c HelloWorld

输出大致是:

1public static void main(java.lang.String[]);
2    Code:
3       0: getstatic     #7  // Field System.out
4       3: ldc           #13 // String 你好,Java!
5       5: invokevirtual #14 // Method PrintStream.println

getstatic(取一个静态字段)、ldc(把一个常量装入栈)、invokevirtual(调用一个方法)——这就是 JVM 的“语言”。你完全不需要读懂它,只要记住一件事:.class 文件不是给 CPU 的,是给 JVM 的

第二站:JVM,一台软件造出来的计算机

第二个命令 java HelloWorld 启动了一个叫 JVM 的程序。JVM 本身是运行在你操作系统上的一个普通程序,但它模拟了一台完整的计算机:有自己的指令集,有自己的内存管理方式。Oracle 官方的定义是:JVM 是一台“抽象的计算机”,有指令集,并在运行时操作内存 2

JVM 拿到 HelloWorld.class 后做三件事。首先是 类加载:JVM 里的类加载器把 .class 文件读进内存,并检查字节码是否合法、安全——乱写的字节码会被当场拒绝,这是 Java 安全模型的一道闸门 4

然后是 执行。执行引擎读一条字节码指令、执行一条。但这里有个精妙之处:JVM 并不是从头到尾傻乎乎地逐条翻译。Java 的官方实现叫 HotSpot,意思是它会一边运行一边统计哪些方法被反复调用——这些是“热点”。刚开始,它用解释器把字节码一条条翻译成机器码执行,好处是启动快;一旦某个方法被调用足够多次,JIT(Just-In-Time,即时编译)编译器就把这段字节码整个编译成针对当前 CPU 的机器码缓存起来,下次直接运行优化过的机器码 3

这就是为什么你常听到“Java 程序越跑越快”:一个长期运行的服务器程序,启动后先“解释”一阵子,随后热点代码被逐段编译成原生指令,性能接近甚至超过直接用 C 写的程序。

“一次编译,处处运行”是怎么做到的

现在可以回答那个著名的口号了。Java 的跨平台,靠的是把“最后一次翻译”推迟到了运行时:

  • 编译只做一次:你在 Windows 上用 javac 编译出的 HelloWorld.class,拷贝到 Mac 或 Linux 上,不用重新编译,字节码是一模一样的。
  • JVM 每个平台各有一个:Windows 上的 JVM、Mac 上的 JVM、Linux 上的 JVM,是不同的程序,由不同的人(或公司)针对各自平台开发。它们读的是同一份字节码,但翻译出来的机器码各不相同——那是各自 CPU 的事 12

打个比方:字节码是一份用“世界语”写成的剧本,原样存档,谁也不用改。每个城市都配了一位口译员(JVM),开演时他把世界语剧本现场翻成当地方言(机器码),本地观众(CPU)这才听得懂。换一座城市,剧本不用重写,换一位口译员即可。

代价也要说清楚:多了一层翻译,程序启动时会比“一次编译成机器码”的语言稍慢;而且没有 JVM 的机器上跑不了 Java 程序。Java 的取舍是——用一点运行时开销,换来“一份代码处处能跑”。

JDK、JRE、JVM:是三个东西还是一件事?

初学 Java 的第一道坎,是安装时撞见三个缩写。它们其实是层层包含的三个“同心圆”,从里到外是:

  • JVM:最小的一层,真正执行字节码的那台“虚拟机”。只装它还不够,因为你的程序要用现成的类库(比如 System.out.printlnSystem 这个现成类),这些代码不在 JVM 里。
  • JRE(Java Runtime Environment,Java 运行环境) = JVM + 核心类库。普通用户要“运行”别人的 .class 程序,装 JRE 就够了。
  • JDK(Java Development Kit,Java 开发工具包) = JRE + 开发工具,最重要的就是编译器 javac,还有 javap、调试器等。你要“开发”程序,必须装 JDK 1
JDK 与 JRE 的关系示意(图片来自 Oracle 官方 Java 平台介绍页)

用厨房来记:JVM 是灶台,JRE 是灶台加齐备的锅碗瓢盆(能做菜了),JDK 还多一套菜刀和菜谱工具(能自己发明新菜了)。普通食客只要吃饭(运行程序),JRE 足够;你是来学做菜的,得装 JDK。如今的发行版也早已把两者打包:直接安装一个 JDK,运行所需的一切就都齐了,不用再单独找 JRE。

走到这里,你已经看到了全貌

把整条链再收一遍:你写的是给人读的 .java 源码;javac 把它编译成给 JVM 读的 .class 字节码;运行 java 命令时,本机安装的那个 JVM 把字节码加载进来,先解释执行、再把热点编译成机器码,最终你的 CPU 执行这些指令,屏幕上才出现那行字。

往后你学的每一个语法细节,都能在这张地图上找到位置:你写方法,是在给 JVM 准备可调用的指令序列;你用对象,是在指挥 JVM 管理内存。而下一篇文章,我们会回到开头那段 HelloWorld,把那句看起来像咒语一样的 public static void main(String[] args) 逐词拆开——你会看到,正是它规定了 JVM 该从哪里开始执行你的程序。