JVM杂谈(三)垃圾回收
程序计数器、虚拟机栈、本地方法栈 3 个区域随线程生灭(因为是线程私有),栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。而 Java 堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期才知道那些对象会创建,这部分内存的分配和回收都是动态的,垃圾回收期所关注的就是这部分内存
哪些对象需要回收
要回收之前肯定需要先判断哪些对象需要回收,通常JVM中采用两种方法,引用计数法与可达性分析算法
引用计数法:给对象添加一个引用计数器,每当有一个地方引用它,计数器就加一;当引用失效的时候,计数器值就减一;任何时刻计数器为0的对象是不可能再被使用的。引用计数算法的实现简单,判定效率也高,在大部分情况还是不错的算法。但是,它很难解决对象之间相互循环引用的问题
可达性分析算法:这个算法的基本思想是通过一系列的称为“GC Roots”的对象作为起始点,从这些起始点所走过的路径称为引用链。当一个对象到“GCRoots”无任何引用链相连的时候,则证明此对象是不可用的。
可作为GC roots的对象包括下面几种:
- 虚拟机栈(栈帧的本地变量表)中引用的对象
- 方法区中的类静态属性引用的对象
- 方法区中的常量引用的对象
- 本地方法栈中JNI(也就是Native方法)引用的对象
TIPS:即使在可达性分析算法中不可达的对象,也并非是非死不可的,也就是还有存活的机会,一个对象的真正死亡至少要经过两次标记过程:如果对象经过可达性分析之后发现没有和GC Roots有关的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是是否有必要执行finalize()方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行
如果这个对象被判定为有必要执行finalize()方法,那么这个对象会被放在一个F-Queue的队列之中,并稍后虚拟机会为其建立一个低优先级的 Finalizer 线程去执行它(切记:这里的执行只是是触发这个方法,并不承诺会等待它运行结束)。finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己 —— 只要重新与引用链上的任何一个对象建立关联即可。finalize() 方法只会被系统自动调用一次
四种引用
前面的两种方式判断存活都和reference引用有关。如果我们希望描述这样一类对象:当内存空间足够的时候,则保留在内存中;如果内存空间在进行垃圾回收之后还是不够用,则可以抛弃这些对象。在JDK1.2之后,Java将引用分为强引用、软引用、弱引用、虚引用四种,这四种引用强度依次减弱
- 强引用:类似于 Object obj = new Object(); 创建的,只要强引用在垃圾回收器就不回收
- 软引用:用于描述一些还有用但非必需的对象。用SoftReference 类实现软引用,在系统要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收。若回收完还是没有足够的内存才会抛出内存溢出正常
- 弱引用:用来描述非必需对象的,强度比软引用更弱一些。用WeakReference 类实现弱引用,对象只能生存到下一次垃圾收集之前。在垃圾收集器工作时,无论内存是否足够都会回收掉只被弱引用关联的对象
- 虚引用:它是一种最弱的引用关系,一个对象的虚引用不会对生存时间构成影响、也无法通过虚引用来取得对象实例。用PhantomReference 类实现虚引用,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知
回收方法区
很多人认为方法区(或者虚拟机中的永久代,注意到此时两者的本质不同,在杂谈一中有提到)是没有垃圾收集的,jvm虚拟机确实未要求虚拟机在方法区实现垃圾回收,而且在方法区进行垃圾回收性价比一般很低。在堆中,尤其是在新生代中,一次垃圾回收一般可以回收 70% ~ 95% 的空间,而方法区的垃圾收集效率远低于此 永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。回收废弃常量和回收Java堆中的对象类似,判断是否还存在引用。而要判定一个类是否是“无用的类”的条件则比较苛刻,需要满足下面三个条件:
- 该类的所有实例都已经回收,也就是Java堆中不存在该类的任何实例
- 加载该类的ClassLoader已经被回收
- 该类对应的java.lang.class对象没有地方引用,也就是没有地方调用该类的类型对象,无法在任何地方通过反射访问该类的方法
满足上面三个条件的无用类可以进行回收,这里仅仅说的是可以,不代表一定会回收。是否对类进行回收,jvm提供了-Xnoclassgc参数进行控制
垃圾回收算法
通常采用标记-清除算法、标记-复制算法、标记-整理算法、分代收集算法
**标记-清除算法:**分为标记和清除两个阶段,首先标记处所以需要回收的对象,在标记完成之后进行统一回收所有被标记的对象。
两个不足:一是效率问题,标记和清除的两个过程的效率都不高;另一个是空间问题,标记和清除之后会产生大量不连续的内存碎片,空间碎片较多导致如果需要分配较大对象时,无法找到足够的连续内存而不得不触发另一次的垃圾收集动作
标记-复制算法:为了解决上述的效率问题,复制算法出现了。复制算法是把空间分成两块,每次只对其中一块进行 GC。当这块内存使用完时,就将还存活的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉
解决了前一种方法的效率问题,但是会存在空间利用率低的问题。因为大多数新生代对象都不会熬过第一次 GC。所以没必要 1 : 1 划分空间。可以分一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另一块 Survivor 上,最后清理 Eden 和 Survivor 空间。大小比例一般是 8 : 1 : 1,每次浪费 10% 的 Survivor 空间
TIPS:注意到,我们无法保证每次回收都只有不到10%的对象存活,怎么办?
- 这里采用的是依靠其它内存(这里指老年代)内存的分配担保,内存分配担保就是当Survivor空间不够用的时候,剩余的对象将会直接通过分配担保机制进入老年代
标记-整理算法:复制收集算法适用于存活率较低的情况,一旦存活率较高,效率将会变低。老年代的存活率很高,于是有了标记-整理算法。标记整理算法的标记过程和标记清除算法一样,但是后续步骤不再是直接对可回收对象进行清理,而是让所有存活的对象都向一边移动,然后直接清理掉边界以外的内存
分代收集算法:根据存活对象划分几块内存区,一般是分为新生代和老年代。然后根据各个年代的特点制定相应的回收算法。对于新生代来说,每次垃圾回收都有大量对象死去,只有少量存活,选用复制算法比较合理。对于老年代来说,老年代中对象存活率较高、没有额外的空间分配对它进行担保。所以必须使用 标记 —— 清除 或者 标记 —— 整理 算法回收
垃圾收集器
介绍垃圾收集器之前先介绍几个概念:
- 并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态
- 并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上
- 吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 ))。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%
收集算法是内存回收的理论,而垃圾回收器是内存回收的实践。
CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,它非常符合那些集中在互联网站或者B/S系统的服务端上的Java应用,这些应用都非常重视服务的响应速度。从名字上(“Mark Sweep”)就可以看出它是基于 “标记-清除”算法实现的
CMS收集器工作流程:
初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”
并发标记:从GC Roots 开始对堆中对象进行可达性分析,找到存活对象,此阶段耗时较长,但 可与用户程序并发执行
重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要“Stop The World”
并发清除:清除掉“死亡”对象
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的
G1收集器
与其他GC收集器相比,G1具备如下特点:
- 并行与并发:G1 能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短“Stop The World”停顿时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行
- 分代收集:G1能够独自管理整个Java堆,并且采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果
- 空间整合:G1运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。此特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC
- 可预测的停顿:G1除了追求低停顿外,还能建立可预测的停顿时间模型。能让使用者明确指定在一个长度为M毫秒的时间段内,消耗在垃圾收集上的时间不得超过N毫秒
在G1之前的其他收集器进行收集的范围都是整个新生代或者老生代,而G1不再是这样。G1在使用时,Java堆的内存布局与其他收集器有很大区别,它 将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但 新生代和老年代不再是物理隔离的了,而都是一部分Region(不需要连续)的集合
如何避免全堆扫描?-- Remembered Set
G1把Java堆分为多个Region,就是把内存“化整为零”的思路。但是Region不可能是孤立的,一个对象分配在某个Region中,可以与整个Java堆任意的对象发生引用关系。在用可达性分析确定对象是否存活的时候,需要扫描整个Java堆才能保证准确性,这显示是对GC效率的极大伤害
为了避免全堆扫描,虚拟机为每一个Region维护了一个与之对应的Remembered Set(看上面算法实现枚举根节点部分)。对于位于不同年代对象之间的引用关系,虚拟机会在程序运行过程中给记录下来。对于“老年代对象引用新生代对象”这种关系,会在引用关系发生时,在新生代边上专门开辟一块空间记录下来并保存到Set中,所以“新生代的 GC Roots ” + “ RememberedSet 存储的内容”,才是新生代收集时真正的 GC Roots 。然后以此来进行可达性分析,垃圾回收
G1收集器运作步骤(不考虑维护Remembered Set的操作)?
1、初始标记:标记一下GC Roots 能直接关联到的对象,修改 TAMS(Nest Top Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可以的Region中创建对象,需要Stop The World
2、并发标记:从GC Root 开始对堆中对象进行可达性分析,找到存活对象,此阶段耗时较长,但 可与用户程序并发执行
3、最终标记:在并发标记期间因用户程序运作而导致标记产生变动的记录,虚拟机将变化记录在线程的Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要Stop The World,但是可并行执行
4、筛选回收:首先对各个Region中的回收价值和成本进行排序,根据用户所期望的GC停顿是时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率
对于内存分配
对象优先在Eden上分配
对象主要分配在新生代的 Eden 区上,如果启动了本地线程分配缓冲区,将线程优先在 (TLAB) 上分配。少数情况会直接分配在老年代中
大对象直接进入老年代
所谓大对象就是需要大量连续空间的Java对象,最典型的大对象就是那种很长的字符串及数组。虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这些设置值的对象直接在老年代中分配。这样做的目的是在避免Eden区及两个Survivor区之间发生大量的内存拷贝
长期存活的对象将进入老年代
虚拟机既然采用分代收集的思想来管理内存,那么内存回收时就必须能够识别哪些对象应当放在新生代,哪些对象应该放在老年代。为了做到这一点,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能够被Survivor容纳的话(容纳不下的话,按照担保原则进入老年代),将被移动到Survivor空间中,并将对象年龄设置为1.对象在Survivor区每熬过一次Minor GC,年龄就增加一岁,当它的年龄增加到一定程度(默认15岁)时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold来设置
动态对象年龄判定
为了能更好地适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间的相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄
空间分配担保
在发生Minor GC时,虚拟机就会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间,若大于,则改为直接进行一次Full GC。若小于,则查看HandlePromotionFailure设置是否允许担保失败;如果允许,那只会进行Minor GC:如果不允许,则也要改为进行一次Full GC
前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用了其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况下,就需要老年代进行分配担保,让Survivor无法容纳的对象直接进入老年代。但是前提老年代本身还有足够空间容纳这些对象。但是实际完成内存回收前是无法知道多少对象存活,所以只好取之前每一次回收晋升到老年代对象容量的平均值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多的空间