程序结构与运行
Java 初级 · 入门与环境
上一节装好了环境,这一节我们「拆开」一个最简单的程序,看清它从源码到屏幕输出之间,JVM 内部究竟发生了什么。理解程序的结构约定与运行全流程,是读懂任何 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 规范定义的「类文件格式」),开头是一段固定内容:
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 方法,典型输出(节选并注释):
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):
package com.demo;
public class App {
public static void main(String[] args) {
System.out.println("包:com.demo");
}
}编译运行时要带完整类名:
# 文件必须位于 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 翻译执行,不能直接扔给操作系统跑。
八、最佳实践
- 写代码先写
public class+ 正确main,再填逻辑,保证可运行可调试。 - 项目一律用包组织(
com.公司.模块),不要全丢默认包。 - 初学阶段多用
System.out.println打印中间结果;进阶后用 IDEA 断点看执行流。 - 理解「编译(
javac)」与「运行(java)」是两个独立动作:前者只产字节码,后者才真正执行。 - 想深入底层,常备
javap -c这个反汇编工具。
📚 官方文档与参考资料
- Java SE 21 文档:https://docs.oracle.com/en/java/javase/21/
- Java 教程 · Java 语言基础:https://docs.oracle.com/javase/tutorial/java/index.html
- OpenJDK 项目:https://openjdk.org/
- JVM 规范(class 文件格式):https://docs.oracle.com/javase/specs/jvms/se21/html/
✏️ 练习与自测
- 写一个类
Greet,带main方法,打印你的名字,用javac/java跑通。 - 把上面类放进
com.test包,移动到com/test/目录后重新编译运行,体会包与目录的对应。 - 故意把文件名改错、或漏写
static、或把参数改成String args,分别观察编译器/运行时的报错信息,理解入口约定。 - 对第 1 题的程序执行
javap -c Greet,尝试对照本文解释main方法字节码里getstatic/ldc/invokevirtual各自在干什么。
本节小结
Java 程序 = 类 + public static void main(String[]) 入口。源码经 javac 编译成中立的字节码 .class(开头 CAFEBABE),由类加载器载入内存、经验证-准备-解析-初始化后,JVM 调用 main;运行时数据分为线程私有的栈/PC 与共享的堆/元空间。包对应目录结构。掌握这条主线,你就真正读懂了 Java 程序的骨架与执行原理。