这是我参与「第三届青训营 -后端场」笔记创作活动的第2篇笔记
之前学习了GO的内存分配和垃圾回收策略,我简单总结了一些Java相关的垃圾回收算法和思想
1、如何考虑 GC
垃圾收集(Garbage Collection,GC)的历史比Java更久远,1960年诞生于MIT。
GC 需要考虑的三件事情
- 哪些内存需要回收
- 回收的时机
- 具体如何回收
哪些内存需要回收
线程私有的空间:程序计数器、虚拟机栈、本地方法栈,栈中的栈帧随着方法的进出而入栈出栈。每个栈帧中分配多少内存,在类加载后就可以确定,当方法或线程结束后,占用的内存自然就可以被全部回收了,不需要考虑太多。
Java堆和方法区,这两个线程共享的内存空间,具有很强的不确定性。因为它们中的内容只有运行时才可知,而且会不断变化。垃圾回收器关注的正是这部分内存,对其进行分配和回收。
2、如何确定一个对象“死去”
在堆中存放着几乎所有的对象实例,垃圾回收器先要确定堆中的哪些对象还“活着(有用)”,哪些对象已经“死去(再也不可能通过任何途径被引用)”。
1、引用计数算法
引用计数算法(Reference Counting),原理简单,判断效率也高,但主流的JVM都没有使用它
。
引用计数的做法是,给每个对象设置一个计数器,每当一个地方引用它,计数器+1;引用失效后,计数器-1。一旦计数器为0,则对象已死。
好处:
- 把内存管理的操作平摊到了程序运行时,每一次对引用的操作中
- 内存管理时不需要了解运行时的对象具体细节,即不需要了解每个对象的所在位置,只需要检查每个对象的引用计数器
缺点:
-
很难解决对象之间循环引用的问题
- 只要存在循环引用,两个对象的计数器就永远不会为0,但他们实际上与程序是脱节的,应当被回收。
-
需要保证线程安全
- 因为多线程环境下,会有多个线程对同一批对象进行各种操作,对引用计数的修改需要保证原子性和可见性
-
每个对象都要占用额外的内存去存储引用计数
-
回收大的数据结构时,依然会面临STW的问题,因为单次回收的对象可能很大
2、可达性分析算法
当前主流的商用程序语言(比如Java、C#)的内存管理子系统,都是采用可达性分析(Reachability Analysis)来判断对象死活的。
基本思路是,通过一系列称为“Gc Roots”的根对象
作为起始节点集,根据引用关系向下搜索,搜索走过的路径称为“引用链”。
如果某个对象到 Gc Roots 之间没有任何引用链(即不可达),说明它不可能被引用到,那么它可以死去了。
1、哪些对象能作为Gc Roots
简单来说,Gc Rootsd 的对象都满足一个条件:目前或永远不可能被回收。
- JVM内部的引用(比如:基本类型对应的包装类型对象、常驻的异常对象、系统类加载器)
- 栈帧中的局部变量表中引用的对象
- 方法区中的类的引用类型的静态变量
方法区中的常量引用的对象
(比如:字符串常量池中的引用)- 本地方法栈中的Native方法引用的对象
- 被同步锁持有的对象(synchronized关键字)
分代收集时,需要考虑其他区域对本区域的跨代引用
除了这些之外,根据用户选用的垃圾回收器,以及当前回收的内存区域,还可以有其他对象临时加入,构成完整的Gc Roots 集合。
比如分代收集和局部回收,如果只针对Java堆中的某一块区域进行垃圾收集,则这块区域的对象也可能被其他区域的对象引用,所以就需要把这些关联区域的对象也加入Gc Roots 集合中,才不会回收掉有用的对象。
为了避免 Gc Roots 集合过于庞大,不同的垃圾回收器也都做了自己的优化。
3、什么是引用
不管是引用计数还是可达性分析,都是通过“该对象是否被引用”来判断对象是否存活的。
1、早期定义
在JDK 1.2时,引用的定义还很单薄:如果 reference 类型的数据中存储的数值,代表的是另外一块内存的起始地址,就称这个reference 类型的数据是代表某个内存、某个对象的引用。
一个对象,只有“被引用”和“未被引用”两种状态。合理,但不够灵活。
早期定义的局限性
在很多场景,需要尽可能做垃圾回收。我们希望,虚拟机可以把一些对象做特殊处理:
- 内存空间尚且足够,就不回收他们
- 内存空间在进行垃圾回收后依然很紧张,就回收掉它们,释放一些内存空间。
2、四种引用状态
-
强引用(Strongly Reference)
- 这是最传统的引用定义,指程序代码中的引用赋值操作。
- 无论什么情况,垃圾回收器永远不会回收被强引用的对象。
-
软引用(Soft Reference)
- 描述一些还有用,但并非必须存在的对象
- 在Java中用 java.lang.ref.SoftReference类来表示
- 被软引用的对象,在系统OOM之前会把这些对象列入回收范围进行二次回收。如果内存还是不足,才会真正抛出OOM
-
弱引用(Weak Reference)
- 和软引用类似,但强度更弱。被弱引用的对象,只能生存到下一次垃圾回收发生为止
- 当垃圾回收器开始工作,无论当前内存是否充足,都会回收掉只被弱引用的对象。
- 弱引用必须和一个引用队列(ReferenceQueue)联合使用。如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。
-
虚引用(Phantom Reference)
-
虚引用必须和引用队列(ReferenceQueue)联合使用
-
如果一个对象具有虚引用,在它被回收之前,把这个虚引用加入到与之关联的引用队列中。
程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收,进而做一些事情
-
Java中用PhantomReference来实现虚引用
-
4、对象“非死不可”吗
被可达性分析算法判定为不可达的对象,也不会立即被回收,而是处于“死缓”阶段。
要真正判断一个对象死亡,要经过两次标记过程
:
- 第一次,进行可达性分析,发现没有与GC Roots相连的引用链
- 第二次,进行一次筛选,判断此对象是否有必要执行 finalize() 方法。成功执行finalize()方法是对象避免死亡的最后一次机会。
finalize()
如果此对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么“没有必要执行finalize()方法”。
如果这个对象被判断为“有必要执行finalize()方法”,它就会被放入一个F-Queue的队列中,并在稍后由一条Finalizer线程去执行它们的finalize()方法。这个Finalizer线程是由虚拟机建立的,调度优先级低。
在执行它们的finalize()方法时,虚拟机只会保证方法被开始执行,但不会等待它执行完毕,因为finalize()方法可能会执行缓慢或死循环,如果等待每个finalize()执行完,可能会导致F-Queue的其他对象永久等待,甚至导致整个内存回收子系统的崩溃。
执行完毕finalize()后,收集器会对F-Queue中的对象进行二次标记。只要对象在finalize()阶段成功加入了引用链,那它就可以继续存活下去。
这种自救机会只有一次,因为一个对象的finalize()方法最多被虚拟机自动调用一次。
(这个方法实际上完全不推荐使用!)
5、回收方法区
虚拟机规范提到,不强制要求虚拟机在方法区中实现垃圾收集。
方法区中的垃圾收集,效率是比较低的:堆空间的一次垃圾收集可以释放70~99%的内存空间,而方法区回收不出来多少内存。
方法区主要回收两部分内容:
- 废弃的常量
- 不再使用的类
如何回收
常量很容易判断,只要没有任何地方引用它,它就是可以被回收的。
类型卸载比较麻烦,需要同时满足三个条件:
- 这个类的所有对象都已经被回收
- 加载这个类的类加载器已经被回收(比较难实现)
- 这个类的Class对象没有被引用。即不可能通过反射来访问这个类
方法区的内存回收是有必要的
在大量使用反射、动态代理、CGLib等字节码框架,这类频繁自定义类加载器的场景中,确实需要虚拟机具备类型卸载的能力,否则方法区可能会OOM。
3、分代收集理论
1、早期的两个分代假说
分代收集理论(Generational Collection)建立在两个分代假说上:
- 弱分代假说:绝大多数对象都是“朝生夕灭”的
- 强分代假说:熬过越多次垃圾回收的对象,就越难消亡
注意,此时还有一条重要的假说未被提及。
2、假说的现实意义
这两个假说奠定了常用垃圾回收器的设计原则:
-
收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(熬过垃圾回收的次数)分配到不同的区域中进行存储。
- 如果一个区域中大多数对象都是朝生夕灭,就把它们集中放在一起,回收的频率可以高一些
- 如果剩下的都是难以消亡的对象,就把它们放在一起,虚拟机以较低的频率回收这个区域
这样就兼顾了垃圾回收的时间开销和内存的有效利用。
3、分代收集理论的完善
Java堆分出不同区域后,垃圾回收器可以每次只回收某一个区域或某几个区域,出现了:
- Minor GC:只针对新生代的垃圾回收
- Major GC:只针对老年代的垃圾回收
- Full GC:全堆回收
针对不同区域,也有相匹配的垃圾回收算法“标记-复制”、“标记-清除”、“标记-整理”。
4、新生代与老年代
商用JVM实现的分代收集理论,至少会把Java堆分为两部分:
- 新生代(Young Generation)
- 老年代(Old Generation)
这两部分的含义是:新生代中每次垃圾收集都会有大量的对象死去,每次回收后存活的少量对象,会逐步晋升到老年代中存放。
5、分代收集与跨代引用
分代收集不能只是简单的划分区域,然后分别收集,因为对象不是孤立的,对象之间可能存在跨代引用
。
要考虑这个因素,那么要进行一次新生代的GC,为了找到被老年代引用的对象,就必须遍历整个老年代,这是非常耗时的
。
所以补充了第三条假说:跨代引用相对于同代引用来说,占极少数
。
这个假说也不是凭空得来的,而是根据前两条假说推理出的隐含依据:存在互相引用关系的两个对象,倾向于同时存活或消亡。
比如有一个新生代的对象存在跨代引用,会导致它能在每次垃圾回收时存活,经历几个周期,它就会晋升到老年代,跨代引用也就被擦除了。
6、跨代引用下的垃圾收集
理论说明,跨代引用的数量是比较少的。
Remembered Set
不必为了少量的跨代引用而扫描整个老年代,可以在新生代上建立一个全局数据结构(记忆集,Remembered Set)。
把老年代划分为若干小块,标识出哪一块存在跨代引用,在发生新生代GC时,只需要遍历这一小块区域即可。
这些老年代对象会被加入GC Roots进行可达性分析,从而避免它们引用的新生代对象被回收
比起记录每个引用或者遍历整个老年代,这样的效率显然更高。
Card Table
HotSpot 给出的解决方案是一项叫做卡表(Card Table )的技术。
该技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。
这个标识位代表,对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。
在进行新生代GC时,在卡表中寻找脏卡,然后把脏卡中的对象加入到 Minor GC 的 GC Roots 里。
当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。
注意,新生代GC时会发生对象的复制,对象的地址会发生变化,所以此时也应该更新引用所在卡的标识位,可以确保脏卡中必定包含指向新生代对象的引用。
7、针对不同分代的垃圾收集
-
部分收集(Partial GC):指目标不是整个Java堆,具体分为这些:
- 新生代收集(Minor GC/ Young GC):只针对新生代的垃圾收集(非常普遍)
- 老年代收集(Major GC/ Old GC):只针对老年代的垃圾收集。(只有CMS收集器会有这种单独收集老年代的行为)
- 混合收集(Mixed GC):针对整个新生代和部分老年代的垃圾收集。(只有G1收集器有这种行为)
-
全堆收集(Full GC):针对整个Java堆和方法区的垃圾收集
8、动态的分代年龄判断
对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置,默认为15
Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积。
当累积的某个年龄大小超过了 survivor 区的一半时
,取这个年龄和 MaxTenuringThreshold 中更小的⼀个值,作为新的晋升年龄阈值。
那么超过这个新阈值的分代年龄对象,都会晋升到老年代中,提前腾出survivor的空间。
4、垃圾回收算法
1、概述
从如何判断对象死亡的角度来看,垃圾收集算法可以划分为两类:
- 引用计数式垃圾收集(直接垃圾收集),主流JVM都不用这种方法
- 追踪式垃圾收集(间接垃圾收集),常见的垃圾回收都属于这种方式
2、标记-清除算法
标记-清除算法(Mark-Sweep),是最早出现、最基础的 GC 算法。
它分为两个阶段:标记、清除。
- 首先标记出所有需要回收的对象,之后统一回收所有被标记的对象
- 或者反过来,标记出所有存活的对象,之后统一回收所有未被标记的对象
标记-清除算法的缺陷
执行效率不稳定
,如果Java堆中包含大量需要回收的对象,则标记、清除两个阶段的效率就会很低- 标记、清除之后,会
产生大量不连续的内存碎片
,会导致如果不够分配大对象,不得不提前触发下一次垃圾回收。
后续的垃圾回收算法,大多都是以标记-清除为基础,对其进行改进得到的。
3、标记-复制算法
1、最初的复制算法
为了解决清除算法在面对大量对象时效率低的问题,复制算法的思想是:
“半区复制”,将可用内存按容量划分为大小相等的两块,每次只使用其中的一块
当这块空间用完,就把这里面还存活的对象复制到另一块内存中,然后把已使用的内存空间全部清理掉
。
优缺点
优点:
- 如果内存中多数对象是要回收的,只需要复制少量的对象,效率很高
- 每次都是针对整个半区进行回收,
不会产生空间碎片
缺点:
- 如果内存中多数对象是需要存活的,就会发生大量的内存复制,效率低
- 可用内存变为了原先的一半,
空间浪费太大了
2、Eden与Survivor
大多数JVM都采用复制算法来回收新生代。
IBM公司发现,新生代中有98%的对象熬不过第一次垃圾收集,所以不需要“对半分配内存”。
具体做法是,把新生代分为一块较大的Eden空间,和两块较小的Survivor空间
。
每次分配内存,只使用Eden和其中一块Survivor。发生垃圾收集,将Eden和Survivor中仍然存活的对象复制到另一块Survivor,然后直接清理干净Eden和上次那块Survivor。
HotSpot默认Eden和Survivor的大小比例是8:1,即每次新生代可用内存空间占整个新生代空间的90%,冗余一个Survivor空间用于复制存活对象,这样的空间浪费是可以允许的。
如果存活对象很多,Survivor放不下,就需要其他内存区域(大多数是老年代)进行“分配担保”。
3、新生代垃圾回收的细节
-
JVM触发了一次Minor GC,Eden区和Survivor from区的存活对象就会被复制到Survivor to区中
-
然后交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区还是空的
-
Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。
- 如果一个对象被复制的次数为 15,那么该对象会晋升至老年代
- 另外,如果单个 Survivor 区已经被占用了 50%,那么较高复制次数的对象也会被提前晋升至老年代
4、分配担保机制
由于采用了较小的内存区域用来复制存活对象,如果这个区域装不下所有的存活对象,就会使用老年代的空间进行分配担保。
具体做法是,把一些存活对象直接晋升到老年代中。
-
JDK 1.6之前:
- 在发生young GC之前,会先检查老年代的最大可用连续空间是否大于新生代所有对象的空间。如果大于,那么这次young GC肯定是安全的
- 如果不满足,就检查虚拟机“允许担保失败”参数是否开启
- 如果开启了,会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC
- 如果没开启,就进行一次Full GC
-
JDK 1.6之后:
- 只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC
4、标记-整理算法
对于老年代,每次回收时对象的存活率比较高,所以不适合使用复制存活对象的算法。而且,没有内存空间能为老年代进行“担保”。
针对老年代,提出了“整理算法”。它也是先进行标记,但不是直接原地删除,而是把所有存活的对象都规整地拷贝到内存的一侧,达到整理内存空间的效果。
移动存活对象,优缺点并存:
缺点:
- 老年代在进行垃圾回收后,会有大量对象存活,移动这些数据很耗时
- 移动存活对象的内存地址意味着需要重新修改referance引用地址,这个更新操作也耗费很大
- 这种对象移动操作必须全程暂停用户应用程序才能进行(读屏障可以解决)
优点:
- 不会产生内存碎片
这种方式跟“标记-清除”的区别,仅仅是处理“被标记对象”的方式不同,一个原地删除,一个将它们移动。
移动更耗时,但访问起来很方便,而如果要针对碎片化的内存进行特殊设计,则比较麻烦
,显著降低访问效率,反而拉低了整体效率。
一种均衡的做法是,平时使用清除算法,允许碎片的存在,但不对它进行优化访问。如果内存碎片影响到了对象分配,就使用一次整理算法,获得规整的内存空间。CMS收集器就是这种策略。
5、总结
-
标记-清除:
- 标记已经死亡的对象,然后删除它们。
- 缺陷是会产生大量的内存碎片,以后需要分配较大的对象时,可能会提前触发下一次垃圾回收
-
标记-复制:
- 把整个堆分为两个部分,每次只使用其中的一块。
- 标记还存活的对象,然后将它们复制到另一半内存中,清空之前的整块内存。
- 好处是,不用考虑内存碎片,直接按顺序分配内存即可。
- 缺陷是内存的利用率不高。把堆对半分是因为,这样就不会出现,存活对象太多,另一半空间放不下的情况
- Enen区和suvivor区的思想是,如果suvivor区放不下存活对象,就使用老年代担保。这样suvivor区就可以设置得小一些,提高内存利用率
-
标记-整理:
- 标记出所有存活的对象,向内存的一端整体移动,清理掉边界外的内存
- 好处是,可以获得规整的内存空间
- 缺陷是,如果存活对象很多,移动过程比较耗时
6、新生代和老年代一般使用什么算法
新生代一般使用“标记复制”算法,老年代一般使用“标记清除”与“标记整理”算法
1、为什么新生代不使用清除算法
在新生代中,每次垃圾收集时都会有大批对象死去,只有少量存活。
所以,如果删除大量的死亡对象,效率肯定不如复制少量的存活对象更高。
而且,清除算法会带来大量内存碎片
。新生代上会频繁为新的对象分配内存,碎片过多肯定会导致垃圾回收多次触发
。
而且复制算法经过改良,可以压缩Survivor区域,内存利用率也不低。如果复制时空间不足,则由老年代来担保。
2、为什么老年代不使用清除算法
老年代的特点是,存活对象比较多。如果去标记出死亡的对象,然后清除它们,效率其实也可以。
但是清除算法会带来大量的内存碎片,针对这种环境就必须设计一个空闲列表来分配内存,这样反而会降低平时分配内存的效率。
所以还是选择了在GC时效率较低的整理算法,它能带来规整的内存空间,平时用起来更方便。
3、为什么老年代不使用复制算法
一方面,绝对不可能让整个老年代划分成大小相等的两块,这样的内存空间浪费太大了。而且不能使用新生代的大Eden区小suvivor区的做法,因为没有额外空间对它进行分配担保。
另一方面,老年代中对象存活率高,复制起来效率不高。
所以选择使用“标记清除”或者“标记整理”算法来进行回收。
5、HotSpot的算法实现细节
1、根节点枚举
固定可作为GC Roots的节点,主要是常量、类的静态属性、本地变量表中的一些内容。
- 所有的垃圾收集器在根节点枚举时都必须先暂停用户线程,这里的目的是为了保障一致性。
- 因为根节点的引用链也是不断变化的,必须抽取一个确定时刻的状态,才能去保证分析的准确性。
- 而每次都查找它们很耗时,应该尽量减少暂停用户线程的时间。
OOPMap
简单来说,通过遍历来获得所有根节点效率太低,应该往O(1)优化。
主流的JVM都使用的是“准确式垃圾收集”,虚拟机可以直接知晓哪些地方存放着对象引用,并不需要完全检查所有执行上下文和全局引用位置。HotSpot的做法是,使用一组称为“OopMap”的数据结构。
一旦类加载动作完成,HotSpot就把类中的每个数据类型在内存中的偏移量都记录下来。
这样收集器就可以直接得知这些信息了,而无需二次遍历。
2、安全点
在OopMap的帮助下,HotSpot可以快速完成根节点的枚举。
但是,在程序运行的过程中,可能导致引用发生变化,那么OopMap的内容就需要更新。如果这类操作非常多,是否需要为每个变化的时刻都生成一个OopMap?
没有这么做,只是在“安全点”记录了这些信息。这就意味着,只有安全点能发生用户线程停顿,开始垃圾收集。
当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才允许请求 Stop-the-world 的线程 进行独占的工作。
在安全点上,没有字节码执行。
3、安全区域
安全区域确保在某一段代码中,引用关系不会发生变化,因此从这个区域中任意地方开始垃圾收集都是安全的。
6、经典垃圾收集器
规范中并未规定垃圾收集器如何实现,所以不同的虚拟机都提供了各种收集器,让用户根据需求组合使用。
HotSpot的各种收集器:
两个收集器之间存在连线,表示它们可以搭配使用。提供这么多种组合,还是因为每款收集器各有优劣,至今没有十全十美的收集器。
1、Serial
这是最基础的收集器,用于新生代,名字的意思是“串行”。
这是一个单线程工作的收集器,单线程指的是它只使用一条线程来进行垃圾收集。
标记-复制
算法。
它的原理和流程比较简单,但由于停止线程这个动作是虚拟机主动发起的,用户线程不可知,突然就被停掉,不是很好。
后续垃圾收集器的一大目标,就是缩短用户线程的停顿时间,但始终没有办法不停顿。
2、ParNew
这是Serial的多线程并行版本,默认开启的收集线程数和cpu数量一样,可以同时使用多条线程进行并行的垃圾收集。
除了Serial外,只有ParNew能与CMS配合工作。CMS具有划时代意义,后来ParNew可以视为合并成了CMS的专用新生代收集器。
标记-复制
算法。
3、Parallel Scavenge
也是针对新生代,支持多线程并行收集,特点是关注点不同。
-
CMS等收集器的关注点是,尽可能缩短垃圾回收时用户线程的停顿时间
-
而Parallel Scavenge的关注点是,
保证一个可控制的吞吐量
,保证用户体验。(这里的吞吐量指的是,单位时间内(运行用户代码+进行垃圾收集),用户代码执行时间的占比)
由于关注吞吐量,所以Parallel Scavenge也被称为“吞吐量优先收集器”。
适合注重吞吐量,或者处理器资源稀缺的场景。
标记-复制
算法。
4、Serial Old
这是Serial的老年代版本,也是单线程的,使用标记-整理
算法。
5、Parallel Old
这是Parallel Scavenge的老年代版本,支持多线程并发收集,使用标记-整理
算法。
6、CMS
CMS(Concurrent Mark Sweep)是一种以达到“最短回收停顿时间
”为目标的收集器。适合关注响应速度的服务器。
它比一般的标记-清除算法要复杂一些,分为以下4个阶段:
- 初始标记:Stop The World,标记GC Roots能直接关联到的对象,比较快。
- 并发标记:执行GC Roots跟踪标记过程,可以和用户线程并发执行,不需要暂停用户线程。
重新标记
:Stop The World,对标记期间产生的对象存活性的再次判断,修正对这些对象的标记,执行时间相对并发标记短。- 并发清除:清除对象,可以和用户线程并发执行。
CMS是清理老年代的。
1、为什么需要两次“stop the world”
在“初始标记”阶段,CMS会快速扫描一下能和GC Roots直接关联到的对象,之后就会解除暂停。
之后和用户线程并发执行,进行对象的可达性分析。
但是,在用户线程执行的过程中,引用关系很可能产生了变化,于是就再次stop the world,修改这些引用发生变化的对象的标记。
比如,一个对象在第一次被判断为了“死亡”,之后用户线程又重新与它建立了引用关系,那么第二次会将它修改为“存活”。
注意:未被“初始标记”阶段标记的对象,在“重新标记”阶段不会被标记为垃圾对象。
这个阶段的停顿时间一般会比初始阶段稍长一些,但远比并发标记的时间短。
由于整个过程中耗时最长的“并发标记”和“并发清除”过程,收集器线程都可以与用户线程一起工作。所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
优点:并发收集、低停顿
2、CMS的并发带来的问题
在并发阶段,它虽然不会导致用户线程停顿,但是占用了一部分CPU的运行资源,导致应用程序变慢了,降低总吞吐量。
CMS默认启动的回收线程数是(处理器核心数 + 3)/4。当处理器核心不足4个时,CMS对用户程序的影响较大。
3、CMS的触发时机
CMS收集器不能像其他收集器那样,等待老年代几乎满了才进行收集。这是因为CMS需要预留足够的内存给用户线程使用
。
默认的策略是,老年代使用了68%的空间后,就会开始回收。
- 如果策略的阈值设置得较低,那么垃圾回收就会更频繁,影响性能
- 如果策略的阈值设置得过高,如果CMS预留的内存无法满足程序分配新对象的需要,JVM就会启动应急预案:冻结用户线程的执行,临时启用Serial Old来重新对老年代进行垃圾回收,这样停顿时间就变得很长。
4、CMS的缺陷
- CMS收集器对处理器资源非常敏感,因为虽然不会停止用户线程,但是会和用户线程一起竞争处理器资源,导致用户线程执行变慢
- CMS无法处理“
浮动垃圾
”,可能导致这次GC没有产生足够的空间,不得不触发一次Full GC - CMS是基于
标记-清除
算法,会产生大量的空间碎片
。如果影响到对象分配,就不得不触发一次Full GC来整理内存。 - 如果在CMS运行过程中,用户线程突然产生了大量的垃圾,JVM就会紧急暂停用户线程,使用Serial Old来重新对老年代进行垃圾回收
什么是浮动垃圾
在“初始标记”第一次判断时,该对象不是垃圾。但到“重新标记”第二次判断的期间,这个对象变为了垃圾,那么本次垃圾回收就无法处理它,只能等到下一次GC时才有机会将它回收,这种对象就是浮动垃圾。
5、为什么CMS用清除算法
因为CMS考虑的是,尽量减少垃圾收集让用户线程停顿的时间,但是工作量无法减少,那么就考虑在某些阶段,让用户线程和垃圾收集并发执行。
正因如此,在用户线程正常执行时,CMS不能去擅自修改任何对象的地址,否则会导致用户线程无法定位到对象。
复制算法和整理算法都需要改变对象的内存地址,所以不适合。
7、G1
G1是一款面向服务器的高性能垃圾收集器,主要针对具有多核处理器和大内存的机器。
JDK 1.9时,G1作为了默认的垃圾收集器,同时CMS被标记为“不推荐使用”。
G1在以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。
1、Region
G1堆内存的布局和其他收集器不同。
G1不再进行固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分成多个大小相等的独立区域(Region)
。
region 的大小是一致的,数值是在 1M 到 32M 字节之间的一个 2 的幂值数,JVM 会尽量划分 2048 个左右的 region。
每个Region都可以根据需要,扮演Eden、Survivor或者老年代空间。
G1可以对扮演不同角色的Region采用不同的收集策略,这样无论是新创建的对象,还是年龄较大的对象,都能获得很好的收集效果。
Region中还有一类特殊的Humongous区域,专门存储大对象。
如果一个对象超过Region容量的50%,它就会被存放在N个连续的Humongous Region中,G1的大多数行为都会把它当做老年代的一部分来看待。
由于堆内存被零散拆分了,所以需要维护一个空闲列表,来记录所有可用的region。
2、设计Region的意义
G1中依然存在新生代、老年代的概念,但它们不是连续的,而是一系列Region的动态集合。
G1能建立起可预测的停顿时间模型的基础,就是选择将Region作为单次回收的最小单元。
具体的做法是,它会根据各个Region里面的垃圾堆积的“价值”,维护一个优先级列表。价值包括两个方面:
- 回收后能获得的空间大小
- 回收所需的时间
每次垃圾回收时,根据用户指定的允许停顿时间,优先回收那些价值大的Region,保证了有限时间内的较高效率
。
相当于垃圾回收的思路转变了:
- 之前是优先回收新生代,因为新生代往往能获得较大的内存,新生代回收完还不够才会去全堆收集
- 现在是,优先回收那些回收价值大的内存,这是一个主动的行为,所以效率就比之前高很多,因为更有针对性。
3、G1的三种模式
Young GC
当所有eden region被耗尽无法申请内存时,就会触发一次young gc。会暂停用户线程,发起多个垃圾回收线程。
存活的对象会被拷贝到survivor region,或者晋升到old region中。被清理的region会被放入空闲列表中,等待下次被使用
Mixed GC
之前的所有垃圾收集器,都是要么针对新生代,要么针对老年代,要么针对整堆进行垃圾回收的。
当越来越多的对象晋升到old region中,达到设定的阈值后,就会触发一次Mixed GC,回收掉高价值的目标
Full GC
如果对象内存分配速度过快,mixed gc来不及回收,导致老年代被填满,就会触发一次full gc
G1的full gc算法就是单线程执行的serial old gc,会导致用户线程被长时间暂停,所以需要避免Full GC。
4、Mixed GC的运行过程
-
初始标记:
- stop the world,标记一下GC Roots能直接关联到的对象
- 并且修改TAMS指针的值,让下一阶段的用户线程能正确在可用的空间分配新对象
-
并发标记:从GC Roots出发,对堆中对象进行可达性分析。和用户线程一起并发执行(只有并发标记阶段能和用户线程并发执行)
-
最终标记
:stop the world,处理并发阶段结束后遗留的STAB记录 -
筛选回收
:- 根据用户设定的时间停顿用户线程,因为涉及到对象地址的修改。
- 对Region的价值进行排序,根据用户期望的停顿时间制定回收计划,构成回收集
- 然后把决定回收的那一部分Region的存活对象复制到空的Region中,清理掉旧Region的全部空间。
5、Card Table
在传统垃圾回收中遇到的跨代引用问题,G1中也同样存在。
由于G1把整个堆拆成了很多个region,所以每个region的不同对象之间是有互相引用的依赖关系的,而且同代引用也会发生在不同region之间。
如果进行回收之前需要遍历所有region来做到准确的垃圾回收,效率极低。
G1也是采用了Remember Set记忆集的思路,做了一个Card Table。
卡表中存放了各个Region之间的引用关系,这样就可以只去扫描相关的Region,不需要全体扫描。
6、三色标记法
G1和CMS一样,在并发标记阶段使用了三色标记法:
漏标问题
正常的引用关系是:
- 一个对象扫描完成,作为灰色节点
- 扫描该对象的成员变量,也就是和它有引用关系的其他对象,扫描完成后,之前的对象变为黑色节点
- 还没有被扫描的对象,作为白色节点
- 如果在并发标记阶段,用户线程把灰色节点和白色节点之间的引用删除,此时扫描灰色节点的成员变量时就不会扫描到该白色节点,它会被视为垃圾
- 但是白色节点和黑色节点产生了引用,那么这个引用是无法被现有的扫描方式所察觉的,就会造成本来存在引用关系的节点被漏标的问题。
CMS和G1如何解决漏标问题
产生漏标问题的条件有两个:
- 黑色对象指向了白色对象 (关注引用的增加)
- 灰色对象指向白色对象的引用消失 (关注引用的删除)
所以要解决漏标问题,打破两个条件之一即可。
-
CMS的做法是:
- 并发标记过程中,使用“增量更新”机制,如果产生了新的引用,就把该黑色节点标记为灰色,之后还会重新扫描,获得最新的引用关系
- 并发标记完成后,要回收的对象就不会再增加了
- 并发标记完成后触发二次停顿,把这些重新建立引用关系的对象移出回收范围
- 并发标记过程中产生的新垃圾不会被回收。
-
G1的做法是:
- 会记录在并发标记阶段产生的新垃圾,然后在最终标记阶段,把这些垃圾也计入回收范围。
- 当灰–>白消失时,要把这个 引用 推到GC的堆栈,保证白还能被GC扫描到。
为什么G1不使用增量更新机制
因为如果把黑色节点标记为灰色节点,之后还要二次查找,效率低。
G1保存了卡表,里面存放了各个Region之间的引用关系。
7、STAB
G1使用原始快照(STAB)算法来解决,Snapshot-At-The-Beginning。
具体做法是:
- 在GC开始之前,创建一个对象快照。
- 在并发标记时所有快照中当时的存活对象就认为是存活的,标记过程中新分配的对象也会被标记为存活对象,不会被回收。
要达到GC与用户线程并发运行,必须要解决回收过程中新对象的分配,所以G1为每一个Region区域设计了两个名为TAMS(Top at Mark Start)的指针,从Region区域划出一部分空间用于记录并发回收过程中的新对象。这样的对象认为它们是存活的,不纳入垃圾回收范围。
8、G1的特点
- 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
- 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
- 空间整合:与 CMS 的“标记--清理”算法不同,G1
从整体来看是基于“标记整理”算法
实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法
实现的,所以不会产生内存碎片
。 - 可预测的停顿:这是 G1 相对于 CMS 的另⼀个优势,降低停顿时间是 G1 和 CMS 共同 的关注点,但 G1
除了追求低停顿外,还能建立可预测的停顿时间模型
,能让使用者明确指定允许的停顿时间。
8、JDK 默认垃圾收集器
jdk1.7 默认垃圾收集器:Parallel Scavenge(新生代)+Parallel Old(老年代)
jdk1.8 默认垃圾收集器:Parallel Scavenge(新生代)+Parallel Old(老年代)
jdk1.9 默认垃圾收集器:G1