Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。《深入理解Java虚拟机》
写本篇文章的主要原因,就是为了应付面试 是构建自己的 JVM 知识体系,作为 Java 开发,对于技术的认知不仅要有广度,更重要的是要有深度。
本文我们主要分析 JVM 的内存划分。这块内容主要涉及以下这几个问题:
首先,第一个问题:JVM的内存区域是如何划分的?
Java 虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程一一对应的数据区域会随着线程开始和结束而创建和销毁。
下图就是 Java 虚拟机定义的各种运行时数据区域:
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区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等信息。)
将代码进行编译成字节码文件,我们通过查看 ,发现在字节码的左边有一个行号标识,它就是指令地址,用于指向当前执行到哪里。
OutOtMemoryError
情况的区域。问题1:使用 PC 寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢?
因为CPU需要不停的切换各个线程,如果一个方法切换到另一个方法,这时候切换回来以后,就得知道接着从哪开始继续执行。JVM 的字节码解释器就需要通过改变 PC 寄存器的值来明确下一条应该执行什么样的字节码指令。
问题2:PC寄存器为什么会被设定为线程私有?
多线程在一个特定的时间段内只会执行其中某一个线程方法,CPU 会不停的做任务切换,这样必然会导致经常中断或恢复。为了能够准确的记录各个线程正在执行的当前字节码指令地址,所以为每个线程都分配了一个 PC 寄存器,每个线程都独立计算,不会互相影响。
栈是一种先进后出的数据结构,就像子弹的弹夹,最后压入的子弹先发射。
Java 虚拟机栈(Java Virtual Machine Stacks)是描述 Java 方法执行的内存区域。每个线程在创建的时候,都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame)对应着一次方法的调用,是线程私有的,生命周期和线程一致。
作用:主管 Java 程序的运行,每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
特点:
Java 虚拟机规范允许 Java 虚拟机栈的大小是动态扩展和收缩的或者是固定不变的。
StackOverflowError
异常;OutOfMemoryError
异常。可以通过参数 -Xss
来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。
具体参数和操作请参考参考:官方文档
栈中存储什么?
IDEA 在 debug 时候,可以在 debug 窗口看到 Frames 中各种方法的压栈和出栈情况:
每个栈帧(Stack Frame)中存储着:
maximum local variables
数据项中。在方法运行期间是不会改变局部变量表的大小的。index0
开始,到数组长度 -1 的索引结束。局部变量表最基本的存储单元是Slot(变量槽)。
关于 Slot 的说明:
《Java虚拟机规范》中没有明确规定变量槽应占用内存的空间大小,但确定:
byte、short、char 在存储前被转换为 int 类型,boolean 也被转换为 int 类型,0 表示 false,非 0 表示 true。
JVM会为局部变量表中的每一个 Slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。
当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个 slot 上。
如果需要访问局部变量表中一个 64bit 的局部变量值时,只需要使用前一个索引即可。(比如:访问 long 或 double 类型变量)
如果当前帧是由构造方法或实例方法创建的,那么该对象引用 this 将会存放在 index 为 0 的 Slot 处,其余的参数按照参数表顺序继续排列
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后声明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。(下图中,this、a、b、c 理论上应该有 4 个变量,c 复用了 b 的槽)
在栈帧中,与性能调优关系最为密切的就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
每个独立的栈帧中除了包含局部变量表之外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也称表达式栈(Expression Stack)。
在方法执行过程中,根据字节码指令,往操作数栈中写入或提取数据,即入栈(push)、出栈(pop)。
关于操作数栈的说明:
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。
在 Class 文件里面,一个方法若要调用其他方法或者访问成员变量,需要通过符号引用来表示。动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
符号引用(Symbolic Reference)保存在 Class 文件的常量池中。
方法调用不同于方法执行,方法调用阶段的唯一任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。Class 文件的编译过程中不包括传统编译器中的连接步骤,一切方法调用在 Class 文件里面存储的都是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。也就是需要在类加载阶段,甚至到运行期才能确定目标方法的直接引用。
在 JVM 中,将符号引用转换为调用方法的直接引用与方法的绑定机制有关:
虚方法和非虚方法:
虚方法表
在面向对象编程中,会频繁的使用到动态分派,如果每次动态分派都要重新在类的方法元数据中搜索合适的目标有可能会影响到执行效率。为了提高性能,JVM 采用在类的方法区建立一个虚方法表(virtual method table),使用索引表来代替查找。非虚方法不会出现在表中。
这个数据结构,便是 Java 虚拟机实现动态绑定的关键所在。
我们之前分析类加载的准备阶段,**它除了为静态字段分配内存之外,还会构造与该类相关联的方法表。**虚方法表会在类加载的连接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM 会把该类的方法表也初始化完毕。
方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。
每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
在执行过程中,Java 虚拟机将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法。这个过程便是动态绑定。
用来存放调用该方法的 PC 寄存器的值。
一个方法的结束,有两种方式:
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。
当一个方法开始执行后,只有两种方式可以退出这个方法:
第一种是执行引擎遇到任意一个方法返回的字节码指令,会有返回值传递给上层的方法调用者,简称正常调用完成;
另一种方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常调用完成。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常调用完成和异常调用完成的区别在于:通过异常完成出口退出的不会给它的上层调用者产生任何的返回值。
栈帧中还允许携带与 Java 虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息,但这些信息取决于具体的虚拟机实现。
本地方法栈是和虚拟机栈非常相似的一个区域,Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用。
本地方法栈也是线程私有的
允许线程固定或者可动态扩展的内存大小
StackOverflowError
异常。OutofMemoryError
异常。堆具有以下特点:
因为堆占用内存空间最大,堆也是Java垃圾回收的主要区域(重点对象),因此也称作「GC堆」(Garbage Collected Heap)。
堆是 JVM 上最大的内存区域,我们申请的几乎所有的对象,都是在这里存储的。
为了进行高效的垃圾回收,虚拟机把堆内存逻辑上划分成三块区域:
Java 虚拟机规范规定:
OutOfMemoryError
异常。由于对象的大小不一,在长时间运行后,堆空间会被许多细小的碎片占满,造成空间浪费。所以,仅仅销毁对象是不够的,还需要堆空间整理。这个过程非常的复杂,我们会在后面文章进行介绍。
那一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?
这和两个方面有关:
Java 的对象可以分为基本数据类型和普通对象。
对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。
对于基本数据类型来说(byte、short、int、long、float、double、char),有两种情况。 我们上面提到,每个线程拥有一个虚拟机栈。当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,都是在堆上分配。
注意,像 int[] 数组这样的内容,是在堆上分配的。数组并不是基本数据类型。
这就是 JVM 的基本的内存分配策略。而堆是所有线程共享的,如果是多个线程访问,会涉及数据同步问题。这同样是个大话题,我们在这里先留下一个悬念。
From Survivor
和 To Survivor
组成-XX:MaxTenuringThreshold
)+1
Minor GC
,每经历一次 Minor GC
,对象年龄都会 +1
-XX:PetenureSizeThreshold
,对象会直接被分配到老年代。-XX:MaxTenuringThreshold=
进行设置。关于元空间,我们还是以一个非常高频的面试题开始:为什么有 Metaspace 区域?它有什么问题?
说到这里,我们回想一下类与对象的区别。对象是一个活生生的个体,可以参与到程序的运行中;类更像是一个模版,定义了一系列属性和操作。
那么我们前面生成的 A.class,是放在 JVM 的哪个区域的?
在 Java 8 之前,这些类的信息是放在一个叫 Perm 区的内存里面的。更早版本,甚至 String.intern
相关的运行时常量池也放在这里。这个区域有大小限制,很容易造成 JVM 内存溢出,从而造成 JVM 崩溃。
Perm 区在 Java 8 中已经被彻底废除,取而代之的是 Metaspace。原来的 Perm 区是在堆上的,现在的元空间是在非堆上的,这是背景。
当使用元空间时,可以加载多少类的元数据就不再由 MaxPermSize
控制, 而由系统的实际可用空间来控制。
关于它们的对比,可以看下这张图。
使用非堆可以使用操作系统的内存,JVM 不会再出现方法区的内存溢出;但是,无限制的使用会造成操作系统的死亡。所以,一般也会使用参数 -XX:MaxMetaspaceSize
来控制大小。
方法区,作为一个概念,依然存在。它的物理存储的容器,就是 Metaspace。
你是否也有看不同的参考资料,有的内存结构图有方法区,有的又是永久代,元数据区,一脸懵逼的时候?
方法区(method area)只是 JVM 规范中定义的一个概念,并没有规定如何去实现它,不同的厂商有不同的实现。
而永久代(PermGen)是 Hotspot 虚拟机特有的概念, Java8 的时候又被元空间取代了,永久代和元空间都可以理解为方法区的落地实现。
方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
对每个加载的类型(类 class、接口 interface、枚举 enum、注解 annotation),JVM 必须在方法区中存储以下类型信息:
JVM 必须保存所有方法的:
栈、堆、方法区的交互关系图如下:
运行时常量池(Runtime Constant Pool)是方法区的一部分。理解运行时常量池的话,我们先来说说字节码文件(Class 文件)中的常量池(常量池表)。
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),用于存放各种字面量和对类型、域和方法的符号引用。
为什么需要常量池?
避免频繁地创建和销毁对象而影响系统性能,实现对象的共享(字符串常量池);对于类共用的元数据信息,使用常量池可以共享使用,而不是不同线程、对象都创建一个副本,节省内存开销(class常量池、运行时常量池)。
常量池可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。
OutOfMemoryError
异常。好了, JVM 内存管理我们就暂时告一段落,读完本文上面的问题你能回答出来了吗?
如果你还想看更多优质原创文章,欢迎关注我的公众号「ShawnBlog」。
《深入理解 Java 虚拟机 第三版》
《Java虚拟机规范.Java SE 8版》
https://mp.weixin.qq.com/s/jPIHNsQwiYNCRUQt1qXR6Q