Skip to content

程序结构与运行 ​

Java 初级 · 入门与环境

上一节装好了环境,这一节我们「拆开」一个最简单的程序,看清它从源码到屏幕输出之间,JVM 内部究竟发生了什么。理解程序的结构约定与运行全流程,是读懂任何 Java 代码的前提。

一、为什么 Java 代码必须写在类里 ​

Java 是「纯面向对象」语言:不存在独立于类的全局函数或全局变量。所有代码(包括入口)都必须属于某个类。一个最小可运行程序:

java
public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, Java!");
    }
}

这里有几个硬性约定,违背了 JVM 就找不到入口或编译器直接报错:

  • 文件名 = public 类名:文件必须叫 HelloWorld.java(大小写敏感),否则编译报错。
  • public static void main(String[] args):这是 JVM 约定的唯一入口。
    • public:JVM 要从类外部调用它,必须公开。
    • static:程序启动时还没有任何对象,JVM 不能先 new 再调用,所以入口必须是类级别的方法。
    • void:入口的返回值给谁看?没有接收方,所以返回 void。
    • String[] args:用于接收命令行参数,例如 java HelloWorld Alice 时 args[0] 为 "Alice"。

这些细节写错一个都跑不起来

少写 static → JVM 找不到 static main;参数写成 String args(少 [])→ 签名不匹配;一个文件写两个 public 类 → 编译器无法确定文件名对应谁。务必死记这个固定签名。

二、编译产物:.class 文件到底是什么 ​

执行 javac HelloWorld.java,会生成 HelloWorld.class。它不是机器码,而是字节码(bytecode)——一种中立的、给 JVM 看的中间指令。

.class 文件有严格的结构(JVM 规范定义的「类文件格式」),开头是一段固定内容:

text
cafebabe 00000037 001d0a00 02001e07 002f0a00 02001e07 ...
│──────│ │──┬──│
魔数      次/主版本号(0x37=55 即 Java 11+)
  • 魔数 CAFEBABE:每个 .class 文件开头都是这 4 字节(咖啡宝贝,Java 的梗),JVM 用它快速判断「这是不是合法的 class 文件」。
  • 主/次版本号:记录编译用的 JDK 版本。高版本 JVM 能跑低版本字节码,反之不行——这就是上一节 UnsupportedClassVersionError 的来历。
  • 后面依次是常量池(Constant Pool)、访问标志、类/父类/接口索引、字段表、方法表、属性表。源码里的字符串 "Hello, Java!"、方法名 main、类名 HelloWorld 等都以「符号引用」形式存在常量池里。

想亲眼看看字节码?

在项目目录执行 javap -c HelloWorld,即可反汇编出人类可读的字节码。这是理解「Java 底层」的最直接入口,下一节会用到。

三、JVM 运行时数据区:程序跑起来后内存长啥样 ​

java HelloWorld 启动一个 JVM 进程,内存被划分为若干区域。理解它们,你就明白「变量存在哪」「对象存在哪」「为什么多线程要用不同栈」:

  • PC 寄存器:每条线程一个,记录「当前执行到哪条字节码指令」。
  • JVM 栈:每条线程一个,方法调用就压入一个栈帧(Stack Frame),方法返回就弹出。栈帧里存局部变量表、操作数栈等。递归太深会 StackOverflowError(栈满了)。
  • 堆(Heap):所有线程共享,new 出来的对象都在这里。字符串常量池也在堆中(JDK 7 之后从方法区移入堆)。
  • 方法区 / 元空间(Metaspace):存放被加载类的结构信息、常量、静态变量等。

对 HelloWorld 而言:启动时 JVM 为 main 创建一个栈帧;"Hello, Java!" 这个字符串对象在堆里(更准确说在字符串常量池);System.out 是一个静态变量,指向堆里的 PrintStream 对象。

四、类加载全流程:从磁盘到能被调用 ​

光有 .class 还不够,JVM 要把它请进内存并准备好,才会调用 main。这经历三个阶段:

  • 加载:类加载器根据全限定名找到 .class 字节码,在堆里生成一个 Class 对象代表这个类。
  • 链接-验证:防止非法/被篡改的字节码破坏 JVM。
  • 链接-准备:为静态变量分配内存并给零值(int 给 0,引用给 null),注意此时还不是你代码里写的初值。
  • 链接-解析:把常量池里的「符号引用」(如 java/lang/System.out)替换为内存中的「直接引用」(真实地址)。
  • 初始化:执行静态变量赋值和 static {} 代码块,然后 JVM 才正式调用 main(String[])。

一个反直觉的点

「准备阶段」给静态变量赋的是零值,不是你写的初值。比如 static int x = 10;,准备阶段 x=0,直到初始化阶段才真正变成 10。理解这点,对排查「静态变量为啥一开始是 0」很有用。

五、看一眼字节码:javap -c ​

编译后用 javap -c HelloWorld 反汇编 main 方法,典型输出(节选并注释):

text
public static void main(java.lang.String[]);
  Code:
     0: getstatic     #2  // System.out
     3: ldc           #3  // 把常量池里的 "Hello, Java!" 压入操作数栈
     5: invokevirtual #4  // 调用 PrintStream.println(String)
     8: return            // 方法返回

逐条解释:

  • getstatic:取出静态字段 System.out 压栈。
  • ldc(load constant):从常量池把字符串字面量加载到操作数栈。
  • invokevirtual:调用实例方法 println(虚方法调用,按对象实际类型分派——多态的底层就靠它)。
  • return:结束 void 方法。

你会发现:所谓「调用方法」,在底层就是一条条入栈、出栈、跳转的字节码指令。Java 代码越写越高级,落到 JVM 上都是这套朴素的操作数栈模型。

六、包(package)机制:为什么代码要分层 ​

当类变多,全丢在一起会命名冲突。Java 用 package 组织类,规则是包名对应目录结构,且习惯用「反向域名」避免冲突(如 com.baidu.demo):

java
package com.demo;

public class App {
    public static void main(String[] args) {
        System.out.println("包:com.demo");
    }
}

编译运行时要带完整类名:

bash
# 文件必须位于 com/demo/App.java
javac com/demo/App.java
java com.demo.App

包目录不匹配会怎样

写了 package com.demo; 但文件没放在 com/demo/ 下,java 会报 Could not find or load main class。包名和目录必须严格对应,这是 JVM 按全限定名定位类的基本方式。

七、常见误区与注意点 ​

WARNING

  • 文件名与 public 类名不一致:编译器直接报错。一个文件最多一个 public 类,且文件名必须等于它。
  • 运行带 .class 后缀:java HelloWorld.class 会报错,正确是 java HelloWorld(给的是类名不是文件名)。
  • 包目录不匹配:package com.demo; 但文件不在 com/demo/ 下,JVM 找不到类。
  • main 签名写错:少 static、参数类型不对,JVM 都找不到入口,报 Main method not found。
  • 以为 .class 是机器码:它是字节码,必须靠 JVM 翻译执行,不能直接扔给操作系统跑。

八、最佳实践 ​

  1. 写代码先写 public class + 正确 main,再填逻辑,保证可运行可调试。
  2. 项目一律用包组织(com.公司.模块),不要全丢默认包。
  3. 初学阶段多用 System.out.println 打印中间结果;进阶后用 IDEA 断点看执行流。
  4. 理解「编译(javac)」与「运行(java)」是两个独立动作:前者只产字节码,后者才真正执行。
  5. 想深入底层,常备 javap -c 这个反汇编工具。

📚 官方文档与参考资料 ​

✏️ 练习与自测 ​

  1. 写一个类 Greet,带 main 方法,打印你的名字,用 javac / java 跑通。
  2. 把上面类放进 com.test 包,移动到 com/test/ 目录后重新编译运行,体会包与目录的对应。
  3. 故意把文件名改错、或漏写 static、或把参数改成 String args,分别观察编译器/运行时的报错信息,理解入口约定。
  4. 对第 1 题的程序执行 javap -c Greet,尝试对照本文解释 main 方法字节码里 getstatic / ldc / invokevirtual 各自在干什么。

本节小结

Java 程序 = 类 + public static void main(String[]) 入口。源码经 javac 编译成中立的字节码 .class(开头 CAFEBABE),由类加载器载入内存、经验证-准备-解析-初始化后,JVM 调用 main;运行时数据分为线程私有的栈/PC 与共享的堆/元空间。包对应目录结构。掌握这条主线,你就真正读懂了 Java 程序的骨架与执行原理。