当前虚拟机的垃圾收集均采分代收集算法,根据对象存活周期的不同将内存分为几块。Java堆分为老年代和年轻代,可根据各自特点选择合适的垃圾收集算法。
例如年轻代中,每次收集都会有大量对象被回收,所以可选择复制算法,只需付出少量对象的复制成本便可完成每次的垃圾收集。
老年代中,对象存活几率比较高,且没有额外空间对其进行分配担保,因此必须选择标记-清除算法或标记-整理算法进行垃圾收集。
标记-清除 或 标记-整理 比 复制算法 慢10倍以上。
解决效率问题,将内存分为大小相同的两块,每次使用其中一块。当其中一块的内存使用完后,就会将还存活的对象复制到另一块中,再将之前那块清空。即每次垃圾收集都只是对内存空间的一半进行回收。
1、标记存活的对象,之后统一回收所有未被标记的对象(一般选择此方法);
2、标记需回收的对象,之后统一回收所有被标记的对象。
此算法最为基础,比较简单,但存在明显问题:
①效率问题:若需标记的对象太多,效率低下;
②空间问题:标记清除后会产生大量不连续的空间碎片。
根据老年代的特点创造的一种标记算法,标记过程仍然与标记-清除算法一致,但后续处理不同,标记完成后将所有存活对象往一端移动,再直接清理掉边界以外的内存空间。
收集算法是内存回收的方法论,垃圾收集器为内存回收的具体实现。
没有最好的垃圾收集器,只有根据具体应用场景选择最适合的垃圾收集器。
年轻代:-XX:+UserSerialGC
老年代:-XX:+UserSerialOldGC
Serial(串行)收集器是最基本且历史最悠久的垃圾收集器。
为单线程收集器。
在进行垃圾回收时会触发STW。
年轻代采用复制算法,老年代采用标记-整理算法。
由于STW会带来不良的用户体验,因此在后续的垃圾收集器设计中不断优化缩短停顿时间。
优点:相对于其他收集器的单线程而已,简单而高效。因为没有线程交互的开销,所以能获得很高的单线程收集效率。
Serial Old收集器为Serial收集器的老年代版本,同样为单线程收集器,拥有两大用途:
①在JDK1.5及以前的版本中与Parallel Scavenge收集器搭配使用;
②作为CMS收集器的后备方案。
年轻代:-XX:+UseParallelGC
老年代:-XX:+UserParalleOldGC
作为Serial收集器的多线程版本,处理使用多线程回收垃圾外,其余行为(例如控制参数、收集算法、回收策略等)和Serial收集器类似。
默认的收集线程数与CPU核数相同(-XX:ParallelGCThreads可指定收集线程数,不推荐修改)。
Parallel Scavenge收集器注重吞吐量(高效率地利用CPU)。
CMS等收集器注重用户线程的停顿时间(提高用户体验)。
吞吐量即CPU中用于运行用户代码的时间与CPU总消耗时间的比值。
Parallel Scavenge收集器提供了很多参数供用户选择最佳的停顿时间或最大吞吐量,若对收集器运作不熟悉的话,建议将内存管理优化交给虚拟机去完成。
年轻代采用复制算法,老年代采用标记-整理算法。
Parallel Old收集器为Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法。
注重吞吐量及CPU资源的场景下,可优先考虑Parallel收集器(JDK8默认的新生代和老年代的垃圾收集器)
年轻代:-XX:+UserParNewGC
老年代搭配 CMS
ParNew收集器与Parallel收集器类似,区别在于ParNew收集器可与CMS收集器配合使用。
年轻代采用复制算法,老年代采用标记-整理算法。
此收集器是许多运行在Server模式下的虚拟机的首选,除了Serial收集器外,只有它能与CMS收集器配合工作。
:老年代:-XX:+UserConcMarkSweepGC
年轻代搭配ParNew
CMS(Concurrent Mark Sweep并行标记清除)收集器注重回收时产生的停顿时间,是停顿时间最短的收集器。非常适合注重用户体验的应用上,是Hotspot虚拟机第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程几乎同时运行。
从字面意思便可看出CMS收集器是使用标记-清除算法实现的,其运作过程相对上述几种收集器来说更加复杂。
暂停所有其他线程,即STW,记录下gc roots直接能引用的对象,速度很快。
例如:Math math = new Math();
此时new Math()即为math的直接引用对象,再往下为间接引用不做记录,例如构造方法中引用了其他成员变量
接着从gc roots的直接引用对象开始遍历整条引用链并进行标记,此过程耗时较长,但无需停顿用户线程,可与垃圾收集线程一起并发运行。由于用户线程继续运行,因此可能会导致已经标记过的对象状态发生改变。
用来修正用户线程继续运行而导致的标记产生变动的那部分对象的标记记录,此阶段STW的停顿时间一般比初始标记阶段的停顿时间长一点,但比并发标记阶段的用时短非常多。主要使用三色标记里的增量更新算法做重新标记。
标记结束之后开启用户线程,同时垃圾收集线程也开始对未标记的区域进行清除,此阶段若有新增对象则会被标记为黑色,不做任何处理(三色标记算法详解见下方)。
最后重置本次GC过程中的标记数据。
优点:
1、并发收集;
2、低停顿;
缺点:
1、对CPU资源敏感,即会和服务抢资源;
2、无法处理浮动垃圾(并发标记和并发清理阶段产生的新垃圾无法在这轮被清理,只能等到下轮GC);
3、由于使用标记-清除算法,因此回收结束会产生大量空间碎片(可通过参数设置让JVM在执行完标记清除之后进行整理:-XX:+UseCMSCompactAtFullCollention);
4、执行过程中的不确定性,存在上一轮垃圾回收还未执行完便又触发新一轮的垃圾回收,特别是在并发标记和并发清除阶段。例如期间出现剩余空间不够新对象存入的情况,便会再次触发full gc,即“concurrent mode failure”并发模式失败,此时会直接进入STW,使用serial old收集器进行回收。
concurrent mode failure虽然不会OOM,但serial收集器为单线程,停顿时间可想而知会被极大延长,可通过设置相关参数进行避免。
老年代并非完全占满才触发full gc,默认占满92%即会触发full gc
若配置了,则只使用第5点设定的回收阈值,即-XX:CMSInitiatingOccupancyFraction设定的值;
若不配置,则JVM只会在第一次的时候使用该回收阈值,之后会根据实际的使用情况自动调整;
-Xms3072M -Xmx3072M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
-XX:SurvivorRatio=8
这般设置可能会由于动态对象年龄判断原则导致频繁full gc
解决上述问题,优化JVM参数配置:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
-XX:SurvivorRatio=8
这样便降低由于对象年龄动态判断原则导致的对象频繁进入老年代的问题,大多数优化目的都是为了让短期存活的对象尽量都留在Survivor区,不进入老年代,即可在minor gc时被回收,就不会因进入老年代而导致的full gc了
以上述电商系统的订单系统为例,一次minor gc要间隔二三十秒,而大多数对象一般在几秒内就变为垃圾,因此可以将默认的15岁改小,例如改成5岁,即对象经过5次minor gc才会进入老年代,完整时间为一两分钟。若对象一两分钟都还存活,则可认为该对象为存活周期较长的对象,可将其移至老年代,而不是继续占用Survivor区空间。
-XX:PretenureSizeThreshold:直接进入老年代的大对象的大小阈值,结合实际情况,预估大对象的大小,一般设置1M已经足够,很少有超过1M的大对象,这些大对象一般即为系统初始化分配的缓存对象,例如大的缓存List、Map之类的对象。
因此再次优化JVM参数配置:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
-XX:SurvivorRatio=8 ‐XX:MaxTenuringThreshold=5 ‐XX:PretenureSizeThreshold=1M
JDK8默认的垃圾收集器为:-XX:+UseParallelGC(年轻代)和-XX:+UseParallelOldGC(老年代)。
若内存较大(超过4G),系统则对停顿时间比较敏感,此时便可改用ParNew + CMS的策略,
即:-XX:+UseParNewGC -XX:+UseConcMarkSweepGC
接着考虑此系统中,哪些对象可能长期存活超过5次以上的minor gc,从而进入老年代。
可想而知例如Spring容器里的Bean、线程池对象、一些初始化缓存数据等,这些加起来也只有几十MB。
特殊情况:
例如秒杀或大促活动,就会突然在某一时刻需要处理五六百单,每秒可能生成一百多M,再加上整个系统的压力剧增,一个订单需好几秒才能处理完成,下一秒又会加入很多新的订单。在某次minor gc之后,会有超过一两百M的对象存活,直接移至老年代。
可预估大概每隔五六分钟便会出现一次,即大概半小时到一小时之间就可能因为老年代装不下而触发full gc。full gc的触发条件还包括老年代空间分配担保机制,历次的minor gc后移至老年代的对象大小肯定非常小,因此几乎不会在minor gc之前因为老年代空间分配担保失败而触发full gc。
其实经历半小时之后,就算触发了full gc,此时也已经过了抢购的最高峰期,所以对使用实际上影响不大,后续大概几小时才做一次full gc。
碎片整理:
由于都是1小时甚至几小时才触发一次full gc,因此可以在每次full gc之后就开始碎片整理,也可以设置成两三次之后再进行碎片整理。
因此,只要年轻代参数设置合理,老年代CMS的参数设置基本可以使用默认值:
‐Xms3072M ‐Xmx3072M ‐Xmn2048M ‐Xss1M ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M
‐XX:SurvivorRatio=8 ‐XX:MaxTenuringThreshold=5 ‐XX:PretenureSizeThreshold=1M
‐XX:+UseParNewGC ‐XX:+UseConcMarkSweepGC ‐XX:CMSInitiatingOccupancyFraction=92
‐XX:+UseCMSCompactAtFullCollection ‐XX:CMSFullGCsBeforeCompaction=0
并发标记时,由于标记期间应用线程仍在执行,此时对象间的引用极有可能发生变化,由此产生多标和漏标现象。
在gc roots可达性分析遍历对象的过程中,会将遇到的对象进行条件标记,由此将三种条件划分成以下三种颜色,即为三色标记:
当对象已被垃圾收集器访问过,且此对象的所有引用均已被扫描(如下图的A,A的引用只有B,也被扫描过)。此时若安全存活,则表示为黑色对象。
若有其他对象的引用指向了黑色对象,则无需重新扫描,因为黑色对象不会直接指向某个白色对象。
当对象已被垃圾收集器访问过,但此对象的引用上至少存在一处未被扫描(如下图的B,B的引用有C和D,C已被扫描过,D未被扫描过),此时便表示为灰色对象。
若对象未被垃圾收集器访问过,则不会被标记到条件,即表示为白色对象,。因此当分析结束之后仍是白色,则说明为不可达对象。
三色标记的简易代码:
public class ThreeColorRemark {
public void main(String[] args) {
A a = new A();
//开始做并发标记
D d = a.b.d; //1、读
a.b.d = null; //2、写
a.d = d; //3、写
}
class A {
B b = new B();
D d = null;
}
class B {
C c = new C();
D d = new D();
}
class C {
}
class D {
}
}
并发标记的过程中,若因方法运行结束导致部分局部变量(gc roots)被销毁,但这个gc roots引用的对象由于在之前已被扫描并标记为非垃圾对象,因此并不会在本轮GC中被回收。
这些本该被回收但由于在标记为非垃圾之后才变为垃圾而无法被回收的对象称为浮动垃圾。
浮动垃圾并不会影响垃圾回收的正确性,只是要等到下一轮GC才能被回收。
针对并发标记或并发清理开始后产生的新对象,通常将其标记成黑色对象,即跳过当前这轮GC,其中在之后的过程中变为垃圾的对象也算浮动垃圾的一部分。
这里的读写屏障为代码级别的屏障,类似AOP的操作,和内存屏障不同
漏标将导致被引用的对象被当成垃圾误回收掉,产生严重bug,为避免出现这种情况,可使用增量更新(Incremental Update)或原始快照(Snapshot At The Beginning,SATB)。
当黑色对象加入新的引用,指向白色对象时,便会将新引用关系记录下来,待并发扫描结束之后再根据记录将里面的黑色对象作为根,进行重新扫描。(即上图中A和D的关系)
简单来说黑色对象若加入新的白色对象的引用,则将此黑色对象置为灰色对象,便可在重新标记阶段被再次扫描。
当灰色对象要去掉白色对象的引用时,便会将要去掉的白色对象保存快照存在一个集合里,待并发扫描结束之后再将集合里的白色对象置为黑色对象,使其在本轮GC中存活,即浮动垃圾。
这样即可让此对象在本轮GC中存活,待下一轮GC重新扫描,避免浮动垃圾被误回收。
以上两种操作均由写屏障实现
给某个对象的成员变量赋值时,底层代码大致如下:
/**
* @param field 某对象的成员变量,如 a.b.d
* @param new_value 新值,如 null
*/
void oop_field_store(oop* field, oop new_value) {
*field = new_value; // 赋值操作
}
写屏障即为在赋值操作的前后加入一些处理操作,类似AOP概念:
void oop_field_store(oop* field, oop new_value) {
pre_write_barrier(field); // 写屏障‐写前操作(原始快照)
*field = new_value;
post_write_barrier(field, value); // 写屏障‐写后操作(增量更新)
}
当对象B的成员变量的引用发生改变时,如去掉引用:a.b.d = null,即可利用写屏障,将B的成员变量原先引用的对象D记录下来:
void pre_write_barrier(oop* field) {
oop old_value = *field; // 获取旧值
remark_set.add(old_value); // 记录原来的引用对象
}
当对象A的成员变量的引用发生改变时,如新增引用:a.d = d,即可利用写屏障,将A的成员变量的新引用对象D记录下来:
void post_write_barrier(oop* field, oop new_value) {
remark_set.add(new_value); // 记录新引用的对象
}
oop oop_field_load(oop* field) {
pre_load_barrier(field); // 读屏障‐读取前操作
return *field;
}
读屏障则直接针对第一步:D d = a.b.d,当读取成员变量时一律进行记录:
void pre_load_barrier(oop* field) {
oop old_value = *field;
remark_set.add(old_value); // 记录读取到的对象
}
现代追踪式(可达性分析算法)的垃圾收集器几乎都借鉴三色标记的算法思想,尽管实现上有所区别:例如白色/黑色集合一般都不出现(但有其他体现颜色的地方)、灰色集合可通过栈/队列缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等。
项目开发中,读写屏障还具备其他功能,例如写屏障可用于记录跨代/区引用的变化,读屏障可用于支持移动对象的并发执行等。
除了功能以外,还需考虑性能,因此应根据每款垃圾收集器的特点进行选择。
SATB相对于增量更新来说,效率更高,因为不需要在重新标记阶段再次深度扫描被删除的引用对象,虽然因此可能产生更多浮动垃圾。
CMS会对新增的引用的根对象进行深度扫描,但对于G1来说,很多对象都在不同的Region,而CMS就一块老年代,因此深度扫描对两者而已,G1消耗代价比较CMS高,因为G1选择SATB,不进行深度扫描,只做简单标记,待下轮GC再重新深度扫描。
在年轻代做gc roots可达性分析过程中,可能会碰到跨代引用的对象,即年轻代的某些对象被老年代里的对象引用,若因此又去老年代扫描则会使效率极其低下。
因此年轻代引入了**记忆集(Remember Set)**的数据结构,在年轻代里开辟一块小空间,目的是记录从非收集区到收集区的指针集合(例如老年代的对象对年轻代的对象的引用,跨代引用一般也很少),避免将整个老年代加入到gc roots的扫描范围内。
实际上,除了年轻代和老年代会存再跨代引用的问题外,所有涉及部分区域收集(Partial GC)行为的垃圾收集器,典型的如G1、ZGC、Shenandoah收集器,都会面临相同的问题。
在垃圾收集的场景中,收集器只需通过记忆集来判断某一块非收集区域(如老年代)是否存在指向收集区域(如年轻代)的指针即可,无需了解跨代引用指针的全部细节。
Hotspot使用**卡表(CardTable)**来实现记忆集,通过标记卡页的状态,每个卡表对应一个卡页,也是当前最常用的一种方式,卡表与记忆集的关系可以类比为HashMap与Map的关系。
卡表使用一个字节数组实现:CARD_TABLE[],会对应一块特定大小的内存块,每个卡表标记着所对应的内存块的元素标识,而此内存块称为:卡页。
Hotspot使用的卡页大小为2^9,即512字节。
老年代会被划分成许多块512字节的空间,即卡页。而每个卡页中都可包含多个对象,只要在卡页里存在至少一个对象的字段为跨代指针(即引用到年轻代的对象),其对应的卡表的元素标识便会被置为1,表示该元素变脏,否则为0.
GC时,只需筛选出本收集区的卡表中变脏的元素,将其加入到gc roots里即可。
在发生引用字段赋值时(例如老年代的对象引用到年轻代的对象),Hotspot会通过写屏障来更新卡表对应的卡页的元素标识,将其置为1,以此来维护卡表状态。