垃圾
什么是垃圾:
在运行中没有任何指针指向的对象.
垃圾回收算法
垃圾回收总体上来说分为两步,一:判断对象是否存活,标记垃圾、二:清除垃圾
标记阶段的主要算法:
- 可达性分析算法
- 引用计数算法
清除阶段的主要算法:
- 标记-清除算法
- 复制算法
- 标记-压缩算法
分代收集
对于不同代的对象采用不同的收集算法.
对于年轻代中的对象,多是朝生夕死的,垃圾较多,使用复制算法来回收
对于老年代中的对象,多是长期存活的,垃圾较少,占用内存时间较多,采用标记-清除算法和标记-压缩算法混合的模式来回收
增量收集
在进行GC时会触发STW,增量收集通过交替进行垃圾回收线程和用户线程来减少系统的停顿时间,同样的整体上会增加垃圾回收的成本,同时会降低系统的吞吐量
分区收集
对整个堆区进行回收时间消耗多,STW的时间也更多,因此G1就使用了Region的思想,将堆空间分成若干个小区间,对小区间单独进行垃圾回收以减少系统的停顿时间,同样的整体上会增加垃圾回收的成本,同时会降低系统的吞吐量
System.gc()
使用System.gc()即Runtime.getRuntime().gc()将显式触发FullGC,但并不一定会调用垃圾回收器
不可达对象的GC行为
首先需要明确的是GC时局部变量表中的引用指向的对象必不会回收,引用为null的必会回收.
另外,由于虚拟机栈帧中局部变量表是可复用的,当局部变量表的slot被其他数据复用了之后局部变量表slot的原引用也就不存在了,其原对象也就不可达了.
public void GCTest03() {
{
byte[] bytes = new byte[5 * 1024 * 1024];
}
//此时GC 虽然bytes的作用域已经结束,当栈帧中局部变量表的slot还有这个引用,就不会被GC
System.gc();
}
public void GCTest04() {
{
byte[] bytes = new byte[5 * 1024 * 1024];
}
int value = 1;
//此时GC,bytes的局部变量表slot已经被value占据,bytes的引用也不存在与栈帧了,bytes的对象也就不可达了,可以被GC
System.gc();
}
GC的安全点与安全区域
GC切入用户线程的时机并不是任何时刻,而是只能在安全点切换.安全点通常是方法调用时,循环跳转,异常跳转等
用户线程如何中断主要有两种
-
1.主动中断,设置一个中断标志,用户线程运行到安全点时主动去轮询中断标志,为真时主动挂起线程
-
2.抢先中断,中断所有线程,如果有线程不在安全点就恢复线程,使其跑到安全点再挂起
当用户线程处于sleep或者blocked状态时,无法响应jvm的中断请求,这时就需要安全区域来切入.
如果在一段代码片段中,引用关系不会发生变化,那么在这段区域内任何时刻GC都是可以的.
引用类别
java从jdk1.2之后扩展了引用的类别
现在主要有:
- 强引用,最常见的引用方式,指传统的引用赋值(Object obj = new Objcet()),只要引用关系存在就不会被GC
- 软引用,在内存空间不够时,会将软引用也纳入垃圾回收的垃圾.回收后空间还不足就直接OOM
- 弱引用,只能存活到下一次GC之前,只要发生GC,就会回收调弱引用的对象
- 虚引用,通过虚引用无法获取到对象的实例,虚引用的目的是追踪GC的过程,在回收虚引用时会将引用加入到虚引用队列中,这样就可以通过队列来追踪GC的过程
- 终结器引用
强软弱虚这四种引用强度依次降低
GC性能指标
- 吞吐量,用户线程运行的时间在总时间的比例
- 暂停时间,垃圾回收时STW的时间
- 内存占用,java堆区所占的内存大小
暂停时间的减少是现在主要的方向
垃圾回收器的串行,并行与并发
串行:指STW时,只能同时进行一个垃圾回收线程回收
并行:指STW时,多个垃圾回收线程同时进行垃圾回收
并发:指一个时间段内垃圾回收线程和用户线程同时执行
经典的垃圾回收器
- 串行:Serial ,Serial Old
- 并行:ParNew,Parallel scavenge,Parallel Old
- 并发:CMS,G1
Serial >> Prarllel >> CMS >> G1 >>ZGC
垃圾回收器之间的组合
使用-XX:+PrintCommandLineFlags
可以查看当前使用的G
GC 的跨代引用与并发标记修正
在进行Minor GC时年轻代的对象可能会被老年代的对象引用,这样跨代引用就需要将老年代的对象加入GC Roots中.
在JVM中使用记忆集(RSet)来记录引用了收集区域的其他区域数据.解决跨代引用加入GC Roots的问题.hotspot中使用卡表来实现记忆集.
卡表的实现
CARD_TABLE [this address >> 9] = 0;
将地址右移9位即除512得到卡表的索引,如果索引处的值为1则表示改区域跨代引用.
卡表更新使用写屏障实现,在引用赋值之前更新卡表为写前屏障,赋值之后更新卡表为写后屏障.
在并发标记阶段用户线程对GC Roots树产生修改的情况下,如何保证标记的正确性.
增量更新(CMS):
已标记的对象A新加了连接到不可达对象B的引用使B变为可达的情况.将这些新加入的引用记录(A–>B)记录下来,在并发标记过程之后STW,以A为根再遍历标记.
原始快照(G1):
已标记的对象A删除了对象B的引用,记录这个被删除的B对象,在并发标记之后STW,以B为根遍历标记.
GC日志
引用计数算法
使用引用计数器来记录一个对象被引用的情况,增加引用就加1,引用失效就减1,当计数为0时就可以回收.
优点:
- 简单,判定效率比可达性分析高,没有延迟.
缺点:
- 需要使用引用计数器,增加了空间开销.需要修改引用计数,消耗时间.没法处理循环引用的问题.
java并没有使用引用计数算法,python使用的是引用计数算法
python解决循环依赖的方式:
1.通过手动解除,将引用置为null.
2.使用弱引用来表示对象之间的引用,GC时回收弱引用
可达性分析算法
可达性分析算法也很简单高效还能解决循环引用问题,防止内存泄露.这种垃圾标记的算法通常也叫做追踪性垃圾收集
可达性分析算法以根对象集合(GC Roots)为起点,从上到下搜索被根对象连接的对象是否可达.经过可达性分析之后,内存中的活对象都会被GC Roots直接或间接引用,这个走过的路径也就是引用链.如果对象没有任何引用链连接上,就可以被标记为垃圾
GC Roots:
- 虚拟机栈中引用的对象
- 本地方法栈中引用的对象
- 方法区中类的静态属性引用的对象
- 方法区中常量引用的对象
- 所有被同步锁synchronized持有的对象
- jvm内部的引用(基本数据类型的Class,一些常驻的空指针异常等异常对象,系统类加载器等等)
- 反映JVM内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存
栈上存放的指针和变量,以及不存在于堆中但指向堆中对象的指针都是Roots
在进行局部回收时也会将一些堆中的引用临时加入GC Roots,比如Minor回收年轻代时就可以将老年代和永久代的指针加入GC Roots以进行局部回收
由于可达性分析时必须要保证一致性,所以在GC过程中会出现STW
优点:
- 可以解决循环引用,防止内存泄露
- 不需要记录对象的引用信息,占用空间小
缺点:
- 判定效率不高,需要遍历GC Roots标记可达对象,时间上慢一些.
finalization机制
再回收一个对象前都会调用对象的finalize()方法,对象可以通过重写finalize方法来添加回收的自定义逻辑.
由于finalization机制的存在,jvm中的对象会存在三种状态
- 可触及,可达性分析后处在引用链中的对象都是可触及的
- 可复活,对象的所以引用都被释放,但对象可能在finalize()方法中复活
- 不可触及,回收调用finalize()之后对象没有复活.这时就是不可触及的
具体过程:
1.再进行一轮可达性分析之后,如果对象到GC Roots没有引用链,则进行第一次标记
2.判断对象是否执行过finalize()
- 执行过finalize(),直接判断为不可触及对象
- 没有执行过finalize()
- 对象没有重写finalize(),对象没有复活的机会,直接判断为不可触及状态
- 对象重写了finalize(),将对象插入到F-Queue队列中,由虚拟机自己创建的finalizer线程依次调用F-Queue中的对象的finalize(),如果finalize()方法中对象和GC Roots建立了引用链,则认为可复活,不进行垃圾回收
标记-清除算法(Mark-Sweep)
标记:从根节点开始遍历,找到所有被引用的对象,并在对象头标记为可达对象.
清除:依次遍历堆内存的所有对象,对于非可达对象就回收,这里并不是直接将内存中的对象置为空,而是将对象的地址放到空闲列表中,下次有新对象就可以直接覆盖
优点:
- 实现简单.
缺点:
- 效率并不高
- 回收的内存区域是不规整的,需要维护一个空闲内存表,用来分配新建对象的空间.
- GC时会触发STW
复制算法(Copying)
复制算法规划了两个大小一样的内存空间(form和to),在对其中的form区进行可达性分析时将可达对象直接复制到to区依次放好.标记完毕之后清空form区.
在新生代的survivor区就是使用的复制算法重复利用s0和s1
优点:
- 不需要进行垃圾的标记和回收,效率更高
- 整理之后的空间是连续的,没有内存碎片
缺点:
- 需要两倍的空间来做复制,有一块区域使用没有实际存储
- 对于G1这种基于Region的垃圾回收器而言,调整堆空间的内存,意味着需要大量调整JVM维护的Region之间的引用关系以及栈上的引用地址,这时的内存开销和时间开销都是很大的
- GC时会触发STW
复制算法在对于垃圾特别多的情况下效果会更理想,因为需要复制的可达对象更少,所以在新生代这种死亡率很高的区域使用了复制算法
标记-压缩算法(Mark-Compact)
标记:从根节点开始遍历,找到所有被引用的对象,并在对象头标记为可达对象.
清除:依次遍历堆内存的所有对象,对于非可达对象就回收,将对象置空
压缩:将清除之后的内存进行整理,将可达对象移动到内存的一端,避免碎片
在老年代回收时使用的就是标记压缩算法
优点:
- 避免了标记清除算法产生的碎片问题
- 相对于复制算法使用内存少
缺点:
- 效率低于复制算法和标记清除算法
- 移动对象需要调整引用,有时间消耗
- 移动过程中会触发STW
三种算法的对比
\ | 标记清除 | 复制 | 标记压缩 |
---|---|---|---|
时间 | 中等 | 快 | 慢 |
内存 | 需要维护空闲内存列表,有内存碎片 | 需要双倍空间,没有内存碎片,不需要维护空闲列表 | 没有内存碎片,不需要维护空闲列表 |
引用调整 | 否 | 是 | 是 |
垃圾回收器之间的搭配
Serial GC & Serial Old GC
Serial GC是最悠久和古老的GC了,而且也是hotspot虚拟机在Client模式下新生代GC的默认选择,
Serial Old GC是Client模式下老年代GC的默认选择,在Server模式下可以搭配Parallel Scavenge GC使用,也可以作为CMS的后备方案.
Serial GC采用的是复制算法,串行回收,STW的方式.
Serial Old GC采用的也是串行回收和STW的方式,而内存回收使用的是标记-压缩算法.
在单CPU的情况下Serial系列的GC性能更优
使用-XX:+UseSerialGC
来将Serial GC作为新生代的GC将Serial Old GC作为老年代的GC
ParNew GC
ParNew GC和Serial GC一样也是使用复制算法+STW并行回收,区别就是ParNew是并行的垃圾回收器.在很多的JVM中都是作为Server模式新生代的默认GC
使用-XX:+UseParNewGC
设置ParNew作为新生代的GC
使用-XX:ParallelGCThreads=8
来设置并行的GC线程数
Parallel Scavenge GC & Parallel Old GC
Parallel Scavenge GC和ParNew GC一样使用的是复制算法+STW并行回收,区别在于Parallel Scavenge GC目标在于达到一个可控制的吞吐量,具有垃圾回收的自适用调节策略将调节Eden和Survivor之间的比例以及晋升年龄来达到吞吐标准.
Parallel Old GC使用的是标记-压缩算法+STW并行回收
Parallel Scanvenge+Parallel Old GC 是JDK8中的默认GC
使用-XX:+UseParallelGC
设置Parallel Scanvenge GC 作为新生代GC
使用-XX:+UseParallelOldGC
设置Parallel Old GC 作为老年代GC
上述两个参数是互相激活的
使用-XX:ParallelGCThreads=8
来设置并行的GC线程数
使用-XX:MaxGCPauseMillis=10
来设置STW的时间为10ms
使用-XX:GCTimeRatio=99
来设置GC时间占总时间的比例为1%
使用-XX:+UseAdaptiveSizePolicy
来设置Parallel Scanvege的自适应调节策略
CMS(Concurrent Mark-Sweep) GC
第一款并发的垃圾回收器,使用的标记-清除算法,也会有STW.主打低延迟
- 初始标记阶段需要STW,仅标记GC Roots直接关联的对象.标记的对象并不多,STW的时间很短
- 并发标记不需要STW,通过直接关联对象遍历对象图,找到所有的可达对象.
- 重新标记需要STW,用于修正那些在并发标记阶段对象可达性发生变化的那一部分对象.
- 并发清理不需要STW,清理掉不可达对象
CMS不会在内存空间不足的时候才开始垃圾回收,而是在达到一定阈值时就会开启回收,这样做的原因是因为CMS清理垃圾是并发的,这个阶段用户线程还在不断产生垃圾.所以需要预留空间给用户线程.如果清理垃圾过程中用户线程空间不足,就会出现Concurrent Mode Failure,这时将调用Serial Old作为预备方案来进行老年代的垃圾回收.
CMS由于回收垃圾阶段是并发的,所以也不能使用标记-压缩算法,因为标记-压缩算法会对对象进行移动,这会导致用户线程的对象引用地址错误.
优点:
- 低延迟
- 并发收集
缺点:
- 会产生内存碎片,分配大对象时可能会没有连续内存导致Full GC
- 由于是并发的,会占用一部分线程和用户线程竞争CPU,会使吞吐量有下降
- 无法处理浮动垃圾.由于垃圾回收阶段是并发的,用户线程就还在不断产生垃圾.这部分垃圾就只能等下次再回收.由于Concurrent Mode Failure的问题,会调用Serial Old再触发一次Full GC
使用-XX:+UseConcMarkSweepGC
使用CMS作为老年代的GC,会触发-XX:+UseParNewGC
.使用ParNew+CMS+Serial Old的组合
使用-XX:CMSInitiatingOccupanyFraction 96
设置堆内存触发CMS的阈值为96%.内存增长快时设低避免出现Concurrent Mode Failure.内存增长慢时设高降低CMS的触发频率
使用-XX:+UseCMSCompactAtFullCollection
执行完GC后对内存进行整理
使用-XX:CMSFullGCsBeforeCompaction=3
在执行过三次Full GC之后对内存进行压缩整理
使用-XX:ParallelCMSThreads=1
设置CMS的线程数量,指垃圾回收线程数
G1
为了在保证延时时间的情况下提高吞吐量的目标,"全功能回收"的垃圾回收器G1诞生.也是目前jdk8之后的默认GC
优点:
- 并行与并发,G1允许同时允许多个垃圾回收线程并行执行,此时需要STW.在垃圾回收的部分工作中可以和用户线程并发执行
- 分代收集,G1仍然是分代型垃圾回收器,也会区分年轻代与老年代,年轻代也一样分为Eden和Survivor 0 和Survivor 1.只是G1不再要求这些区内存整体连续,而是使用多个可能不连续的Region来表示一个区域.
- 空间整合,G1以Region作为垃圾回收的基本内存单位,Region之间使用的复制算法,而整体上又是使用的标记-压缩,这两种算法都不会产生内存碎片
- 可预测的时间停顿模型,G1由于使用Region设计,回收的区域可以进行调整,因此控制停顿时间,在停顿时间要求范围内对优先队列中的价值高的Region进行回收,提高回收效率.
缺点:
- 内存大的情况下G1比CMS效率高,内存小的情况下G1可能不如CMS.平衡点在6-8GB
使用-XX:+UseG1GC
使用G1作为垃圾回收器
使用-XX:G1HeapRegionSize=1024
设置Region的大小为1m,值需要是2的幂,大小在1m-32m.
使用-XX:MaxGCPauseMillis=200
设置STW的预期最大停顿时间为200ms.
使用-XX:ParallelGCThread=8
设置并行的垃圾标记线程数为8
使用-XX:ConcGCThreads=1
设置并发的垃圾清理线程为1.
使用-XX:InitiatingHeapOccupanyPercent=45
设置触发并发回收GC的堆占用率阈值为45.
G1的Region设计
G1中不再使用连续的内存来表示Eden、S0、S1、old,而是使用多个Region来共同表示.一个Region只能表示一种角色,在使用期间不能转换角色,回收复制后可以作为其他角色.同时如果对象大小超过1.5个region大小,就放到Humongous区.
设置H区的原因就是避免一些短期存在的大对象造成内存的应用率下降.如果一个H区存不下就寻找连续的H区来存储,如果找不到只能触发Full GC
在Region分配对象时使用指针碰撞来分配内存
G1回收的过程
1.当Eden中内存不够时触发Young GC,触发STW启动多个并行的垃圾回收线程,移动活对象到Survivor区晋升对象到Old区.
2.当堆空间内存占用率超过阈值(默认45%)时开始Young GC之后再并发标记
3.标记完成后G1在保证延时的情况下回收价值高的Region(年轻代与老年代都有).
4.当内存仍然不够时触发独占式单线程STW的Full GC
具体细节看5.3.1 G1的垃圾回收过程
G1回收过程一: 年轻代GC
JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。
年轻代垃圾回收只会回收Eden区和Survivor区。
YGC时,首先G1停止应用程序的执行(Stop-The-World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。
然后开始如下回收过程:
第一阶段,扫描根。
根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。跟引用连同RSet记录的外部引用作为扫描存活对象的入口。
第二阶段,更新RSet。
处理dirty card queue(见备注)中的card,更新RSet。此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用。
备注:
对于应用程序的引用赋值语句object.field=object,JVM会在之前和之后执行特殊的操作以在dirty card queue中入队一个保存了对象引用信息的card。在年轻代回收的时候,G1会对Dirty Card Queue中所有的card进行处理,以更新RSet,保证RSet实时准确的反映引用关系。
那为什么不在引用赋值语句处直接更新RSet呢?这是为了性能的需要,RSet的处理需要线程同步,开销会很大,使用队列性能会好很多。
第三阶段,处理RSet。
识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
第四阶段,复制对象。
此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阈值会被复制到Old区中空的内存分段。如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。
第五阶段,处理引用。
处理Soft,Weak,Phantom,Final,JNI Weak 等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。
G1回收过程二: 并发标记过程
- 初始标记阶段: 标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC。
- 根区域扫描(Root Region Scanning): G1 GC扫描survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在young GC之前完成。
- 并发标记(Concurrent Marking): 在整个堆中进行并发标记(和应用程序并发执行),此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
- 再次标记(Remark): 由于应用程序持续进行,需要修正上一次的标记结果。是STW的。G1中采用了比CMS更快的初始快照法: snapshot-at-the-beginning(SATB)。
- 独占清理(cleanup,STW): 计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的。
- 并发清理阶段: 识别并清理完全空闲的区域。
G1回收过程三: 混合回收
当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region。这里需要注意: 是一部分老年代,而不是全部老年代。可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC并不是Full GC。
并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过-XX:G1MixedGCCountTarget设置)被回收。
混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。
由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高,越会被先回收。并且有一个阈值会决定内存分段是否被回收。-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。
混合回收并不一定要进行8次。有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少。
G1回收可选的过程四: Full GC
G1的初衷就是要避免Full GC的出现。按时如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。
要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢?比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到full gc,这种情况可以通过增大内存解决。
导致G1Full GC的原因可能有两个:
Evacuation的时候没有足够的to-space来存放晋升的对象;
并发处理过程完成之前空间耗尽。