1. JVM自动内存管理机制
Java虚拟机的自动内存管理机制使得我们不用像C、C++那样,需要在程序中考虑如何分配内存以及何时回收它们。这样做的好处是不容易出现内存泄漏和内存溢出问题,但由于Java把内存控制的权利交给了Java虚拟机,一旦出现内存泄露和溢出的问题就会比较难以排查。因此,了解Java的垃圾收集机制是十分有必要的。
总的来说,垃圾收集器需要关注以下三件事情:
-
判断哪些内存是需要回收的; -
什么时候进行垃圾回收; -
如何进行回收。
第一件事情不难理解,在回收对象之前当然是要判断出有哪些对象是可以被回收的,否则后续的工作就无法继续进行。
由于垃圾回收是需要占据一定的计算机资源的,同时在一些步骤中还需要暂停用户线程,所以我们需要在合适的时间进行回收,以免对用户程序造成过大的影响。一般来说,对象实例化时如果没有足够的内存供其申请,就会发生垃圾回收。
要回收对象,就肯定需要具体的回收方法,针对此,不同的垃圾收集器给出了不同的解决方法。总体上来说它们并无“好坏”之分,可以根据具体的场景选择合适的垃圾收集器。
2. 可达性分析算法
当前主流的商用程序语言的内存管理系统都是基于可达性分析算法实现的,Java也不例外。可达性分析算法的功能是判断对象是否存活(即是否可以被回收),其基本思路就是从一些可以判定是存活着的对象出发,搜索它们的引用链,将引用链中涉及到的对象都进行标记,那么剩下的对象就是可以被回收的对象。
总结一下,我们可以知道,可达性分析的关键在于:
2.1 枚举 GC Roots
作为可达性分析的基础操作,我们首先需要枚举出所有的 GC Roots 对象。在 Java 中,固定可以作为 GC Roots 的对象包括:
-
虚拟机栈(栈帧中的本地变量表)中引用的对象,如各个线程被调用的方法栈中使用到的参数、局部变量、临时变量等。 -
在本地方法栈中(即通常所说的Native方法)引用的对象。 -
类中的静态变量所引用的对象; -
常量,如字符串常量等; -
所有被同步锁持有的对象; -
反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还会有其它的对象被加入,共同构成完整GC Roots集合。例如基于分代回收或局部回收的垃圾收集器,每次相当于只对部分内存进行了垃圾回收,那么这时候其它内存区域的对象也需要被考虑进去。
2.2 引用链的搜索
引用链是指以GC Roots为起始节点集,根据引用关系向下进行搜索,搜索过程中所走过的路径就是引用链。也就是说,在引用链上的对象是可达的,而其他不在引用链上的对象就是不可达的。
最简单的方法就是根据当前内存的快照,从GC Roots开始进行引用链的搜索。早起的垃圾收集器(如Serial、ParNew等)就是这么干的。但这种方法有一个问题就是需要基于一个能保障一致性的快照中才能够进行搜索分析,即对象间的引用关系是不能被改变的,否则就可能会引起错误。这就导致在这个过程中需要暂停用户线程,以保持引用的“不变性”。
长时间的暂停用户线程在一些情况下是难以接受的,当然也有一些改进的方法,比如说在同时使用多条线程进行垃圾收集(ParNew、Parallel Scavenge等),但这并不能从根本上解决问题。
想要“解决”这个问题,我们首先需要了解下可达性分析的过程,了解在什么情况下对象间引用关系发现变化后会造成分析的错误。这里规定:
一个简单的所搜中的快照可以是下面这样的:
现在我们考虑这种情况:搜索到上图中灰色对象的时候,它下方的白色对象的引用关系突然改变了,“移动”到了黑色对象上,就像下面这样(红色虚线表示新的引用关系):
这时候就会产生错误,被移动的白色对象明明是“可达”的,但在引用链搜索标记的过程中却没有标记到它,也就是说它会被当做是“垃圾”被收集掉,这样就会产生“对象消失”的问题。
通过上面的分析可以知道,产生“对象消失”问题必须满足以下两个条件:
因此,只要破坏了这两个条件其中的任意一个,就可以解决“对象消失”的问题。由此分别产生了两种解决方案:增量更新(破坏第一个条件)和原始快照(破坏第二个条件)。
以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的(写屏障类似于一个虚拟机层面的AOP操作)。在垃圾收集的过程中,从GC Roots开始搜索并标记的整个过程是耗时较长的,通过增量更新或原始快照,引用链搜索的过程中就无需暂停用户线程(一开始的枚举GC Roots和最后的通过增量更新或原始快照进行重新标记的过程还是需要暂停用户线程的,但这相对来说速度是很快的)。
3. 分代收集理论
当前商业虚拟机的垃圾收集器,大多数都遵循了分代收集理论进行设计,其基于两个符合大多数程序运行实际情况的经验法则:
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
基于收集理论,一般来说可以将Java堆分为新生代和老年代。在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
正如一开始所说的,除了固定的GC Roots,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还会有其它的对象被加入。假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。
但遍历无疑会带来很大的性能负担,这时候就需要分代理论的第三条经验法则:
- 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。
依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。
4. 垃圾收集算法
4.1 标记清除算法
标记清除算法主要分为标记和清除两个阶段:
标记清除算法是后两种(标记复制、标记整理)算法的基础,后面的算法都是基于此进行改进得到的。既然提到了改进,就说明这种算法存在其缺点:
4.2 标记复制算法
标记复制算法基于标记清除算法改进得到,其基本流程如下:
现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代。标记复制算法很好的解决了标记清除算法中内存碎片的问题,但它有一个缺点就是内存没有被充分的利用(总是有一部分内存是空闲的)。但基于之前提到的,新生代中的对象都是朝生夕灭的,我们可以控制“空闲内存”的大小以减轻内存资源的浪费。
Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略:
-
将新生代分为一块较大的Eden空间和两块较小的Survivor空间; -
每次分配内存只是用Eden空间和一块Survivor空间; -
发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。
这种方案很好的控制了内存浪费的问题,每次只有一块较小的Survivor空间是闲置的。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局,其默认的Eden和Survivor的大小比例是8∶1,即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%)。
当然,我们无法完全保证每次垃圾收集后存活的对象都是很少的(即一个Survivor中可以被放下)。因此,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保。
4.3 标记整理算法
标记复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费大量的内存空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的标记整理算法:
标记复制算法的好处是不会产生内存碎片,但它需要移动存活对象并更新其引用,这个过程是需要暂停用户线程的。
5. 常见的垃圾收集器
5.1 Serial & Serial Old
Serial(新生代)/ Serial Old(老年代):
5.2 ParNew
ParNew:
5.3 Parallel Scavenge & Parallel Old
Parallel Scavenge(新生代)/ Parallel Old(老年代):
5.4 CMS(Concurrent Mark Sweep)
CMS:
-
基于标记清除算法 -
是一种以获取最短回收停顿时间为目标的收集器;
基本步骤:
-
初始标记:枚举 GC Roots,仅标记 GC Roots 直接关联的对象,速度很快;(Stop The World) -
并发标记:从 GC Roots 直接关联的对象出发遍历整个对象图,时间很长,但不需要停顿用户线程,且是并发的(具体原理在 2.2 节中有描述); -
重新标记:对并发标记进行修正,时间相对也很快;(Stop The World) -
并发清除:清理标记阶段被判定已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。 -
优点:“并发低停顿收集器”,并发收集、低停顿; -
缺点:
5.5 G1 (Garbage First)
G1:
-
开创了收集器面向局部收集的设计思路和基于Region的内存布局形式,目标是建立可预测的停顿时间模型; -
之前的垃圾收集器收集的范围要么是新生代,要么是老年代,再要么就是整个Java堆;G1则不同,它可以面向堆内存任何部分来组成回收,衡量的标准不再是分代,而是看哪块内存垃圾最多,回收的收益最大; -
虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间; -
Region中还有一类特殊的Humongous区域,专门用来存储大对象; -
G1将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,因此能建立可预测的停顿时间模型。
参考书目
深入理解Java虚拟机(第3版)
|