1、前言
对于C、C++程序员来说,在内存管理领域,他们拥有对象的“所有权”。从对象建立到内存分配,不仅需要照顾到对象的生,还得照顾到对象的消亡。背负着每个对象生命开始到结束的维护和管理责任。
对于JAVA程序来说,因为JVM虚拟机的加持,不再需要为每个对象去写配对的delete/free代码。交由虚拟机去管理内存,因而相对来讲不容易出现内存移除和内存泄漏的问题。不过也正是JAVA程序员把内存控制权交给了JVM,一旦出现了内存泄露和溢出的问题,修正起来会比较艰难,如果你不了解虚拟机的化。因而从事JAVA的程序员,多多少少需要了解JVM的内存模型,帮助我们更好应对JAVA内存问题。
2、JVM内存模型
很多Java开发人员会把Java内存区域划分为堆内存(Heap)和栈内存(Stack)。这种划分方式是直接继承C、C++程序的内存布局。在Java中实际内存区域划分会更复杂。
开篇一张图:
线程隔离的数据区,或称为“线程私有的内存”。他们的生命周期与线程相同。线程开辟的时候,会分配该内存空间,当线程被销毁,则这么部分内存空间也会随即释放。
2.1、 程序计数器
程序计数器为当前线程所执行的字节码的行号指示器。由于JVM的多线程是通过时间片轮转切换,依次分配处理器来执行的。因为在任何一个确定的时刻,一个处理器只能执行一条线程指令。当处理器被切换到另一个线程指令执行的时候,处理器需要记住当前指令中断的位置,以便下次执行的时候从当前中断位置恢复。该中断的位置成为指令字节码的行号。程序计数器就是用来存储该行号,因此程序的分支,循环,跳转,异常处理,线程恢复等都需要依赖这个计数器。
如果一个线程正在执行一个JAVA方法,则该计数器记录的是当前正在执行的虚拟机字节码指令的地址;
如果一个线程正在执行的是本地(Native)方法,则该计数器的值为空。
该内存区域也是唯一一个在《Java虚拟机规范》中没有规定任何OOM情况的区域。为线程私有。
2.2、虚拟机栈
Java虚拟机以方法作为最基本的执行单元,“栈帧”则是用于支持虚拟机进行方法调用和执行的数据结构,也是虚拟机运行时数据区中的虚拟机栈的栈元素。
虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程。虚拟机栈也是线程私有的。
例如举个简单的例子,我们同步将虚拟机栈内存放大:
// 有一段代码
double methodA() {
int quantity = 10;
double result = methodB(quantity);
return result;
}
double methodB(int quantity){
if(isVip()) {
return quantity * _basePrice * 0.9;
} else {
return quantity * _basePrice * 0.98;
}
}
boolean isVip(){
retrun _isvip == 1 ? true : false;
}
处理器在执行该段代码的时候,先执行methodA(),中间发现调用了methodB(),后面发现又调用了isVip()。此时方法methodA,methodB,isVip执行时的数据结构被称为栈帧。
则该线程的虚拟机栈模型如下:
方法执行methodA方法,method方法对应的栈帧(栈帧1)被压入栈底位置,此时methodA为当前活动栈帧;
当方法methodA调用methodB方法,此时methodB方法对应的栈帧(栈帧2)也被压入栈中,此时执行methodB方法;
当方法methodB调用isVip方法,继续将isVip方法对应的栈帧(栈帧3)压入栈中;
当isVip方法执行完毕,对应的isVip栈帧执行出栈操作,并将结果记录下来;
当methodB方法执行完毕,同样对应的栈帧2执行出栈操作;
methodA执行完毕,对应的栈帧1执行出栈操作;此时虚拟机栈中没有任何的栈帧;当线程执行结束后,该虚拟机栈也会随即消亡(实际上是在等待被回收)。
试想一下:如果一个递归方法,且没有合适的条件退出。会导致死循环递归,那么最终该虚拟机栈也会被压爆。这时候虚拟机会抛出StackOverflowError异常。
StackOverflowError异常:指线程请求的栈深度大于虚拟机所允许的深度,将抛出该异常。
OutOfMemoryError异常:如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存,则会抛出该异常。(HotSpot虚拟机的栈容量是不可以动态扩展的,所以在此虚拟机上是不会出现虚拟机栈导致的OutOfMemoryError)。
2.2.1、局部变量表
是一组变量值的存储空间,用于存放方法的参数和方法内部定义的局部变量。
局部变量是以变量槽(Slot)为最小单位。每个变量槽都应该能存放一个虚拟机基本数据类型(boolean,byte,char,short,int,float,long,double),对象引用(reference类型或returnAddress类型)的数据。
当一个方法被调用时,JVM会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果执行的是实例方法(非static),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可通过“this”来访问。
2.2.2、操作数栈
操作数栈是方法执行算数运算或调用其他方法进行参数传递时候的媒介。操作数栈也可以称为表达式栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据。
2.2.3、动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个方法的引用是为了支持方法调用过程中的动态链接。
Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或第一次使用时被转化为直接引用(称为静态解析)。另一部分将在每次运行期间转化为直接引用,这部分就称为动态连接。
2.2.4、方法出口
当一个方法执行后,要么正常调用完成,将返回值返回给上层调用者;要么异常调用完成,因为异常导致程序退出。
但是不管如何退出,在方法退出之后,程序都必须返回到最初方法调用时的位置,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。
方法退出的过程实际上等同于把当前栈帧出栈,所以退出时可能执行的操作有:
1、恢复上层方法的局部变量表和操作数栈;
2、把返回值(如果有的话)压入调用者栈帧的操作数栈中;
3、调整PC计数器的值以指向方法调用指令后面的一条指令等。
2.2.5、附加信息
其他附加信息。不过一般会把动态连接,方法返回地址,其他附加信息统一称为栈帧信息。
2.3、本地方法栈
本地方法栈与虚拟机栈的作用非常类似。只是虚拟机栈为Java方法服务,而本地方法栈为使用本地方法(Native)服务。HotSpot虚拟机通常直接把本地方法栈和虚拟机栈合二为一,统称为栈。同样本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。
2.4、Java堆
对于Java应用程序,Java堆是整个虚拟机内存中最大的一块。是被所有线程共享的一块内存区域。Java中几乎所有的对象实例以及数组都在堆上分配。
因此堆是GC执行垃圾回收的重点关注对象。
堆空间的模型如下:
方法区:详见2.5。
老年代(Tenure / Old Gen):存储长期存活对象,老年代占堆空间的2/3。如果老年代内存满了,会触发Major GC。
新生代(Young Gen):生命周期较短的对象,占对空间的1/3。其中新生代又分为Eden,From Survivor,To Survivor。
伊甸空间(Eden):顾名思义,伊甸园为一切初始的地方。这里指对象的生命周期刚出生便是在这块内存区域。如果Eden空间不足以给新对象分配足够的内存,则会触发Minor GC对Eden进行垃圾回收,将不需要的对象销毁,剩余对象放进S0(From Survivor)区。如果再次触发GC,会将S0复制到S2。如果再次触发GC,存活对象从S2复制到S0。GC过程该空间会重复此步骤,直到对象存活周期经历过15次GC(默认15次,可配置)依然没有被回收,将会转移到老年代。
S0空间(From Survivor)/ S2空间(To Survivor):这两个成为幸存空间,Eden、S0、S2的内存占用比例默认为8:1:1。当新生代内存达到一定量时,如果直接进行垃圾回收(清理)会带来空间碎片问题。因此当进行清理之前,会将存活的对象放进S0和S2区域,有助于垃圾回收和清理。
为什么Eden、S0、S2的内存占用比例默认为8:1:1?
IBM公司研究表明,新生代中的对象约98%生命周期都是很短的。8:1:1是基于大量实验和数据收集分析统计之后的比较合理的比例。
Minor GC / Young GC:新生代GC
Major GC:老年代GC,对于高响应要求的系统,需要尽量减少Major GC,会导致响应超时
Full GC:清理整个Heap空间,包括新生代,老年代,永久代
为什么要把堆空间进行分代?不分代不能工作吗?
其实分代的意义是为了优化垃圾回收(GC)的性能,简单理解就是分而治之。分代以后对部分需要清理对象只需要小范围进行回收即可,无需扫描整个堆空间。不过后面的G1垃圾收集器开始,取消了内存分代,取而代之的是每个平等的region。
一个对象创建中堆空间的内存申请和分配流程大致如下:
此外JVM提供了一些操作对空间的参数选项,常见的有:
参数 | 描述 |
-Xms | 堆内存初始大小 |
-Xmx | 堆内存最大允许大小 |
-Xns | 新生代内存初始大小 |
-Xmn | 新生代最大允许大小 |
-XX:SurvivorRatio=8 | 年轻代中Eden区与Survivor区的容量比例值,默认为8,即8:1 |
-Xss | 线程栈内存大小。JDK1.5后默认每个为1M,减少该值能生成更多线程 |
2.5、方法区
方法区也是线程共享的内存区域,用于存储已被JVM加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。别名也叫“非堆”,目的是与Java堆区分开。
1、类型信息:类class,接口interface,枚举enum,注解annotation
2、字段信息(域信息):域名称,信息,类型的修饰特征符public, abstract,final......
3、方法信息:返回类型void等,参数列表,方法修饰特征public, protected
/**
* Student:方法区
* stuInstance: 栈区
* new Student(): 堆区
*/
Student stuInstance = new Student();
说到这里,很多人会把方法区称为“永久代”,或者进行等价。本质上不是的,起初HotSpot设计团队选择把分代设计扩展至方法区,或者说用永久代来实现方法区,这样做的目的是HotSpot的GC回收器能够像Java堆一样管理这部分内存,就不用单独为方法区编写一个专门的内存管理工作。
JDK8之后废弃了永久代,改为元空间(Meta Space)。元空间与永久代类似,最大的区别是元空间直接使用本地内存,而不是JVM。因此JDK8过后,元空间就不再会出现OOM问题。
2.6、运行时常量池
运行时常量池是方法区的一部分。class文件中除了有类的版本,字段,方法,接口等描述信息以外,还有常量池表,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
常量池是方法区的一部分,当然如果无法申请到内存时,也会抛出OutOfMemoryError。
3、直接内存
直接内存并不是JVM的内存区域,属于操作系统本身的内存。JDK1.4加入的NIO类,引入了Channel与缓冲区Buffer。它可以直接使用Native函数库直接分配直接内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用来进行操作,可以显著提高性能。
为什么这里要讲直接内存?直接内存虽然不受到Java堆的限制,但是收到了操作系统总内存大小以及处理器寻址空间的限制。 通常我们在用-Xmx设值堆大小信息时,会经常忽略直接内存;有可能使得内存区域大于物理内存限制,而导致动态扩展时出现OOM异常。
直接内存既然不属于Java内存,那么自然也JVM GC也无法回收他。如果需要回收,需要主动调用Unsafe的freeMemory方法。
可以通过-XX:MaxDirectMemorySize来指定直接内存的容量大小,如果不指定,默认与Java堆的最大值一致。
直接内存导致内存溢出,一个明显的特征是在Heap Dump文件中不会看见明显的异常情况,如果发现内存溢出之后产生的Dumo文件很小,而程序中又直接或间接使用了Directmemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因。
4、小结
JVM专栏第一篇。明白了JVM的内存模型,对于JVM内存的一些问题处理应该会更加得心应手(面试唬人)。
参考资料:《深入理解Java虚拟机》 - 第三版