一、预备知识
1. 如何判断一个对象是不是垃圾
1.1 可达性分析
以GC ROOT为起点进行扫描,能够被扫描到的对象,都是存活的对象,而无法被扫描到的对象,就是“垃圾”,需要被回收。GC ROOT一般包括但不限于:
- 虚拟机栈中局部变量表中引用的对象
- 本地方法栈中 JNI 中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中的常量引用的对象
- 被同步锁持有的对象
1.2 对象引用
可达性分析是通过 “GC ROOT” 和对象引用来完成的可达性分析,GC ROOT上面已经说过了,这里主要说说对象引用。 如果对象引用只有 :被引用 和 没被引用,那么对于描述一些“食之无味,弃之可惜”的对象就显得无能为力。 例如,对于有些对象,我们希望在内存足够多的时候保留,内存吃紧的时候被回收(例如缓存),或是对于有些对象我们希望当仅仅被某个特殊引用引用着时被回收(WeakHashMap)等,仅靠 “被引用”和“未被引用” 这两个状态,显然无法满足一些特殊情况的要求,为此,在JDK1.2之后,Java对引用概念进行了扩充。
强引用
- 能够被一个或多个线程找到的引用
- 例如: Object obj = new Object();
- GC不收集强引用对象
软引用
软引用是用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
使用:
SoftReferenc<Type> ref = ...;
使用场景:
- 用于缓存.
- 举例: 在浏览器实现后退这一功能时:
- 如果每次后退都再重新请求一次网页,那么时间开销会相对较大,用户体验差;
- 如果每次后退都将页面缓存起来,那么内存可能会溢出;
- 那么,如果用软引用来指向缓存的页面的话,当内存不足时,会优先收集它们!
弱引用
弱引用也是用来描述非必须对象的,他的强度比软引用更弱一些.
WeakReference<Type> ref = ...
;在垃圾回收时,如果这个对象只被弱引用关联(没有任何强引用关联他),那么这个对象就会被回收。
弱引用的出现主要是为了解决过期映射的收集的, 考虑下面这个场景:
- 在使用HashMap中,如果HashMap里保存了<key0, value0>,且key0永远都不会再出现了,那么显然应该收集掉<key0,value0>. 但是,这个键值对缺被HashMap强引用着,导致无法被回收..
问题的解决就是用WeakHashMap, 也就是弱引用的应用了, 使用弱引用,当没有外界对象引用<key0,value0>时,WeakHashMap只是一个弱引用,在GC的时候,就会回收这个需要被收集的对象。
虚引用
一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获取一个对象的实例。
为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
PhantomReference<Type> ref = ... ;
应用场景:
- jdk中直接内存的回收就用到虚引用,由于jvm自动内存管理的范围是堆内存,而直接内存是在堆内存之外(其实是内存映射文件,自行去理解虚拟内存空间的相关概念),所以直接内存的分配和回收都是有Unsafe类去操作,java在申请一块直接内存之后,会在堆内存分配一个对象保存这个堆外内存的引用,这个对象被垃圾收集器管理,一旦这个对象被回收,相应的用户线程会收到通知并对直接内存进行清理工作。
- 虚引用必须配合引用队列一起使用,因为上面引用的get操作,也就是`ref.get()` 将返回`null`, (软引用和弱引用对象的get都是返回指向的对象), 所以,必须搭配引用队列一起使用.
- <font size=5 color=blue>引用队列</font>
目前对引用队列的理解:
- 先捋清楚概念:
1. 引用对象: 例如`SoftReference<T>`
2. 被引用对象: 即引用对象指向的对象
3. 引用队列: 里面保存着引用对象
- 在创建引用对象的时候,可以将引用队列作为构造参数传入,这样的话,当被指向的对象被GC收集的时候 ( 注意,这里是指对象被GC收集了,但对象所占的内存并没有被释放!!!因为收集完对象之后并不是立即释放内存,而是等一会. [参考](https://www.zhihu.com/question/21663879) ),引用队列会把引用对象Enqueue到队列里去. 引用队列的remove操作如果队列为空的话是阻塞的.
- 虚引用是通过和引用队列的搭配,来实现在对象被收集时,发出系统通知.
- 猜测: 1个队列对应1个虚引用,实现了对特定对象的回收时的系统通知!
2. 对象的生命周期
为了更好的理解垃圾回收,我们来从对象的第一视角来看,一个对象从产生到被回收的过程。
第一阶段:创建阶段
此阶段就是new一个对象的过程,当被初始化结束赋值给某个引用的时候,就进入了应用阶段
第二阶段:应用阶段
此阶段对象至少被一个强引用持有着,正在被使用
第三阶段:不可见阶段
当前程序找不到对象了, 也就是说, 程序的执行位置超出了对象的作用域;
1
2
3
4
5for(...){
Object obj = new Object();
....
}
//程序执行到这, 就超出了obj的作用域但并不是说不存在强引用持有着当前对象! 尽管无法被当前程序发现,但该对象仍可能被JVM等系统下的某些已装载的静态变量或线程或JNI等强引用持有着,这些特殊的强引用被称为”GC root”!
第四阶段:不可达阶段
该对象不再被任何强引用所持有。
第五阶段:收集阶段
当垃圾回收器发现该对象已经处于“不可达阶段” ,那么这个对象会被第一次标记,然后进行判断是否需要执行
finalize()
,如果这个对象“没有重写finalize()方法” 或者已经执行过finalize()方法了 那么对象就直接进入终结阶段等待被回收。 否则的话,说明该对象需要执行finalize()
,那么就将它放到F-Queue队列中等待Finalizer
线程来挨个执行这个队列里的对象的finalize()
方法。 (ps:这里说的执行仅仅是触发其开始执行,但并不一定会等到它执行结束,因为如果finalize死循环了就把其他的对象给耽误了。)第六阶段:终结阶段
当对象不需要执行finalize()方法 或 执行完finalize()方法后仍然处于不可达状态时,则该对象进入终结阶段。在该阶段是等待垃圾回收器对该对象空间进行回收。
第七阶段:空间回收阶段
垃圾回收器对该对象的所占用的内存空间进行回收或者再分配了,则该对象彻底消失了,称之为“对象空间重新分配阶段”。
3. 分代收集预备知识
我们都知道GC堆会被分代进行收集,一般会被划分为新生代和老年代,那么为什么要分代呢?
分代收集理论
弱分代假说
绝大多数对象都是朝生夕灭的。
强分代假说
熬过越多次垃圾收集过程的对象就越难以消亡
跨代引用假说
跨代引用相对于同代引用来说仅占极少数
因为跨代引用仅占了极少数,所以我们大可不必为少量的跨代引用对象而去扫描整个老年代,所以就在新生代上使用Remeber Set来记录哪些对象存在老年代的引用,这样就可以不用去扫描整个老年代了,Remeber Set具体如何实现的,到后面再说,这里知道它存在的意义就可以了。
分代收集定义:
Partial GC
- 新生代收集(Young GC/Minor GC)
- 老年代收集(Old GC/ Major GC)
需要注意的是,对老年代的单独收集实际上只在CMS中存在,其他都不存在只针对老年代的收集,都是在Full GC的时候对老年代收集。 - 混合收集(Mixed GC)
指的是收集整个新生代和部分老年代,目前只在G1收集里有。
Full GC
对整个Java堆和方法区的垃圾收集。
4. 垃圾收集算法
注意,这里的垃圾收集算法只是针对“垃圾收集”这一过程,垃圾收集器的工作比较复杂,“垃圾收集”只是其中的一个环节。下面来看看几种常见的垃圾收集算法。
标记 - 清除
- 标记阶段:
- 在标记阶段,程序会检查每个对象是否为活动对象,如果是活动对象,则程序会在对象头部打上标记。
- 清除阶段:
- 在清除阶段,会进行对象回收并取消标志位,另外,还会判断回收后的分块与前一个空闲分块是否连续,若连续,会合并这两个分块。回收对象就是把对象作为分块,连接到被称为 “空闲链表” 的单向链表,之后进行分配时只需要遍历这个空闲链表,就可以找到分块。
在分配时,程序会搜索空闲链表寻找空间大于等于新对象大小 size 的块 block。如果它找到的块等于 size,会直接返回这个分块;如果找到的块大于 size,会将块分割成大小为 size 与 (block - size) 的两部分,返回大小为 size 的分块,并把大小为 (block - size) 的块返回给空闲链表。
不足:
- 标记和清除过程效率都不高;
- 会产生大量不连续的内存碎片,导致无法给大对象分配内存。
标记 - 整理
让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
优点:
- 不会产生内存碎片
不足:
- 需要移动大量对象,处理效率比较低。
复制
将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。
主要不足是只使用了内存的一半。
现在的商业虚拟机都采用这种收集算法回收新生代,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor。
HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象。
新生代使用:
- 复制算法
老年代使用:
- 标记 - 清除 、 标记 - 整理
二、垃圾回收器
Serial / Serial Old收集器
特点:
- 都是串行的垃圾收集器,最古老的GC,都默认工作在JVM的Client模式下;
- Serial工作在新生代,Serial工作在老年代
- 它们的优点是简单高效,在单个 CPU 环境下,由于没有线程交互的开销,因此拥有最高的单线程收集效率。
- 它收集一两百兆垃圾的停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿时间是可以接受的
Parallel Scanvenge / Parallel Old
特点:
- 主打吞吐量( 较短的STW和吞吐量,这两个不能兼得,而 Parallel GC 就是优先考虑吞吐量的GC)
- $吞吐量= \frac{运行用户代码时间}{运行用户代码时间+垃圾收集时间}$
- 在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。
ParNew / CMS + Serial Old
特点
- ParNew 是 Serial 的并行版本,工作在年轻代,工作在JVM的Server模式下, 因为CMS是工作在老年代的多线程GC,而年轻代里的多线程GC,只有ParNew。(虽然Parallel Old也是多线程的,但它是面向吞吐量的,要和Parallel Scanvenge搭配)
- CMS是工作在老年代下的,当因为浮游垃圾导致了Concurrent Mode Fail的时候,会触发Full GC, 此时会有后备预案Serial Old 来对老年代进行收集。
CMS
解决的问题
CMS的目标是以缩短STW,减少停顿感,尽管目前已不推荐使用ParNew+CMS,但作为初代考虑 “ 用户线程与GC线程并发执行 ”这一GC还是先来学习一下它的思想。
流程
初始标记
初始标记仅仅只是标记一下GC ROOTs能直接关联到的对象,速度很快,需要停顿。
并发标记
进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,但不需要停顿用户线程,用户线程和GC线程在此阶段可以并发执行。
重新标记
为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。 举个例子,在使用三色标记法标记的时候,如果在并发标记的过程中, 用户线程做了一个操作,使得已经被标记完的对象指向了一个尚未被扫描的对象,然后指向这个尚未被扫描的对象的引用又被null
掉了,这时就会出现空指针的问题,所以会使用 写屏障+SATB 来解决这个问题(具体在“三色标记法”章节中)
而重新标记这个阶段就是为了让这些对象重新标记的,这一阶段需要停顿。
重新标记
并发清除:不需要停顿。
优点:
- 并发收集,低停顿。
缺点:
- 吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
- 无法处理浮动垃圾,可能出现 Concurrent Mode Failure,触发Full GC。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。 如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
- 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余足够,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
G1垃圾收集器
一、特点与设计目标
- 软实时、低延时、可设定STW停顿目标
- 适用于较大的堆(>4G) (因为可以进行部分扫描)
- 设计目标就是尽可能少的调优.
- 设定最大STW的停顿时间:-XX:MaxGCPauseMillis=N
二、G1内部细节
- 无需回收整个堆,而是回收一个Collection Set
- 两种GC:
- Fully young GC
- Mixed GC
- 估计每个Region中的垃圾比例,优先回收垃圾多的Region!
三、Card Table & Remember Set
每一个Region都被分成了多个Card(如下图的长条),每一个Region都有一个RSet(Remember Set), 每一个RSet里记录了引用当前Region的那些Card的位置,如此以来,在找跨代引用的时候, 就可以不用进行全堆扫描了,扫描RSet里记录的Card就可以!例如:A.a引用了b对象,于是在b对象所在Region的RSet中记录了A.a所在的Card的位置
那么随之而来的一个问题就是,当出现引用关系改变的时候会需要去写RSet,那么在多线程环境下,RSet显然就成了临界资源,那么如何高效地写RSet就成了一个问题。 解决方法是通过 dirty queue来做,下面来说。
Write Barrier + Dirty Queue 来实现对RSet的更新
在进行赋值操作的时候(即引用指针发生变化的时候),JVM注入的一小段代码。object.field = <reference>(putfield)
- 当更新指针时(例如:
A.a = b
):- 将A.a所在的Card置为Dirty
- 将这个Dirty Card压入Dirty Card Queue
- 有一个Refinement线程去清空Dirty Card Queue,就是对RSet的更新。
四、流程
fully young GC
- Stop The World :
- 第一步:构建Collection Set ( Eden+Survivor )
- 第二步:扫描GC Root:
- 扫描与GC Root直联的对象. 这里详细说一下为什么要进行GC Root的扫描,实际上,在进行mixed GC的时候,会先进行一次Young GC,然后!因为fully young gc是stop the world的!! 所以在这个时间段内趁机进行一次 GC ROOTs的扫描,这也成为“借道(Piggybacking)”
> 事实上,当达到 IHOP 阈值时,G1 并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的 STW 时间段,完成初始标记,这种方式称为借道(Piggybacking)
- 第三步:更新Remember Set:
- 就是将Dirty Card Queue 排空; 在发生引用的赋值时,例如:`A.a = B.b;` 此时,a对象是对b对象的引用,这时,需要更新b对象的RememberSet,写入`A.a这个对象引用了我`这个信息,具体的操作就是在RSet中写入一个**Entry<a对象所在Region的首地址, a对象所在的Card在其所处Region的偏移量>** ( ps: RSet是一个类似HashMap的数据结构 ) ; 但是! 这个写入操作并不是在赋值时就立刻写入,而是将a对象所在的Card给标记成dirty,然后将这个Card给丢到`Dirty Card Queue`中,如果队列内元素数量不是太多那么一般就不去处理,等到在当前阶段(就是Fully Young GC的更新RSet阶段)进行更新; 但如果数量超过一定的界限,那么就会激活Refinement线程开始更新RS,避免当前STW过久; 如果使用Refinemement线程去更新RS仍然还是有很多DirtyCard,就说明用户线程产生的DirtyCard太快了,那么会让应用线程也参与到更新RS的工作中,以此将队列中的DirtyCard排空;
- 第四步:Process Remember Set:
- 确定完Card了之后,就去扫描Card,找出老年代中引用了当前对象的那些对象! (这些对象就是跨代引用的对象,其实最开始的初始标示的集合里 包括了 和GC ROOT直接相连的对象 和 在老年代里引用了(当前Collection Set里的对象)的那些对象!)
- 第五步:对象拷贝 (这里使用的是复制算法)
- 将Eden和Suvivor中的对象拷贝到另一些Survivor区域中.
- 第六步:处理引用队列
- 对软引用,弱引用,虚引用的处理。
Old GC
- 第一步:先进行一次 Fully young GC;
- 第二步:恢复应用线程,使用三色标记法并发标记对象:
- 三色标记法:
- 黑色表示已经访问完的;
- 灰色表示正在处理访问的;
- 白色表示尚未被访问的;
- 在标记过程中可能会发生两个问题:
- 问题一:已经访问过的对象,突然变成了垃圾,这就是浮游垃圾:
- 解决方法: 浮游垃圾的产生,这些垃圾只能等到下次GC的时候被回收;
- 问题二:某灰色对象引用了对象A,在并发操作的过程中,断开了灰色对象对对象A的引用,并添加了黑色对象对对象A的引用,此时对象A依然是处于强引用状态,但却永远无法被访问到(因为黑色对象已经被处理过了),因此会被误判成垃圾给释放掉。
- 解决方法: 使用Write Barrier实现SATB(Snapshot-At-The-Begining)
- 在并发标记的过程中,当对象引用发生
null
的改变时,例如原本A.a=B.b;
, 然后执行了A.a=null;
就发生了null
的改变,此时,会将B.b
给标记下来。 简而言之,在并发标记期间给null掉的对象,仍然视为存活的对象,如果被误判了,那么就是浮游垃圾,会在下次GC回收。
- 在并发标记的过程中,当对象引用发生
- 三色标记法:
- 第三步:STW,重新标记Remark:
- SATB/Reference processing :保证标记的对象都真的是活对象
- CLeanUp:回收需要回收的Region,这里只会回收整个Region为空的Regin,如果一个Region只有部分的空间被利用,将无法被回收。。因此会产生碎片。。解决方法就是
Mixed GC
恢复应用线程
下面是一次G1 Old GC流程
Mixed GC
- 不一定会立刻发生,而是根据建立的预测模型来进行判断是否需要进行.
- 选择若干个Region进行回收, 包括:
- 默认1/8的Old Region (优先挑选垃圾最多的Region)
- 可用-XX:G!MixedGCCountTarget=N 来设定(默认N=8)
- Eden + Survivor Region
- STW, Parallel Copying
- 默认1/8的Old Region (优先挑选垃圾最多的Region)
Mixed GC主要可以分为两个阶段:
全局并发标记(global concurrent marking)
全局并发标记又可以进一步细分成下面几个步骤:- 初始标记(initial mark,STW)。它标记了从GC Root开始直接可达的对象。初始标记阶段借用young GC的暂停,因而没有额外的、单独的暂停阶段。
- 并发标记(Concurrent Marking)。这个阶段从GC Root开始对heap中的对象标记,标记线程与应用程序线程并行执行,并且收集各个Region的存活对象信息。过程中还会扫描上文中提到的SATB write barrier所记录下的引用。
- 最终标记(Remark,STW)。标记那些在并发标记阶段发生变化的对象,将被回收。
- 清除垃圾(Cleanup,部分STW)。这个阶段如果发现完全没有活对象的region就会将其整体回收到可分配region列表中。 清除空Region。
拷贝存活对象(Evacuation)
Evacuation阶段是全暂停的。它负责把一部分region里的活对象拷贝到空region里去(并行拷贝),然后回收原本的region的空间。Evacuation阶段可以自由选择任意多个region来独立收集构成收集集合(collection set,简称CSet),CSet集合中Region的选定依赖于上文中提到的停顿预测模型,该阶段并不evacuate所有有活对象的region,只选择收益高的少量region来evacuate,这种暂停的开销就可以(在一定范围内)可控。