一、如何判定对象为垃圾对象
在堆里面存放着Java世界中几乎所有的对象实例, 垃圾收集器在对堆进行回收前, 第一件事就是判断哪些对象已死(可回收).
1. 引用计数法
在JDK1.2之前,使用的是引用计数器算法。 在对象中添加一个引用计数器,当有地方引用这个对象的时候,引用计数器的值就+1,当引用失效的时候,计数器的值就-1,当引用计数器被减为零的时候,标志着这个对象已经没有引用了,可以回收了!
问题:如果在A类中调用B类的方法,B类中调用A类的方法,这样当其他所有的引用都消失了之后,A和B还有一个相互的引用,也就是说两个对象的引用计数器各为1,而实际上这两个对象都已经没有额外的引用,已经是垃圾了。但是该算法并不会计算出该类型的垃圾。
2. 可达性分析法
在主流商用语言(如Java、C#)的主流实现中, 都是通过可达性分析算法来判定对象是否存活的: 通过一系列的称为 GC Roots 的对象作为起点, 然后向下搜索; 搜索所走过的路径称为引用链/Reference Chain, 当一个对象到 GC Roots 没有任何引用链相连时, 即该对象不可达, 也就说明此对象是不可用的, 如下图:虽然E和F相互关联, 但它们到GC Roots是不可达的, 因此也会被判定为可回收的对象。
注: 即使在可达性分析算法中不可达的对象, VM也并不是马上对其回收, 因为要真正宣告一个对象死亡, 至少要经历两次标记过程: 第一次是在可达性分析后发现没有与GC Roots相连接的引用链, 第二次是GC对在F-Queue执行队列中的对象进行的小规模标记(对象需要覆盖finalize()方法且没被调用过).
在Java, 可作为GC Roots的对象包括:
- 方法区: 类静态属性引用的对象;
- 方法区: 常量引用的对象;
- 虚拟机栈(本地变量表)中引用的对象.
- 本地方法栈JNI(Native方法)中引用的对象。
二、如何回收
回收策略
垃圾收集策略有分代收集和分区收集。
分代收集算法
1. 标记-清除算法(老年代)
该算法分为“标记”和“清除”两个阶段: 首先标记出所有需要回收的对象(可达性分析), 在标记完成后统一清理掉所有被标记的对象.
该算法会有两个问题:
- 效率问题,标记和清除效率不高。
- 空间问题: 标记清除后会产生大量不连续的内存碎片, 空间碎片太多可能会导致在运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集。
所以它一般用于"垃圾不太多的区域,比如老年代"。
2. 复制算法(新生代)
该算法的核心是将可用内存按容量划分为大小相等的两块, 每次只用其中一块, 当这一块的内存用完, 就将还存活的对象(非垃圾)复制到另外一块上面, 然后把已使用过的内存空间一次清理掉.
优点:不用考虑碎片问题,方法简单高效。 缺点:内存浪费严重。
现代商用VM的新生代均采用复制算法, 但由于新生代中的98%的对象都是生存周期极短的, 因此并不需完全按照1∶1的比例划分新生代空间, 而是将新生代划分为一块较大的Eden区和两块较小的Survivor区(HotSpot默认Eden和Survivor的大小比例为8∶1), 每次只用Eden和其中一块Survivor. 当发生MinorGC时, 将Eden和Survivor中还存活着的对象一次性地拷贝到另外一块Survivor上, 最后清理掉Eden和刚才用过的Survivor的空间. 当Survivor空间不够用(不足以保存尚存活的对象)时, 需要依赖老年代进行空间分配担保机制, 这部分内存直接进入老年代。
复制算法的空间分配担保: 在执行Minor GC前, VM会首先检查老年代是否有足够的空间存放新生代尚存活对象, 由于新生代使用复制收集算法, 为了提升内存利用率, 只使用了其中一个Survivor作为轮换备份, 因此当出现大量对象在Minor GC后仍然存活的情况时, 就需要老年代进行分配担保, 让Survivor无法容纳的对象直接进入老年代, 但前提是老年代需要有足够的空间容纳这些存活对象. 但存活对象的大小在实际完成GC前是无法明确知道的, 因此Minor GC前, VM会先首先检查老年代连续空间是否大于新生代对象总大小或历次晋升的平均大小, 如果条件成立, 则进行Minor GC, 否则进行Full GC(让老年代腾出更多空间). 然而取历次晋升的对象的平均大小也是有一定风险的, 如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然可能导致担保失败(Handle Promotion Failure, 老年代也无法存放这些对象了), 此时就只好在失败后重新发起一次Full GC(让老年代腾出更多空间).
3. 标记-整理算法(老年代)
标记清除算法会产生内存碎片问题, 而复制算法需要有额外的内存担保空间, 于是针对老年代的特点, 又有了标记整理算法. 标记整理算法的标记过程与标记清除算法相同, 但后续步骤不再对可回收对象直接清理, 而是让所有存活的对象都向一端移动,然后清理掉端边界以外的内存.
4. 方法区回收(永久代)
在方法区进行垃圾回收一般”性价比”较低, 因为在方法区主要回收两部分内容: 废弃常量和无用的类.
回收废弃常量与回收其他年代中的对象类似, 但要判断一个类是否无用则条件相当苛刻:
- 该类所有的实例都已经被回收, Java堆中不存在该类的任何实例;
- 该类对应的Class对象没有在任何地方被引用(也就是在任何地方都无法通过反射访问该类的方法);
- 加载该类的ClassLoader已经被回收. 但即使满足以上条件也未必一定会回收, Hotspot VM还提供了-Xnoclassgc参数控制(关闭CLASS的垃圾回收功能). 因此在大量使用动态代理、CGLib等字节码框架的应用中一定要关闭该选项, 开启VM的类卸载功能, 以保证方法区不会溢出.
分区收集
分区算法则将整个堆空间划分为连续的不同小区间, 每个小区间独立使用, 独立回收. 这样做的好处是可以控制一次回收多少个小区间
在相同条件下, 堆空间越大, 一次GC耗时就越长, 从而产生的停顿也越长. 为了更好地控制GC产生的停顿时间, 将一块大的内存区域分割为多个小块, 根据目标停顿时间, 每次合理地回收若干个小区间(而不是整个堆), 从而减少一次GC所产生的停顿
三、垃圾回收器
Serial:Serial收集器是Hotspot运行在Client模式下的默认新生代收集器, 它在进行垃圾收集时,会暂停所有的工作进程,用一个线程去完成GC工作
特点:简单高效,适合jvm管理内存不大的情况(十兆到百兆)。
Parnew:ParNew收集器其实是Serial的多线程版本,回收策略完全一样,但是他们又有着不同。
我们说了Parnew是多线程gc收集,所以它配合多核心的cpu效果更好,如果是一个cpu,他俩效果就差不多。(可用-XX:ParallelGCThreads参数控制GC线程数)
Cms:CMS(Concurrent Mark Sweep)收集器是一款具有划时代意义的收集器, 一款真正意义上的并发收集器, 虽然现在已经有了理论意义上表现更好的G1收集器, 但现在主流互联网企业线上选用的仍是CMS(如Taobao),又称多并发低暂停的收集器。
由他的英文组成可以看出,它是基于标记-清除算法实现的。整个过程分4个步骤:
- 初始标记(CMS initial mark):仅只标记一下GC Roots能直接关联到的对象, 速度很快
- 并发标记(CMS concurrent mark: GC Roots Tracing过程)
- 重新标记(CMS remark):修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录
- 并发清除(CMS concurrent sweep: 已死对象将会就地释放)
可以看到,初始标记、重新标记需要STW(stop the world 即:挂起用户线程)操作。因为最耗时的操作是并发标记和并发清除。所以总体上我们认为CMS的GC与用户线程是并发运行的。
优点:并发收集、低停顿
缺点:
- CMS默认启动的回收线程数=(CPU数目+3)*4 当CPU数>4时, GC线程最多占用不超过25%的CPU资源, 但是当CPU数<=4时, GC线程可能就会过多的占用用户CPU资源, 从而导致应用程序变慢, 总吞吐量降低.
- 无法清除浮动垃圾(GC运行到并发清除阶段时用户线程产生的垃圾),因为用户线程是需要内存的,如果浮动垃圾施放不及时,很可能就造成内存溢出,所以CMS不能像别的垃圾收集器那样等老年代几乎满了才触发,CMS提供了参数-XX:CMSInitiatingOccupancyFraction来设置GC触发百分比(1.6后默认92%),当然我们还得设置启用该策略-XX:+UseCMSInitiatingOccupancyOnly
- 因为CMS采用标记-清除算法,所以可能会带来很多的碎片,如果碎片太多没有清理,jvm会因为无法分配大对象内存而触发GC,因此CMS提供了-XX:+UseCMSCompactAtFullCollection参数,它会在GC执行完后接着进行碎片整理,但是又会有个问题,碎片整理不能并发,所以必须单线程去处理,所以如果每次GC完都整理用户线程stop的时间累积会很长,所以XX:CMSFullGCsBeforeCompaction参数设置隔几次GC进行一次碎片整理(默认为0)。
G1:同优秀的CMS垃圾回收器一样,G1也是关注最小时延的垃圾回收器,也同样适合大尺寸堆内存的垃圾收集,官方也推荐使用G1来代替选择CMS。G1最大的特点是引入分区的思路,弱化分代的概念,合理利用垃圾收集各个周期的资源,解决了其他收集器甚至CMS的众多缺陷。
因为每个区都有E、S、O代,所以在G1中,不需要对整个Eden等代进行回收,而是寻找可回收对象比较多的区,然后进行回收(虽然也需要STW操作,但是花费的时间是很少的),保证高效率。
新生代收集
G1的新生代收集跟ParNew类似,如果存活时间超过某个阈值,就会被转移到S/O区。
年轻代内存由一组不连续的heap区组成, 这种方法使得可以动态调整各代区域的大小
老年代收集
分为以下几个阶段:
- 初始标记 (Initial Mark: Stop the World Event) 在G1中, 该操作附着一次年轻代GC, 以标记Survivor中有可能引用到老年代对象的Regions.
- 扫描根区域 (Root Region Scanning: 与应用程序并发执行) 扫描Survivor中能够引用到老年代的references. 但必须在Minor GC触发前执行完
- 并发标记 (Concurrent Marking : 与应用程序并发执行) 在整个堆中查找存活对象, 但该阶段可能会被Minor GC中断
- 重新标记 (Remark : Stop the World Event) 完成堆内存中存活对象的标记. 使用snapshot-at-the-beginning(SATB, 起始快照)算法, 比CMS所用算法要快得多(空Region直接被移除并回收, 并计算所有区域的活跃度).
- 清理 (Cleanup : Stop the World Event and Concurrent) 在含有存活对象和完全空闲的区域上进行统计(STW)、擦除Remembered Sets(使用Remembered Set来避免扫描全堆,每个区都有对应一个Set用来记录引用信息、读写操作记录)(STW)、重置空regions并将他们返还给空闲列表(free list)(Concurrent)