本篇文章整理一下书中Java虚拟机内存的相关知识。书名:深入理解Java虚拟机,强烈推荐入手一读。
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。为了线程切换后能回复到正确的执行位置,每条线程都有一条独立的程序计数器,各条线程之间互不影响,独立存储,我们称这类内存区域为线程私有内存。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在小hiing的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空。
此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
Java虚拟机栈也是线程私有。虚拟机栈描述的是Java执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈,动态链接,方法出口等信息。每一个方法从调用直至执行完成的过程,就对应这一个栈帧在虚拟机栈中入栈到出栈的过程。
局部变量表所需的内存空间在编译期间就已经分配完成,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
在Java虚拟机规范中,这个区域会抛出两种异常:
本地方法栈为虚拟机使用到的Native方法服务。在虚拟机规范中可以自由实现。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError异常和OutOfMemoryError异常。
对于大多数应用来说,Java堆是Java虚拟机所管理的最内存中最大的一块。Java堆是被所有线程共享的内存区域吗,在虚拟机启动时创建,几乎所有的对象实例都要在这里分配内存。
Java堆是垃圾收集器管理的主要区域,也被称为GC堆。根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上连续即可。如果堆中没有内存完成实例分配,并且堆也无法再扩展是,将会抛出OutOfMemoryError异常。
方法区也是线程共享的内存区域,它用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。方法区使用永久代来实现。如果方法区无法满足内存分配的需求时,将会抛出OutOfMemoryError异常。
运行时常量是方法区的一部分,Java语言并不要求常量一定只有在编译器才产生,运行期间也可能将新的常量放入池中。当常量池无法再申请到内存时,抛出OutOfMemoryError异常。
直接内存并不是虚拟机运行时数据的一部分,也不是Java虚拟机规范中定义的内存区域。在JDK1.4后,使用NIO可以使用Native函数直接分配堆外内存,在一些场景中能显著提高性能,避免了在Java堆和Native堆中来回复制数据。
直接内存的分配不会受到Java堆的限制,但是还是会受到本地总内存的大小以及处理器寻址空间的限制。如果无法申请到内存时会抛出OutOfMemoryError异常。
顺带一提:我们常用的Parcelable序列化使用的就是直接内存,所以效率要比Serializable效率要高。
垃圾回收只要发生在Java堆中。
引用计数法:如果对象被引用,计数器+1,引用失效,计数器-1。因为无法解决对象循环引用或相互引用的问题,所以该方法几乎已经废弃。
可达性分析法:以一系列的GC ROOT为根节点,从这些节点开始向下搜索,搜索所走过的路径成为引用链,当一个对象到GC ROOT没有任何引用链相连时,则证明此对象是不可用的。
在Java语言中,可作为GC ROOT的对象包括以下几种:
JDK1.2后,Javaui引用的概念进行了扩充:
一个对象宣告死亡,至少要经历两次标记过程:
第一个发现对象没有GC ROOT引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize方法,当没有覆盖finalize方法,或者已经执行过finalize方法,视为没有必要执行。
如果一个对象被判断为有必要执行finalize方法,那么这个对象会被放置在一个叫做F-Quene队列中。稍后GC将对F-Quene中的对象进行二次标记,如果finalize方法没有成功拯救自己,对象就会被回收。
因为方法区的回收效率很低,所以JAVA虚拟机规范中说过不要求虚拟机在方法区实现垃圾收集。
永久代的垃圾收集只要回收两部分内容:废弃常量和无用的类。
如果一个常量在常量池中,如果没有被引用,也没有字面量的引用,常量则被废弃回收;
判断一个类是否被回收需要满足三个条件:
虚拟机可以对满足条件的无用类回收,但是仅仅是可以,是否需要被回收虚拟机设置进行控制,在大量使用反射,动态代理,CGLib,等ByteCode框架,动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能,以保证永久代不会溢出。
标记清除算法
首先标记处所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它的不足之处只要有两个:一个是效率问题,标记和清楚两个过程的效率都不高,另一个是空间问题,标记清除之后会产生大量不连续的碎片,空间碎片太多可能会导致以后再程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前出触发另一次垃圾收集动作。
复制算法
为了解决效率问题,复制算法出现了,他将可用内存按容量分为大小相等的两块,每次只是用其中一块,当这一块内存用完了,就将存活的对象复制到另外一块内存上 ,然后把已使用或的内存空间一次清理掉。
现在的商业虚拟机都采用复制算法来回收新生代,将内存分为一块较大的Eden区和两块较小的Survivor空间,每次使用Eden区和一块Survivor区。当回收时,将Eden和Survivor中还存活的独享一次性复制到另一块Survivor上,租后清理掉Eden和刚才的Survivor。
标记整理算法
标记和过程与标记清除算法一样,回收时先让所有存活动向都向一端移动,然后持戒清理掉端边界以外的内存。
分代收集算法
当前商业虚拟机都采用分代收集算法。把Java堆分为新生代和老年代。新生代每次都有大批对象死去,只有少量存活,那就选用复制算法,老年代对象存活率较高,没有额外空间对他进行担保,就必须使用标记清理或标记整理算法。
对象优先在Eden分配
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说就是一个坏消息,经常出现大对象容易导致内存还有不少空间就提前触发垃圾收集以获取足够的连续空间来安置他们。
虚拟机提供配置,可以令大于这个设置值的对象直接进入老年代。这样做的目的是避免在Eden区以及两个Survivor区之间发生大量的内存复制。
长期存活的对象将进入老年代
虚拟机给每个对象定义了一个对象年龄计数器,如果对象在Minor GC后仍然存活并且能被Survivor容纳的话,将被移动到Survivor空间中,并且年龄设置为1,每次在Survivor中熬过一次Minor GC,年龄+1,当年龄增加到一定程度(默认为15岁),就会晋升到老年代。
动态对象年龄判定
为了能更好的适应不同程序的内存情况,虚拟机并不是永远的要求对象必须达到了指定年龄才能晋升到老年代。如果Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入到老年代。
空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看是否允许担保失败,如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均值;如果小于或者不允许冒险,那这时就改为进行一次Full GC。
JDK 6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则就会进行Full GC。