上个章节我们深度介绍了一下JVM的内存模型,并且对JVM的内存划分和运行逻辑有了初步的认识,那么什么是垃圾回收?为什么要学习垃圾回收呢?
当我们在Java代码中创建一个对象,一个属性,或者一个函数的时候都相当于在他们指定的内存区域申请一块空间将数据存储到内存中,这样程序在运行的时候我们才能获取到当前的数据进行计算,通过内存结构我们也了解了在Java中一个线程在运行的时候会在虚拟机栈中开辟一个栈空间,并且程序运行之后对应的栈帧就会出栈销毁,但是这个过程中我们可能需要访问线程共享区域的数据,这些数据可能在一个线程运行完毕之后就不需要了,但是这些数据是存储在堆空间中的,栈帧销毁的只是存储他引用的空间,那在堆内存中申请的空间就会堆积起来,当数据足够多的时候JVM初始堆的最大值无法承受空间的话就会出现OOM错误。我们先观察下图。
观察上图我们会发现在虚拟机栈运行时的栈帧中创建的临时内存会随着栈帧出栈的时候直接销毁,但是局部变量表中可能会存储其他对象的引用,这个引用的具体内容可能存储在堆区用于线程共享。那么就算这个线程销毁了这个线程在堆区开辟的空间中的数据仍然存在,堆区是有限的,所以我们要不定时的在堆区寻找这种“已经丢失引用的对象”并且将他们从堆区中释放恢复空间,这样才能让程序在不间断的运行中保持稳定防止OOM。那么垃圾回收的意义主要就是堆JVM的堆区的内存进行整理。
2. 如何判断内存中的垃圾 2.1 引用计数法引用计数法主要的方式就是对内存区创建的对象进行计数,当对象被引用之后这个对象的计数器+1,当对象的引用丢失之后计数器-1,被引用的次数>0的对象代表存活,这样的对象不会被垃圾回收器回收,被引用的次数为0的时候垃圾回收器会将其回收。这个方式的好处是很容易做标记,对每个对象的状态可以很直观的获取并且可以实时的识别对象是否需要被回收,但是某种情况会使该方法出现问题。
下面我们查看一个引用计数法无法回收的案例
public class Obj {
public Obj obj = null;
public static void main(String[] args) {
Obj o1 = new Obj();//o1记录Obj1的引用地址计数器+1
Obj o2 = new Obj();//o2记录Obj2的引用地址计数器+1
o1.obj = o2;//o1.obj记录了Obj2的引用地址计数器+1
o2.obj = o1;//o2.obj记录了Obj1的引用地址计数器+1
o1 = null;//o1引用清除Obj1的引用地址计数器-1
o2 = null;//o2引用清除Obj2的引用地址计数器-1
}
}
这段程序运行的过程中o1和o2都被引用了两次但是只清除了一次引用,这样对于计数器来说两个对象的引用次数还是1,但是其实两个对象已经失去引用了,这样会造成垃圾对象存在于内存区但是回收器会当他是存活对象,这种对象多了还是会大量的占用有限的内存空间导致OOM的出现。
2.2 可达性分析法针对引用计数法的问题垃圾内存识别的算法也进行了改良,可达性分析主要的检测方式是利用类似链表的方式来进行链式判断内存对象是否可达,如果没有一个原点能最终指向到该对象那么这个对象就是一个丢失了引用的对象在内存中就会被标记为垃圾下一次垃圾回收检测的时候就会将它作为垃圾清除。
链法检测可达性需要定义对象的原点,我们通常将原点称为GC Roots,从这些对象起点来对其内部的引用链进行逐一排查进行标记,最后没有被标记的对象就是不可达对象。
那么什么样的对象可以作为GC Roots对象呢?
Java语言中把下列对象划分为GC Roots对象:
-
虚拟机栈中引用的对象,这种对象会在虚拟机栈运行时在栈中保存其内存地址随着栈帧出栈之后保存内存地址的变量随着栈帧的出栈一起销毁,如果这个对象在栈中有引用就说明对象是存活的所以这种对象会被作为GC Roots对象
-
全局定义的静态变量,我们在全局使用static修饰符的变量和对象会在元数据区保存,这样的数据属于线程共享区所以任何线程都可能会引用该对象,这个对象也可以作为GC Roots对象
-
常量池中的引用,这种对象是通过static final修饰的对象也是作为GC Roots的必要对象
-
在使用JNI(Java Native Interface)模式运行时,由于Java有些时候需要借用其他语言比如C++的库来执行程序的时候会放到本地方法栈中运行,那么这些地方引用的对象也会变成GC Roots的切入点。
2.2.1 三色标记法
我们在这里简单的对可达性分析的算法做一个介绍,上面的内容我们已经确定了可以作为GC Roots的对象之后,垃圾回收器是如何识别哪个对象是需要回收的呢?
这里我们简单介绍一下CMS回收器采用的三色标记法,三色标记算法的方式是从GC Roots节点开始遍历访问,在其内部的所有可访问的引用中递归扫描将走过的节点标记颜色,标记过颜色的对象证明是存活对象。通常会把节点划分成三种颜色:
白色:尚未访问过的节点。
黑色:已经访问过并且对象内部引用的其他对象也全部访问过的节点。
灰色:已经访问过但是对象内部引用的其他节点未完全访问的节点,当内部对象被完全访问后该节点会转为黑色。
这里我们简单的使用图解来描述一下标记过程,初始阶段所有节点都是白色。
然后从GC Roots的直接引用开始探索,将直接引用节点先变成灰色如下图。
扫描到节点1和节点2之后继续向下扫描,我们发现节点1有两个引用节点4和节点5,节点2直有一个直接引用节点3。而节点6并不是节点2引用的节点所以可达性分析无法感知它的存在。进而标记为下图。
得到当前节点之后继续向下走,节点5没有其他节点,所以节点5标记为黑色,节点4存在节点3的引用所以走到节点3并且将节点4标记为黑色,节点2向下走走到节点3发现节点3没有其他引用将它标记为黑色。
标记到本轮之后只有节点4走到节点3的最后一步没有移动了,这时节点3已经被标记为黑色没有其他可达路径所以本轮标记到此结束,标记后我们发现节点1,2,3,4,5都是存活对象,而节点6,7,8,9,10是从GC Roots链路无法达到的节点,也就是说本轮标记结束之后我们暂时可以确定的是节点6,7,8,9,10都是可以清除的对象,但是到此我们还不能直接将其清除,因为程序在运行时内存区域的对象瞬息万变可能在本次标记后节点引用链路会随着代码运行在此发生变化所以本次标记仅仅是首次尝试,所以在标记过程中可能会出现两种情况:
浮动垃圾:
浮动垃圾是指当GC线程在访问标记过程中之前的引用链路发生断裂导致本来应该被回收的节点被标记成了灰色节点的情况,结合下图参考,当标记节点1为黑色之后GC标记走到了节点5和节点4,这个时候如果程序出现了给节点1-->节点4的链路设置为null的情况那就相当于节点4没有可达链路了,但是这个时候节点4已经标记为灰色,GC在走过他之后会认为他是存活对象,这样就会产生浮动垃圾。
漏标:
漏标的情况和浮动垃圾类似也是在标记过程中如果发生了节点的引用交换导致下次被引用的对象被提前认为是垃圾对象,这里也结合下图我们来分析一下,当遇到当前情况GC节点走到节点4的时候我们将节点4到节点7的链路断开,这样节点7就变成不可达了,但是如果同一时间我们将节点1到节点7建立新的引用那么我们的节点7就不是垃圾对象又变成存活对象了,但是GC标记已经将节点1变成了黑色就不会从头再走一遍了,这样就会发生漏标导致本来应该存活的对象被认为是可清理的对象。
漏标解决方法:
将节点7存储到特定集合中,等并发标记遍历完毕后再对集合中对象进行重新标记。关于漏标的具体解决方案本文就不做在深入的阐述了不然篇幅过大且难懂。需要了解的小伙伴可以去网络上搜集更深入的资料研究交流。
2.3 关于逃逸分析逃逸分析主要用于优化JVM的运行内存,通过逃逸分析可以将一些不需要GC处理的对象从堆中转移到虚拟机栈中单独处理,这里只做简单介绍,JVM存在三种运行模式。
解释模式(Interpreted Mode):只使用解释器(-Xint 强制JVM使用解释模式),执行一行JVM字节码就编译一行为机器码
编译模式(Compiled Mode):只使用编译器(-Xcomp JVM使用编译模式),先将所有JVM字节码一次编译为机器码,然 后一次性执行所有机器码
混合模式(Mixed Mode):依然使用解释模式执行代码,但是对于一些 "热点" 代码采用编译模式执行,JVM一般采用混合模 式执行代码 解释模式启动快,对于只需要执行部分代码,并且大多数代码只会执行一次的情况比较适合;编译模式启动慢,但是后期执行速度快,而 且比较占用内存,因为机器码的数量至少是JVM字节码的十倍以上,这种模式适合代码可能会被反复执行的场景;混合模式是JVM默认采 用的执行代码方式,一开始还是解释执行,但是对于少部分 “热点 ”代码会采用编译模式执行,这些热点代码对应的机器码会被缓存起 来,下次再执行无需再编译,这就是我们常见的JIT(Just In Time Compiler)即时编译技术。 在即时编译过程中JVM可能会对我们的代码最一些优化,比如对象逃逸分析等
对象逃逸分析:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。这样的对象就会被放置在堆中,如果我们发现该虚拟机栈中的一函数在执行的时候内部创建的对象并没有机会被外部的任何地方访问那么我们就可以直接的将这个对象创建在栈中,这样就会发生对象逃逸,这个对象就不需要进入堆内存的垃圾回收区域。JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,JDK7之后默认开启逃逸分析,如果要 关闭使用参数(-XX:-DoEscapeAnalysis)
3. 垃圾回收的算法 3.1 标记清除算法
标记清除算法主要是先执行GC的标记算法,标记之后将可清除的内存块数据直接清除来释放空间,这种清除算法份分两个阶段:
-
标记阶段:找到所有可访问的对象,做个标记
-
清除阶段:遍历堆,把未被标记的对象回收
缺点:
(1)因为涉及大量的内存遍历工作,所以执行性能较低,这也会导致“stop the world”时间较长,java程序吞吐量降低;
(2)对象被清除之后,被清除的对象留下内存的空缺位置会造成内存不连续,空间浪费。
3.2 标记整理算法
标记-整理算法适合用于存活对象较多的场合,如老年代。它在标记-清除算法的基础上做了一些优化。
-
标记阶段:它的第一个阶段与标记/清除算法是一模一样的。
-
整理阶段:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。
上图中可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
优点
标记/整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。
缺点
标记/整理算法唯一的缺点就是效率也不高。不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法。
3.3 标记复制算法
复制算法简单来说就是把内存一分为二,但只使用其中一份,在垃圾回收时,将正在使用的那份内存中存活的对象复制到另一份空白的内存中,最后将正在使用的内存空间的对象清除,完成垃圾回收。
优点
复制算法使得每次都只对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
缺点
复制算法的代价是将内存缩小为原来的一半,这个牺牲太大了所以他的使用场景不适合大内存空间的堆区。
重要
现在的虚拟机使用复制算法来进行新生代的内存回收。因为在新生代中绝大多数的对象都是“朝生夕亡”,所以不需要将整个内存分为两个部分,而是分为三个部分,一块为Eden(伊面区)和两块较小的
Survivor(幸存区)空间(默认比例->8:1:1)。每次使用Eden和其中的一块Survivor,垃圾回收时候将上述两块中存活的对象复制到另外一块Survivor上,同时清理上述Eden和Survivor。所以每次新生代就可以使用90%
的内存。只有10%的内存是浪费的。(不能保证每次新生代都少于10%的对象存活,当在垃圾回收复制时候如果一块Survivor不够时候,需要老年代来分担,大对象直接进入老年代)
总的来讲:复制算法不适用于存活对象较多的场合,如老年代(复制算法适合做新生代的GC)
3.4三种算法的总结相同点
-
三个算法都基于根搜索算法去判断一个对象是否应该被回收,而支撑根搜索算法可以正常工作的理论依据,就是语法中变量作用域的相关内容。
-
在GC线程开启时,或者说GC过程开始时,它们都要暂停应用程序(stop the world)。
区别
三种算法比较:
效率:复制算法>标记-整理算法>标记-清除算法;
内存整齐度:复制算法=标记-整理算法>标记-清除算法
内存利用率:标记-整理算法=标记-清除算法>复制算法
4. 结合垃圾回收算法衍生的内存分区策略(部分摘录网络)由于码字太辛苦,很多概念性的介绍就从网络文章中汇总了一下。
针对上面的垃圾回收算法JVM将内存划分为新生代和老年代,并且在新生代还分Eden区和Survivor区,这样可以在不同的内存空间中应用最适合的垃圾回收算法来针对内存中不同的对象特性有计划的进行回收,这样可以避免单一回收策略的不适用场景导致GC过程中频发出发SWT(stop the world)这样的代价显然是惨痛的。所以我们还是先观察一下堆区的基本结构。
根据常用的垃圾回收算法和堆区结构划分JVM的“分代回收策略”就产生了。
4.1 分代回收策略首先这不是一种新算法,它是一种思想。现在使用的Java虚拟机并不是只是使用一种内存回收机制,而是分代收集的算法。就是将内存根据对象存活的周期划分为几块。一般是把堆分为新生代、和老年代。短命对象存放在新生代中,长命对象放在老年代中。
Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。
Java 堆是垃圾收集器管理的主要区域,而 Java 自动内存管理最核心的功能是 堆 内存中对象的分配与回收,因此也被称作GC 堆(Garbage Collected Heap)。
从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分:
堆分为新生代(占堆1/3),老生代(占堆2/3)
-
新生代(内部比例8:1:1)
-
Eden 空间
-
From Survivor 空间
-
To Survivor 空间
-
-
老年代
进一步划分的目的是更好地回收内存,或者更快地分配内存。
核心思想就是根据各个年代的特点不同选用不同到垃圾收集算法。
-
年轻代:使用复制算法
-
老年代:使用标记整理或者标记清除算法。
为什么要有年轻代:
分代的好处就是优化GC性能,如果没有分代每次扫描所有区域能累死GC。因为很多对象几乎就是朝生夕死的,如果分代的话,我们把新创建的对象放到某一地方,当GC的时候先把这块存朝生夕死(80%以上)对象的区域进行回收,这样就会腾出很大的空间出来。
HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8:1:1。一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC年龄就会增加1岁,当它的年龄增加到一定次数(默认15次)时,就会被移动到年老代中。年轻代的垃圾回收算法使用的是复制算法。
4.2 垃圾回收流程(年轻代)年轻代GC过程:GC开始前,年轻代对象只会存在于Eden区和名为From的Survivor区,名为To的Survivor区永远是空的。如果新分配对象在Eden申请空间发现不足就会导致GC。
yang GC:Eden区中所有存活的对象都会被复制到To,而在From区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到To区域。经过这次GC后,Eden区和From区已经被清空。这个时候,From和To会交换他们的角色,也就是新的To就是上次GC前的From,新的From就是上次GC前的To。不管怎样都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到To区被填满,To区被填满之后,会将所有对象移动到年老代中。这里注意如果yang GC 后空间还是不够用则会 空间担保 机制将数据送到Old区
卡表 Card Table:
-
为了支持高频率的新生代回收,虚拟机使用一种叫做卡表(Card Table)的数据结构,卡表作为一个比特位的集合,每一个比特位可以用来表示年老代的某一区域中的所有对象是否持有新生代对象的引用。
-
新生代GC时不用花大量的时间扫描所有年老代对象,来确定每一个对象的引用关系,先扫描卡表,只有卡表的标记位为1时,才需要扫描给定区域的年老代对象。而卡表位为0的所在区域的年老代对象,一定不包含有对新生代的引用。
老年代中存放的对象是存活了很久的,年龄大于15的对象 或者 触发了老年代的分配担保机制存储的大对象。在老年代触发的gc叫major gc也叫full gc。full gc会包含年轻代的gc。full gc采用的是 标记-清除 或 标记整理。在执行full gc的情况下,会阻塞程序的正常运行。老年代的gc比年轻代的gc效率上慢10倍以上。对效率有很大的影响。所以一定要尽量避免老年代GC!
4.4 垃圾回收流程(元空间)永久代的回收会随着full gc进行移动,消耗性能。每种类型的垃圾回收都需要特殊处理元数据。将元数据剥离出来,简化了垃圾收集,提高了效率。
-XX:metaspaceSize 初始空间的大小。达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:
如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxmetaspaceSize时,适当提高该值。
-XX:MaxmetaspaceSize:
最大空间,默认是没有限制的。
4.5 垃圾回收流程总结大致的GC回收流程如上图,还有一种设置就是 大对象直接进入老年代:
-
如果在新生代分配失败且对象是一个不含任何对象引用的大数组,可被直接分配到老年代。通过在老年代的分配避免新生代的一次垃圾回收。
-
设置了-XX:PretenureSizeThreshold 值,任何比这个值大的对象都不会尝试在新生代分配,将在老年代分配内存。
内存回收跟分配策略
-
优先在Eden上分配对象,此区域垃圾回收频繁速度还快。
-
大对象直接进入老生代。
-
年长者(长期存活对象默认15次)跟 进入老生代。
-
在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象会群体进入老生代。
-
空间分配担保(担保minorGC),如果Minor GC后 Survivor区放不下新生代仍存活的对象,把Suvivor 无法容纳的对象直接进人老年代。
垃圾回收器的种类颇多,这里就不码字了(真的很累啊),所以就将网络上相对较好的介绍整理至此
CMS 收集器CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
CMS收集器非常符合在注重用户体验的应用上使用
CMS收集器是 HotSpot 第一款真正意义上的并发收集器,实现了让垃圾收集线程与用户线程(基本上)同时工作。
CMS 收集器使用 “标记-清除”算法。
整个过程分为四个步骤:
-
初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
-
并发标记:
-
-
同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。
-
在阶段结束,闭包结构不能保证包含当前所有的可达对象。
-
-
-
因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。
-
所以此算法里会跟踪记录这些发生引用更新的地方。
-
-
重新标记:
-
-
修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,
-
停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。
-
-
并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
优点:并发收集、低停顿。
缺点:
-
对 CPU 资源敏感。
-
无法处理浮动垃圾。
-
收集结束时会有大量空间碎片产生。
Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。
它收集器是一个单线程收集器了:
-
新生代采用标记-复制算法
-
老年代采用标记-整理算法
它最大的特点就是进行GC时,会阻塞其他线程。
它的优点是简单高效,在单线程收集器中几乎就是最快的存在,但是由于会阻塞其他线程,这让他的使用起来体验并不算好。
ParNew 收集器ParNew 收集器是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为和 Serial 收集器完全一样。
它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器配合工作。
Parallel Scavenge 收集器JDK1.8 默认使用的是 Parallel Scavenge + Parallel Old
JDK1.8 默认收集器
Parallel Scavenge 收集器几乎和 ParNew 是一样。
区别在于:
-
Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)
-
CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。
Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:
一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。
Parallel Old 收集器Parallel Scavenge 收集器的老年代版本。
使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。
G1 收集器G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。
以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。
非常强的一款垃圾收集器,甚至它可能会引领JVM垃圾收集的未来。
它具备一下特点:
-
并行与并发:
-
-
G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短停顿时间。
-
部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。
-
-
分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
-
空间整合:
-
-
G1 从整体来看是基于标记-整理算法实现的收集器;
-
从局部上来看是基于标记-复制算法实现的。
-
-
可预测的停顿:降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。
G1 收集器的运作大致分为以下几个步骤:
-
初始标记
-
并发标记
-
最终标记
-
筛选回收
G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。
这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。
ZGC 收集器与G1 类似,但又互有不同,可以自行了解。



