垃圾收集器在对堆回收之前,第一件事情就是要确定这些对象哪些还“存活”着,哪些对象已经“死去”(即不可能再被任何途径使用的对象)、
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1;当引用失效时,计数器减1;任何时刻计数器都为0的对象就是不可能再被使用的。
引用计数算法的实现简单,判断效率也很高,在大部分情况下它都是一个不错的算法。但是Java语言中没有选用引用计数算法来管理内存,其中最主要的一个原因是它很难解决对象之间相互循环引用的问题。
例如:在testGC() 方法中,对象objA和objB都有字段instance,赋值令objA.instance=objB及objB.instance=objA,除此之外这两个对象再无任何引用,实际上这两个对象都已经不能再被访问,但是它们因为相互引用着对象方,异常它们的引用计数都不为0,于是引用计数算法无法通知GC收集器回收它们。
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024 * 1024;
/**
* 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否被回收过
*/
private byte[] bigSize = new byte[2 * _1MB];
public static void main(String[] args) {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
//假设在这行发生了GC,objA和ojbB是否被回收
System.gc();
}
}
主流的判断算法,这个算法的基本思路就是通过一系列名为”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,下图对象object5, object6, object7虽然有互相判断,但它们到GC Roots是不可达的,所以它们将会标记为是可回收对象。
在Java语言里,可作为GC Roots对象的包括如下几种:
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。
真正的判断一个对象死亡,至少要经过俩次标记过程:如果对象在进行根搜索后发现没有与GC roots相关联的引用链,那他将会第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这俩种情况都视为“没有必要执行”。
两次标记过程:
筛选的条件是此对象是否有必要执行finalize()方法。
当对象没有覆盖finalize方法,或者finzlize方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,对象被回收。
如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为 F-Queue 的队列之中,并在稍后由一条虚拟机自动建立的、低优先级的Finalizer线程去执行。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象finalize()方法中执行缓慢,或者发生死循环(更极端的情况),将很可能会导致F -Queue 队列中的其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。
Finalize()方法是对象脱逃死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,如果对象要在finalize()中成功拯救自己—-只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。
整个流程如下:
针对HotSpot VM的实现,它里面的GC其实准确分类只有两大种:
Young GC 是发生在新生代中的垃圾收集动作,所采用的是复制算法。
当对象在 Eden ( 包括一个 Survivor 区域,这里假设是 from 区域 ) 出生后,在经过一次 Young GC 后,如果对象还存活,并且能够被另外一块 Survivor 区域所容纳( 上面已经假设为 from 区域,这里应为 to 区域,即 to 区域有足够的内存空间来存储 Eden 和 from 区域中存活的对象 ),则使用复制算法将这些仍然还存活的对象复制到另外一块 Survivor 区域 ( 即 to 区域 ) 中,然后清理所使用过的 Eden 以及 Survivor 区域 ( 即 from 区域 ),并且将这些对象的年龄设置为1,以后对象在 Survivor 区每熬过一次 Minor GC,就将对象的年龄 + 1,当对象的年龄达到某个值时 ( 默认是 15 岁,可以通过参数 -XX:MaxTenuringThreshold 来设定 ) ,这些对象就会成为老年代。
Young GC触发条件: 一般是新生代中Eden区满时,Young GC
Full GC 就是收集整个堆,包括新生代,老年代,永久代(在JDK 1.8及以后,永久代会被移除,换为metaspace)等收集所有部分的模式
针对不同的垃圾收集器,Full GC的触发条件可能不都一样
最简单的分代式GC策略,按HotSpot VM的serial GC(serial+serial old)的实现来看,触发条件是
标记-清除算法将垃圾回收分为两个阶段:
缺点:
复制算法过程:
优点:
缺点:
jvm新生代对此算法进行了修改,并不需要根据1:1划分内存空间,而是将内存划分为一块较大的EdenSpace和两块较小的SurvivorSpace,如图:
由于复制算法的缺点,及老年代的特点(存活率高,没有额外内存对其进行空间担保),老年代一般不使用复制算法。
标记-清除过程:
过程如图:
由于老年代存活率高,没有额外内存对老年代进行空间担保,那么老年代只能采用标记-清理算法或者标记整理算法。
把Java堆分为新生代和老年代。根据各个年代的特点采用最适当的收集算法。
在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,选用:复制算法
在老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清除”或者“标记-压缩”算法来进行回收。
如果说收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。
gc收集器的各个年代分布,?
代表g1收集器, 连线代表可以搭配使用:
默认的新生代收集器
采用收集算法:复制算法(copying)
搭配:CMS 或Serial Old(MSC)
优点:简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。在用户的桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面应用基本上不会再大了),停顿时间完全可以控制在几十毫秒最多一百多毫秒以内,只要不是频繁发生,这点停顿是可以接受的。
缺点: GC时暂停线程带给用户不良体验
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为都与Serial收集器完全一样。同样适用于新生代
ParNew是除了Serial之外唯一能与CMS配合工作的,但是由于多线程的交互开销,在单CPU的情况下,效果并不比serial好
采用收集算法:复制算法(copying)
搭配:CMS 或Serial Old(MSC)
优点:高效
缺点:GC时暂停线程带给用户不良体验,单线程下效果不一定优于Serial
使用于新生代, Parallel Scavenge主要在于精确的控制吞吐量, 适合后台运算、交互不多的任务(CMS等则是尽可能的缩短垃圾收集时用户线程停顿的时间, 用于交互任务)
与其他收集器不同的是:
1)ParNew,CMS等收集器的关注点在于尽可能缩短垃圾收集时用户线程的停顿时间;而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。[备注:吞吐量:运行代码时间/(运行用户代码时间+垃圾收集时间)]
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户的体验;而高吞吐量则可以最高效率地利用CPU时间,尽快地完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
2)Parallel Scavenge可采用GC自适应的调节策略。 使用自适应的调节策略: 即不需要指定新生代的大小,Eden与Surivior的比例,晋升老年代的年龄等细节参数,虚拟机自动根据根据当前系统的状态动态调整这些参数,以提供最合适的停顿时间或最大的吞吐量。
参数:用于精确控制吞吐量
采用收集算法:复制算法(copying)
搭配:Parallel Old或Serial Old(MSC)
适用 1).运行在Client模式下的虚拟机中的老年代 2).在Server模式下,它主要还有两大用途 ①.与Parallel Scavenge搭配 ②.作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure的时候使用
特点: 1.单线程GC,Serial收集器的老年代版本 2.在GC时暂停所有用户线程
算法:采用标记-整理算法
优点:简单,高效
缺点:GC时暂停线程带给用户不良体验
搭配:Serial Old(MSC)或ParNew
Parallel Scavenge的老年代版本,适用于注重吞吐量和CPU资源敏感的场合
特点 1).多线程GC(并行):Parallel Scavenge的老年代版本 2).在GC时暂停所有用户线程
算法:采用标记-整理算法
优点:高效
搭配:Parallel Scavenge(这个组合适用于一些长期运行且对吞吐量要求较高的后台程序)
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。
CMS GC 的设计目标是避免在老年代垃圾收集时出现长时间的卡顿,主要通过两种手段来达成此目标:
特点: 牺牲了吞吐量,但是响应速度很快。
此阶段的优化,减少停顿时间,启初始标记并行化,-XX:+CMSParallelInitialMarkEnabled
,同时调大并行标记的线程数,线程数不要超过cpu的核数。
此阶段的优化:
这个阶段要处理年轻代和老年代的引用关系,会消耗大量的时间STW,可以在remark之前先执行一次ygc,并将对象放入幸存代或晋升到老年代,这样扫描的时间就会大大减少.参数:-XX:+CMSScavengeBeforeRemark
缺点: 1). 对CPU资源非常敏感。主要是并发标记阶段会与用户线程交替运行,会抢占一部分CPU资源
2).无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。 浮动垃圾:在并发清除阶段,用户线程仍在运行,这段时间用户线程产生的新的垃圾, 这部分CMS无法在当次收集。
也是因为并发清除阶段用户线程还需要运行,所以无法等老年代几乎填满了才运行, 需要预留一部分空间给用户线程在清除阶段使用, 可以通过-XX:CMSInitiatingOccupancyFration
来设置触发百分比, 如果CMS期间预留内存不足,则会出现concurrent mode fai lure
, 此时虚拟机启动预备方案,临时启用Serial Old进行回收(full gc)。
3).产生空间碎片,影响大对象的分配。 这是由于该收集器是由“标记-清楚”算法实现的所引起的。所以往往存在有很大空间剩余,当无法找到足够大的连续空间来分配当前对象,不得不提前出发一次Full GC。
解决:
4). 减少 Remark 阶段 STW 的时间,-XX:+CMSScavengeBeforeRemark
remark 之前先进行一次 ygc
-XX:+CMSScavengeBeforeRemark
。在执行remark操作之前先做一次Young GC,目的在于减少年轻代对老年代的无效引用,降低remark时的开销。2.
内存碎片问题。CMS是基于标记-清除算法的,CMS只会删除无用对象,不会对内存做压缩,会造成内存碎片,这时候我们需要用到这个参数:-XX:CMSFullGCsBeforeCompaction=n
。
意思是说在上一次CMS并发GC执行过后,到底还要再执行多少次full GC才会做压缩。默认是0,也就是在默认配置下每次CMS GC顶不住了而要转入full GC的时候都会做压缩。
设置触发CMS GC触发的两个参数:
-XX:CMSInitiatingOccupancyFraction=70
,在老年代内存使用达到70会触发gc-XX:+UseCMSInitiatingOccupancyOnly
, 如果不设置这个参数,上面的阈值只会第一次GC有效,后续会自动上调导致上面的参数不生效默认情况下是 ParNew(年轻代)+CMS(老年代)
G1 的主要关注点在于达到可控的停顿时间,在这个基础上尽可能提高吞吐量。G1 被设计用来长期取代 CMS 收集器,和 CMS 相同的地方在于,它们都属于并发收集器,在大部分的收集阶段都不需要挂起应用程序。区别在于,G1 没有 CMS 的碎片化问题(或者说不那么严重),同时提供了更加可控的停顿时间。
如果你的应用使用了较大的堆(如 6GB 及以上)而且还要求有较低的垃圾收集停顿时间(如 0.5 秒),那么 G1 是你绝佳的选择,是时候放弃 CMS 了。
执行垃圾收集时,和 CMS 一样,G1 收集线程在标记阶段和应用程序线程并发执行(也会伴随着STW),标记结束后,G1 也就知道哪些区块基本上是垃圾(存活对象极少),G1会先从这些区块下手,因为从这些区块能很快释放得到很大的可用空间(这也是G1名字的由来,优先收集垃圾多的分区).
在 G1 中,目标停顿时间非常非常重要,用 -XX:MaxGCPauseMillis=200
指定期望的停顿时间。G1
不是一个实时收集器,它会尽力满足我们的停顿时间要求,但也不是绝对的,它基于之前垃圾收集的数据统计,估计出在用户指定的停顿时间内能收集多少个区块。G1
使用了停顿预测模型来满足用户指定的停顿时间目标,并基于目标来选择进行垃圾回收的区块数量。G1 采用增量回收的方式,每次回收一些区块,而不是整堆回收。
G1 收集器主要包括了以下 4 种操作:
年轻代中的垃圾收集流程(Young GC):
可以看到年轻代收集概念上和之前介绍的其他分代收集器差别不大,也是STW的,但是它的年轻代会动态调整。 G1 通过控制年轻代 Region 的个数来控制 YGC 的时间开销。
这个过程类似CMS,它主要是为了 Mixed GC 提供标记服务,分为四个步骤执行:
到这里,G1 的一个并发周期就算结束了,其实就是主要完成了垃圾定位的工作,定位出了哪些分区是垃圾最多的
将对象分为三类:
工作过程: (1) 根对象被被置为黑色,子对象被置为灰色 (2) 继续由灰色遍历,将已经扫描了的子对象置为黑色 (3) 遍历完了所有可达对象后,所有可达对象变成了黑色,不可达对象还是白色,需要被清理
但是标记过程中,应用程序也再运行,对象的指针可能改变,就会产生对象丢失问题.本来不该被回收的对象,再扫描完成后还是白色。如下
// 初始状态,A引用了B,C对象,B引用了D对象
A->B->D
->C
// 运行期间做了改变
A->B
->C->D
// 这个时候发生了改变 B.d = null; C.d = D
// 但是这个时候已经将C标记成了黑色,B也是黑色。由于D改变了引用关系没有被标记到还是白色,这样就漏标了
G1 采用 SATB 来解决漏标问题: (1) 开始的时候生成一个快照,标记存活对象 (2) 并发标记的时候所有改变对象入队(把所有旧的引用所指向的对象都变成非白的)
这个算法解决了漏标的问题,但是会产生浮动垃圾,将再下次被收集
并发周期结束后是混合垃圾回收周期,不仅进行年轻代垃圾收集,而且回收之前标记出来的老年代的垃圾最多的部分区块。Mixed GC 采用复制清除算法,当GC完成后会成功释放空间
关键参数:
G1HeapWastePercent
: 再 glob concurrent marking 结束后,就可以知道 old
有多少空间要被回收,再每次YGC之后,会检测是否达到这个参数,达到这个参数就触发 Mixed GCG1MixedGCLiveThresHoldPercent
: old 中的 region,里面存活对象的占比要再这个参数之下,该 Region 就会被放入 CSet(
也就是垃圾占比高于这个值就会被纳入收集集合)本质上G1是不提供 Full GC 的,它会采用 Serial Old 来收集整个GC Heap。
以下几种会导致 Full GC 的情况,是我们需要极力避免的:
1.concurrent mode failure:并发模式失败,CMS 收集器也有同样的概念。G1 并发标记期间,如果在标记结束前,老年代被填满,G1 会放弃标记。这个时候说明了:
2.晋升失败: 跟CMS类似,堆空间垃圾太多导致无法完成年轻代的拷贝到老年代,不得不退化成 Full GC来完成垃圾回收
3.疏散失败:年轻代垃圾收集的时候,如果 Survivor 和 Old 区没有足够的空间容纳所有的存活对象。这种情况肯定是非常致命的,因为基本上已经没有多少空间可以用了,这个时候会触发 Full GC 也是很合理的(这个时候最简单的就是增加堆的大小)
4.大对象分配失败,我们应该尽可能地不创建大对象,尤其是大于一个区块大小的那种对象
G1 调优的目标是尽量避免出现 Full GC,其实就是给老年代足够的空间,或相对更多的空间,有以下几点我们可以进行调整的方向:
另外 G1 很重要的目标是达到可控的停顿时间
常用的参数:
-XX:MaxGCPauseMillis=200
(指定目标停顿时间,默认值 200 毫秒)
。这个停顿时间并不是设置的越短越好,如果设置的太短就会导致每次收集的CSet越小,垃圾越来越多,最后退化成 Serial 的 Full GC-XX:InitiatingHeapOccupancyPercent=45
(整堆使用达到这个比例后,触发并发 GC 周期,默认
45%。降低这个数值,使并发周期提前进行,可以降低晋升失败避免full gc
)。这里的堆占比不包括young,但是包括 old+humongous-XX:G1HeapWastePercent
,old region中垃圾占比达到这个值就触发Mixed GC-XX:ParallelGCThreads=n
(STW期间,并行GC线程数)-XX:ConcGCThreads=n
(并发标记阶段,并行执行的线程数,增加这个值可以让并发标记更快完成,如果没有指定这个值,JVM 会通过以下公式计算得到:ConcGCThreads=(
ParallelGCThreads + 2) / 4^3)-XX:+PrintGC
: 打印 GC 日志-XX:+PrintGCDetails
: 打印详细 GC 日志-XX:PrintHeapAtGC
: 在GC前后打印堆信息-XX:+PrintGCTimeStamps
: 在每次GC日志前加一个时间戳,标识JVM启动到现在的时间-Xloggc:gc.log
: 保存GC日志, 输出到文件中保存起来, 这里文件名是 gc.log-XX:+PrintTenuringDistribution
: 打印 survivor 每段年龄的大小每一种收集器的日志形式都是由它们自身的实现所决定的,换而言之,每个收集器的日志格式都可以不一样。但虚拟机设计者为了方便用户阅读,将各个收集器的日志都维持一定的共性,例如以下两段典型的GC日志:
33.125: [GC [DefNew: 3324K->152K(3712K), 0.0025925 secs] 3324K->152K(11904K), 0.0031680 secs]
100.667: [Full GC [Tenured: 0K->210K(10240K), 0.0149142 secs] 4603K->210K(19456K), [Perm : 2999K->2999K(21248K)], 0.0150007 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]
最前面的数字“33.125:”和“100.667:”代表了GC发生的时间,这个数字的含义是从Java虚拟机启动以来经过的秒数。
GC日志开头的“[GC”和“[Full GC”说明了这次垃圾收集的停顿类型,而不是用来区分新生代GC还是老年代GC的。如果有“Full”,说明这次GC是发生了Stop-The-World的。 例如下面这段新生代收集器ParNew的日志也会出现“[Full GC”(这一般是因为出现了分配担保失败之类的问题,所以才导致STW)。如果是调用System.gc() 方法所触发的收集,那么在这里将显示“[Full GC (System)”。 [Full GC 283.736: [ParNew: 261599K->261599K(261952K), 0.0000288 secs] 接下来的“[DefNew”、“[Tenured”、“[Perm”表示GC发生的区域,这里显示的区域名称与使用的GC收集器是密切相关的,例如上面样例所使用的Serial收集器中的新生代名为“Default New Generation”,所以显示的是“[DefNew”。如果是ParNew收集器,新生代名称就会变为“[ParNew”,意为“Parallel New Generation”。如果采用Parallel Scavenge收集器,那它配套的新生代称为“PSYoungGen”,老年代和永久代同理,名称也是由收集器决定的。
后面方括号内部的“3324K->152K(3712K)”含义是“GC前该内存区域已使用容量-> GC后该内存区域已使用容量 (该内存区域总容量)”。而在方括号之外的“3324K->152K( 11904K)”表示“GC前Java堆已使用容量 -> GC后Java堆已使用容量 (Java堆总容量)”。
再往后,“0.0025925 secs”表示该内存区域GC所占用的时间,单位是秒。有的收集器会给出更具体的时间数据,如“[Times: user=0.01 sys=0.00, real=0.02 secs]”,这里面的user、sys和real与Linux的time命令所输出的时间含义一致,分别代表用户态消耗的CPU时间、内核态消耗的CPU事件和操作从开始到结束所经过的墙钟时间(Wall Clock Time)。CPU时间与墙钟时间的区别是,墙钟时间包括各种非运算的等待耗时,例如等待磁盘I/O、等待线程阻塞,而CPU时间不包括这些耗时,但当系统有多CPU或者多核的话,多线程操作会叠加这些CPU时间,所以读者看到user或sys时间超过real时间是完全正常的。
对象的内存分配,往大方向上讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地在栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB 上分配。少数情况下也可能会直接分配在老年代中。
有以下几条普遍的内存分配规则
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。
所谓大对象就是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串及数组(byte[] 数组就是典型的大对象)。大对象对虚拟机的内存分配来说就是一个坏消息(替Java虚拟机抱怨一句,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,写程序的时候应当避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。
虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁)时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold来设置。
为了能更好地适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。