MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java虚拟机中运行时数据区

2022-11-205.8k 阅读

Java虚拟机运行时数据区概述

Java虚拟机(JVM)在执行Java程序的过程中,会把它管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途、创建和销毁时间,有些区域随着JVM进程的启动而存在,有些则依赖用户线程的启动和结束而建立和销毁。运行时数据区主要包含程序计数器(Program Counter Register)、Java虚拟机栈(Java Virtual Machine Stack)、本地方法栈(Native Method Stack)、堆(Heap)和方法区(Method Area)这几个部分,下面我们逐一深入探讨。

程序计数器

程序计数器的作用

程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在JVM的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

线程私有特性

由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

字节码执行原理与程序计数器的关联

当一个线程开始执行时,它会从方法区中获取对应的字节码指令,程序计数器就会指向当前正在执行的字节码指令的地址。每执行完一条指令,程序计数器的值就会自动增加,指向下一条待执行的指令。例如,假设有如下简单的Java代码:

public class ProgramCounterDemo {
    public static void main(String[] args) {
        int a = 10;
        int b = 20;
        int c = a + b;
        System.out.println(c);
    }
}

这段代码被编译成字节码后,JVM在执行时,程序计数器会依次指向加载常量10、20的指令,执行加法运算指令,以及调用System.out.println方法的指令等。通过程序计数器,JVM能够有条不紊地按照字节码的逻辑顺序执行程序。

存储内容

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

Java虚拟机栈

栈的基本概念

Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

栈帧结构

  1. 局部变量表:局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为字节码文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。局部变量表的容量以变量槽(Slot)为最小单位,32位以内的类型(boolean、byte、char、short、int、float、reference和returnAddress)占用一个变量槽,64位的类型(long和double)占用两个变量槽。例如:
public class StackFrameDemo {
    public void localVarTest() {
        int i = 10;
        long l = 20L;
        double d = 30.0;
        boolean b = true;
    }
}

在上述代码中,i占用一个变量槽,ld各占用两个变量槽,b占用一个变量槽。局部变量表的槽位是可以复用的,当一个局部变量的作用域结束后,在其作用域之后声明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。 2. 操作数栈:操作数栈也常被称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。在方法执行过程中,根据字节码指令,往操作数栈中写入数据或提取数据,即入栈(push)和出栈(pop)操作。例如,对于上述代码中int c = a + b;这条语句,在字节码层面,首先会将ab的值入栈,然后执行加法指令,从操作数栈中弹出ab的值进行相加,再将结果入栈。操作数栈的深度在编译期就已经确定,保存在方法的Code属性的max_stacks数据项中。 3. 动态链接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池中。比如System.out.println,在编译时并不知道具体要调用哪个println方法(因为可能有多个重载版本),只有在运行时通过动态链接,将符号引用转换为直接引用,才能确定具体调用的方法。 4. 方法出口:当一个方法执行完毕后,有两种方式退出该方法。一种是正常完成出口,即执行引擎遇到任意一个方法返回的字节码指令(如return指令),会有返回值传递给上层调用方法(如果有返回值的话),并恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中,然后调整程序计数器的值以指向方法调用指令后面的一条指令,方法正常完成退出。另一种是异常完成出口,在方法执行过程中遇到了异常,并且这个异常没有在方法内得到处理,导致方法退出。无论是哪种方式退出,在方法退出后,都需要释放栈帧占用的内存空间。

虚拟机栈的异常情况

  1. StackOverflowError:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。这通常发生在递归调用没有正确的终止条件时。例如:
public class StackOverflowDemo {
    public static void recursiveMethod() {
        recursiveMethod();
    }

    public static void main(String[] args) {
        recursiveMethod();
    }
}

上述代码中,recursiveMethod方法无限递归调用自身,没有终止条件,很快就会导致栈深度溢出,抛出StackOverflowError异常。 2. OutOfMemoryError:如果Java虚拟机栈可以动态扩展(当前大部分JVM都可以),当扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常。虽然这种情况相对较少见,但在一些极端情况下,比如在一个大循环中不断创建新的栈帧,而内存又不足时,就可能发生。

本地方法栈

本地方法栈的作用

本地方法栈与Java虚拟机栈所发挥的作用非常相似,其区别只是Java虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。

实现方式

本地方法栈的具体实现依赖于JVM厂商。有些JVM将本地方法栈和Java虚拟机栈合二为一,有些则分别实现。在HotSpot虚拟机中,直接把本地方法栈和Java虚拟机栈合二为一。

本地方法调用示例

以调用System.currentTimeMillis()方法为例,该方法是一个本地方法,其底层实现是依赖操作系统的时钟机制。当Java程序调用System.currentTimeMillis()时,JVM会通过本地方法栈进入到本地方法的实现中,获取当前系统时间并返回给Java程序。下面是一个简单的本地方法调用示例:

public class NativeMethodDemo {
    // 声明本地方法
    public native long getSystemTime();

    static {
        // 加载本地库
        System.loadLibrary("NativeMethodDemo");
    }

    public static void main(String[] args) {
        NativeMethodDemo demo = new NativeMethodDemo();
        long time = demo.getSystemTime();
        System.out.println("System time: " + time);
    }
}

在上述代码中,getSystemTime是一个本地方法,通过System.loadLibrary("NativeMethodDemo")加载本地库(这里假设已经有对应的本地库实现),然后在main方法中调用该本地方法获取系统时间。

异常情况

本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常,与Java虚拟机栈类似。当本地方法递归调用过深导致栈深度超过限制时,会抛出StackOverflowError;当本地方法栈动态扩展无法获取足够内存时,会抛出OutOfMemoryError。

堆的地位与作用

Java堆是Java虚拟机所管理的内存中最大的一块,它被所有线程共享,在虚拟机启动时创建。堆是存放对象实例以及数组(不管是对象数组还是基本类型数组)的地方。几乎所有的对象实例和数组都在堆上分配内存。这部分内存区域也是垃圾收集器管理的主要区域,因此很多时候也被称为“GC堆”(Garbage Collected Heap)。

堆的内存划分

  1. 新生代与老年代:现代的垃圾收集器大部分都采用分代收集算法,因此Java堆还可以细分为新生代(Young Generation)和老年代(Old Generation)。新生代主要存放新创建的对象,其又可以进一步划分为一个伊甸园区(Eden Space)和两个幸存者区(Survivor Space,一般称为From Survivor和To Survivor)。当新对象创建时,大部分对象会首先分配在伊甸园区。在进行垃圾回收时,伊甸园区中存活的对象会被复制到其中一个幸存者区(假设为From Survivor),伊甸园区被清空。当From Survivor区满时,其中存活的对象会被复制到To Survivor区,From Survivor区被清空,同时From Survivor和To Survivor的角色互换。经过多次垃圾回收后,仍然存活的对象会被移动到老年代。老年代主要存放经过多次垃圾回收后仍然存活的对象,比如一些生命周期较长的对象。
  2. 永久代与元空间(不同JVM版本差异):在JDK 1.7及之前的版本,方法区是堆的一个逻辑部分,被称为永久代(PermGen),它用于存放类的元数据信息(如类的结构、方法、字段等)、常量池等。但是从JDK 1.8开始,移除了永久代,取而代之的是元空间(Meta Space),元空间并不在堆内存中,而是使用本地内存。这样做的原因主要是为了避免永久代在内存管理上的一些问题,比如容易出现的PermGen Space内存溢出错误,并且元空间可以随着本地内存的增加而动态扩展。

堆内存分配示例

public class HeapAllocationDemo {
    public static void main(String[] args) {
        byte[] buffer1 = new byte[1024 * 1024]; // 分配1MB的数组
        byte[] buffer2 = new byte[2048 * 1024]; // 分配2MB的数组
        // 这里对象buffer1和buffer2都在堆上分配内存
    }
}

在上述代码中,buffer1buffer2数组对象都在堆上分配内存。随着程序中不断创建对象,堆内存会逐渐被占用,当堆内存不足时,垃圾收集器会进行垃圾回收,如果回收后仍然无法满足内存需求,就会抛出OutOfMemoryError异常。

堆的垃圾回收

垃圾回收器在堆上主要执行两个任务:发现不再使用的对象和回收这些对象占用的内存空间。常用的垃圾回收算法有标记 - 清除算法、复制算法、标记 - 整理算法等。例如,在新生代中,由于对象创建和消亡频繁,一般采用复制算法,将存活对象复制到另一个区域,然后清空原区域,这样可以高效地回收内存。而在老年代中,由于对象存活率高,一般采用标记 - 清除或标记 - 整理算法,标记出存活对象,然后清除或整理内存空间。

方法区

方法区的作用

方法区也是被所有线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。可以将方法区看作是Java虚拟机的“知识库”,存储着与类相关的各种元数据。

方法区存储内容详解

  1. 类型信息:包括类的全限定名、直接超类的全限定名、实现的接口列表、类的修饰符(如public、abstract、final等)、类的常量池等。类的常量池是方法区的重要组成部分,它存储了编译期生成的各种字面量和符号引用,比如字符串常量、基本类型常量、方法和字段的符号引用等。例如:
public class MethodAreaTypeInfoDemo {
    private static final int CONSTANT_VALUE = 10;
    public String str = "Hello, Method Area";

    public void method() {
        System.out.println(str);
    }
}

在上述代码中,CONSTANT_VALUE常量和str字符串字面量都会存储在类的常量池中,类的修饰符public、字段str和方法method的相关信息也会存储在方法区的类型信息中。 2. 静态变量:被static修饰的变量,其生命周期与类相同,存储在方法区。静态变量在类加载的准备阶段被初始化,赋予初始值(如0、null等),在初始化阶段才会根据程序员编写的代码进行真正的赋值。例如,上述代码中的CONSTANT_VALUE就是一个静态变量,存储在方法区。 3. 即时编译器编译后的代码缓存:对于热点代码(被多次调用的方法等),JVM会使用即时编译器(Just - In - Time Compiler,JIT)将其编译为本地机器码,以提高执行效率。编译后的本地机器码会缓存在方法区中,下次调用该热点代码时可以直接执行本地机器码,而不需要再次解释执行字节码。

方法区的实现与内存管理

在JDK 1.7及之前,方法区由永久代实现,永久代有固定的大小限制,这就容易导致在应用程序加载大量类时出现PermGen Space内存溢出错误。从JDK 1.8开始,方法区由元空间实现,元空间使用本地内存,理论上只要本地内存足够,就不会出现类似永久代的内存溢出问题。不过,如果应用程序不断加载类,耗尽本地内存,同样会抛出OutOfMemoryError异常。例如,在一些动态生成大量类的框架(如Spring动态代理、CGLIB等)中,如果使用不当,就可能导致方法区(元空间)内存不足。

运行时常量池

运行时常量池是方法区的一部分,它是在类加载后,将编译期生成的常量池加载到内存中形成的。运行时常量池相对于编译期常量池的一个重要特征是具有动态性,不仅可以存储编译期已知的常量,还可以在运行时动态添加常量。例如,String.intern()方法就可以将运行时新创建的字符串添加到运行时常量池中。

public class RuntimeConstantPoolDemo {
    public static void main(String[] args) {
        String str1 = new String("Hello");
        String str2 = str1.intern();
        String str3 = "Hello";
        System.out.println(str2 == str3); // true
    }
}

在上述代码中,str1是通过new创建的字符串对象,str1.intern()会将"Hello"字符串添加到运行时常量池中,并返回常量池中的引用,str3直接指向运行时常量池中的"Hello"字符串,所以str2 == str3结果为true

运行时数据区之间的协作关系

在Java程序运行过程中,各个运行时数据区并非孤立存在,而是紧密协作。例如,当一个Java方法被调用时,首先在Java虚拟机栈中创建对应的栈帧,栈帧中的局部变量表可能会引用堆上的对象实例。方法中如果涉及到访问类的静态变量或调用类的静态方法,就需要从方法区中获取相关的类信息和静态变量数据。如果方法调用了本地方法,JVM会通过本地方法栈进入本地方法的执行。在执行过程中,程序计数器始终控制着指令的执行顺序。同时,堆内存中的对象实例的创建和销毁由垃圾收集器管理,这又与方法区中的类元数据信息密切相关,因为垃圾收集器需要根据类的元数据来判断对象是否存活。

例如,下面的代码展示了不同数据区之间的协作:

public class CollaborationDemo {
    private static int staticVar = 10;
    private int instanceVar = 20;

    public void method() {
        int localVar = 30;
        CollaborationDemo demo = new CollaborationDemo();
        System.out.println(staticVar + demo.instanceVar + localVar);
    }

    public static void main(String[] args) {
        CollaborationDemo demo = new CollaborationDemo();
        demo.method();
    }
}

在上述代码中,main方法调用method方法时,在Java虚拟机栈中创建栈帧。method方法中的localVar存储在栈帧的局部变量表中,demo对象在堆上创建,staticVar存储在方法区中。通过这种协作,Java程序能够顺利执行各种复杂的逻辑。

总结运行时数据区的重要性

运行时数据区是Java虚拟机的核心组成部分,它为Java程序的运行提供了必要的内存环境和数据存储结构。程序计数器控制着指令的执行流程,Java虚拟机栈和本地方法栈负责方法的调用和执行,堆用于存储对象实例,方法区存储类的元数据信息。各个数据区之间相互协作,共同保证了Java程序的高效、稳定运行。深入理解运行时数据区的原理和机制,对于优化Java程序性能、排查内存相关问题以及掌握垃圾收集等技术都具有至关重要的意义。无论是开发大型企业级应用,还是编写小型的Java程序,了解运行时数据区的工作原理都是成为优秀Java开发者的必备知识。