关于垃圾回收,主要回答两个问题:哪些内存需要回收、如何回收。
一、哪些内存需要回收?- 对象存活判定算法
1. 堆和方法区是回收重点
计数器、栈是线程私有内存,方法、线程结束就会回收。而堆和方法区则不同,运行时才能确定的接口不同实现、以及方法的不同分支,其所需内存都是不同的。
2. 当GC聚焦于堆,哪些对象可回收?
1)引用计数算法:对象有引用计数器,引用加1,引用失效减1,为0可回收。该方法简单,无法解决对象循环引用问题。
2)可达性分析算法(常用):通过GC root作为起点,搜索路径为引用链,当一个对象到GC root没有任何引用链则可回收。可达性分析对时间敏感,必须在确保一致性的快照中进行,要求GC时停顿所有Java执行线程。
引用根据引用强度递减分为:1)强引用:代码中普遍存在Object obj=new Object(),强引用永远不会被GC回收;2)软引用:系统即将内存溢出前,将软引用对象纳入回收范围进行二次回收,如果回收后仍然没有足够内存,才会报错;3)弱引用:只生存到下次GC前,GC启动时无论内存是否足够,都会回收弱引用对象;4)虚引用:对象生存时间==无引用,区别在于对象被回收时收到系统通知。
对象一次不可达不会回收,至少经过二次标记才会真正回收。虚拟机会标记不可达对象,同时决定是否执行其finalize方法。finalize方法是Object类的一个空实现方法,当对象要被GC回收时,就会调用这个方法;日常编程不建议使用。如果对象没有override finalize方法或者finalize方法已经被执行过,则不执行;否则,将对象放在F-Queue队列中,等待虚拟机自动建立的低优先级的Finalizer线程去执行它;并且GC会对队列中的对象进行二次标记;如果对象重写了finalize方法,则移出即将回收的集合,实现自救。
3. 方法区回收
虚拟机规范不要求在方法区实现GC。堆中的新生代,一次GC可以回收70%-95%,方法区(永久代)的效率远低于此。方法区回收内容为废弃常量(比如没有任何string变量引用的字面值)和无用的类。无用的类符合以下条件:该类的所有实例已被回收;类的加载器已被回收;类的Class对象没有引用(不存在反射引用)。虚拟机不是必然回收无用的类,通过参数-Xnoclassgc控制。
4. HotSpot的算法实现
1)枚举根节点:通常选取栈中引用的对象、方法区中静态属性/常量引用对象、本地方法栈中JNI引用对象作为GC root。HotSpot采用准确式GC:无需遍历所有GC root,用OopMap(Ordinary Object Pointer普通对象指针)存储引用的位置。
2)安全点:基于空间考虑,HotSpot不会为所有指令生成OopMap,只在安全点生成。也就是说程序执行时并非所有地方都能停下来执行GC,只有到达安全点才行。安全点设置多少,在哪里设置?安全点少导致GC等待时间长,过多又导致空间负担。VM通常在长时间执行位置设置安全点,如方法调用、循环、异常跳转等位置。以下两种方式让线程在GC发生时跑到最近的安全点再停顿:(1)抢先式中断(preemptive suspension、少用):GC发生时VM中断所有线程,如果某线程不在安全点,则恢复其运行到安全点。(2)主动式中断(voluntary suspension、常用): 不直接操作线程而是设置标识位,各线程轮询该标识位,为true则主动挂起执行。轮询标识位的位置在安全点和创建对象的地方。
3)安全区域:代码中对象引用关系不变的区域,区域内任何位置开始GC都安全。线程进入安全区域时对自己进行标记,GC时忽略安全区域内的线程;线程离开安全区域前检查GC是否已结束,没有则等待。
二、如何回收?- 垃圾回收算法
1. 算法
1)标记清除算法(Mark-Sweep、基础算法): 首先标记所有需要回收的对象,然后统一清除、回收。标记把内存分为存活对象、可回收、未使用。后面其他算法都基于该算法并改进。缺点是产生内存碎片,后续为大对象分配内存时可能由于没有足够的连续内存而触发GC。
2)复制算法: 内存分为大小相等的两块(AB),每次使用一块(A),当A内存用完,就将还活着的对象复制到B(连续内存),然后把A清除,下次分配内存在A区分配。不存在内存碎片。不足: 内存利用率仅为原有的一半。
适用于新生代回收(对象大多朝生夕死),内存分为Eden和两个Survivor,Eden和一块Survivor作为A,另一块Survivor作为B。HotSpot默认Eden/Survivor空间比例为8:1,内存利用率从50%提升至90%。当存活对象超过10%(B survivor),老年代进行分配担保。不适用老年代,更多的复制操作降低效率。配置:SurvivorRatio
3)标记-整理算法(Mark-Compact):用于老年代,标记过程同标记清除算法,整理过程是让所有存活对象向一端移动,然后直接清理端边界以外的内存。
4)分代收集算法(generational collection):堆分为新生代/老年代,新生代采用复制算法,老年代采用标记清除或标记整理算法。
2. 收集器
收集器是算法的具体实现,HotSpot有7种收集器,分为新生代、老年代收集器。
2.1 新生代收集器
1)Serial收集器(基础款/单线程): VM client模式下的默认收集器。对应的老年代收集器是SerialOld/CMS。单线程完成GC,GC时其他线程停止执行。直观影响:服务器每小时会停止响应5分钟用于GC。比喻:妈妈打扫房间时你只能老老实实呆着,如果她一边打扫,你一边扔纸屑,什么时候能打扫完?VM client下默认使用Serial+CMS。配置UseSerialGC指定Serial+SerialOld。
2)ParNew收集器(多线程): Serial收集器的多线程版本,默认开启线程数=CPU核数。常用于VM Server模式。配合CMS/SerialOld收集器。配置:ParallelGCThreads用于限制线程数;UseParNewGC指定与SerialOld组合
3)Parallel Scavenge收集器: 关注点是吞吐量(运行用户代码时间/(运行用户代码时间+GC时间)),其他收集器的关注点是缩短GC时用户线程的停顿时间。停顿时间短适合用户交互程序,高吞吐量意味着高效率利用CPU,适用于用户交互少的服务端后台运算。MaxGCPauseMillis设置GC停顿时间,并非越短越好,停顿变短的代价是吞吐量和新生代空间;推荐使用UseAdaptiveSizePolicy,只需通过MaxGCPauseMillis和GCTimeRatio设定目标,无须配置-Xmn/SurvivorRatio/PretenureSizeThreshold等参数,VM自动调节以达到最优吞吐量。UseParallelGC指定与SerialOld组合;UseParallelOldGC:指定与ParallelOld组合。
2.2 老年代收集器
1)Parallel Old收集器:Parallel Scavenge的老年代版本,二者配合使用。
2)CMS(Concurrent Mark Sweep): GC线程与用户线程同时工作,采用标记-清除算法。分为四步:1.初始标记:暂停用户线程,标记GC root直接关联的对象;速度快;单线程。2.并发标记:GC roots tracing,与用户线程并发工作;耗时;单线程。3.重新标记:暂停用户线程,修正并发标记期间因用户程序继续运作而导致标记发生的改变,多线程。4.并发清除:与用户线程并发工作,耗时,单线程
缺点:1.浮动垃圾:并发清理阶段用户线程的运行产生新垃圾,需要下次GC处理。2.需要预留内存给GC期间仍在运行的用户线程,导致不能等到老年代几乎填满再收集;预留空间不足会出现Concurrent Mode Failure,此时VM将启动后备方案-Serail Old收集器,停顿时间长。3. 内存碎片
UseConcMarkSweepGC指定与ParNew组合; CMSInitiatingOccupancyFraction指定触发百分比(老年代空间占用); UseCMSCompactAtFullCollection:默认开启,用于在CMS顶不住要开启FullGC时开启内存碎片的合并整理过程;CMSFullGCsBeforeCompaction:执行多少次不压缩内存碎片的Full GC后,执行一次压缩,默认值0,表示每次Full GC都要碎片整理。
3)G1收集器(Garbage First/前沿): 面向服务器应用,用于替代CMS。G1不仅是新生or老年代收集器,而是面向整个堆的收集器。
内存布局:堆划分为多个大小相等的区域Region,新生、老年代都是一部分Region的集合。跟踪各个Region垃圾的价值=回收所获的的空间/回收时间,维护优先列表,每次根据允许的收集时间,收集价值最大的Region。能实现可预测的停顿,使用者指定M时间内消耗在GC的时长不超过N;每个Region维护remember set记录区域间对象引用,避免全堆扫描。
步骤:1.初始标记:标记直接关联对象(同CMS),记录在TAMS(Next Top At Mark Start);2.并发标记:对象变化记录在remember set logs;3.最终标记:把remember set logs数据合并到remembered set;4.筛选回收:对各个region的回收性价比排序,根据用户期望的GC停顿时间制定回收计划,不推荐用户程序并发,从而解决浮动垃圾问题。
三、理解GC日志
PrintGCDetails用于告诉虚拟机发生GC时打印日志,并在进程退出时输出当前的内存各区域分配情况。
理解GC日志是处理VM内存问题的基础技能,日志包含: 1.GC发生时间,格式是VM启动后经历的时间(100.667);2.停顿类型GC/Full GC;3.GC发生区域:新生代DefNew/ParNew/PSYoungGen,老年代Tenured,永久代Perm,不同收集器命名不同;4. 3324k->152k(3712k): 代表GC前已使用、GC后已使用以及该区域总容量;5. GC耗时。
四、内存分配与回收策略
JVM自动内存管理解决两个问题:给对象分配内存,回收分配给对象的内存。
1. 对象主要分配在新生代Eden区,Eden空间不足触发Minor GC。由-Xms/-Xmx20M,Xmn10M限定堆大小20M不可扩展,新生/老年代各10M。SurvivorRatio限定Eden区8M,两个Survivor各1M。复制算法A区域9M,B区域1M,老年代作为担保区域10M。执行程序创建3个2M对象new byte[2M],都存在于新生代;再创建4M对象,此时新生代可用空间不足,触发Minor GC:试图将存活对象6M拷贝到Survivor,Survior空间不足,启用担保,拷贝6M对象到老年代;Minor GC结束,创建4M对象在新生代。
2. 大对象直接分配在老年代:JVM不喜欢大对象,尤其是生命周期短的,比如很长的字符串/数组,写程序应当避免,大对象导致内存还有很多空间时就触发GC。配置:PretenureSizeThreshold指定晋升老年代对象大小,大于它的对象直接在老年代分配
3. 长期存活对象进入老年代:对象在Eden经过首次Minor GC后进入Survior,则年龄为1;对象在Survivor每经过一次GC,年龄加1;年龄超过阀值(默认15)则晋升老年代,年龄阀值通过MaxTenuringThreshold设置,只对serial/ParNew有效。
4. 动态对象年龄判定:当survivor内相同年龄对象的大小总和大于Survivor一半,年龄大于或等于该年龄的对象进入老年代
5. 空间分配担保:Minor GC前,JVM检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,成立则Minor GC安全,否则查看HandlePromotionFailure是否允许失败。允许则继续查看老年代剩余空间是否大于历次晋升老年代对象的平均大小,大于则尝试Minor GC,小于或者不允许失败,则进行Full GC让老年代腾出更多空间。Minor GC失败也会触发Full GC,通常会把HandlePromotionFailure打开减少Full GC的次数。
6. 如果启动了TLAB,则分配在本线程的TLAB
7. 建议打开空间分配担保以降低Minor GC的频率、大对象直接分配到老年代以降低minor GC的频率,但坏处是可能增加full GC的频率,因此不建议使用大对象;建议关闭空间分配担保的HandlePromotionFailure以降低Full GC的频率
五、GC名词
新生代 young generation,对象存活率低,分为Eden/Survivor
新生代GC-复制算法-分配担保:类比银行贷款的担保人,当借款人无法还款时,从担保人账户扣款
新生代GC-minor GC,当新生代Eden区容量不足时发生,速度快
老年代 tenured generation:对象存活率高
老年代GC-Full GC:速度比minor GC慢10倍; 代码中调用System.gc()会触发full GC。日志显示Full GC (System) 4603K->210K。