读懂 JVM 内存管理这篇就够了
Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。《深入理解Java虚拟机》
写本篇文章的主要原因,就是为了应付面试 是构建自己的 JVM 知识体系,作为 Java 开发,对于技术的认知不仅要有广度,更重要的是要有深度。
本文我们主要分析 JVM 的内存划分。这块内容主要涉及以下这几个问题:
- JVM 是如何进行内存区域划分的?分别是干什么的?
- Java虚拟机规范唯一一个没有规定 OutOtMemoryError 情况的区域?
- 为什么使用PC寄存器记录当前线程的执行地址呢?
- PC 寄存器为什么会被设定为线程私有?
- 举例栈溢出的情况?
- 垃圾回收是否会涉及到虚拟机栈?
- JVM 是如何执行方法调用的?
- Java 8 的内存分代改进
- 对象在堆中的生命周期
- 为什么需要有元空间,它又涉及什么问题?
首先,第一个问题:JVM的内存区域是如何划分的?
JVM 的内存结构
Java 虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程一一对应的数据区域会随着线程开始和结束而创建和销毁。
下图就是 Java 虚拟机定义的各种运行时数据区域:
JVM 内存区域划分如图所示:
- JVM 堆中的数据是共享的,是占用内存最大的一块区域。
- 可以执行字节码的模块叫作执行引擎。
- 执行引擎在线程切换时怎么恢复?依靠的就是程序计数器。
- JVM 的内存划分与多线程是息息相关的。像我们程序中运行时用到的栈,以及本地方法栈,它们的维度都是线程。
- Java 8 及之后的版本,彻底移除了持久代,而使用元数据区来进行替代
- 本地内存包含元数据区和一些直接内存。
JVM 的角度看,JVM 内存之外的部分叫作本地内存。
JDK 1.8 同 JDK 1.7 最大的区别是:元数据取代了永久代。
元空间的本质和永久代类似,都是对JVM规范中的方法区的实现。其元空间和永久代之间的最大区别在于:元数据空间不在虚拟机中,而是在本地内存中。
下面我们就来一一解读下这些内存区域。
程序计数器
程序计数寄存器(Program Counter Register, PC),Register 的命名源于 CPU 的寄存器,CPU 只有把数据装载到寄存器才能够运行。
在介绍 Java 的计数器前,我们先来了解一下 CPU 中的 PC。
在 CPU 中 PC 是一个物理设备,在任何时候,PC 中存储的都是内存地址,而 CPU 就根据 PC 中的内存地址,到相应的内存取出指令然后执行,并且更新 PC 的值。在计算机通电后这个过程会一直不断的反复进行。
而 Java 中 PC 是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
作用
程序计数器用来存储指向下一条指令的地址,即将要执行的指令代码。由执行引擎读取下一条指令。
注意它的功能是用来取得程序执行指令内存地址,并不是数据内存地址。
案例演示:
public class PCRegisterTest {
public static void main(String[] args) {
int i = 10;
int j = 20;
int k = i + j;
}
}
(分析:进入class文件所在目录,执行javap -v xx.class反解析(或者通过IDEA插件Jclasslib直接查看,上图),可以看到当前类对应的Code区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等信息。)
将代码进行编译成字节码文件,我们通过查看 ,发现在字节码的左边有一个行号标识,它就是指令地址,用于指向当前执行到哪里。
概述
- 它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域。
- 在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。
- 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的 Java 方法的 JVM 指令地址,如果是在执行 native 方法,则是未指定值(undefined)。
- 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令.
- 它是唯一一个在Java虚拟机规范中没有规定任何
OutOtMemoryError
情况的区域。
PC寄存器的常见问题
问题1:使用 PC 寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢?
因为CPU需要不停的切换各个线程,如果一个方法切换到另一个方法,这时候切换回来以后,就得知道接着从哪开始继续执行。JVM 的字节码解释器就需要通过改变 PC 寄存器的值来明确下一条应该执行什么样的字节码指令。
问题2:PC寄存器为什么会被设定为线程私有?
多线程在一个特定的时间段内只会执行其中某一个线程方法,CPU 会不停的做任务切换,这样必然会导致经常中断或恢复。为了能够准确的记录各个线程正在执行的当前字节码指令地址,所以为每个线程都分配了一个 PC 寄存器,每个线程都独立计算,不会互相影响。
虚拟机栈
栈是一种先进后出的数据结构,就像子弹的弹夹,最后压入的子弹先发射。
Java 虚拟机栈(Java Virtual Machine Stacks)是描述 Java 方法执行的内存区域。每个线程在创建的时候,都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame)对应着一次方法的调用,是线程私有的,生命周期和线程一致。
作用:主管 Java 程序的运行,每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
特点:
- 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器;
- JVM 直接对虚拟机栈的操作只有两个
- 每个方法执行,伴随着入栈(进栈/压栈)
- 方法执行结束出栈;
- 虚拟机栈中内存会随着线程的销毁而清空,所以不存在垃圾回收问题。
栈中可能出现的异常
Java 虚拟机规范允许 Java 虚拟机栈的大小是动态扩展和收缩的或者是固定不变的。
- 如果采用固定大小的 Java 虚拟机栈,那每个线程的 Java 虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过 Java 虚拟机栈允许的最大容量,Java 虚拟机将会抛出一个
StackOverflowError
异常; - 如果 Java 虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个
OutOfMemoryError
异常。
可以通过参数 -Xss
来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。
具体参数和操作请参考参考:官方文档
栈的存储单位
栈中存储什么?
- 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在。
- 在这个线程上正在执行的每个方法都各自有对应的一个栈帧。
- 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。
栈运行原理
- JVM 直接对 Java 栈的操作只有两个,对栈帧的压栈和出栈,遵循「先进后出/后进先出」原则。
- 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
- 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
- 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,称为新的当前栈帧。
- 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧中引用另外一个线程的栈帧。
- 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
- Java 方法有两种返回函数的方式,一种是正常的函数返回,使用 return 指令,另一种是抛出异常,不管用哪种方式,都会导致当前栈帧被弹出。
IDEA 在 debug 时候,可以在 debug 窗口看到 Frames 中各种方法的压栈和出栈情况:
栈帧的内部结构
每个栈帧(Stack Frame)中存储着:
- 局部变量表(Local Variables):存放方法参数和局部变量的区域。
- 操作数栈(Operand Stack):也称为表达式栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
- 动态链接(Dynamic Linking):指向运行时常量池的方法引用。
- 方法出口(Return Address):方法正常退出或异常退出的地址。
- 一些附加信息
继续分析栈帧中的五部分。
局部变量表
- 局部变量表也被称为局部变量数组或者本地变量表。
- 是一组变量值存储空间,主要用于存储方法参数和定义在方法体内的局部变量,包括编译器可知的各种 Java 虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用和 returnAddress 类型。
- 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题。
- 局部变量表所需要的容量大小是编译期确定下来的,并保存在方法的 Code 属性的
maximum local variables
数据项中。在方法运行期间是不会改变局部变量表的大小的。 - 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
- 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
- 参数值的存放总是在局部变量数组的
index0
开始,到数组长度 -1 的索引结束。
槽 Slot
局部变量表最基本的存储单元是Slot(变量槽)。
关于 Slot 的说明:
-
《Java虚拟机规范》中没有明确规定变量槽应占用内存的空间大小,但确定:
- 一个 Slot 可以保存一个类型为:boolean、byte、char、short、int、float、reference 或 returnAddress 数据;
- 而 64 位的类型 long 和 double 要占用两个连续的 Slot。
-
byte、short、char 在存储前被转换为 int 类型,boolean 也被转换为 int 类型,0 表示 false,非 0 表示 true。
-
JVM会为局部变量表中的每一个 Slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。
-
当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个 slot 上。
-
如果需要访问局部变量表中一个 64bit 的局部变量值时,只需要使用前一个索引即可。(比如:访问 long 或 double 类型变量)
-
如果当前帧是由构造方法或实例方法创建的,那么该对象引用 this 将会存放在 index 为 0 的 Slot 处,其余的参数按照参数表顺序继续排列
- 这里就引出一个问题:静态方法中为什么不可以引用 this?
- 如下图可以看到,this 在 static 中是没有的,而在非 static 中默认是 0,也就是 slot 中的第一位即 0 位
-
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后声明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。(下图中,this、a、b、c 理论上应该有 4 个变量,c 复用了 b 的槽)
-
在栈帧中,与性能调优关系最为密切的就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
-
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
操作数栈
每个独立的栈帧中除了包含局部变量表之外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也称表达式栈(Expression Stack)。
在方法执行过程中,根据字节码指令,往操作数栈中写入或提取数据,即入栈(push)、出栈(pop)。
关于操作数栈的说明:
- 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
- 操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,此时这个方法的操作数栈是空的
- 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的 Code 属性的 max_stack 数据项中
- 栈中的任何一个元素都可以是任意的 Java 数据类型
- 32bit 的类型占用一个栈单位深度
- 64bit 的类型占用两个栈单位深度
- 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问
- 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新 PC 寄存器中下一条需要执行的字节码指令
- 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证
- 另外,我们说 Java 虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈
动态链接
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。
在 Class 文件里面,一个方法若要调用其他方法或者访问成员变量,需要通过符号引用来表示。动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
符号引用(Symbolic Reference)保存在 Class 文件的常量池中。
JVM 是如何执行方法调用的?
方法调用不同于方法执行,方法调用阶段的唯一任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。Class 文件的编译过程中不包括传统编译器中的连接步骤,一切方法调用在 Class 文件里面存储的都是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。也就是需要在类加载阶段,甚至到运行期才能确定目标方法的直接引用。
在 JVM 中,将符号引用转换为调用方法的直接引用与方法的绑定机制有关:
- 静态绑定:Java 虚拟机中的静态绑定指的是在解析时便能够直接识别目标方法的情况。即当一个字节码文件被装载进 JVM 内部时,被调用的目标方法在编译期可知,且运行期保持不变。
- 动态绑定:如果被调用的方法在编译期无法被确定下来,也就是说,只能在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态绑定。
虚方法和非虚方法:
- 如果方法在编译器就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法,比如静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。
- 其他方法称为虚方法。
虚方法表
在面向对象编程中,会频繁的使用到动态分派,如果每次动态分派都要重新在类的方法元数据中搜索合适的目标有可能会影响到执行效率。为了提高性能,JVM 采用在类的方法区建立一个虚方法表(virtual method table),使用索引表来代替查找。非虚方法不会出现在表中。
这个数据结构,便是 Java 虚拟机实现动态绑定的关键所在。
我们之前分析类加载的准备阶段,**它除了为静态字段分配内存之外,还会构造与该类相关联的方法表。**虚方法表会在类加载的连接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM 会把该类的方法表也初始化完毕。
方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。
每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
在执行过程中,Java 虚拟机将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法。这个过程便是动态绑定。
方法返回地址(return address)
用来存放调用该方法的 PC 寄存器的值。
一个方法的结束,有两种方式:
- 正常执行完成
- 出现未处理的异常,非正常退出
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。
- 方法正常退出时,调用者的 PC 计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。
- 而通过异常退出的,返回地址是要通过异常表来确定的,栈帧中一般不会保存这部分信息。
当一个方法开始执行后,只有两种方式可以退出这个方法:
-
第一种是执行引擎遇到任意一个方法返回的字节码指令,会有返回值传递给上层的方法调用者,简称正常调用完成;
- 一个方法的正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定;
- 在字节码指令中,返回指令包含 ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn(long)、freturn(float)、dreturn(double)以及areturn(引用类型),另外还有一个 return 指令供声明为 void 的方法、实例初始化方法、类和接口的初始化方法使用。
-
另一种方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常调用完成。
- 方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常调用完成和异常调用完成的区别在于:通过异常完成出口退出的不会给它的上层调用者产生任何的返回值。
附加信息
栈帧中还允许携带与 Java 虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息,但这些信息取决于具体的虚拟机实现。
本地方法栈
本地方法栈是和虚拟机栈非常相似的一个区域,Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用。
-
本地方法栈也是线程私有的
-
允许线程固定或者可动态扩展的内存大小
- 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个
StackOverflowError
异常。 - 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么 Java 虚拟机将会抛出一个
OutofMemoryError
异常。
- 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个
堆
堆具有以下特点:
- 堆内存最大
- 线程共享
- 堆的目的就是存放对象,几乎所有的对象实例都在此分配。当然,随着优化技术的更新,某些数据也会被放在栈上等。
内存划分
因为堆占用内存空间最大,堆也是Java垃圾回收的主要区域(重点对象),因此也称作「GC堆」(Garbage Collected Heap)。
堆是 JVM 上最大的内存区域,我们申请的几乎所有的对象,都是在这里存储的。
为了进行高效的垃圾回收,虚拟机把堆内存逻辑上划分成三块区域:
- 新生代:新对象和没达到一定年龄的对象都在新生代;
- 老年代:被长时间使用的对象,老年代的内存空间应该要比年轻代更大;
- 元空间(JDK1.8 之前叫永久代):像一些方法中的操作临时对象等,JDK1.8 之前是占用 JVM 内存,JDK1.8 之后直接使用物理内存。
Java 虚拟机规范规定:
- Java 堆可以是处于物理上不连续的内存空间中,只要逻辑上是连续的即可,像磁盘空间一样;
- 实现时,既可以是固定大小,也可以是可扩展的,主流虚拟机都是可扩展的(通过 -Xmx 和 -Xms 控制);
- 如果堆中没有完成实例分配,并且堆无法再扩展时,就会抛出
OutOfMemoryError
异常。
由于对象的大小不一,在长时间运行后,堆空间会被许多细小的碎片占满,造成空间浪费。所以,仅仅销毁对象是不够的,还需要堆空间整理。这个过程非常的复杂,我们会在后面文章进行介绍。
那一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?
这和两个方面有关:
- 对象的类型;
- 以及 Java 类中存在的位置。
Java 的对象可以分为基本数据类型和普通对象。
-
对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。
-
对于基本数据类型来说(byte、short、int、long、float、double、char),有两种情况。 我们上面提到,每个线程拥有一个虚拟机栈。当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,都是在堆上分配。
注意,像 int[] 数组这样的内容,是在堆上分配的。数组并不是基本数据类型。
这就是 JVM 的基本的内存分配策略。而堆是所有线程共享的,如果是多个线程访问,会涉及数据同步问题。这同样是个大话题,我们在这里先留下一个悬念。
对象在堆中的生命周期
- 在 JVM 内存模型的堆中,堆被划分为新生代和老年代
- 新生代又被进一步划分为 Eden 区和 Survivor 区,Survivor 区由
From Survivor
和To Survivor
组成
- 新生代又被进一步划分为 Eden 区和 Survivor 区,Survivor 区由
- 当创建一个对象时,对象会被优先分配到新生代的 Eden 区,时 JVM 会给对象定义一个对象年轻计数器(
-XX:MaxTenuringThreshold
) - 当 Eden 空间不足时,JVM 将执行新生代的垃圾回收(Minor GC)
- JVM 会把存活的对象转移到 Survivor 中,并且对象年龄
+1
- 对象在 Survivor 中同样也会经历
Minor GC
,每经历一次Minor GC
,对象年龄都会+1
- JVM 会把存活的对象转移到 Survivor 中,并且对象年龄
- 如果分配的对象超过了
-XX:PetenureSizeThreshold
,对象会直接被分配到老年代。
对象的分配过程
- new 的对象先放 Eden(伊甸园)区。此区有大小限制。
- 当 Eden 的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对 Eden区 进行垃圾回收(Minor GC), 将 Eden 区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到 Eden 区。
- 然后将 Eden 中的剩余对象移动到幸存者 0 区。
- 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者 0 区的,如果没有回收,就会放到幸存者 1 区。
- 如果再次经历垃圾回收,此时会重新放回幸存者 0 区,接着再去幸存者1区。
- 啥时候能去养老区呢?可以设置次数。默认是 15 次。可以设置参数:
-XX:MaxTenuringThreshold=<N>
进行设置。 - 在养老区,相对悠闲。当养老区内存不足时,再次触发 Major GC,进行养老区的内存清理。
- 若养老区执行了 Major GC 之后发现依然无法进行对象的保存,就会产生 OOM 异常。
元空间
关于元空间,我们还是以一个非常高频的面试题开始:为什么有 Metaspace 区域?它有什么问题?
说到这里,我们回想一下类与对象的区别。对象是一个活生生的个体,可以参与到程序的运行中;类更像是一个模版,定义了一系列属性和操作。
那么我们前面生成的 A.class,是放在 JVM 的哪个区域的?
在 Java 8 之前,这些类的信息是放在一个叫 Perm 区的内存里面的。更早版本,甚至 String.intern
相关的运行时常量池也放在这里。这个区域有大小限制,很容易造成 JVM 内存溢出,从而造成 JVM 崩溃。
Perm 区在 Java 8 中已经被彻底废除,取而代之的是 Metaspace。原来的 Perm 区是在堆上的,现在的元空间是在非堆上的,这是背景。
当使用元空间时,可以加载多少类的元数据就不再由 MaxPermSize
控制, 而由系统的实际可用空间来控制。
关于它们的对比,可以看下这张图。
然后,元空间的好处也是它的坏处。
使用非堆可以使用操作系统的内存,JVM 不会再出现方法区的内存溢出;但是,无限制的使用会造成操作系统的死亡。所以,一般也会使用参数 -XX:MaxMetaspaceSize
来控制大小。
方法区,作为一个概念,依然存在。它的物理存储的容器,就是 Metaspace。
方法区
- 方法区(Method Area)与 Java 堆一样,是所有线程共享的内存区域。
- 它用于存储已被虚拟机加载的类型信息、常量、静态变量、JIT编译后的代码等数据。
- 虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。
- 方法区的大小和堆空间一样,可以选择固定大小也可选择可扩展,方法区的大小决定了系统可以放多少个类,如果系统类太多,导致方法区溢出,虚拟机同样会抛出 OOM 异常。
你是否也有看不同的参考资料,有的内存结构图有方法区,有的又是永久代,元数据区,一脸懵逼的时候?
方法区(method area)只是 JVM 规范中定义的一个概念,并没有规定如何去实现它,不同的厂商有不同的实现。
而永久代(PermGen)是 Hotspot 虚拟机特有的概念, Java8 的时候又被元空间取代了,永久代和元空间都可以理解为方法区的落地实现。
对于方法区,Java8 之后的变化
- 移除了永久代(PermGen),替换为元空间(Metaspace);
- 永久代中的 class metadata 转移到了 native memory(本地内存,而不是虚拟机);
- 永久代中的字符串常量池(interned Strings)和类的静态变量(class static variables)转移到了 Java heap;
- 永久代参数 (PermSize MaxPermSize) -> 元空间参数(MetaspaceSize MaxMetaspaceSize)。
方法区内部结构
方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
类型信息
对每个加载的类型(类 class、接口 interface、枚举 enum、注解 annotation),JVM 必须在方法区中存储以下类型信息:
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于 interface或是 java.lang.Object,都没有父类)
- 这个类型的修饰符(public,abstract,final 的某个子集)
- 这个类型直接接口的一个有序列表
域(Field)信息
- JVM 必须在方法区中保存类型的所有域的相关信息以及域的声明顺序;
- 域的相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient 的某个子集)
方法(Method)信息
JVM 必须保存所有方法的:
- 方法名称
- 方法的返回类型
- 方法参数的数量和类型
- 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract 的一个子集)
- 方法的字符码(bytecodes)、操作数栈、局部变量表及大小(abstract 和 native 方法除外)
- 异常表(abstract 和 native 方法除外)
- 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
栈、堆、方法区的交互关系图如下:
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。理解运行时常量池的话,我们先来说说字节码文件(Class 文件)中的常量池(常量池表)。
常量池
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),用于存放各种字面量和对类型、域和方法的符号引用。
为什么需要常量池?
避免频繁地创建和销毁对象而影响系统性能,实现对象的共享(字符串常量池);对于类共用的元数据信息,使用常量池可以共享使用,而不是不同线程、对象都创建一个副本,节省内存开销(class常量池、运行时常量池)。
常量池可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。
运行时常量池
- 运行时常量池(Runtime Constant Pool)是方法区的一部分。
- 常量池表(Constant Pool Table)是 Class 文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
- 在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
- JVM为 每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
- 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
- 运行时常量池,相对于 class 文件常量池的另一重要特征是:具备动态性。
- 运行时常量池类似于传统编程语言中的符号表(symboltable),但是它所包含的数据却比符号表要更加丰富一些。
- 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛
OutOfMemoryError
异常。
好了, JVM 内存管理我们就暂时告一段落,读完本文上面的问题你能回答出来了吗?
如果你还想看更多优质原创文章,欢迎关注我的公众号「ShawnBlog」。
参考与感谢
《深入理解 Java 虚拟机 第三版》
《Java虚拟机规范.Java SE 8版》
https://mp.weixin.qq.com/s/jPIHNsQwiYNCRUQt1qXR6Q