垃圾收集算法
一、 标记-清除算法(Mark-Sweep)
算法分为“标记”和“清除”两个阶段,首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。标记判定为可达性分析算法。
不足之处有两个:
- 效率问题,标记和清除两个过程的效率都不高;
- 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太对可能会导致以后在程序运行的过程中需要分配较大的对象时,无法找到足够的连续内存而不得不提前出发另一次垃圾收集动作。
二、 复制算法(Copying)
为了解决效率问题,出现了复制算法,将可用的容量划分为大小相等的两块,每次使用其中一块,当这块内存用完了,就将还存货的对象复制到另一块上面,然后再把已使用的内存空间一次性清理掉。,内存分配不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。算法代价为内存缩小到原来的一半。
三、 标记-整理算法(Mark-Compact)
复制算法在对象存活率较高时,就要进行较多的复制操作,效率会变低。更关键的是会浪费50%空间。所以老年代一般不能直接选用这种算法。
根据老年代的特点,就提出了一种“标记-整理”算法,整理不是直接对可回收对象进行清理,而是让所有存活对象向一端移动,然后直接清理掉端边界以外的内存。
四、分代收集算法(Generational Collection)
根据对象存活周期的不同,将内存划分为几块。一般是新生代和年老代。新生代采用复制算法。老年代使用“标记—清理”或者“标记-整理”算法。
新生代中的对象98%都是“朝生夕死”的,所以不需要1:1比例来划分内存空间,而是将内存分为一块较大的Eden空间和两个较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden区和Survivor还存活着的对象一次性地赋值到另外一块Survivor空间上,然后清理到Eden和刚刚用过的Survivor空间。
HotSpot算法实现
1、枚举根节点
可达性分析有一个问题就是会导致GC进行时必须停止Java执行线程,因为枚举根节点的时候对象关系不断变化时,分析结果准确性就不能得到保证。即时是CMS收集器,枚举根节点也是必须要停顿的。
在HotStop的实现中,使用一组称为OooMap数据结构来达到这个目的,在类加载的时候HotStop就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样GC在扫描的时候就可以直接得知这些信息了。
2、安全点及安全区域
在OopMap的协助下,HotSpot可以快速且准确的完成GC Roots的枚举,但是HotSpot并没有为每条指令都生成OopMap,因为这样需要的额外空间太多。
而是在特定的位置记录了这些信息,这些位置称为安全点(Safepoint),即程序执行的时候并非在所有的地方都能停顿下来,而是在到达安全段时才能暂停。
安全点的选定基于“是否具有让程序长时间执行的特征”进行选定的。因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长而过长时间运行,“长时间运行”最明显的特征就是指令序列复用。例如方法调用,循环跳转,异常跳转等,具有这种功能的指令才会产生Safepoint。
还有一个问题,如何在GC时使所有的线程都跑到最近的安全点再停顿下来。两个方案
- 抢先式中断
不需要线程主动配合,而是先把所有线程都中断,然后如果不在安全段就恢复线程,让它跑到安全点。现在基本没有这种实现方式。 - 主动式中断
当GC需要中断线程时,不直接对线程操作,而是在安全点以及创建对象需要分配内存的地方设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断线程挂起。
安全点并没有解决,程序不执行时如何跑到安全点的问题。因为这种一般是线程处于sleep状态或者Blocked状态,这个时候线程无法相应JVM的中断请求。
这个时候就需要安全区域了,安全区域是指一段代码片段中,引用关系不会发生变化,这个区域任意时刻开始GC都是安全的。安全区域可以看作是安全点的扩展。
当线程执行到安全区域的代码时,首先标识自己进入安全区域了,这时当GC时,JVM就不用管标识为安全区域状态的线程了。
垃圾收集器
图中展示了7种不同分代的收集器:
Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1;
而它们所处区域,则表明其是属于新生代收集器还是老年代收集器:
- 新生代收集器:Serial、ParNew、Parallel Scavenge;
- 老年代收集器:Serial Old、Parallel Old、CMS;
- 整堆收集器:G1;
两个收集器间有连线,表明它们可以搭配使用:
Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;
并发垃圾收集和并行垃圾收集的区别
- 并行(Parallel)
指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态;
如ParNew、Parallel Scavenge、Parallel Old; - 并发(Concurrent)
指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行);
用户程序在继续运行,而垃圾收集程序线程运行于另一个CPU上;
如CMS、G1(也有并行)。
Minor GC和Full GC的区别
- Minor GC又称新生代GC,指发生在新生代的垃圾收集动作;
因为Java对象大多是朝生夕灭,所以Minor GC非常频繁,一般回收速度也比较快; - Full GC又称Major GC或老年代GC,指发生在老年代的GC;
出现Full GC经常会伴随至少一次的Minor GC(不是绝对,Parallel Sacvenge收集器就可以选择设置Major GC策略);
Major GC速度一般比Minor GC慢10倍以上;
1、 Serial收集器
Serial收集器是最基本、发展历史最悠久的收集器。
特点
- 针对新生代
- 采用复制算法
- 单线程收集
-
进行垃圾回收时,必须暂停所有工作线程,直到完成(Stop The World)。
优点以及应用场景
- 虚拟机运行在client模式下默认新生代收集器
- 简单高效(与其他收集器的单线程比)
参数设置
“-XX:UseSerialGC”:添加该参数来显式的使用串行垃圾收集器;
2、ParNew收集器
ParNew收集器是Serial收集器的多线程版本。
特点
- 除了使用多线程进行垃圾收集以外,其余行为和特点和Serial收集器一样
-
两者共用了很多相同的代码
应用场景
- 在server模式下,ParNew是一个很重要的收集器,因为除了Serial收集器,目前只有它能够与CMS收集器配合工作。
- 在单个cup的环境下,ParNew效率比Serial低,因为存在线程交互开销。
设置参数
- "-XX:+UseConcMarkSweepGC":指定使用CMS后,会默认使用ParNew作为新生代收集器;
- "-XX:+UseParNewGC":强制指定使用ParNew;
- "-XX:ParallelGCThreads":指定垃圾收集的线程数量,ParNew默认开启的收集线程与CPU的数量相同;
为什么除了Serial只有ParNew能与CMS收集器配合
- CMS作为老年代收集器,但却无法与JDK1.4已经存在的新生代收集器Parallel Scavenge配合工作;
- 因为Parallel Scavenge(以及G1)都没有使用传统的GC收集器代码框架,而另外独立实现;而其余几种收集器则共用了部分的框架代码;
3、 Parallel Scavenge收集器
Parallel Scavenge收集器是一个新生代收集器,也是使用复制算法以及并行的多线程收集器。它与吞吐量密切相关,也称为吞吐量收集器(ThroughPut Collector)
特点
- 前面写的,与ParNew相似,新生代、复制、多线程收集;
- 它的关注点与其他收集器不同,其他都是尽可能地缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可以控制的吞吐量(Throughput)。
吞吐量=运行用户代码时间/(运行用户代码时间+垃圾回收时间)
应用场景
- 高吞吐量为目标,可以高效率地利用CUP时间,尽快地完成计算任务,主要适合后台运行且不需要太多交互的任务。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。
设置参数
- "-XX:MaxGCPauseMillis":控制最大垃圾收集停顿时间,大于0的毫秒数;
- "-XX:GCTimeRatio": 设置垃圾收集时间占总时间的比率,相当于吞吐量的倒数,0
垃圾收集执行时间占应用程序执行时间的比例的计算方法是:
1 / (1 + n)
例如,选项-XX:GCTimeRatio=19,设置了垃圾收集时间占总时间的5% -- 1/(1+19);
默认值是1%--1/(1+99),即n=99; - "-XX:+UseAdptiveSizePolicy":采用自适应调节策略
开启这个参数后,就不用手工指定一些细节参数,如:新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX: PretenureSizeThreshold)等;
JVM会根据当前系统运行情况收集性能监控信息,动态调整这些参数,以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomiscs)
这是一种值得推荐的方式: - 只需设置好内存数据大小(如"-Xmx"设置最大堆);
- 然后使用"-XX:MaxGCPauseMillis"或"-XX:GCTimeRatio"给JVM设置一个优化目标;
- 那些具体细节参数的调节就由JVM自适应完成;
这也是Parallel Scavenge收集器与ParNew收集器一个重要区别;
四、Serial Old收集器
Serial Old是 Serial收集器的老年代版本;
特点
- 针对老年代;
- 采用"标记-整理"算法(还有压缩,Mark-Sweep-Compact);
- 单线程收集;
应用场景
- 主要应用于Client模式;
- 在JDK1.5及之前,与Parallel Scavenge收集器搭配使用(JDK1.6有Parallel Old收集器可搭配;
- 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用
五、 Parallel Old收集器
Parallel Old垃圾收集器是Parallel Scavenge收集器的老年代版本;JDK1.6中才开始提供;
特点
- 针对老年代;
- 采用"标记-整理"算法;
-
多线程收集;
应用场景
- JDK1.6及之后用来代替老年代的Serial Old收集器;
- 特别是在Server模式,多CPU的情况下;
- 这样在注重吞吐量以及CPU资源敏感的场景,就有了Parallel Scavenge加Parallel Old收集器的"给力"应用组合;
设置参数
- "-XX:+UseParallelOldGC":指定使用Parallel Old收集器;
六、 CMS收集器(Concurrent Mark Sweep)
并发标记清理(Concurrent Mark Sweep,CMS)收集器也称为并发低停顿收集器(Concurrent Low Pause Collector)或低延迟(low-latency)垃圾收集器。
特点
- 针对老年代;
- 基于标记-清除算法(不进行压缩操作,会产生内存碎片);
-以获取最短回收停顿时间为目标; - 并发收集、低停顿
- 需要更多的内存
应用场景
- 与用户交互场景较多的场景
- 希望系统停顿世家年最短,注重服务的响应时间
- 比如场景的web,B/S系统的服务器上的应用
设置参数
"-XX:+UseConcMarkSweepGC":指定使用CMS收集器;
CMS收集器运行过程
- 1、 初始标记(CMS initial mark)
仅标记一下GC Roots能直接关联到的对象,速度很快,但是会Stop The World。 - 2 、并发标记(CMS concurrent mark)
进行GC Roots Tracing的过程,标记出刚才产生的集合中活的对象,应用程序在运行,并不能保障可以标记所有的存活对象。 - 3、重新标记(CMS remark)
为了修正上一步并发标记期间因用户程序继续运行导致标记变动的那部分对象的标记记录;
需要Stop The World 停顿时间比初始标记稍长,但远比并发标记时间短;
采用多线程并行执行来提升效率。 - 4、并发清除(CMS concurrent sweep)
回收所有垃圾对象。
整个过程中耗时最长的并发标记和并发清除都可以与用户线程一起工作;所以总体上说,CMS收集器的内存回收过程与用户线程一起并发执行;
缺点
- 1、对CPU资源非常敏感
并发收集虽然不会暂停用户线程,但因为占用一部分CPU资源,还是会导致应用程序变慢,总吞吐量降低。
CMS的默认收集线程数量是=(CPU数量+3)/4;
当CPU数量多于4个,收集线程占用的CPU资源多于25%,对用户程序影响可能较大;不足4个时,影响更大,可能无法接受。 - 2、无法处理浮动垃圾
在并发清除时,用户线程新产生的垃圾,称为浮动垃圾;只能留到下次GC清理,这使得并发清除时需要预留一定的内存空间给用户线程使用,不能像其他收集器在老年代几乎填满再进行收集;
可以认为CMS所需要的空间比其他垃圾收集器大;
"-XX:CMSInitiatingOccupancyFraction":设置CMS预留内存空间;- JDK1.5默认值为68%;
- JDK1.6变为大约92%;
- 可能出现"Concurrent Mode Failure"失败而导致另一次Full GC 。
如果CMS预留内存空间无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败;
这时JVM启用后备预案:临时启用Serail Old收集器,而导致另一次Full GC的产生;
这样的代价是很大的,所以CMSInitiatingOccupancyFraction不能设置得太大。 - 3、产生大量内存碎片
由于CMS基于"标记-清除"算法,清除后不进行压缩操作;
产生大量不连续的内存碎片会导致分配大内存对象时,无法找到足够的连续内存,从而需要提前触发另一次Full GC动作。- 解决办法
- "-XX:+UseCMSCompactAtFullCollection":默认开启
使得CMS出现上面这种情况时不进行Full GC,而开启内存碎片的合并整理过程;
但合并整理过程无法并发,停顿时间会变长; - "-XX:+CMSFullGCsBeforeCompaction":设置执行多少次不压缩的Full GC后,来一次压缩整理;
为减少合并整理过程的停顿时间;
默认为0,也就是说每次都执行Full GC,都会进行压缩整理;
总结
总体来看,与Parallel Old垃圾收集器相比,CMS减少了执行老年代垃圾收集时应用暂停时间;
但却增加了新生代垃圾收集时应用暂停的时间、降低了吞吐量而且需要占用更大的堆空间;
七、G1收集器(Garbage-First)
特点
- 1、并行与并发
- 面向服务器应用的收集器
- 能充分利用多CPU、多核环境下的硬件优势;
- 可以并行来缩短"Stop The World"停顿时间;
- 也可以并发让垃圾收集与用户程序同时进行;
- 2、分代收集
能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;能够采用不同方式处理不同时期的对象;
虽然保留分代概念,但Java堆的内存布局有很大差别 将整个堆划分为多个大小相等的独立区域(Region);新生代和老年代不再是物理隔离,它们都是一部分Region(不需要连续)的集合; - 3、 结合多种垃圾收集算法,空间整合,不产生碎片
从整体看,是基于标记-整理算法;从局部(两个Region间)看,是基于复制算法;
这是一种类似火车算法的实现;都不会产生内存碎片,有利于长时间运行; - 4、 可预测的停顿:低停顿的同时实现高吞吐量
G1除了追求低停顿处,还能建立可预测的停顿时间模型;
可以明确指定M毫秒时间片内,垃圾收集消耗的时间不超过N毫秒;
应用场景
- 面向服务端应用,针对具有大内存、多处理器的机器;
- 最主要的应用是为需要低GC延迟,并具有大堆的应用程序提供解决方案;
- 如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;
- 用来替换掉JDK1.5中的CMS收集器;
设置参数
- "-XX:+UseG1GC":指定使用G1收集器;
- "-XX:InitiatingHeapOccupancyPercent":当整个Java堆的占用率达到参数值时,开始并发标记阶段;默认为45;
- "-XX:MaxGCPauseMillis":为G1设置暂停时间目标,默认值为200毫秒;
- "-XX:G1HeapRegionSize":设置每个Region大小,范围1MB到32MB;目标是在最小Java堆时可以拥有约2048个Region;
为什么G1收集器可以实现可预测的停顿?
- 1、可以有计划地避免在java堆上进行全区域的垃圾收集;
G1跟踪各个Region获得其收集价值大小(回收所获得的空间大小以及回收所需要时间的经验值),在后台维护一个优先列表;每次根据允许的收集时间,优先回收价值最大的Region(名称Garbage-First的由来)。这就保证了在有限的时间内可以获取尽可能高的收集效率;
一个对象被不同区域引用的问题
判断对象存活时,是否需要扫描整个Java堆才能保证准确?在其他的分代收集器,也存在这样的问题(而G1更突出):新生代回收的时候不得不扫描老年代?
无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描:
1、每个Region都有一个对应的Remembered Set;
2、 每次Reference类型数据写操作时,都会产生一个Write Barrier暂时中断操作;
3、然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的 Region(其他收集器:检查老年代对象是否引用了新生代对象);
4、如果不同,通过CardTable把相关引用信息记录到引用指向对象所在Region对应的Remembered Set中;
5、进行垃圾收集时,在GC Roots的枚举范围加入Remembered Set,就可以保证不进行全局扫描,也不会有遗漏。
G1收集器运行过程
- 1、初始标记(Initial Marking)
仅标记一下GC Roots能直接关联到的对象;并且修改TAMS(Next Top at Mark Start),让下一阶段并发标记时,用户程序能够在正确可用的Region中创建新的对象。需要"Stop The World",但速度很快; - 2 、并发标记(Concurrent Marking)
- 进行GC Roots Tracing的过程;
- 刚才产生的集合中标记出存活对象;
- 耗时较长,但应用程序也在运行;
- 并不能保证可以标记出所有的存活对象;
- 3、“最终标记”(Final Marking)
为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录; 上一阶段对象的变化记录在线程的Remembered Set Log;这里把Remembered Set Log合并到Remembered Set中;
需要"Stop The World",且停顿时间比初始标记稍长,但远比并发标记短;采用多线程并行执行来提升效率; - 4、 筛选回收(Live Data Counting and Evacuation)
- 首先排序各个Region的回收价值和成本;
- 然后根据用户期望的GC停顿时间来制定回收计划;
- 最后按计划回收一些价值高的Region中垃圾对象;
回收时采用"复制"算法,从一个或多个Region复制存活对象到堆上的另一个空的Region,并且在此过程中压缩和释放内存;可以并发进行,降低停顿时间,并增加吞吐量;
这里有一个疑问 筛选回收时会Stop The World嘛?
深入理解JAVA虚拟机第二版中是会的。
内存分配与回收策略
对于Client模式下的JVM来说(只有32位的JDK安装才有Client模式的JVM,64位的JDK只有Server模式的JVM),默认的新生代和老年代的垃圾收集器是单线程的Serial(复制算法,并且存在担保机制,默认Eden区和Survivor是8:1)和Serial Old(标记-整理算法)。下面将研究,在这种组合的情况下,JVM的内存分配和回收策略(ParNew和Serial的组合也差不多)。
- 对象优先分配到新生代的Eden区
- 大对象直接进入老年代:
所谓的大对象就是指需要大量连续内存空间的JAVA对象,最典型的大对象就是那种很长的字符串和数组。经常产生大对象容易导致额外的GC操作,JVM中提供了一个-XX:PretenureSizeThreshold参数(这个参数只对Serial和ParNew这两个新生代垃圾收集器有效),令大于这个参数的对象直接在老年代中分配,这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝。为什么?就在于Serial使用的是复制算法。 - 长期存活的对象将进入老年代
JVM产生一个对象的时候,首先将其放在新生代的Eden区中,并且随着mirror GC的产生,大部分的对象都被回收了,那么“熬过”这次GC的对象呢?JVM给了每个对象一个“年龄计数器”,所谓的年龄计数器就是指,这个对象熬过第一次GC,并且进入了Survivor区中,那么就将这个对象的年龄设为1,之后,每熬过一次GC,年龄+1,当这个值到达一个阀值(默认15,可通过-XX:MaxTenuringThreshold来设置)时,这个对象就会被移到老年代中。 - 动态对象年龄判断
为了更好适应不同程序的内存状况,JVM也不是要一个对象必须满足MaxTenuringThreshold设置的年龄阀值才能进入老年代。如果Survivor中的对象满足同年龄(比如N)对象所占空间达到了Survivor总空间的一半的时候,那么年龄大于或者等于N的对象都可以进入老年代,无需等待阀值。 - 空间分配担保
新生代采用复制算法,会造成空间的浪费,故而提出了一种“空间担保机制”来提高复制算法的空间利用率,使复制算法的浪费从50%降到了10%。而老年代的内存就充当了这个担保者,并且由于没有其他内存来担保老年代,所以老年代如果不想产生空间内存碎片那么只能使用“标记-整理”算法。
如何保证老年代有足够的空间来执行空间担保机制呢?Full GC,是否触发根据经验值判断,即使不允许担保失败,也有可能发生担保失败。
当发生YGC的时候,JVM都会检测之前每次晋升到老年代的对象的平均大小是否大于老年代的剩余内存空间,如果大于,则触发Full GC;如果小于,则查看HandlePromotionFailure设置是否允许担保失败;如果允许,则不会触发Full GC,反之,触发Full GC,保证老年代有足够的空间支持空间分配担保成功。
在每次GC发生的时候,我们也不知道到底会有多少对象被回收,又有多少对象能存活。故而只好取之前每次回收晋升到老年代的对象的平均值作为经验值来判断,但是如果某次GC后存活对象激增,仍然会导致担保失败,那么只能重新进行Full GC了,虽然这样会绕个圈子,但是大部分情况下还是会将HandlePromotionFailure的值设为true,从而避免Full GC过于频繁。换句话说,就是大部分情况,允许担保失败。