这篇文章按“结构流程”把 Java 程序从源码到运行的每一步串起来:
编译期(javac) → 运行期(java + JVM) → 类加载(ClassLoader) → 执行(解释器 + JIT)


0. 先定一个目标:Java 程序“运行”的定义是什么?

很多人说“运行 Java 文件”,但更精确的说法是:

  • JVM 不会直接执行 .java 源文件
  • JVM 执行的是 .class 字节码(或者 jar 包里的 class)

因此,Java 程序跑起来至少经历两段:

  1. 编译期.javajavac.class(字节码)
  2. 运行期java 启动 JVM → 类加载/链接/初始化 → 找到 main → 执行字节码(解释 + JIT)

1. 总体流程图:从源码到 JVM 退出(主线必须背)

.java 源码

javac 编译器

词法/语法/语义分析\n(可含注解处理)

输出 .class 字节码

java 命令启动器

创建 JVM 进程\n初始化运行环境

定位主类 Main Class

ClassLoader 加载主类

Linking(链接)\n验证 / 准备 / 解析

Initialization(初始化)\nstatic 赋值 + static 块

调用 main(String[] args)

执行引擎执行字节码\n解释器 + JIT

main 返回且无非守护线程

JVM 退出


2. .java 文件的结构:入口在哪里?

一个最小可运行 Java 程序的关键点在于 入口方法

public class Hello {
    public static void main(String[] args) {
        System.out.println("hi");
    }
}
  1. 程序入口public static void main(String[] args)
  2. java 命令运行的是“主类的全限定名”,不是文件名
    • 有包名:java com.example.Hello
    • 没包名:java Hello
  3. 一个 .java 文件可以写多个类,但 public 顶层类通常要求和文件名一致(Hello.java 对应 public class Hello

3. 编译期:javac 做了什么?为什么是 .class

3.1 你看到的命令

javac Hello.java

3.2 编译器内部的“结构流程”

典型阶段:

  1. 词法分析:把字符流切分成 token(关键字、标识符、符号…)
  2. 语法分析:把 token 组合为 AST(抽象语法树)
  3. 语义分析:符号解析、类型检查、重载匹配、可达性检查…
  4. (可选)注解处理器:编译期生成代码或校验(如 Lombok、部分框架)
  5. 生成字节码:输出 .class

3.3 产物:一个 .java 可能生成多个 .class

  • 顶层类一个 .class
  • 内部类/匿名类常会额外生成多个 .class(如 Outer$Inner.class

为什么 Java 是“跨平台”?
因为输出的是统一的字节码,由不同平台的 JVM 实现去执行/编译成本地机器码。


4. 运行期:java 命令如何启动 JVM?

4.1 命令

java com.example.Hello

这里 com.example.Hello主类的全限定名

4.2 进程层面发生了什么

  1. 操作系统启动一个 Java 进程(java 可执行程序)
  2. java 启动并初始化 JVM
  3. JVM 准备运行环境:类加载器体系、内存结构、线程等
  4. JVM 根据 classpath/modulepath 定位主类,开始类加载流程
  5. 类初始化完成后,调用 main

5. 核心重点:类是怎么被“找到并加载”的?(ClassLoader + 三阶段)

这是面试最容易深挖的部分:加载、链接、初始化
你只要把这三件事说清楚,回答就很稳。

5.1 类加载三大阶段(重点)

首次主动使用该类

Loading(加载)

Linking(链接)

Verification(验证)\n校验字节码合法/安全

Preparation(准备)\n为 static 字段分配内存并赋默认值

Resolution(解析)\n符号引用 -> 直接引用

Initialization(初始化)

static 显式赋值

static 块(static {})

类可用:new / 调静态 / 访问静态...

5.2 每一步重点解释

Loading(加载)
  • 通过 类加载器(ClassLoader) 从 classpath / jar / modulepath 等位置找到 .class
  • 把字节码读进 JVM,形成 JVM 内部的类结构
Linking(链接)
  • 验证:确保字节码合法、安全(避免恶意字节码破坏 JVM)
  • 准备:为 static 字段分配内存,并赋“默认值”
  • 解析:把常量池里的“符号引用”解析为可直接定位的“直接引用”

“准备阶段 static 就赋初值了吗?”
准备阶段是默认值,真正的显式赋值发生在初始化阶段(少数编译期常量除外)。

Initialization(初始化)
  • 执行:
    • static 字段的显式赋值(如 static int x = 10;
    • static { ... } 静态代码块
  • 每个类只会初始化一次(通常在首次“主动使用”触发)

6. 什么时候会触发“类初始化”?

关键词:首次主动使用(active use)

6.1 典型触发点

  • new 一个类:new A()
  • 调用静态方法:A.foo()
  • 访问/设置静态非 final 字段:A.x
  • 反射触发:Class.forName("A")
  • 初始化子类时,先初始化父类

6.2 不触发或容易误判的情况

  • 引用 static final编译期常量(可能被内联,不触发初始化)
  • 仅创建数组引用:A[] arr = new A[10];(通常不触发 A 初始化)
  • 仅拿到 A.class(通常不算主动使用)

7. 双亲委派与 ClassLoader(为什么能防止伪造核心类?)

7.1 双亲委派是什么?

加载一个类时:

  1. 先让父加载器尝试加载
  2. 父加载器找不到,子加载器才自己加载

7.2 为什么要这样做?

  • 避免重复加载同名类
  • 安全性:保证 java.lang.String 这类核心类只由更高层加载器加载,避免应用伪造核心类

7.3 什么时候会“打破委派”?

常见于:

  • SPI / 插件化
  • 应用隔离(如 Tomcat、多应用容器)
  • 自定义类加载器实现特殊来源

“原则与好处”->“为什么现实会打破”(框架/容器需求)。


8. .class 到底怎么执行?解释器 vs JIT(“Java 越跑越快”的原因)

JVM 执行字节码主要靠两套机制协同:

  1. 解释执行(Interpreter)

    • 逐条读取字节码并执行
    • 优点:启动快
    • 缺点:长期性能一般
  2. 即时编译(JIT, Just-In-Time)

    • JVM 识别热点方法(调用次数多、循环频繁)
    • 将热点字节码编译为本地机器码,CPU 直接执行
    • 优点:长期性能好
    • 常见现象:程序运行一段时间后更快

一句话记忆:

先解释跑起来,再把热点编译加速。


9. 运行时内存结构:对象在哪里?方法调用在哪里?

9.1 一张图建立直觉

Java 虚拟机 JVM

线程区(每线程独立)

元空间 Metaspace(共享)

类元信息/常量池/方法元数据

执行引擎
解释器 + JIT

Java 栈
栈帧:局部变量/操作数栈/返回地址

堆 Heap(共享)

对象实例(new 出来的)

GC 垃圾回收

PC 程序计数器

本地方法栈

9.2 常用的“标准描述”

  • 堆(Heap):绝大多数对象实例
  • 栈(Java Stack):方法调用形成栈帧,局部变量等
  • 元空间(Metaspace):类元信息、常量池等(JDK8+)
  • PC 计数器:记录当前线程执行到哪条字节码
  • GC:负责回收堆中不再可达对象(注意:资源释放要手动 close)

10. jar 是怎么跑的?(部署场景常见)

你常见的生产运行方式是 jar:

java -jar app.jar

jar 内部通过 MANIFEST.MF 指定入口:

  • Main-Class: com.example.Main

本质上仍然是:

  • 从 jar 找 .class
  • 类加载 → 初始化 → 调用 main

11. 一个重要的例子:父子类初始化顺序

这段代码解释“初始化触发与顺序”:

class Parent {
    static int p = initP();
    static { System.out.println("Parent static block"); }
    static int initP() {
        System.out.println("Parent.p init");
        return 1;
    }
}

class Child extends Parent {
    static int c = initC();
    static { System.out.println("Child static block"); }
    static int initC() {
        System.out.println("Child.c init");
        return 2;
    }
}

public class Demo {
    public static void main(String[] args) {
        System.out.println(Child.c);
    }
}

预期输出大致顺序为:

  1. Parent 的静态初始化(字段赋值、static block)
  2. Child 的静态初始化
  3. main 打印

重点:

  • “初始化子类前先初始化父类”
  • “静态字段显式赋值和 static block 都在 Initialization 阶段”

12. 要点清单

Q1:Java 文件是怎么运行的?

答:.java 先经 javac 编译为 .class 字节码;运行时用 java 启动 JVM,类加载器加载主类并经历加载-链接-初始化,然后调用 main,执行引擎解释执行并对热点做 JIT 编译,最终程序结束 JVM 退出。

Q2:类加载过程有哪些阶段?

答:加载(Loading)→ 链接(Linking:验证/准备/解析)→ 初始化(Initialization:static 赋值与 static 代码块)。

Q3:static 字段什么时候赋初值?

答:准备阶段赋默认值,初始化阶段执行显式赋值与 static 块(编译期常量可能被内联是例外)。

Q4:什么时候会触发类初始化?

答:首次主动使用:new、调用静态方法、访问静态非 final 字段、Class.forName、初始化子类先初始化父类。

Q5:解释执行与 JIT 的区别?

答:解释执行启动快但慢;JIT 把热点字节码编译为机器码长期更快,所以 Java 常见“越跑越快”。

Q6:双亲委派有什么作用?

答:避免重复加载并保证核心类安全(防止伪造 java.lang.*),父加载器优先,父找不到才子加载。

Q7:JVM 运行时内存怎么划分?

答:堆放对象;栈放栈帧和局部变量;元空间放类元信息;PC 记录执行位置;GC 回收堆中不可达对象。


13. 面试“回答思路模板”(建议照这个结构说,1 分钟很稳)

你可以按这个顺序回答任何“Java 如何运行”的问题:

  1. 先总述两段式:编译期 .java → .class,运行期 JVM 执行字节码
  2. 展开运行期关键:ClassLoader 加载主类 → 链接(验/备/解)→ 初始化(static)
  3. 入口与执行方式:找到 main,解释执行 + JIT 编译热点
  4. 补一句内存与收尾:堆/栈/元空间/PC,main 结束且无非守护线程 JVM 退出

这个结构的好处:层次清晰、可扩展、面试官容易顺着你设定的路线追问(而不是乱问)。


14. 一句话速记(临场救命)

javac 编译成 class 字节码,java 启动 JVM,ClassLoader 加载主类并经历加载-链接(验/备/解)-初始化(static),调用 main,解释+JIT 跑起来,对象在堆、调用在栈、类信息在元空间,main 结束 JVM 退出。


参考:你可以继续扩展的方向(进阶/加分)

  • classpath 与 modulepath 的区别(模块化系统)
  • 反射/动态代理与类加载的关系
  • JIT、逃逸分析、内联、锁消除(性能面试)
  • GC 算法与调参(高级岗位)

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐