上一篇主要讲解的是JVM内存管理,内存分区,在本篇博客中主要讲解的是垃圾收集器以及内存分配策略。
1、概述
JAVA语言中,JVM内存管理都是“自动化”的,为啥还需要继续关注JVM内存管理呢?原因很简单,JVM内存管理不是万能的,也会出现内存泄漏以及内存溢出等问题,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对JVM内存管理进行监控、干预。
由上一篇博客知道,JVM内存分区主要分为5部分,它们分别是:1、程序计数器;2、虚拟机栈;3、本地方法栈;4、JAVA堆;5、方法区。其中1、程序计数器;2、虚拟机栈;3、本地方法栈,这三个区域随线程的创建和灭亡,栈中的栈帧随着方法的进入和退出有条不紊的执行着出栈和入栈操作,每一个栈帧中分配多少内存基本上是在类结构确定下来时就知道的,因此这几个区域的内存分配和回收都具有确定性,在这几个区域就不需要过多考虑内存回收的问题,因为方法结束或者线程结束,内存就自然释放了。
但是JAVA堆和方法区则不一样,这两块区域是所有线程共享的,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收是动态的,垃圾回收器所关心的也正是这部分内容。
2、对象的存活判断
垃圾收集器在对堆里面的对象进行垃圾回收前,需要确定哪些对象是存活的,哪些对象是死亡的,只能回收死亡的对象。
2.1、引用计数法
引用计数法原理:
给对象中添加一个引用计数器,每次有一个地方引用它时,计数器就+1,当引用失效时,计数器就-1,任何时刻计数器为0的对象就是不可能被再次使用。
客观的说引用计数法实现比较简单,判断效率也很高,但是在主流的JAVA虚拟机里面没有采用引用计数法来管理内存,其主要原因就是无法解决对象之间的互相循环引用的问题。
2.2、可达性分析法
可达性分析法的原理:
通过一系列称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为“引用链”,当一个对象到GC Roots没有任何引用链相连时,则说明该对象是不可用的。
在JAVA语言中,可作为GC Roots的对象包括以下几种:
- 1、虚拟机栈(栈帧中的本地变量表)中引用的对象
- 2、方法区中类静态属性引用的对象
- 3、方法区中常量引用的对象
- 4、本队方法栈中JNI(即一般所说的Native方法)引用的对象
2.3、再谈引用
以上的引用计数法和可达性分析法来判断对象的存活时,都会用到“引用”有关.
在JDK 1.2之前,JAVA中的引用定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表一个引用。这种定义很狭隘,一种对象在这种定义下只有被引用和没有被引用两种状态,而对于一些“食之无味,弃之可惜”的对象就显得无能为力。
我们希望能描述这样一些对象:当内存空间还足够时,则能保留在内存之中,如果内存空间在进行垃圾收集后还比较紧张,则可以抛弃这些对象。
在JDK 1.2之后,JAVA对引用的概念进行了扩充,将引用分为4种,1、强引用;2、软引用;3、弱引用;4、虚引用。这4种引用强度逐次递减。
1、强引用
类似于Object obj = new Object(),这类引用只要还在,垃圾回收器就不会回收
2、软引用
用来描述一些还有用但非必需的对象
3、弱引用
用来描述一些还有用但非必需的对象
4、虚引用
最弱的一种引用,,不能通过改引用获取一个对象的实例,也不会对其生存空间造成影响,它的唯一作用就是在这个对象被收集器回收时,收到一个系统通知。
2.4、判断对象存活的流程
即使在可达分析法中不可达的对象,也并非是“非死不可”的,这时候这些对象处于“缓刑”阶段,要真正宣告一个对象死亡,其至少要经历两次标记过程,具体过程如下:
如果对象在经过可达分析法后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为有必要执行finalize()方,那么这个对象将会放置到一个F-Queue的队列之中,并在稍后由一个虚拟机自动建立的、低优先级的Finalize线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中其他对象永远处于等待,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC会对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己(重新与引用链中的任何一个对象建立关联即可),那么第二次标记时,该对象将被移除“即将回收”的集合,如果该对象这时候还没有逃脱,那该对象基本就被回收了。
2.5、回收方法区
方法区(在HotSpot虚拟机中叫做永久代)也是有垃圾回收的,只不过性价比比较低,在堆中,尤其是新生代,常规进行一次垃圾回收,一般回收70%--95%的空间,而在方法区的垃圾回收效率远低于此。
方法区中垃圾回收主要是两部分内容:1、废弃常量;2、无用的类。
判断一个常量是否是“废弃常量”比较简单,而要判断一个类是否是无用的类的条件比较复杂,需同时满足以下三点:
1)该类所有的实例都被回收
2)加载该类的ClassLoader已经被回收
3)该类对应的java.lang.Class对象没有任何地方被引用。
3、垃圾回收算法
3.1、标记-清除算法
最基础的收集算法是“标记-清除”算法,该算法主要分为“标记”和“清除”两个阶段:首先标记所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
问题:
1)效率问题
标记和清除两个过程的效率都不高
2)空间问题
标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得已提前触发另一次垃圾收集动作。
3.2、复制算法
为了解决效率问题,出现了复制算法,该算法将内存容量分为大小相等的两块,每次使用其中的一块,当这一块的内存用完了,就将还存活的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。
现在JAVA堆中新生代内存区域采用复制算法,但是并不是按照1:1的比例来划分内存的,而是将内存划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor,当回收时,将Eden和Survivor中还存活的对象一次性的复制到另一块Survivor空间上,最后清理掉Eden和Survivor内存空间。HotSpot默认的Eden和Survivor的比例是8:1.。
这样做的好处就是只有10%的内存会被浪费,但是如果当90%以上的对象可回收时,这时候Survivor的内存空间会不够用,需要依赖其他内存*(老年代)进行分配担保。
3.3、标记-整理算法
复制算法在对象存活率较多的时候就要进行较多的复制操作,效率将会变得低下,更关键的是,如果不想浪费50%的空间,就需要额外的空间进行分配担保,所以老年代一般不能直接选用这种算法。
由老年代的特点,提出了另一种算法----“标记--整理”算法,标记过程和“标记--清除”算法一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
3.4、分代收集算法
当前商业虚拟机都采用的是“分代收集”,该算法没有新的思想,只是根据对象的存活周期采用上述的几种算法。
4、HotSpot算法实现
上面讲述的都是从理论层面来说明垃圾回收的相关问题,而在实际的虚拟机中如何实现这些算法时,需要有更加严格、完善的考量,下面拿HotSpot的算法来说明。
4.1、枚举根节点
由可达分析法可知,判断对象是否存活时,需要从GC Roots节点找,可作为GC Roots的节点有1、全局性引用(例如常量或者类静态属性);2、执行上下文(例如栈帧中的本地变量表),因此GC Roots容量非常大,如果逐个检查这里面的引用,那么会消耗很长时间,将会导致上时间的GC停顿,这是绝对不能允许的。
由于目前的主流JAVA虚拟机使用的都是准确式的GC,虚拟机应当知道哪些地方存放着对象的引用,在HotSpot的实现中,是使用一组成为OopMap的数据结构来达到这个目的的 。
4.2、安全点
在OopMap的协助下,HotSpot可以快速且准确的完成GC Roots枚举,但一个很现实的问题是:引用关系变化,或者OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那么将会产生大量的额外空间,这样GC的空间成本会很大。
实际上,HotSpot也的确没有为每条指令都生成OopMap,只是在“特殊位置”记录了这些信息,这些位置称为安全点,及程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。
安全点的选定标准:“是否具有让程序长时间执行的特征”为标准选定的。如方法调用、循环跳转等-
4.3、安全区域
未完,待续