看了很多文章,也阅读了很多书籍,对于JVM的内存分配、管理讲的都很透彻,但是所有的讲解几乎都是局限在Java程序的层面去理解的,没有继续向下的深入分析,即使有也是简单的文字描述。
总说“大而全”不好,大而全的内容确实容易描述和记忆,但是对于积累知识的终极目标不是就是在自身的领域中既有深度也有广度的“大而全”么。今天就从计算机组成的视角认识一下JVM内存的分配在HotSpot虚拟机上的实现。
从上图可以看出,JDK8版本中,狭义上的JVM内存模型分为:程序计数器、本地方法栈、虚拟机栈和堆,其中前三者为线程私有的,最后的堆是线程共有的(所以GC主要发生在堆上)。广义上的JVM内存模型,还包含一部分的本地内存,本地内存又可分为元空间与直接内存。
- 线程私有
- 未定义任何OutOfMemoryError异常
程序计数器,简称PC Register,它与CPU的寄存器还有本质上的区别,JVM的程序计数器不是独立的硬件,只是一块很小的内存空间,里面存放的就是当前Java线程正在执行的JVM指令对方法起始的偏移量。程序计数器只有在当前java线程执行的是java方法时才会有值,在执行本地方法时,其值为:undefined。
从计算机组成的角度来说,程序计数器中存放的就是一个整数,代表的就是当前正在执行的JVM指令是相对于方法的开始向后偏移的第几个指令。
为什么程序计数器也叫做行号指示器
在反编译class文件的时候,为了好越多,反编译器会对反编译出来的指令进行格式化,格式化后的就是一行为一条指令,而且每一行的前面还有一个看起来像是“行号”的东西,而程序计数器中存储的就是这个“行号”,所以又成为“行号指示器”。但实际上这个看起来像是“行号”的东西实质上就是指令的偏移量。
本地方法栈本质上与虚拟机栈类似,但其是用于运行Native方法的。具体不做过多的介绍。
- 线程私有
- 定义有OutOfMemoryError异常
虚拟机栈是Java虚拟机的重要组成部分,方法的运行就依靠于它。一个方法调用在虚拟机栈中就是一个“栈帧”,栈底就是main方法的栈帧。栈帧主要由局部变量表、操作数栈、动态链接、返回地址(也叫方法出口)以及其它的附加信息组成。java文件中的一个方法调用,就对应着虚拟机栈中的一次入栈与出栈。
局部变量表是当前方法的入参与方法内的局部变量的存储空间,局部变量表的容量在class文件编译时根据方法的max_locals属性就确定了最大容量。局部变量表的基本组成单位是“变量槽”(Variable Slot,一般简称Slot)。Java虚拟机规范中规定了“一个变量槽占用32位的空间”,32位的空间可以涵盖Java绝大部分的基本数据类型,其余的(例如:long和double)一个变量槽存不下的就用两个,但是程序在编译期间会检查,如果需要一次性访问两个变量槽(例如:独读取一个double类型的值)时,不允许采用任何方式只读取其中的一个,万一有发生这种情况则在编译阶段就报错。
虚拟机通过索引定位的方式来使用局部变量表,但实际使用中索引的起始是从1开始,局部变量表的0号索引位存储的就是this关键对当前方法所属对象实例的引用,即堆空间中的一个内存地址。
从计算机组成的角度来说,局部变量表就是内存中的一块儿确定大小的连续的空间,其所占用的字节空间大小是32或64的整数倍。
int i = 1 + 2;
例如上面的这段代码,操作数栈中存储的就是1和2这两个数以及它们相加的结果3这个数据,但随着程序的运行,1和2这两个数会先后入栈,这两次的入栈操作,对应着程序计数器中记录的内容已经变化的了两次(第一次是数字“1”入栈,第二次是数字“2”入栈,当程序计数器继续记录下一个指令的偏移量时,操作数栈中已经入栈的数字“1”和“2”会出栈,然后这两个数相加的结果数字“3”会入栈。
从计算机组成的角度来说,操作数栈是一块物理上不连续但逻辑上连续的内存空间,其所占用的字节空间大小是32或64的整数倍。
动态连接,也叫reference,所存储的内容为了标示当前栈帧属于那个方法,存储的就是执行运行时常量池中该栈帧所属方法的引用。
从计算机组成的角度来说,动态连接部分存储的是一个内存地址。
返回地址中记录的信息就是上一个方法栈帧在调用此方法时所对应的程序计数器的值,为了在当前方法执行结束(正常执行完毕结束或遇到异常中途退出结束)时,恢复调用当前这个方法的方法继续运行。
返回地址中的值,在方法正常结束(未抛异常的执行完毕或抛出了异常但在当前方法中已经catch掉了)时用于恢复上一个方法的执行;当方法异常结束(抛出了异常没有catch住)时,异常处理器在构造异常实例的时候,会使用便利异常堆栈,使用返回地址中的信息构建“异常堆栈”。
从计算机组成的角度来说,返回地址中存储的内容和程序计数器中记录的一样,是一个整数。
通过对象实例调用方法时发生了什么
- 首先通过对象的元数据引用,找到对象的元数据,并找到对应的方法。
- 然后创建新的虚拟机栈的栈帧,将当前的程序计数器的内容复制到新栈帧的返回地址中,在新栈帧的动态连接中>写入当前方法的引用。
- 如果新的方法需要入参,且入参的值正好位于当前方法栈帧操作数栈的顶部,则将操作数栈顶部的数据作为新方法栈帧的局部变量表的一部分(可能是复制数据,可能是内存地址共享,取决于具体的虚拟机实现)。
- 将程序计数器清空,开始新方法的执行。
- 线程共有
- 定义有OutOfMemoryError异常
Object obj = new Object();
堆是JVM中占用内存空间最大的区域,也是GC管理器最关注的区域。堆中存放的就是对象实例,以上面的代码为例,“new Object()“所开辟的内存空间,主要就是在堆中。
对象实例首先分配在新生代的Eden区,如果Eden区域没有足够的空间可以容纳新的对象实例,会触发一次Minor GC,还在继续用的对象会被转移进Survivor,当存活超过一等的Minor GC次数,即对象年龄达到一定值后,会转移入老年代。
从计算机组成的角度来说,堆中存放的就是程序运行中产生的对象及其全部变量等信息。
元数据区是在JDK8中新加的内容,是对Java虚拟机规范中的“方法区”描述的实现。JDK7及其以前,是使用“永久代”来实现方法区的,但是实际使用中发现来GC管理、性能上存在问题,且Oracle为了将Hotspot与JRockit整合,组装两者的长处,所以改用元数据区来实现方法区。
从计算机组成的角度来说,元数据区中存储的就是class文件的信息,以及将class中的一些符号引用解析后的直接引用内存地址信息。
直接内存,在JDK1.4引入NIO后,基于NIO的MMap所使用的内存,就位于直接内存中。
从计算机组成的角度来说,直接内存中存储的是数据内容。
使用代码不停的创建新的字符串,得到结果如下两图,堆空间在发生变化,但元数据区几乎没有变化。可以得出结论:字符串常量池位于堆中。