Java的代码是如何运行的?——深入讲解Java运行时数据区

110 阅读15分钟

以下是JVM的整体架构,包括源文件通过javac编译成字节码文件类加载系统通过加载、链接、初始化的过程把源文件加载到JVM运行时数据区,在运行时数据区域不同部分协调工作,执行方法、创建对象、分配和回收内存。最后执行引擎通过解释或编译字节码指令确保高效执行。

JVM整体架构

2.1 类加载器系统(Class Loader System)

源代码通过javac编译生成.class文件,之后JVM会使用类加载器读取.class文件,并将其加载到JVM内存中。

2.1.1 类加载器的结构

一个类加载器系统(Class Loader System)包括三个加载器:

  • Bootstrap ClassLoader(启动类加载器):用于加载Java核心类库(如:java.lang)。
  • Extension ClassLoader(扩展类加载器):用于加载扩展类库。
  • Application ClassLoader(应用类加载器):加载应用程序的类。

2.1.2 类加载过程

  1. Loading(加载):将.class文件载入内存。
  2. Linking(链接)
  • Verify(验证):确保字节码的正确性。
  • Prepare(准备):为类分配静态存储空间并初始化默认值。
  • Resolve(解析):将符号引用替换为直接引用。
  1. Initialization(初始化):初始化静态字段,执行静态代码块。

2.2 运行时数据区(Runtime Data Access)

当类加载过程结束后,Java程序进入运行阶段。

2.2.1 方法区(Method Area)

概述

  1. 存储元信息
  • 类的名称、父类、方法、字段等。
  • 静态变量的定义和初始化值。
  • 常量池(如字符串字面值和符号引用)。
  1. 方法区中的元信息拱JVM使用,不会直接参与对象实例化
  2. 方法区的数据所有线程共享

用途

  1. 存储类信息,包含类的完整结构:
  • 类的完全限定名java.lang.String
  • 父类名java.lang.Object
  • 修饰符publicabstract
  • 接口列表
  • 字段、方法定义
  1. 存储方法的字节码
  • JVM执行方法时所需字节码
  • 方法入口地址、局部变量表、操作数栈等
  1. 静态变量
  • 类中所有static修饰的字段即静态变量存储在方法区中
  • 静态变量属于类本身,与类的实例无关
  1. 常量池
  • 存储编译期生成的常量,如字符串字面量(从Java 7开始,字符串常量池被转移到了堆区)、数字常量
  • 运行时常量池用于动态生成常量(如调用String.intern()时)
  1. 类加载器引用:方法区保留对这些类加载器的引用

实现 JVM规范并未明确方法区的实现方式,不同JVM实现方式可能不同。

  1. 在HotSpot JVM中
  • 基于永久代(Permanent Generation,简称PermGen)(Java 8之前)
  • 基于元空间(MetaSpace)实现
  1. PermGen(Java 7 及之前)
  • 存储:类元数据、静态变量、常量池等
  • 大小:通过JVM参数配置:
-XX:PermSize=<InitialSize> -XX:MaxPermSize=<MaxSize>

但是固定大小容易导致OutOfMemoryError(特别是在使用大量动态代理、反射或类加载时),并且性能开销较大,因为需要管理固定内存 3. Metaspace(Java 8及之后) Metaspace将方法区转移本地内存(Native Memory)

  • 特点:不再受JVM堆内存的限制;动态扩展大小,减少OutOfMemoryError问题
  • 使用以下参数调整大小:
-XX:MetaspaceSize=<InitialSize> -XX:MaxMetaspaceSize=<MaxSize>

2.2.2 堆区(Heap Area)

概述

  1. 对象分配:
  • 所有通过new关键字创建的对象(如对象实例、数组)都存储在堆区。
  • JVM会动态分配堆内存以满足对象创建需求。
  1. 垃圾回收
  • 不再被引用的对象会被垃圾回收器(Gabage Collector)回收,释放内存。
  • 垃圾回收机制避免内存泄漏,优化内存使用。
  1. 堆区中的数据所有线程共享

内存划分 Java堆是垃圾收集器管理的内存区域,因此也被称为GC堆,从回收内存的角度来看,由于现代垃圾回收器绝大部分都是基于分带收集理论设计的,所以Java堆中经常会出现新生代、老年代、永久代、元空间等名词。

分代优化了GC性能,为了进行高效的垃圾回收,虚拟机把堆内存从逻辑上划分成三块区域:

  1. 新生代
  • 存储新创建的对象,大多数对象在生命初期会在新生代中分配内存
  • 新生代通常分为Eden区和两个Survivor区(S0和S1)
  • 新生代中的对象存活时间较短,一般经过几次垃圾回收后,如果对象仍然存活,就会被晋升到老年代
  1. 老年代
  • 老年代存储的是长时间存活的对象,即哪些经过多次垃圾回收仍然没有被回收的对象。
  • 老年代垃圾回收通常发生得较少,因为其垃圾回收代价较大
  1. 永久代/元空间
  • 永久代:Java 7 之前版本的堆区的部分,存储类的元数据(如类的结构信息、方法字节码、静态变量等)
  • 元空间:从Java 8开始,永久代被移除,取而代之的是元空间,存储类元数据,元空间存储在本地内存而不是JVM堆内存中。

内存管理

  1. 内存分配
  • 对象创建:当程序创建对象时,JVM在堆区中分配内存,存储对象的数据,Java对象的内存分配通常由对象分配算法来处理
  • 数组分配:数组是对象,因此它也在堆区分配,数组长度在运行时决定,需要动态分配内存。

2. 垃圾回收 JVM使用垃圾回收器(GC)自动管理堆区的内存回收,堆区的内存回收主要分为以下几类

  • Minor GC: 发生在新生代,主要回收新创建的短命对象快速频繁
  • Major GC/Full GC: 发生在老年代,回收长时间存活的对象通常较慢,频率较低,但耗时长。 堆区采用以下垃圾回收算法:
  • 标记清除算法(Mark-Sweep):遍历堆区对象,标记所有可达对象,然后清除不可达对象。
  • 标记复制算法(Mark-Copy):将对象从一个区域复制到另一个区域,以此清理垃圾。
  • 标记整理算法(Mark-Compact):在标记过程中将存活的对象压缩到堆区的一端,避免内存碎片。
  • 分片收集:基于对象的生命周期不同,将堆区分为新生代和老年代,采用不同的垃圾回收策略 GC的触发条件:
  • 堆内存空间不足:当堆区的内存使用达到一定阈值时,垃圾回收就会触发
  • System.gc():开发者可以手动调用System.gc()来建议JVM执行垃圾回收 常见的垃圾回收器:
  • Serial GC:单线程垃圾回收器,用于单核机器或小型应用
  • Parallel GC:多线程垃圾回收器,用于多核机器,可以并行处理垃圾回收任务
  • CMS(Concurrent Mark-Sweep) GC:尽量减少应用时间的垃圾回收器
  • G1(Garbage First) GC:JVM提供的最新垃圾回收器,适用于大堆内存,能平衡GC的停顿时间和吞吐量

3. 堆区调优

  • 堆的初始大小:-Xms<size>
  • 堆的最大大小:-Xmx<size>
  • 新生代大小:
-XX:NewSize=<size> //新生代大小
-XX:MaxNewSize=<size> //新生代最大大小
  • 垃圾回收器选择
-XX:+UseParallelGC //使用Parallel GC
-XX:+UseG1GC //使用G1 GC

2.2.3 栈区(Stack Area)

概述

栈区也称为虚拟机栈(Virtual Machine Stacks),每个线程在创建的时候都会创建一个虚拟机栈,其内部保存了一个个的栈帧,用来存储方法调用。虚拟机栈是线程私有的,随着线程的创建而创建,线程的终止而销毁

  1. 线程独立栈:
  • 每个线程都会分配自己的栈区域,用来存储方法调用的栈帧(Stack Frame)
  1. 栈帧结构:
  • 局部变量表:存储局部变量(如方法参数临时变量)。
  • 操作数栈:存储方法执行过程中的中间结果
  • 方法返回地址:记录方法执行后的跳转地址
  1. 每次方法调用都会生成一个栈帧,方法执行完毕后,栈帧会被销毁(出栈)。

栈是一种快速的分配存储方式,访问速度仅次于程序计数器。在JVM对虚拟机栈执行操作时,每执行一个方法就会伴随压栈,方法执行结束后出栈。

栈可能出现的异常:

  • 如果是固定大小的Java虚拟机栈:如果线程请求分配的栈容量大于Java虚拟机栈允许的最大容量,则会抛出JVM会抛出StackOverflowError异常。
  • 如果Java虚拟机栈可以动态扩展,并且在尝试扩展时没有申请到足够内存,或者在创建新的线程没有足够的内存分配给新的虚拟机栈,则JVM会抛出OutOfMemoryError异常。
  • 可以通过-Xss设置栈的最大空间。

什么是栈帧?

  • 每个线程都有自己的栈,栈帧是存储栈中数据的基本单位。
  • 一个线程上执行的每个方法都有各自对应的一个栈帧。
  • 栈帧是一个内存区域,用于存储方法执行过程中的数据。

栈运行原理

  • 在一个活动线程中,同一时间只会有一个活动的栈帧,称为当前栈帧。当前栈帧对应当前执行的方法,称为当前方法,当前方法所属的类称为当前类
  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作
  • 方法中调用了其他方法,新的栈帧会被创建出来,放在栈顶,成为新的当前栈帧。在方法返回时,当前栈帧会传回此方法的执行结果给前一个栈帧,然后JVM销毁当前栈帧,前一个栈帧重新称为当前栈帧。
  • 不同线程所包含的栈帧不能互相引用
  • Java方法返回函数的方式一种是return指令返回,另一种是抛出异常,不管哪种方式都会导致栈帧被弹出。

IDEA Debug以下代码,可以在Debug窗口中看到各种方法对应的栈帧入栈和出栈情况:

package base;  
  
public class Stack {  
    public void method1(){  
        System.out.println("执行了method1方法。");  
        method2();  
    }  
    public void method2(){  
        System.out.println("执行了method2方法。");  
        method3();  
    }  
    public void method3(){  
        System.out.println("执行了method3方法。");  
        method4();  
    }  
    public void method4(){  
        System.out.println("执行了method4方法。");  
        method5();  
    }  
    public void method5(){  
        System.out.println("执行了method5方法。");  
        method6();  
    }  
    public void method6(){  
        System.out.println("执行了method6方法。");  
    }  
    public static void main(String[] args) {  
        Stack stack = new Stack();  
        stack.method1();  
    }  
}

入栈和出栈

栈帧的内部结构

每个栈帧包含了以下部分:

  1. 局部变量表
  • 用于存储方法的局部变量,包括:方法参数方法内的局部变量
  • 局部变量表通过索引下标访问,索引从0开始。
  • 不同数据类型占用不同槽位
  • intfloat:占用一个槽位。
  • longdouble:占用两个槽位。
  • 引用类型(如对象引用、字符串):占一个槽位。
  1. 操作数栈
  • 用于操作数的临时存储,方法执行的过程中会频繁使用。
  • 栈式计算模型:JVM指令执行时,操作数会被压入或弹出操作数栈
  1. 动态链接
  • 每个栈顶包含一个指向方法运行时常量池的引用,用于支持动态链接
  • 动态链接是为了解决方法调用时的符号引用解析实际的内存地址
  1. 方法返回地址
  • 存储调用方法返回时的指令地址。
  • 如果方法通过异常完成,返回地址可能为空
  1. 附加信息:JVM实现可能需要存储其他额外信息,如调试信息

2.2.4 程序计数器(PC Register)

程序计数器用于跟踪指令执行

  • 每个线程都有自己的程序计数器,存储当前线程正在执行的字节码指令地址。
  • 在方法调用时,程序计数器会更新以跳转到新方法的指令地址

2.2.5 本地方法栈(Native Method Stack)

概述 调用本地方法:如果代码中调用JNI(Java Native Interface)方法或底层操作系统相关的功能(如文件操作设备操作),就会通过本地方法栈实现。

为什么要用本地方法?

  • Java应用有时需要与Java外面的环境交互
  • JVM的一些指令需要操作系统支持。通过本地方法,可以实现Java与实现了jre的底层系统交互。

特点

  1. 本地方法栈也是线程私有的。
  2. 如果线程请求的栈容量超过本地方法栈运行的最大容量,会抛出StackOverflowError异常;如果本地方法栈可动态扩展,并且在尝试扩展时没有申请到足够内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,会抛出OutOfMemoryError异常。
  3. 基于C或C++实现。
  4. 本地方法通常用于操作底层硬件或操作系统资源提高性能(关键部分用C/C++实现)、使用现成的非Java库
  5. 定义本地方法:本地方法通过native关键字声明,没有具体实现体方法的实现由外部的本地库提供
public class NativeExample {
    public native void nativeMethod();
}

  1. 本地方法栈包括:局部变量、操作数站、动态链接信息、返回地址

运行原理

  1. 调用本地方法
  • 加载本地库:通过System.loadLibrary();加载本地动态链接库。
static {
//nativeLib是动态链接库名称,如nativeLib.dll
   System.loadLibrary("nativeLib");
}
  • 通过JNI接口调用本地方法,JVM将控制权交给本地方法栈
  • 执行本地代码:本地方法在本地方法栈执行,本地方法可以通过JNI调用JVM其他功能。
  1. 返回到JVM
  • 本地方法执行完毕后,将结果返回到调用者(可能是JVM或另一个本地方法)
  • 如果是本地方法抛出异常,则会返回到JVM, 由Java程序处理。

2.2.6 执行引擎(Execution Engine)活动

  1. 字节码解释和执行执行引擎负责将加载到内存的字节码指令
  2. 即时编译(JIT): 对热点代码进行优化编译成高效的本地机器代码以提升性能。
  3. 垃圾回收执行引擎触发垃圾回收器(GC)回收无用对象,减少内存使用。

在Java程序运行阶段,运行时数据区域不同部分协调工作,执行方法、创建对象、分配和回收内存。方法和对象的元信息存储在方法区和堆区中,栈区位每个线程管理方法调用和局部变量,程序计数器和本地房发展保证指令的正确执行和外部调用。执行引擎通过解释或编译字节码指令确保高效执行。

2. 代码执行的流程

2.1 源代码文件编译成字节码文件

使用IDEA创建了一个JVM类,在JVM类中有一个main函数调用了一个私有的静态方法add(): 源代码 运行这个类,会在target文件夹中生成对应的字节码文件JVM.class: class文件 JVM.class文件实际上是二进制文件,包含了Java字节码。只不过打开后看到的内容被解释为了可见的文本字符。字节码是一种中间语言,提供了JVM执行的指令集。

在Windows操作系统中,可以通过以下指令查看文件的二进制数据:

Get-Content JVM.class -Encoding Byte | % { [Convert]::ToString($_, 2).PadLeft(8, '0') }

得到部分输出结果: 运行结果 我们通过java提供的字节码反汇编工具javap将Java编译后的字节码指令返回变成可读的字节码指令。

使用以下命令查看字节码: javap -c JVM.class

得到输出内容: 指令集

字节码解释:

  1. aload_0
  • 将局部变量表中索引为0的值加载到操作数栈中。
  • 索引0通常是this引用(对于非静态方法)。
  1. invokespecial #1:
  • 调用父类java/lang/Object的构造方法<init>
  • #1是常量池中的引用,指向方法Object.<init>()
  1. return:方法执行完毕,返回给调用者。
  2. iconst_1:将整数1压入操作数栈。
  3. istore_1:将栈顶元素压入局部变量表索引1(局部变量是按照索引存储的)。
  4. iconst_2:将整数2压入操作数栈。
  5. istore_2:将栈顶元素压入局部变量表索引2
  6. iload_1:将局部变量表索引为1的值加载到操作数栈。
  7. iload_2:将局部变量表索引为2的值加载到操作数栈。
  8. invokestatic #7
  • 调用静态方法 add(int,int)
  • #7是常量池中的引用,指向add:(II)I,即add方法的描述符。
  • 将操作数栈的顶部两个值作为参数传递给add方法
  1. istore_3
  • 将静态方法add返回的值3存入局部变量表索引为3的位置。
  • 索引3对应int sum = JVM.add(a,b);
  1. return:方法执行完毕,程序正常退出。

2.2 JVM执行字节码文件

当源代码被编译成字节码文件后,通过类加载系统将字节码加载到内存,并传递给运行时数据区。当类加载完成后,字节码由JVM执行引擎执行,同时运行时数据存储在运行时数据区中。

对字节码文件进行解释和编译: 执行引擎将字节码翻译成底层机器代码,通过解释器或即时编译之一的方式运行:

  • 解释器:逐条解释并执行字节码指令
  • JIT编译器:将热点字节码段编译成本地机器码,编译后直接运行在物理CPU上,以提高性能。 同时,执行引擎需要配合运行时数据区,完成字节码的运行。

参考:

  1. 周志明. 深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)[M]. 北京: 机械工业出版社, 2021.
  2. JVM 基础 - JVM 内存结构 | Java 全栈知识体系