在没有系统了解java垃圾回收机制之前,脑子里头对什么引用计数法,可达性分析法,标记清除,复制算法,分代收集算法,都是了解点意思,但是脑袋里其实一团浆糊( ̄. ̄),然后还一堆回收器的,啥serial,parallel,CMS,G1,我更懵逼了,看书也很难直接联系起来他们的关系 ,每次直接摆烂算球(`Д´*)9。这次学习后系统了解后终于能串起来,整理一下笔记和一些个人理解。
文章目录
一.判断对象已死
垃圾回收前,首先要确认那些对象是存活的,那些对象已经死去;一般会采用引用计数法和可达性分析法来进行确认;在java中,通过可达性分析法确认那些对象是存活的,在python中采用的是引用计数法来进行确认;
1.引用计数法
什么是引用计数法?
对每个对象保存一个整型的引用计数器属性,用于记录被对象引用的情况,**每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;**任何时刻计数器为零的对象就是不可能再被使用的。但是最大的问题是,引用计数法无法解决循环引用的问题,这也直接导致java没有使用这种算法。
引用计数法的优缺点?
优点:实现简单,判断效率高,垃圾容易辨别;
缺点:无法解决循环引用的问题,每次赋值需要更新计数器,增加了时间开销;
测试代码
通过代码确认java中没有使用引用计数法来确认垃圾对象:
package test.example.jvm;
/**
*@ClassName TestJvmJinfo
*@Description -Xms30m -Xmx30m -Xmn10m -XX:SurvivorRatio=2 -XX:+PrintGCDetails
**/
public class TestJvmJinfo {
TestJvmJinfo testJvmJinfo;
byte[] bytes = new byte[5 * 1024 * 1024];
public static void main(String[] args) {
TestJvmJinfo test1 = new TestJvmJinfo();
TestJvmJinfo test2 = new TestJvmJinfo();
test1.testJvmJinfo = test2;
test2.testJvmJinfo = test1;
test1 = null;
test2 = null;
System.gc();
}
}
分析:可以看出即使存在循环引用,gc发生时,还是回收了创建的两个TestJvmJinfo对象;
2.可达性分析法
什么是可达性分析法?
是以根对象(GCRoots)为起始点,按照从上到下的方式搜索被GCRoots集合所连接的目标对象是否可达,使用可达性分析算法后,内存中存活的对象都被GCRoots集合直接或间接连接着,搜索所走过的路径称为引用链,被GCRoots直接或者间接引用的对象可以确认为存活对象,对于不可达的对象,可以标记为垃圾对象;
那些是GCRoots?
- 虚拟机栈-栈帧中的局部变量表中引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量所引用的对象,例如字符串常量池中引用的String对象(见字符串常量池篇末尾)
- 所有被synchronized持有的对象
- 反映java虚拟机内部情况的JMXBean,JVMTId中注册的回调,本地代码缓存等
- 本地方法栈内JNI,引用的对象
- Java虚拟机内部的引用,例如基本数据类型对应的class对象,一些常驻的异常对象,如nullpointerException,OOMerror,系统类加载器
除了固定的GC Roots集合之外,根据用户选择的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象临时性的加入,共同构成完整GCRoots集合,比如如果young gc回收新生代的话,此时老年代也是GCRoots
3.对象可能的三种状态
可触及:从GCRoots可以触及的对象
可复活:对象的引用被释放,但是可以通过重写了finalize方法复活
不可复活:调用了finalize方法后没有复活的对象,标记为不可复活对象;
注意:finalize方法只会被调用一次,同时只能回收不可复活的对象
4.关于finalize方法及对象的自我拯救
对象回收过程
判断一个对象是否能被回收,则至少需要经历两次标记;思路如下:
如果对象没有被GCRoot直接或者间接引用,则被第一次标记;
然后再对第一次标记的对象进行如下判断来进行第二次标记:
如果对象没有重写finalize方法或者finalize已经执行过一次,则直接视为不可复活状态
如果对象重写了finalize方法并且还未被执行过,那么会将对象加入到一个F-queue队列中,该队列中的对象由一个优先级比较低的Finalizer线程触发其中的finalize方法
如果对象重写了finalize方法,并且在方法中该对象又与GCRoots直接或间接引用的对象搭上边则会被移出F-queue队列;如果未搭上边,那么finalize方法再不会被调用,对象直接变成不可触及;
对象的自我拯救
代码演示:
package test.example.jvm;
import java.util.concurrent.TimeUnit;
/**
*@ClassName TestJvmFinalize
*@Description 测试finalize finalize方法只会被执行一次
* 第一次Gc先标记该对象,下一轮GC会调用finalize方法,如果重写了finalize方法会将对象加入到一个队列中,
* 交给finalizer线程进行处理,如果没有重写则直接清除,标记为不可复活对象
* 如果重写了finalize方法并且在finalize方法内和gc root再次挂上钩,则会移除改队列
* 如果再次置为空,并且再次调用触发GC,会发现,只能复活对象一次,所以finalize方法只能被调用一次
**/
public class TestJvmFinalize {
public static TestJvmFinalize staticObj = null;
@Override
protected void finalize() throws Throwable {
super.finalize();
TestJvmFinalize.staticObj = this;
System.out.println("复活静态变量staticObj");
}
public static void main(String[] args) throws InterruptedException {
staticObj = new TestJvmFinalize();
//将staticObj 置为空
staticObj = null;
//进行gc垃圾回收, 这时应该是第一次标记该对象,会被加入到F-queue队列等待Finalizer线程处理
System.gc();
// 因为finalizer线程优先级比较低,所以等一秒
TimeUnit.SECONDS.sleep(1);
if (staticObj == null) {
System.out.println("staticObj对象已死");
} else {
System.out.println("staticObj对象还活着");
}
//再次尝试杀死它
staticObj = null;
System.gc();
if (staticObj == null) {
System.out.println("staticObj对象已死");
} else {
System.out.println("staticObj对象还活着");
}
}
}
运行结果为:
复活静态变量staticObj
staticObj对象还活着
staticObj对象已死
分析:按照上述的对象回收过程分析出,staticObj对象被置为null后,加入到F-queue等待Finalizer线程执行finalize方法,等待一秒后,staticObj的finalize方法被执行,此时在方法中又与GCRoots打上边了;之后会发现,该对象在GC后没有被杀死;之后再次置为null尝试杀死它,再次进行GC后发现finalize方法没有再执行了,彻底被杀死了,和上述的理论过程相符。
二.垃圾回收相关算法
通过一中的学习描述,我算是知道了引用计数法和可达性分析这两个名字搞定了,都是用来判断对象是否可回收的两种实现方法,引用计数法无法解决循环依赖的问题,java采用了可达性分析来确认,所以以后千万不能说java通过引用计数法来判断对象是否可回收。
然后当确认了那些对象要回收之后,我们如何来回收这些对象呢,然后就引出了标记-清除,复制算法,标记-整理;分别了解一下含义和各自的优缺点;然后再结合堆中新生代和老年代的各个特点,分析这些算法都用在堆的那个区域;
1.标记-清除算法
标记-清除分为两个阶段
标记阶段: 通过可达性分析来进行标记嘛,谨记,标记的是可达对象,回收的是没有标记的对象;
清除阶段: 对堆内存进行线性遍历,判断每个对象的header中有没有标记为可达对象,如果没有标记,不好意思啦,撒哟啦啦;
优点:不会占用太多额外内存,实现简单,适用于回收率比较小的内存区域,重要的优点是对象的位置没有发生改变,减少了维护空闲列表的成本
缺点:效率不高(线性遍历还能快了?),缺点最关键的是会产生内存碎片(谨记);
2.标记-复制算法
复制算法将内存分成两块,每次只使用其中一块,当垃圾回收时,将其中正在使用的对象移动到另一块;
优点:重要的有点是不会产生内存碎片,并且高效率实现简单,适用于回收率比较大的内存区域;
缺点:会增大内存的开销,因为分配对象时有一块内存是无法使用的;
3.标记-整理算法
标记-整理算法其实是有两个阶段,标记-整理,标记阶段和上述的1中的标记阶段类似,整理阶段是将存活的可达对象按照顺序压缩到内存的一段,然后再清理其余的空间;
优点:不会产生内存碎片,不需要占用太多内存(与复制算法相比);
缺点:因为移动了对象的位置,增加了维护成本,速度相对于前两者速度较慢
总结
是否产生内存碎片 | 回收速度 | 是否需要额外内存 | 分配对象空间的算法 | |
---|---|---|---|---|
标记清除(mark-sweep) | 是 | 中 | 否 | 空闲列表 |
标记复制(coping) | 否 | 快 | 是 | 指针碰撞 |
标记整理(mark-compact) | 否 | 慢 | 否 | 空闲列表 |
根据jvm中堆中各个区域的特点分析各个堆中新生代和老年代应该采用那这种回收算法
新生代:新生代中大多数的对象都是朝生夕死的,回收率高,而且需要回收的次数多,因为新生代需要回收的次数多,而且对象大多存活时间不久,那么就要用比较快速的回收算法来保证速度,那么根据以上的三种算法,新生代采用的都是标记复制算法,放入课程的截图帮助理解:
老年代:老年代的对象大多都是久经沙场的"老对象",理论上对老年代回收率不会很高,所以如果我们采用标记复制算法,那么这势必是一笔很大的内存开销,既然回收效率不高,一般我们需要采用标记清除或者是标记整理算法;
个人觉得以上三种是理解jvm垃圾回收机制的关键,所有有一些例如分代收集算法,分区算法,增量收集算法自己只要知道概念意思即可,稍微罗列一下概念
分代收集算法:对于不同生命周期的对象可以采取不同额收集方式,以便提高回收效率,这个很好理解,参考java中的堆空间的设计;
分区算法:为了控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理的回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的时间,这个后续了解G1回收器的时候自己就能体会出来;G1回收器章节会深有体会;
增量收集算法:每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程,依次反复,直到垃圾收集完成;G1回收器章节会深有体会;
内存泄漏和内存溢出(OOM): 对象不再被程序用到,但是GC却不能回收就是发生内存泄漏了(例如数据库连接,IO,网络连接未关闭);内存溢出即是堆内存不足导致的;
三.垃圾回收器
我们先了解几组概念:
**串行,并行,并发:**串行单线程执行,并发多个线程同时进行,并发在单位时间内多个程序同时抢占同一个cpu时间片执行程序
**安全点(safe point):**程序执行并非在所有地方都能停顿下来开始GC,只有特定的位置才能停顿下来开始GC,这些位置称为安全点(一般设置为方法调用,循环跳转,异常跳转等执行时间较长的执行)
**安全区域(safe region):**是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中任何位置开始GC都是安全的。
1.垃圾回收器的分类
七大回收器之间的关系图(先有个大致印象,帮助理解):
按照回收方式分类
串行回收器:Serial,Serial old
并行回收器:ParNew,Parallel Scavenge和Parallel Old
并发回收器:CMS,G1
按照回收区域分类
新生代:Serial,ParNew,Parallel Scavenge,G1
老年代:CMS,Serial old,Parallel Old,G1
2.性能指标
有几个GC性能指标需要罗列下,在看完垃圾回收中,总结垃圾回收器始终在围绕这两个指标在做取舍,此两者不可兼得:
**吞吐量:**运行用户代码的时间占总运行时间的比例,即为 程序运行的时间 / (程序运行的时间 + 内存回收的时间),吞吐量优先就需要程序STW的时间尽可能短
**暂停时间:**执行垃圾收集时,程序的工作线程被暂停的时间;暂停时间优先简单理解为就是单次GC导致的STW的时间要短;想要暂停时间短,就势必会牺牲吞吐量,因为对导致GC的次数增多从而产生线程之间的上下文切换;虽然单次暂停时间降低,但是增加了总体STW的时间;
3.Serial和Serial old
Serial回收器和Serial old回收器都是串行回收器
Serial回收器用于新生代的垃圾回收,使用标记-复制算法单线程来进行垃圾回收,回收过程中暂停应用程序
Serial old回收器用于老年代的垃圾回收,使用标记-整理算法单线程来进行垃圾回收,回收过程中暂停应用程序,同时也可以作为CMS垃圾回收器的备选方案;
此两者是jvm最早的垃圾回收器,两者在都是串行处理,所以在关系图中他们两者是实线关系,表示两者可搭配使用
在client模式下,Serial回收器和Serial old回收器默认作为新生代和老年代的垃圾回收器
参数设置:-XX:+UseSerialGC 指定新生代和老年代使用串行回收器,等同于Serial和Serial old组合
优点:单CPU环境下处理速度快(减少GC线程和用户线程上下文切换)
缺点:多CPU环境下处理速度慢,对于交互强的应用而言,不会采取串行垃圾收集器
4.ParNew,Parallel Scavenge和Parallel Old
ParNew回收器
ParNew回收器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数**(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。在进行新生代垃圾回收时虽然也要触发STW,但是是并行回收速度快,示意图如下:
重点:ParNew是并行的,标记复制算法,且与Seria收集器共用了大量代码,用于老年代回收,而且因为与serial一脉相称,所以ParNew也可以搭配Serial Old使用;
Parallel Scavenge和Parallel Old
Parallel Scavenge:
也是并行回收器;应用于新生代的回收器,同样采用标记复制算法,但是于ParNew不同的是Parallel Scaveng更加侧重于关于吞吐量(上文提及过);它提供了两个参数精确控制吞吐量:
-XX:MaxGCPauseMills 最大垃圾收集停顿的时间
-XX:GCTimeRatio 直接设置吞吐量
Parallel Old:
Parallel Old是为了能有与Parallel Scavenge搭配的老年代并行处理的垃圾处理器而诞生的,他同样也要**侧重于关于吞吐量(上文提及过),使用标记-整理算法;
总结:
Parallel Scavenge和Parallel Old不是同时出现的,是先有Parallel Scavenge回收器用于新生代与Serail Old回收器搭配使用,Parallel Old为了搭配与Parallel Scavenge同样设计理念的老年代回收器而诞生;Parallel Scavenge和Parallel Old组合是jdk 1.8中server模式下默认的垃圾回收器组合;
参数-XX:+UseParallelGC 等同于使用Parallel Scavenge和Parallel Old组合
5.CMS垃圾回收器
CMS即Concurrent Mark Sweep(并发,标记,清除)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。 总结就是不能容忍在比较大的暂停时间(上文有概念)即STW时间不能太长,为了达到这一目的,尽可能的让用户线程与GC线程并发执行,减少STW的时间
CMS回收流程
示意图如下:
分析CMS收集器的回收过程有四步:
1)初始标记(CMS initial mark) :先标记与GCRoots直接引用的对象,这个阶段从上图可以看出此过程是需要STW的;
如上图,先找直接引用对象的话,即先标记object1和object2;
2)并发标记(CMS concurrent mark) :并发标记是于用户线程同时执行的,此时去根据步骤1中标记的对象开始向下继续标记;如上图的话就是标记上图中白色部分的对象,此过程执行时间较长;
3)重新标记(CMS remark) :为了修正并发标记期间,因用户程序继续运作导致标记产生变动的那一部分对象的标记记录,重新标记从上图看出其实也是需要STW的;
例如用户线程在步骤二过程中断开了与object3的引用,此时重新标记就不会再次标记object3了,会进行修正
4)并发清除(CMS concurrent sweep): 并发清除是于用户线程一起进行的,清理删除标记阶段判断的已经死亡的对象,释放内存空间。注意是标记-清除算法,速度快但是会产生内存碎片
以上初始标记和重新标记阶段仍然是会产生STW的,并不是整个过程都是并发执行的这个很重要;
优缺点
**优点:**低延迟,减少了暂停时间
缺点:
-
当CPU数量少的话,那么如果在上述步骤2和4两个阶段还要分出几个CPU去处理垃圾回收,用户线程会显的非常卡顿
-
使用标记-清除算法,会产生内存碎片,当无法找到足够连续内存的时候会触发full gc进行整理,而此时的备选方案就是Serial Old回收器,Serial Old是单线程的,生产上这显然无法容忍;
-
会产生浮动垃圾,产生垃圾对象,CMS无法进行标记,导致新产生的垃圾对象没有被及时回收,只能在下一次执行GC时释放空间;这个很好理解,当在上述并发标记过程中,用户线程断开了static变量2与object6的引用,那么object6和object7应该被回收才对;但是判断GCRoots的直接可达是在初始标记过程完成的,所以此时无法判断,如下图;
CMS相关参数整理
- -XX:+UseConcMarkSweepGC : 手工指定CMS收集器执行内存回收任务,开启后,自动将-XX:UseParNewGC打开,即ParNew(Young区)+CMS(old区)+Serial GC组合
- -XX:CMSlnitiatingOccupanyFraction: 设置堆内存使用率的阈值,一旦达到该阈值,则开始进行回收,jdk5及之前默认68,即老年代的空间使用率达到68%时会执行一次CMS回收,JDK6及以上默认值为92%;
- -XX:+UseCMSCompactAtFullCollection: 用于执行完Full GC后对内存空间进行压缩整理,不过内存压缩无法并发执行,会带来停顿时间更长的问题
- -XX:ParallelCMSThreads:设置CMS的线程数量,默认启动的线程数是(ParallelGCThreads+3)/4
- -XX:CMSFullGCsBeforeCompaction:设置执行多少次FullGC后对内存空间进行压缩整理
6.G1回收器
了解完CMS后,发现CMS的缺点都挺致命的哈︿( ̄︶ ̄)︿,所以jdk 1.9就将CMS标记为废弃了,默认使用G1回收器,jdk14 直接把CMS删除了;
G1回收器的知识点比较多,自己想单独整理一篇笔记