JVM虚拟机系列(一)运行时数据区域

844 阅读8分钟

本篇文章是JVM虚拟机系列的第一篇文章,主要讲解JVM运行时的内存区域。熟悉JVM运行时的内存区域对我们日常开发中处理诸多问题很有帮助,比如:深入理解线程安全的处理策略问题,Java内存模型问题,堆、栈内存溢出问题,内存泄露问题等。

本文从如下6个部分分析Java虚拟机运行时数据区。

    1. 运行时数据区
    1. 程序计数器
    1. 虚拟机栈和本地方法栈
    1. Java堆
    1. 方法区和运行时常量池

“内存分配和垃圾收集是一堵墙,墙外的人想进去,墙里的人想出来”。
Java与C++语言由一堵内存动态分配和垃圾收集技术组成的高墙分开,熟悉这堵墙,对我们在日常开发中遇到的内存问题和线程安全问题非常有帮助。

1. 运行时数据区域

JVM虚拟机在运行时,会把它所管理的内存区域划分成若干个不同的数据区域,有些区域会在虚拟机进程被启动时而创建,有些区域伴随着线程的启动和结束而创建和销毁。根据Java虚拟机规范的规定,Java虚拟机所管理的内存划分为如下区域,如图所示:

JVM运行时内存区域概念图.png

2. 程序计数器

程序计数器(Program Counter Register):是线程私有的,很小的内存区域。作为当前线程执行的字节码行号指示器,当字节码解释器工作时,改变程序计数器记录的行号来获取下一条字节码指令。分支、循环、跳转、异常、线程恢复等功能都会依赖程序计数器。 Java虚拟机中,每个线程有独立的程序计数器来记录线程执行的字节码指令,当多线程切换时,可以通过程序计数器来恢复线程正确的执行位置。 如果线程执行的是Java方法时,程序计数器记录的是当前线程正在执行的字节码指令地址;如果线程执行的是本地方法(Native方法),程序计数器为空(Undefine)。

特点:

  • 线程私有,生命周期跟随线程
  • 不会抛出内存溢出异常,仅记录字节码指令行号,所以不会内存溢出
  • 不需要GC回收的区域
  • Java方法和Native方法,程序计数器记录值不同。Java方法:字节码指令地址;Native方法:空

3. 虚拟机栈和本地方法栈

虚拟机栈(Java Virtual Machine Stacks):与程序计数器一样,属于线程私有,伴随线程生命周期。虚拟机栈描述了Java方法执行的内存模型:每个方法在执行时会创建一个栈帧(Stack Frame),栈帧用于存储局部变量表、操作数栈、动态链接和方法放回地址等信息,每个方法从调用直至执行完成的过程,对应着一个栈帧在虚拟机中入栈到出栈的过程。

本地方法栈(Native Method Stack):和虚拟机栈发挥的作用相似,线程私有,跟随线程生命周期,区别在于本地方法栈是描述Native方法的内存模型。

虚拟机栈模型图.png

  • 局部变量表(Local Variable Table):是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。用于存储各种基本数据类型(boolean,byte,char,short,int,float,long,double)、对象引用(reference类型:不代表对象本身,仅是指向对象的地址或句柄地址)和returnAddress类型(指向字节码指令的地址)。变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位。虚拟机没有明确指定Slot的内存空间大小,只是导向性的说明long和double类型的数据占用2个Slot,其余的数据类型占用1个Slot。局部变量表所需的内存空间在编译期完成分配,占用空间大小记录在Code属性的max_locals数据项中。当进入一个方法时,该方法所需要的局部变量表空间在栈帧中完全确定,方法运行期间不会改变局部变量表的大小。
  • 操作数栈(Operand Stack):也称为操作栈,是一个后入先出(LIFO)栈。操作数栈最大深度在编译的时候记录在Code属性的max_stacks数据项中。
  • 动态连接(Dynamic Linking):每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
  • 方法返回地址(Return Address):当方法执行时,有两种方式可以退出方式,一种退出方式是遇到方法返回字节码指令,是否有返回值方会给上层方法的调用者,根据不同的字节码返回指令确定,eg: 整型字节码返回指令ireturn。另一种退出方式是,方法执行过程中遇到异常,并且该异常在方法体内没有得到处理,无论是JVM内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要本方法表中没有搜索到匹配的异常处理器,就会导致方法退出,这种方式产生的退出无法给上层调用者产生返回值。上述两种方式退出之后,都会返回到方法被调用的位置,所以方法返回时可能需要在栈帧中保存一些信息,一般正常退出时,调用者的PC计数器的值作为返回地址,栈帧中保存这个计数器值。方法异常退出时,返回地址通过异常处理器表来确定,栈帧中不会保存这部分信息。

特点:

  • 线程私有,生命周期跟随线程
  • 栈大小无法扩充时抛出OutOfMemoryError异常;达到栈设置的上限时抛出StackOverFlowError异常
  • 栈大小通过-Xss设置

4. Java堆

Java堆(Java Heap):对象实例和数组均在Java堆上分配,属于线程共享的区域,在虚拟机启动时创建。 Java堆是垃圾收集器管理的主要区域,因此也被成为“GC堆”(Garbage Collected Heap)。

Java堆内存模型.png

从内存回收的角度看,由于收集器采用分代收集算法,所以Java堆也被细分为:新生代和老年代;当新生代采用复制算法时,新生代又可以被细分为:Eden空间,From Survivor 空间和 To Survivor空间。具体后续会专门分析垃圾收集器相关的细节。

从内存分配的角度看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。

根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。在实现上,可以是固定大小的,也可以是可扩展的(通过-Xms和-Xmx控制,当-Xms和-Xmx相同时表示固定大小)。如果在堆中没有内存完成实例分配,并且也无法在扩展堆内存时,抛出OutMemoryError异常。

特点:

  • Java堆是虚拟机所管理的内存中最大的一块
  • 虚拟机内所有线程共享区域
  • GC回收最频繁的区域
  • 内存无法扩展时或达到上限时,抛出OutOfMemoryError异常

5. 方法区和运行时常量池

方法区(Method Area):存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,属于线程共享的区域,在虚拟机启动时创建。Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是方法区也被称为非堆(Non-Heap),目的是和Java堆区分开。可以通过-XX:PermSize和-XX:MaxPermSize限制方法区大小。 方法区的内存回收主要针对的是常量池的回收和对类型的卸载,要求比较苛刻,回收效果不理想。但是该区域的回收是有必要的。

运行时常量池(Runtime Constant Pool):是方法区的一部分,主要用来存放编译期生成的字面量和符号引用,这部分内容在类被加载后放入方法区的运行时常量池中。运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言不要求常量池一定只有编译期才能产生,即除了Class文件中的常量池内容外,在运行期间也可以将新的常量放入运行时常量池,比如String类的intern()方法。 运行时常量池是方法区的一部分,所以受到内存的限制,当常量池无法申请到内存时,会抛出OutOfMemeoryError异常。

方法区内存模型.png 图中Class文件中的常量池是于编译期确定的类中的符号描述,包含字段,方法,类等信息。

特点:

  • 虚拟机内所有线程共享区域
  • 方法区存放类相关的信息,运行时常量池存放类中的常量池数据
  • 内存无法扩展时或达到上限时,抛出OutOfMemoryError异常
  • GC回收低效的区域,主要回收废弃常量和卸载的类

结语:本文主要讲解了JVM的内存划分,根据线程作用域不同,分为线程私有和线程共享的区域,每个区域的职责和各区域的特点。了解了这些区域划分是跨过高墙了解内存管理的第一步。