Java — 浅谈JVM的内存分区管理
一.内存分区概述
内存管理是JVM中的一个重要事宜,相较于C++的手动控制内存,这降低了开发者的门槛并提高了程序的可维护性。
连续内存被抽象成不同作用的不同区域,这其实建立在操作系统本身的内存分区上:其中操作系统将内存分为:栈(Stack),堆(Heap),未初始化数据段(BSS),数据段(Data Segment),文本段(Literal Segment),代码段(Code Segment)。而在JVM中,其被分为:程序计数器(PC),虚拟机栈(JVM Stack), 本地方法栈(Native Stack), 堆(Heap), 方法区(Method Area)。
二.程序计数器
程序计数器是一种寄存器,在JVM层面其用来存储字节码的指令地址,用于取址执行。因为JVM虚拟机没有向外暴露查询接口,因此无法获取到其内部的具体数值,故在此仅作为概念供了解即可。
例如写了一个demo,反编译成.class文件后可以发现,字节码指令前的数字为字节码指令偏移地址,正是程序计数器需要的数据。
三.虚拟机栈
在JVM中对方法的调用,对应着栈帧的出栈入栈,就像下面这个示意图一样。A栈帧入栈,B栈帧入栈,B栈帧出栈,A栈帧出栈。我们也知道,方法栈有一定的深度限制,超出会导致StackOverFlow的异常报错。这里关注三个内容:
1.栈帧。2.栈帧的生成时机。3.栈帧的构成。
首先栈帧,栈帧是对方法调用的一种封装,那么一次方法调用对应一个栈帧的入栈出栈。
其次生成时机,栈帧的生成是根据程序运行时的实际情况决定的,是动态的。因此编译期间无法确定方法栈的深度,也无法检查出StackOverFlow。
最后是构成,其包括了局部变量表,操作数栈,动态链接和返回地址和一些其他信息。
1.局部变量表
①.局部变量表主要存储方法的参数,局部变量。包括基本数据类型和对象的引用地址。
②.其存储的基本单位为Slot,32位(4字节)以内的数据类型占一个slot,64位(long,double)则占用两个slot。
③.局部变量表中存储数字,byte,short,char都会被转化为int,boolean也同样。
④.局部变量表的大小在编译器可以决定下来,因此这部分在运行时不变。
⑤.局部变量表中含有直接或间接的引用类型变量时,其不会被垃圾回收处理。
总结以上,调用方法的参数,局部变量相关类型和数量都是在源码中可以确定的,也就是说其大小是可以确定的。且其在表中以slot形式存储。
通过反编译获得的局部变量表如下,由slot存储,参数String类型的args占用一个slot,名为aa的一个变量占用一个slot。
2.操作数栈
JVM的操作数栈是一个用于存储操作数的栈,操作数大部分时候是指方法内的变量。其作用一是方便存储变量及其中间结果,二是能够方便指令顺序读取操作数,虚拟机在执行时会通过指令类型从操作数栈取出栈顶的操作数进行计算再将结果入栈并继续后续指令,这里有点类似汇编。
可以看到反编译结果:通过iload将m和n压栈,再取出栈顶元素相加后将结果压栈。
3.动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所述方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。之前说过,Java类加载过程中有一个过程叫链接,JVM会将class对象中部分符号引用替换成直接引用(详见类加载一文),而部分多态情况下JVM无法确定具体调用的具体类型,只能在运行时根据实际的类型信息进行链接,这就是动态链接。
4.返回地址
用于控制方法返回,作用于两种情况:正常方法执行完成返回和遇到异常返回。
四.本地方法栈
首先,本地方法指的是由非Java语言编写的通常为C/C++方法,本地方法栈就是用来支持本地方法的调用逻辑的。其他与虚拟机栈类型,不做展开讲解。
五.方法区
1.概念
方法区是Java虚拟机规范中定义的需要存在的抽象分区。而对于不同的虚拟机实现,其具体实现方式存在差异。
以HotSpot虚拟机为例,在JDK8以前,其开发者将面向堆的分代设计复用在了方法区上,使用“永久代”来作为HotSpot上的方法区实现。
但后来在JDK8之后借鉴了JRockit的设计思路,使用“元空间”来替代“永久代”作为新的实现方式。
总结来说,“方法区”是抽象,“永久代”和“元空间”是实现。这其中的差异需要明确。
那么,为什么要用元空间这种本地内存的方式来代替永久代呢?,首先这里简单说一下永久代的两个缺点:
1.可能引起内存溢出。永久代的大小设置多少可以通过虚拟机启动参数来指定,但其中存储的数据大小是动态变化的,若阈值设置的太小则可能导致频繁的类卸载或内存溢出问题。设置的太大则可能存在空间浪费
2.永久代的复杂设计本身不是方法区需要的,可能带来未知异常。它本身是面向堆来设计的,储存在其中的对象不是内存连续的,需要通过额外的储存信息以及额外的对象查找机制来定位对象,所以较麻烦。
之前是由于代码复用的考虑而使用了永久代,这些问题通过使用元空间可以回避掉。
2.类型信息
之前说过,类加载过程中有一个过程叫加载,这个阶段虚拟机会读取Class文件中的内容生成Class对象,Class对象中存储了一些类型信息,这些信息就是存储在方法区的。主要是指诸如“类签名”,“方法”,“属性”的信息,从字节码看大概如下:
此外,还有LineNumberTable,这其实对应了代码行数与字节码指令之间的映射关系,也解释了为什么在IDE中下达断点之后可以精确停在某一行,因为JVM执行的并不是代码而是字节码,所以需要这个表来辅助。
3.常量池
常量池到底有什么作用呢?在Java中大部分类都不是孤岛,他们之间存在着相互调用的关系。以这样一个Test类为例,它继承了Lock类,因此拥有lock的能力,又拥有String属性,因此可以调用string相关方法。
这种调用关系是怎么实现的呢?首先,最简单粗暴的方式是将对应类的源码直接引入目标类(也就是这里的Test类)中一起编译,但这样会造成代码的大量膨胀,大量的代码重复并不合理。
比较合理的方式是通过类似指针的方式在Test的字节码中留下一个指针,指向想要调用的其他类的字节码,于是这个指针起到了链接的作用。
要注意的是这里的常量池不是用于存储代码中定义的常量和字面量的,它更像是一张链接表。例如1-4行对应了方法和属性需要的外部链接。5-6行对应了类信息需要的外部链接
4.运行时常量池
简单来说,运行时常量池存在两种类型的数据:
第一种是编译期间产生的,主要是字节码中定义的静态信息,比如由字节码产生的class对象,字节码生成的字面量。
第二种是运行期间产生的,这部分比较灵活,比如运行时会将一部分符号引用转换为直接引用,那么这部分直接引用可以存储进来。再比如常见的字符串常量池等。
当方法区内存占用到达一定阈值,还是需要进行垃圾回收的。比如通过类加载进入方法区的类型信息在内存紧张时会对部分类进行卸载。另外字符串常量池也会存在部分回收。
六.堆
堆主要存在各种对象的对象结构体,而其他区域则通过引用来索引到这个对象。
堆没有严格的划分方式,比较常见是通过垃圾回收的角度进行划分。而堆是垃圾回收的主战场,可以关注后续文章内容。