程序运行时,内存到底是如何进行分配的?
有很多人将Java的内存分为堆内存(heap)和栈内存(Stack),这种划分方式在一定程度上体现了这两块区域是Java工程师最关注的内存区域。但是其实这种划分方式并不完全准确。
Java的内存区域划分实际上远比这复杂:Java虚拟机在执行Java程序的过程中,会把它所管理的内存划分为不同的数据区域。下面这张图描述了一个HelloWord.java文件被JVM加载到内存中的过程:
Java程序是多线程的, CPU可以在多个线程中分配执行时间片段 。当某一个线程被CPU挂起时,需要记录代码已经执行到的位置,方便CPU重新执行此线程时,知道从哪行指令开始执行。这就是程序计数器的作用 。
“程序计数器”是虚拟机中一块较小的内存空间, 主要用于记录当前线程执行的位置 。
如图:每个线程都会记录一个当前方法执行到的位置,当CPU切换回某一个线程上时,则根据程序计数器记录的数字,继续向下执行指令。
实际上除了上图演示的恢复线程操作之外,其他一些我们熟悉的分支操作,循环操作,跳转,异常处理等也都需要依赖这个计数器来完成。
关于程序计数器有几点需要注意:
虚拟机栈也是线程私有的,与线程的生命周期同步。在Java虚拟机规范中,对这个区域规定了两种异常状况:
在我们学习Java虚拟机的过程当中,经常会看到一句话:
JVM是基于栈的解释器执行的,DVM是基于寄存器解释器执行的。
这个“基于栈”指的就是虚拟机栈 。虚拟机栈的初衷是用来描述Java方法执行的内存模型 ,每个方法被执行的时候,JVM都会在虚拟机栈中创建一个栈帧 ,接下来看看什么是栈帧。
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,每一个线程在执行某个方法时,都会为这个方法创建一个栈帧。
可以这样理解:一个线程包含多个栈帧,而每个栈帧内部包含:局部变量表 , 操作数栈 , 动态连接 , 返回地址 等。如图:
局部变量表是变量值的存储空间,我们调用方法时传递的参数,以及在方法内部创建的局部变量都保存在局部变量表中。在Java编译成class文件的时候,就会在方法的Code属性表中的max_locals数据项中,确定该方法需要分配的最大局部变量表的容量。
系统不会为局部变量赋于初始值(实例变量和类变量会被赋予初始值),也就是说不存在类变量那样的准备阶段。
操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出栈LIFO。
同局部变量表一样,操作数栈的最大深度也在编译的时候写入方法的Code属性表中的max_stacks数据项中。栈中的元素可以是任意Java数据类型,包括long的double。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的。在方法执行的过程中,会有各种字节码指令被压入和弹出操作数栈(比如:iadd指令就是将操作数栈中栈顶的两个元素弹出,执行加法运算,并将结果重新压回到操作数栈中)。
动态链接的主要目的是为了支持方法调用过程中的动态连接(Dynamic Linking)
在一个class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其所在内存地址中的直接引用 ,而符号引用存在于方法区中。
Java虚拟机中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用 ,持有这个引用的目的就是为了支持方法调用过程中的动态连接(Dynamic Linking)。
当一个方法开始执行后,只有两种方式可以退出这个方法:
无论当前方法采用何种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行。而虚拟机栈中的“返回地址”就是用来帮助当前方法恢复它的上层方法执行状态。
一般来说,方法正常退出时,调用者的PC计数值可以作为返回地址,栈帧中可能保存此计数值。而方法异常退出时,返回地址是通过异常处理器表确定的,栈帧中一般不会保存此部分信息。
其实局部变量表和操作数栈在代码执行期间是协同合作来达到某一运算效果的。
在.java被编译成.class时,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到了方法表的Code属性中。
本地方法栈和上面介绍的虚拟机栈基本相同,只不过是针对本地(native)方法。
Java堆(Heap)是JVM所管理的内存中最大的一块,该区域唯一目的就是存放对象实例 ,几乎所有对象的实例都在堆里面分配,因此它也是Java GC管理的主要区域,有时候也叫做“GC堆”。同时它也是所有线程共享的内存区域 ,因此被分配在此区域的对象如果被多个线程访问的话,需要考虑线程安全问题 。
按照对象存储时间的不同,堆中的内存可以划分为新生代(Young) 和老年代(Old) ,其中新生代又被划分为Eden和Survivor区。
图中不同的区域存放不同生命周期的对象,这样可以根据不同的区域使用不同的垃圾回收算法,从而更具有针对性,提高GC效率。
方法区(Method Area)也是JVM规范里规定的一块运行时数据区。方法区主要是存储已经被JVM加载的类信息(版本,字段,方法,接口),常量,静态变量,即时编译器编译后的代码和数据。该区域同堆一样,也是被各个线程共享的内存区域。
经常会把方法区和永久区混淆,这里做下对比:
总结下就是:
递归调用是造成StackOverflowError的一个常见场景:
public class StackOver{
private int number;
public static void main(String[] args){
StackOver so = new StackOver();
try{
so.method();
}catch(StackOverflowError e){
printMsg
}
}
public void method(){
number++
method();
}
}
在method方法中,递归调用了自身,并且没有设置递归结束条件。则会产生异常。
原因是每调用一次method()方法时,都会在虚拟机栈中创建出一个栈帧。因为是递归调用,method()方法并不会退出,也不会将栈帧销毁,所以必然会导致StackOverflowError。因此当需要使用递归时,需要格外谨慎。
理论上,虚拟机栈,堆,方法区都有发生OutofMemoryError的可能。但是实际项目中,大多发生在堆中。如下:
public class HeapError{
public static void main(String[] args){
ArrayList list = new ArrayList();
while(true){
list.add(new HeapError());
}
}
}
在一个无限循环中,动态的向ArrayList中添加新的HeapError。这会不断的占用堆中的内存,当堆内存不够时,必然会产生OutofMemoryError,也就是内存溢出异常。
对于JVM运行时内存布局,我们需要始终记住一点:上面介绍的5块内容都是在Java虚拟机规范中定义的规则,这些规则只是描述了各个区域是负责做什么事情,存储什么样的数据,如何处理异常,是否允许线程间共享等。
一定不能将它们理解为虚拟机的“具体实现” ,虚拟机的具体实现有很多,比如Sun公司的HotSpot,JRocket以及我们很熟悉的Android Dalvik和ART等。这些具体实现在符合上面5种运行时数据区的前提下,又各自有不同的实现方式。
总结来说,JVM的运行时内存结构中一共有两个“栈”和一个“堆”,分别是:Java虚拟机栈和本地方法栈,以及“GC堆”和方法区。除此之外还有一个程序计数器。
JVM内存只有堆和方法区是线程共享的数据区域,其他区域都是线程私有的。并且程序计数器是唯一一个在Java虚拟机规范中没有规定OutofMemoryError情况的区域。