JVM中垃圾收集器的理论知识-刘宇
作者:刘宇
CSDN博客地址:https://blog.csdn.net/liuyu973971883
有部分资料参考,如有侵权,请联系删除。如有不正确的地方,烦请指正,谢谢。
一、GC的简单概要
1.1、什么是GC
GC俗称垃圾回收,它是用于释放我们JVM运行时数据区域的内存空间的,从而去防止内存溢出。
1.2、需要GC的内存区域
虚拟机栈、本地方法栈、程序计数器它们不需要GC,因为它们都会随着线程的结束自动清除。那么需要GC的就剩方法区和堆了。
1.2.1、方法区
GC的非主要工作区域,可以不要求虚拟机在这区域实现GC,这区GC的“性价比”一般比较低。
- 主要回收两部分内容:废弃常量与无用类
- 类回收需满足如下3个条件:
- 该类所有的实例都已经被GC,也就是JVM中不存在该Class的任何实例
- 加载该类的ClassLoader已经被GC
- 该类对于的java.lang.Class对象没有在任何地方被引用,如不能在任何地方通过反射访问该类的方法。
1.2.2、堆区
GC主要工作区域,因为它是JVM中内存最大的一块,为了高效的GC,会把它细分更多的子区域。在新生代中,常规应用进行一次GC一般可以回收70~95%的空间,而方法区的GC效率远小于此。
1.3、内存回收
GC要做的就是将那些dead的对象所占用的内存回收掉,在Hotspot中,没有引用的对象就是dead的,并且Hotspot将引用分为了四种:
- Strong(强引用):默认通过Object o = new Object()这种方式赋值的引用
- Soft(软引用):继承Reference,内存不够时一定会被GC、长期不用也会被GC
- Weak(弱引用):继承Reference,一定会被GC,当被mark为dead,会在ReferenceQueue中通知
- Phantom(虚引用):继承Reference,本来就没引用,当从jvm heap中释放会通知
1.4、GC的时机
在分代模型的基础上,GC从时机上分为两种:Scavenge GC和Full GC。
1.4.1、Scavenge GC(Minor GC)
- 触发时机:新对象生成时,Eden空间满了
- 理论上Eden区大多数对象会在Scavenge GC回收,复制算法的执行效率会很高,Scavenge GC时间比较短。
1.4.2、Full GC
- 对整个JVM进行整理,包括Young、Old和Perm(永久代)
- 主要的触发时机:Old满了、Perm满了、System.gc()
- 效率很低,尽量减少Full GC
二、垃圾判断算法
2.1、引用计数算法(Refeience Counting)
给对象添加一个引用计数器,当有一个地方引用它,计数器+1,当引用失效,计数器-1,任何时刻计数器为0的对象就是不可能再被使用的,即可回收的对象。
缺点: 引用计数器算法无法解决对象循坏引用的问题。如:对象A内部引用对象B,对象B内部引用对象A,而这两个对象并没有被外部引用,那么此时就无法回收。
2.2、根搜索算法(GC Roots Tracing)
在实际的生成语言中,Java、C#等都是使用根搜索算法判定对象是否存活,它是用过一些列的称为“GC Roots”的点作为起始进行向下搜索,当一个对象到GC Root是没有任何引用链(Reference Chain)相连,则证明此对象是不可用的。
GC Roots包括:
- 在VM栈(帧中的本地变量)中的引用
- 方法区中的静态引用
- JNI(即一般说的Native方法)在的引用
三、JVM常见的GC算法
3.1、标记-清除算法(Mark-Sweep)
算法分为“标记”和“清除”两个阶段,首先标记出所有需要回收的对象,然后回收所有需要回收的对象
缺点:
- 效率问题:标记和清理两个过程效率都不高,需要扫描所有对象,堆越大GC越慢
- 空间问题:标记清理之后会产生大量不连续的内存碎片,空间碎片太多可能会导致后续使用中无法找到足够的连续内存而提前触发另一次的垃圾收集动作。
图例:
下面展示了栈帧中引用对象的视图,通过根搜索算法来扫描出不可达的对象,从栈帧中的根节点开始搜索,红色区域为不可达的对象,最后阴影区域为回收后的空间,产生了空间碎片。
3.2、复制(Copying)搜集算法
将可用内存划分为两块,每次只使用其中的一块,当半区内存用完了,仅将还存活的对象复制到另一块上面,然后就把原来的整块内存空间全部清理掉。目前的商业虚拟机中都是用的这一种算法来回收新生代(刚实例化的对象)。 它会将内存分为一块较大的eden空间和两快较小的survivor空间(from和to),分配比例是8:1:1。
优点:
- 内存分配不用考虑内存碎片化等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
- 只要扫描存活对象,效率高
- 不会产生碎片
缺点:
- 需要浪费额外的内存作为复制区
用途:
- 适用于生命周期比较短的对象,因为每次GC总能回收大部分的对象,复制开销就比较小。
图例:
将内存分为一块较大的eden空间和两快较小的survivor空间(from和to),每次使用eden和其中一块survivor,当eden区满了之后会将存活的对象复制到第一个Survivor(from)区中,然后清理eden区,随后当eden区再次满的时候会将eden和survivor(from)还存活的对象一次性拷贝到另一块survivor(to)空间上,然后清理掉eden和survivor(from)空间,随后将from与to互换身份。当survivor(to)区被填满之后,会将从from区复制过来的对象复制到年老代中。
3.3、标记-整理(Mark-Compact)算法
标记过程仍然一样,但是后续步骤不是进行直接清理,而是将所有存活的对象向一端移动,然后直接清理掉这端边界以外的内存。
优点:
- 没有内存碎片,因为在清理之前做了整理操作
缺点:
- 比Mark-Sweep耗费更多的时间,因为它需要对存活的对象进行整理(Compact)
图例:
下图上面一部分就是整理过后的一端,下面一部分就是清除后的一端
3.4、分代收集(Generational Collecting)算法
- 当前商业虚拟机的垃圾收集都是采用“分代收集”(Generational Collecting)算法,根据对象不同的存活周期将内存划分为几块。
- 一般是把Java对分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法,譬如新生代每次GC都有大批对象死去,只有少量存活,那就选择复制算法,只需要付出少量存活对象的复制成本就可以完成收集
案例:
综合前面集中GC算法的优缺点,针对不同生命周期的对象采用不同的GC算法。在新生代我们可以使用复制收集算法,老年代我们可以使用标记-清除或者标记-整理算法。
新生代:
- 新生代中都存放了新实例化的对象。新生代用复制算法进行GC(理论上新生代中的对象生命周期非常短,所以适合复制算法)
- 新生代分为三个区,一个Eden区,两个survivor区(可以通过参数设置survivor数量)。对象在Eden区中生成,当Eden区满时,还存活的对象将被复制到第一个Servivor(from)区,当Eden区再次变满了的时候,会将Servivor(from)区以及eden区的存活对象将被复制到另一个Servivor(to)区,当第二个Servivor(to)区也满的时候,会将从第一个Servivor(from)复制过来并且此时还存活的对象复制到老年代中去,随后两个Servivor区互换身份。
- Eden和2个Survivor区的缺省比例是8:1:1,也就是10%的空间浪费,乐意根据GC log的信息调整大小的比例。
老年代:
- 存放了经过一次或者多次还存活的对象
- 一般采用Mark-Sweep或者Mark-Compact算法进行GC
- 有多种垃圾收集器可以选择。每种垃圾收集器可以看作一个GC算法的具体实现。可以根据具体应用的需求虚选用合适的垃圾收集器(追求吞吐量?追求最短的响应时间?)
永久代:
- JDK1.8之前存在永久代,1.8之后称为元空间
- 并不属于堆(Heap),但GC也会涉及到这个区域
- 存放了每个Class的结构信息,包括常量池、字段描述、方法描述。与垃圾收集要收集的Java对象关系不大。
疑问:为什么新生代使用复制算法,而老年代使用Mark-Sweep或者Mark-Compact算法?
- 因为我们知道java中大部分的对象的生命周期都是非常短的,那么在新生代中能够存活下来的对象就不会很多,所以采用复制算法,效率会很高;而如果老年代采用复制算法,那么由于大量的对象,复制所消耗的资源是很大的。
- 新生代采用复制算法时,如果Servivor的to区不能够存放Eden区所存活下来的对象时,那么他们会直接进入老年代,因为老年代具有空间担保的概念(在新生代进行Minor GC前,先检查老年代最大可用的连续空间是否大于新生代所有对象总空间)。如果老年代使用复制算法,那么后面就没有存储空间为老年代做空间担保了。
四、垃圾收集器
分代模型是GC的宏观愿景,而垃圾收集器是GC的具体实现,在Hotspot JVM中提供了多种垃圾回收器,我们需要根据具体的应用选择不同的回收器,没有万能的垃圾回收器。
4.1、垃圾收集器的“并行”和“并发”
- 并行(Parallel):指多个收集器的线程同时工作,但是用户线程处于等待状态
- 并发(Concurrent):指收集器在工作的同时,可以允许用户线程工作,并发不代表解决了GC停顿的问题,在关键的步骤还是要停顿。比如在收集器标记垃圾的时候。但是在清除垃圾的时候,用户线程就可以和GC线程并发执行。
4.2、Serial收集器
单线程收集器,收集时会暂停所有工作线程(Stop The World,简称STW),使用复制收集算法,是虚拟机运行在Client模型下时新生代的默认收集器。
- 最早的收集器,单线程进行GC
- New和Old Generation都可以使用
- 在新生代采用的是复制算法,在老年代采用Mark-Compact算法
- 因为是单线程GC,没有多线程切换的额外开销,简单实用
- 是Hotspot虚拟机的Clent模式下缺省的收集器
4.3、Serial Old收集器
- Serial Old是单线程的收集器
- 使用的是标记-整理算法
- 是老年代的垃圾收集器
4.4、ParNew收集器
- ParNew收集器就是Serial的多线程版本
- 除了使用多个收集线程外,其余行为包括算法、STW、对象分配规则、回收策略等都和Serial收集器一致
- 在单CPU的环境中,ParNew收集器并不会比Serial收集器有更好的效果
- 可以通过-XX:ParallelGCThreads来控制GC线程数的多谢。需要结合具体的CPU的个数
- 是Hotspot虚拟机的Server模式下缺省的收集器
4.5、Parallel Scavenge收集器
- Parallel Scavenge收集器也是一个多线程收集器
- 此收集器使用的是复制算法
- 但它的对象分配规则与回收策略都与ParNew收集器有所不同,它是以吞吐量最大化(即GC时间占总运行时间最小)为目标的收集器实现,它允许较长时间的STW换取总吞吐量最大化。
4.6、Parallel Old收集器
- 老年代版本吞吐量优先的多线程收集器
- 使用的是标记-整理算法
- 在JDK1.6才出现的Parallel Old
- Parallel Scavenge+Parallel Old=高吞吐量,但是GC停顿可能不理想
4.7、CMS(Concurrent Mark Sweep)收集器
CMS是一种以最短停顿时间为目标的收集器,使用CMS并不能达到GC效率最高(总体GC时间最小),但它能尽可能降低GC时服务的停顿时间,CMS收集器使用的是标记-清除算法
- 追求最短停顿时间,非常适合Web应用
- 只针对老年区,一般结合ParNew使用
- Concurrent,GC线程和用户线程并发工作(尽量并发)
- 使用整理-清除算法
- 只有在多CPU环境下才有意义
- 使用-XX:+UseConcMarkSweepGC打开
缺点:
- CMS以牺牲CPU资源的代价来减少用户线程的停顿。当CPU个数少于4的时候,有可能对吞吐量影响非常大。
- CMS在并发清理的过程中,用户线程还在跑。这时候需要预留与部分空间给用户线程。
- CMS使用Mark-Sweep,会带来碎片问题。碎片过多的时候就会容易频繁出发Full GC
五、安全点(Safe Point)及安全区域(Safe Region)
5.1、什么是安全点
HotSpot虚拟机采取的是可达性分析算法。即通过 GC Roots 枚举判定待回收的对象。如何找到哪些是 GC Roots呢,有如下两种方法:
- 遍历方法区和栈区查找(保守式 GC),该方法成本太高。
- 通过 OopMap 数据结构来记录 GC Roots 的位置(准确式 GC),该方法够让虚拟机快速定位到 GC Roots。
- 在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得更高。
- 实际上,HotSpot并没有为每条指令都生成OopMap,而只是在“特定的位置”记录了这些信息,这些位置称为安全点(Safepoint) ,既程序执行时并非在所有地方都能停顿下来开始GC,只有在达到安全点时才能暂停。这里解释一下:当JVM遇到空间不够的时候会执行垃圾回收,但并不是在系统的任何时刻都可以执行垃圾回收,必须要等到程序执行到一个称之为安全点这样的一个位置上才可以进行GC。
5.2、如何选定安全点
如果安全点设置的太多,GC就会过于频繁,从而增大运行时负荷;如果安全点太少,GC等待时间太长。所以,JVM一般会在如下几个位置选择安全点:
- 循环的末尾
- 方法临返回前
- 用方法之后
- 异常的位置
5.3、如何让所有线程都跑到最近的 Safe Point 上再停下来
当需要GC时,我们必须让所有的线程都停到安全点上才能GC,JVM是采用什么样的方式使所有线程都跑到最近的安全点上的呢?
主要有两种方式:
- 抢占式中断:它不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。
- 主动式中断:当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮循这个标志,发现中断标志为真时就自己中断挂起。设置该标志的地方和安全点是重合的【这个很关键,这样通过标志来中断刚好是在安全点上发生的】,另外再加上创建对象需要分配内存的地方。
注意:现在几乎没有虚拟机采用抢占式中断来暂停线程从而响应GC事件。
5.4、什么是安全区域
- 在使用Safepoint似乎已经完美地解决了如何进入GC的问题,但实际上情况却并不一定。Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但如果程序在“不执行”的时候呢?所谓程序不执行就是没有分配CPU时间,典型的例子就是处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,JVM也显示不太可能等待线程重新分配CPU时间。对于这种情况,就需要安全区域(SafeRegin)来解决了。
- 在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了,在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。
六、GC垃圾收集器的JVM参数定义
6.1、参数:UseSerialGC
虚拟机运行在Client模型下的默认值,打开此开关后,使用Serial+Serial Old的收集器组合进行内存回收
6.2、参数:UseParNewGC
打开此开关后,使用ParNew+Serial Old的收集器组合进行内存回收
6.3、参数:UseConcMarkSweepGC
打开此开关后,使用ParNew+CMS+Serial Old的收集器组合进行内存回收。Serial Old收集器将作为CMS收集器出现Concurrent Mode Failure失败后的后备收集器使用
6.4、参数:UseParallelGC
虚拟机运行在Server模式下的默认值,打开此开关后,jdk1.7u4及之前使用Parallel Scavenge+Serial Old(PS: MarkSweep),之后使用的是Parallel Scavenge+Parallel Old的收集器组合进行内存回收
6.5、参数:UseParallelOldGC
打开此开关后,使用Parallel Scavenge+Parallel Old的收集器组合进行内存回收
6.6、参数:SurvivorRatio
新生代中Eden区域与Survivor区域的容量比值,默认为8,代表Eden:Survivor=8:1
6.7、参数:PretenureSizeThreshold
直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配。