概述:一次编写,到处运行的基石

Java 的核心魅力在于其平台无关性,这背后是 “编译器 + 虚拟机 (JVM)” 的精密分工。整个体系可以概括为两大阶段:

  1. 编译期:将 .java 源码编译成与平台无关的 .class 字节码(Bytecode)。
  2. 运行期:不同平台上的 JVM 读取并执行相同的 .class 字节码,通过 “解释执行”“即时编译 (JIT)” 两种方式,将其转换为特定 CPU 的本地机器指令 (Native Code)。

第一阶段:编译期 (Compilation) - javac 的工作

详细编译流程

当我们执行 javac HelloWorld.java 时,编译器内部进行了多个复杂的步骤:

  1. 词法分析 (Lexical Analysis)

    • 任务:将源代码的字符流转换为一系列的 标记 (Tokens)
    • 过程:扫描源码,识别出哪些字符组合是关键字(public, class)、标识符(HelloWorld)、运算符(+, =)、分隔符({, ;)、字面量("Hello")等。它会忽略空格和注释。
  2. 语法分析 (Syntax Analysis)

    • 任务:根据 Java 语法规则,将 Token 序列构建成一棵 抽象语法树 (Abstract Syntax Tree - AST)
    • 过程:检查代码结构是否符合语法规范。例如,检查 if 语句后是否有括号,方法声明是否有返回值等。如果不符合,就会抛出语法错误。
  3. 语义分析 (Semantic Analysis)

    • 任务:对 AST 进行上下文相关性的检查,确保代码的“含义”是正确的。
    • 过程
      • 类型检查:确保赋值语句左右类型兼容(如不能将 String 直接赋给 int 变量)。
      • 变量声明检查:确保使用的变量已声明。
      • 可达性检查:检查是否有无法执行到的代码(如 return 语句后的代码)。
      • 常量折叠优化:在编译期直接计算常量表达式的结果(如 final int a = 1 + 2; 会直接优化为 final int a = 3;)。
  4. 生成字节码 (Bytecode Generation)

    • 任务:将经过验证的 AST 转换为 JVM 的指令集——字节码,并写入 .class 文件。
    • .class 文件结构:这不仅仅是指令,它是一个非常规范的结构化文件,包含:
      • 魔数 (Magic Number)0xCAFEBABE,用于标识这是一个有效的 .class 文件。
      • 版本号:编译此类的 JDK 主版本和次版本号。
      • 常量池 (Constant Pool):一块类似“符号表”的区域,存放了类中所有的字面量(常量)、类名、方法名、字段名等引用。
      • 访问标志:标识类的修饰符,如 public, abstract, final 等。
      • 类信息:当前类的全限定名。
      • 超类信息:父类的全限定名。
      • 接口信息:实现的接口列表。
      • 字段表:类中所有字段的信息(名称、类型、修饰符)。
      • 方法表:类中所有方法的信息(名称、返回值类型、参数类型、修饰符),最重要的是方法的 Code 属性,里面存放了该方法的字节码指令。
      • 属性表:其他附加信息,如源码文件名、行号表(用于调试)、局部变量表等。

至此,平台无关的字节码文件已生成,它可以被复制到任何安装了 JVM 的操作系统上。


第二阶段:运行期 (Runtime) - JVM 的工作

步骤一:类加载 (Loading) - “按需取货”

JVM 不是一次性加载所有类,而是在程序首次主动引用一个类时,才由类加载子系统 (ClassLoader Subsystem) 负责加载。它遵循双亲委派模型 (Parent-Delegation Model),如下图所示,该机制保证了 Java 核心库的安全性和避免类的重复加载。

Yes
No
Custom ClassLoader
Application ClassLoader
Extension ClassLoader
Bootstrap ClassLoader
? Can Parent Load?
Parent Loads
Child Attempts to Load

具体加载过程分为三步:

  1. 加载 (Loading)

    • 通过类的全限定名获取其二进制字节流(ClassLoader.loadClass() 方法)。
    • 将这个字节流所代表的静态存储结构转换为方法区 (Method Area) 的运行时数据结构。
    • 在内存的堆 (Heap) 中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
  2. 链接 (Linking)

    • 验证 (Verification):确保 .class 文件的字节流符合 JVM 规范,是安全的。包括文件格式验证、元数据验证、字节码验证(如检查跳转指令不会跳到方法体以外的字节码上)、符号引用验证。这是 JVM 安全的重要屏障。
    • 准备 (Preparation):为类的静态变量 (static variables) 在方法区分配内存,并将其初始化为默认零值(如 int 初始化为 0,引用类型初始化为 null)。注意,这里是默认值,不是代码中赋的值(static int a = 100; 在此阶段后 a0 而不是 100)。
    • 解析 (Resolution):将常量池内的符号引用 (Symbolic References) 替换为直接引用 (Direct References) 的过程。可以理解为将“假设存在的某个类的一个叫 getName 的方法”这样的描述,转换为一个确切的内存地址或句柄。
  3. 初始化 (Initialization)

    • 执行类的构造器 <clinit>() 方法 的过程。<clinit>() 方法是由编译器自动收集类中所有静态变量的赋值动作静态代码块 (static {}) 中的语句合并产生的。
    • 此时,静态变量 a 才会被赋值为代码中定义的 100。虚拟机会保证在子类的 <clinit>() 方法执行前,父类的 <clinit>() 方法已经执行完毕。

步骤二:运行时数据区 (Runtime Data Areas) - “内存舞台”

各区域详解:

  • 程序计数器 (Program Counter Register)

    • 线程私有。它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。它是程序控制流的指示器。
    • 如果线程正在执行的是一个 Native 方法,那么这个计数器的值则为空 (Undefined)。
  • Java 虚拟机栈 (Java Virtual Machine Stack)

    • 线程私有。它的生命周期与线程相同。每个方法被执行时,JVM 都会同步创建一个栈帧 (Stack Frame) 用于存储:
      • 局部变量表 (Local Variable Array):存放方法参数和方法内部定义的局部变量。以变量槽 (Slot) 为最小单位。
      • 操作数栈 (Operand Stack):用于执行字节码指令的工作区,如同 CPU 的寄存器。方法执行时,各种字节码指令往操作数栈中写入和提取内容。
      • 动态链接 (Dynamic Linking):指向运行时常量池中该栈帧所属方法的引用。这是方法实现多态(后期绑定)的基础。
      • 方法返回地址 (Return Address):存放调用该方法的程序计数器的值,以便方法结束时能返回到正确的位置。
    • 我们常说的“栈内存”就是指这里,通常指的就是局部变量表部分。
  • 堆 (Heap)

    • 所有线程共享。此内存区域的唯一目的就是存放对象实例数组。几乎所有对象都在这里分配内存。它是垃圾收集器 (Garbage Collector, GC) 管理的主要区域,因此也被称为“GC 堆”。
    • 从内存分配的角度看,为了更好的管理内存和进行垃圾回收,Java 堆又被细分为:新生代 (Young Generation)老年代 (Old Generation)。新生代又分为 Eden 区和两个 Survivor 区。
  • 方法区 (Method Area)

    • 所有线程共享。用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
    • 在 JDK 8 之前,方法区的实现被称为“永久代 (PermGen)”。从 JDK 8 开始,方法区由元空间 (Metaspace) 实现,其内存不再属于 JVM 管理的堆内存,而是使用本地内存 (Native Memory)。
  • 运行时常量池 (Runtime Constant Pool)

    • 是方法区的一部分。.class 文件中的常量池表(Constant Pool Table)在类加载后,会放入这个区域。相比 Class 文件常量池,运行时常量池具备动态性,即并非预置入 Class 文件中的常量才能进入池子,运行期间也可能将新的常量放入池中(例如 String.intern() 方法)。
  • 本地方法栈 (Native Method Stack)

    • 线程私有。与虚拟机栈作用非常相似,其区别只是虚拟机栈为执行 Java 方法(字节码)服务,而本地方法栈则是为虚拟机使用到的 Native 方法(如 C/C++ 编写的方法)服务。

步骤三:执行引擎 (Execution Engine) - “翻译官与工人”

字节码本身是给机器执行的,不是给人读的。执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令

  1. 解释器 (Interpreter)

    • 工作原理:逐条读取字节码,逐条翻译成机器指令并执行。
    • 优点无需等待,启动速度快。
    • 缺点:执行速度慢,因为每次执行都需要翻译。
  2. 即时编译器 (Just-In-Time Compiler - JIT)

    • 工作原理:JVM 会分析代码的执行频率。对于那些被频繁执行的热点代码 (Hot Spot Code),JIT 编译器会将其整个方法关键循环一次性编译成本地机器码,然后进行深度优化,并缓存起来(存放在方法区的代码缓存中)。下次再执行这段代码时,直接执行高效的本地机器码即可。
    • 优点极大的提升性能,通过编译优化(如方法内联、逃逸分析、循环展开、锁消除等)可以产生出比 C/C++ 编译器更优的代码。
    • 缺点:编译过程需要消耗 CPU 时间和内存,存在一定的“预热”时间。

现代高性能 JVM(如 HotSpot)采用的是“解释器 + JIT 编译器”的混合模式:

  • 程序刚启动时,解释器首先发挥作用,避免冗长的编译时间,让程序快速启动。
  • 随着程序运行,JVM 通过性能分析识别出热点代码。
  • 识别后,JIT 编译器逐步在后台进行编译,并将优化后的机器码投入使用。
  • 这种两者结合的架构,完美平衡了启动速度和峰值性能。

步骤四:垃圾收集 (Garbage Collection) - “清洁工”

Java 的自动内存管理主要由垃圾收集器在对堆内存进行操作。它自动回收不再被使用的对象所占用的内存。

  • 如何判定对象可回收?

    • 引用计数法(Java 未采用):存在循环引用问题。
    • 可达性分析算法(Java 采用):从一系列称为 “GC Roots” 的根对象(如虚拟机栈中引用的对象、静态属性引用的对象、常量引用的对象等)开始,向下搜索,所走过的路径称为“引用链”。如果一个对象到 GC Roots 没有任何引用链相连,则证明此对象是不可用的,可以被回收。
  • 垃圾收集算法:主要有标记-清除、标记-复制、标记-整理等。

  • 垃圾收集器:实现了上述算法的具体产品,如 Serial, Parallel Scavenge, CMS, G1, ZGC 等,适用于不同的应用场景(追求高吞吐量还是低延迟)。


总结:一个简单的执行序列

当你运行 java HelloWorld 时:

  1. 操作系统启动 JVM 进程。
  2. JVM 的 Bootstrap ClassLoader 加载核心 Java 类(如 java.lang.Object, java.lang.System)。
  3. JVM 找到 HelloWorld 类的 main 方法入口,创建主线程。
  4. 应用程序类加载器 (App ClassLoader) 加载 HelloWorld 类(触发加载、链接、初始化)。
  5. 执行引擎开始执行 main 方法的字节码。
  6. 遇到 new 指令,在中为新对象分配内存。
  7. 遇到方法调用,在当前线程的 JVM 栈上创建新的栈帧。
  8. 遇到 System.out.println() 调用,这是一个 Native 方法,可能会使用本地方法栈
  9. JIT 编译器在后台分析,如果发现 main 方法或其中的循环是热点代码,则将其编译成本地机器码缓存。
  10. 方法执行完毕,栈帧出栈。如果堆中的对象不再被引用,垃圾收集器会在某个时刻自动回收其内存。
  11. main 方法执行结束,主线程终止,JVM 进程退出。

这就是一个简单的 Java 程序从编写到运行,直至结束的完整、详细的生命周期。

Logo

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

更多推荐