JVM是如何分配管理内存的?

写在前面:博主是一只经过实战开发历练后投身培训事业的“小山猪”,昵称取自动画片《狮子王》中的“彭彭”,总是以乐观、积极的心态对待周边的事物。本人的技术路线从Java全栈工程师一路奔向大数据开发、数据挖掘领域,如今终有小成,愿将昔日所获与大家交流一二,希望对学习路上的你有所助益。同时,博主也想通过此次尝试打造一个完善的技术图书馆,任何与文章技术点有关的异常、错误、注意事项均会在末尾列出,欢迎大家通过各种方式提供素材。

  • 对于文章中出现的任何错误请大家批评指出,一定及时修改。
  • 有任何想要讨论和学习的问题可联系我:zhuyc@vip.163.com。
  • 发布文章的风格因专栏而异,均自成体系,不足之处请大家指正。

JVM是如何分配管理内存的?

本文关键字:JVM、虚拟机栈、Java堆、方法区、运行时常量池


本文成文参考了《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》和《Java虚拟机规范(Java SE 8版)》,这是两本难得的好书,推荐大家购买实体书籍,后续会考虑在"借书下饭"栏目下开设子专栏,如果需要电子版尝鲜可以关注后私信我。

一、JVM内存区域

Java程序在运行时,首先要读取编译后的class文件,由于我们在编写源码时会定义和使用各种结构和对象,那么在进行加载时,JVM会将分配得到的内存划分为多个区域。通常我们会粗浅地将内存划分为“栈”和"堆"两个区域,但是对于Java虚拟机来说我们应该进一步剖析
在这里插入图片描述
由JVM创建的不同区域,有些会随着虚拟机启动而创建,随着虚拟机退出而销毁,如:方法区(Method)、Java堆。还有一些是与线程一一对应的,会随着线程开始和结束而被创建和销毁,如:PC寄存器、Java虚拟机栈、本地方法栈。

1. PC寄存器

虽然Java虚拟机支持多线程同时执行,但是在任意时刻,一条JVM线程只会执行一个方法的代码,这个正在被线程执行的方法称为该线程的当前方法,对应的Java虚拟机栈被称为当前栈帧
PC寄存器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器,每一条JVM线程都有自己的PC寄存器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个寄存器来完成。
如果当前方法不是native的,那PC寄存器就保存Java虚拟机正在执行的字节码指令的地址,如果该方法是native的,则PC寄存器的值是undefined。(关于native的说明见:3.本地方法栈

2. Java虚拟机栈

每一条JVM线程都有自己私有的Java虚拟机栈,与线程同时创建,用于存储栈帧,其中包含局部变量和一些尚未算好的结果。另外,需要注意的是:栈(Stack)、Heap(堆)、Java虚拟机栈(Java VM Stack)、Java堆(Java Heap)的概念是不同的,Java虚拟机本身也是一个由其他语言编写运行的软件,所以本文只讨论JVM所管理的内存区域,并不探讨各区域在堆栈中的分布。
Java虚拟机规范既允许Java虚拟机栈被实现为固定大小,也允许根据计算动态来扩展和收缩。Java虚拟机栈描述的是Java方法执行的线程的内存模型:每个方法被执行的时候,Java虚拟机都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接等信息,每一个方法从被调用,到执行完毕的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
当调用新的方法时,新的栈帧会随之创建,程序的控制权也会进行移交给调用的新方法,成为新的当前栈帧。在方法执行完毕进行返回时,当前栈帧会传回此方法的执行结果给前一个栈帧(调用这个新方法的栈帧),然后虚拟机就会丢弃当前栈帧,前一个栈帧成为当前栈帧,大家可以用这篇文章来理解一下这个过程:Java方法的嵌套与递归调用

  • 局部变量表

每个栈帧内部都包含一组被称为局部变量表的变量列表,长度在编译期时被确定。一个局部变量可以保存一个类型为boolean、byte、char、short、int、float、reference或returnAddress的数据。两个局部变量可以保存一个类型为long或者double的数据。
局部变量使用索引来进行定位访问,首个局部变量的索引值为0,最大值小于局部变量表的长度。对于long和double,由于占用了两个连续的局部变量,则采用局部变量中较小的索引值来定位。

  • 操作数栈

每个栈帧内部都包含一个被称为操作数栈的后进先出栈,操作数栈的最大深度在编译器被确定,一般的操作数栈指的就是“当前栈帧的操作数栈”。
在栈帧刚刚创建时,操作数栈是空的。JVM提供一些字节码指令来从局部变量表或对象实例的字段中复制常量或变量的值到操作数栈中,也提供了一些指令用于从操作数栈取走数据、操作数据以及把操作结果重新入栈。在调用方法时,操作数栈也用来准备调用方法的参数以及接收方法返回结果。
在任意时刻,操作数栈都会有一个确定的栈深度,一个long或者double类型的数据会占用两个单位的栈深度,其他数据类型会占用一个单位的栈深度。

  • 动态链接

每个栈帧内部都包含一个指向当前方法所在类型的运行时常量池的引用,来对当前方法的代码实现动态链接。在class文件里面,一个方法如果要调用其他方法,或者访问成员变量,需要通过符号引用来表示,动态链接的作用就是将这些符号引用所表示的方法转换为对实际方法的直接引用。

3. 本地方法栈

由于有时可能需要调用其他语言(如C语言)所编写的方法,就需要使用到传统的栈(C stack)来支持native方法的执行。native在Java语言中是一个修饰符,如果一个方法被native修饰,那么就代表这个方法是一个java调用非java代码的接口。在定义一个native method时,不需要指定方法体,与声明接口中的方法类似,具体的方法实现会在dll或其他库文件中,在运行时需要一并加载。
本地方法栈与Java虚拟机栈的作用十分相似,区别就在于Java虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机调用的本地方法服务。

4. Java堆

Java堆是JVM所管理的内存中最大的一块区域,并且是被所有线程共享的一块内存区域,在虚拟机启动时被创建。Java堆中主要存储的就是对象的实例,包括数组类型的实例。
Java堆中所存储的对象由自动内存管理系统,也就是垃圾收集器进行管理,不需要手动进行销毁和释放。另外,Java堆所对应的区域不需要连续。

5. 方法区

方法区与Java堆一样,是一块各个线程共享的内存区域,用于存储已被虚拟机加载的类的结构信息,包括运行时常量池、构造函数和普通方法、静态变量等数据。
方法区在虚拟机启动的时候被创建,虽然逻辑上方法区属于堆的一部分,但是也可以选择在这个区域不实现垃圾收集与压缩。
原文引述《Java虚拟机规范(Java SE 8版)》中的内容
这个版本的Java虚拟机规范也`不限定实现方法区的内存位置``和编译代码的管理策略。方法区的容量可以是固定的,也可以随着程序执行的需求动态扩展,并在不需要过多空间时自动收缩。方法区在实际内存空间中可以是不连续的。
原文引述《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》中的内容
在JDK 6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了,到了JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。
引述这两段话的原因在于不少初学者都在纠结很多类中定义的结构到底存储在什么位置的问题,笔者在这里帮助大家再次明确一下:

  • 不同版本的JVM有对方法区的管理方式并不相同
  • 有多种Java虚拟机都可以运行Java程序,对各区域的管理也有差别
  • HotSpot VM与JRockit VM已在Oracle JDK8版本完成合并

在这里插入图片描述
所以当我们在进行探讨时一定要明确具体的虚拟机和JDK版本,方法区本身是有JVM分配管理的区域之一,从上面的叙述中我们已经知道,对于Oracle JDK8版本,已经不再使用永久代来实现方法区,方法区中的内容全部移动存储至本地内存的元空间中

6. 运行时常量池

首先强调:运行时常量池并不等同于常量池! 运行时常量池是方法区的一部分,是class文件中每一个类或接口的常量池表的运行时表示,包括了若干种不同的常量:在编译期可知的数值字面量以及在运行期解析后才能获得的方法或字段引用。
在Java虚拟机加载类和接口后,就会创建对应的运行时常量池。Java虚拟机为每个类型都维护着一个常量池,是Java虚拟机中的运行时数据结构。运行时常量池中的所有引用最初都是符号引用,对于不同的结构(类、接口、数组等)的符号引用,也会有相应的规范和格式。
由于运行时常量池的符号引用较为复杂,同时对常量池的解释涉及到类的加载机制,所以在本文中不再赘述,后续将在其他文章中说明(包括字符串常量的规定策略)。

二、常见结构存储位置

在阅读了前面的内容后,我们可以记一波结论了,了解一些底层的东西有利于我们去解释或记忆某些用法和特点。

1. 普通成员变量

普通的成员变量由于是创建对象后才能使用的,所以基本数据类型的值或引用(与成员变量类型无关)都存放在对应的实例空间,在Java堆中。

2. 静态成员变量与静态代码块

静态成员变量与静态代码块都是直接在类下使用static声明的结构,存储在方法区中。

3. 构造方法和动态代码块

构造方法也是类似于方法的一种结构,被new调用时才会执行,而动态代码块被编译后会出现在构造函数中,它们都存储在方法区中。

  • 编译前
public class Person{

    {
        System.out.println("init");
    }

    public Person(){
        System.out.println("default");
    }

    public Person(int a){
        System.out.println("another");
    }

}
  • 编译后


在这里插入图片描述

4. 普通方法与静态方法

普通方法和静态方法虽然在调用和使用时有所区别,但本质上都是方法结构,对于一个类来说只要加载一次就可以了,也存放在方法区中。

5. 方法局部变量

在方法中定义的变量,由于有局部变量表的存在,基本数据类型直接存放在JVM栈中,对于引用类型的变量,在JVM栈中只存放引用(reference),而对应的实例存放在Java堆中。

在这里插入图片描述

小山猪的沙塔 CSDN认证博客专家 全栈开发工程师 大数据高级开发 大数据金牌讲师
若非一番寒彻骨,哪得梅花扑鼻香。全栈开发工程师,大数据高级开发工程师。大数据金牌讲师,知名机构合作讲师,各云大学及平台合作讲师,高校外聘讲师。微信公众号:微光点亮星辰,在学习的道路上一同见证点点滴滴。