垃圾回收概述

什么是垃圾

垃圾收集并不是从Java开始的概念,早在1960年第一门开始使用内存动态分配和垃圾回收技术的Lisp语言诞生了这个概念

垃圾收集是Java的招牌能力,极大提高了开发效率。如今垃圾收集几乎是现代语言的标配,即使经过了如此长时间的发展,Java的垃圾收集机制也在不断演进,这也是面试热点

垃圾指的是没有任何指针指向的对象,这个对象就是需要被回收的垃圾,是内存的泄露

我们之前讲过标量和聚合量,那么我们说的这个对象其实是聚合量的概念

假如不即使对垃圾进行清理,那么这个数据就会一直占用内存空间,最后导致空间不足,导致内存溢出

内存溢出:程序运行过程中申请的内存大于系统能够提供的内存,导致无法申请到足够的内存 内存泄漏:分配内存给临时变量,用完之后却没有被GC回收,始终占用着内存

为什么需要GC

1、如果不回收垃圾,那么内存迟早就会被消耗完毕

2、垃圾回收也可以清除内存中的记录碎片,JVM将整理出来的碎片内存分配给新的对象

3、没有GC就不能保证应用程序的正常进行

垃圾回收相关算法

垃圾标记阶段

GC之前,需要区分内存中哪些是存活的对象,哪些是已经死亡的对象。只有被标记为已经死亡的对象,GC才会在执行垃圾回收的时候释放空间,因此这个阶段我们称为垃圾标记阶段

判断对象的存活一般有两种方式:引用计数算法和可达性分析算法

标记阶段:引用计数算法

引用计数算法,也叫做引用标记算法。

每个对象中都保存一个整型的引用计数器属性,用于记录对象被引用的次数,如果被引用那么就+1,假如是0那么就是没有被引用

优点:

1、实现简单,垃圾对象便于辨识

2、判定效率高,回收没有延迟性

缺点:

1、每个对象都有一个存储计数器,增加了空间的开销

2、每次赋值都需要更新计数器,增加了时间开销

3、无法处理循环引用的问题,这是一个十分致命的缺点,这也是Java为什么不使用这个计数器的原因

循环引用的意思是,现在有对象A和对象B,A引用B,B引用A 这样即使对象不用,那么仍然是可以被指向,不能回收,导致内存泄露 但是注意,Java没有使用这种算法,所以在Java中没有这种内存泄露

那么Python其实使用了这种引用计数算法,那么Python解决他的循环引用问题使用了两种方式:

1、手动解除

2、使用弱引用 weakref,weakref是Python提供的标准库,旨在解决循环引用问题

标记阶段:可达性分析算法

可达性分析算法也叫做根搜索算法、追踪性垃圾收集算法

那么可达性分析算法解决了引用计数算法中的循环引用问题,而且效率也还可以,那么Java和C#就使用这种算法

这种情况下产生的垃圾收集也叫做追踪性垃圾收集

可达性分析的思路:

1、以根对象集合(GC Roots)作为起点,按照从上向下的方式搜索被根对象集合所连接的目标对象是否可达

2、使用可达性分析算法之后,内存中的存活对象都会被根对象集合直接或者间接连接,搜索走过的路程叫做引用链(Reference Chain)

3、如果目标对象没有任何引用链相连,那么则是不可到达的,就意味着对象已经死亡,可以被标记为垃圾对象

4、可达性分析算法中,只有能被根对象集合直接或者间接链接的对象才是存活对象

垃圾回收概述和相关算法 - 图1

其实我们只要想象一下图的深度优先就可以明白了

GC Roots

在Java语言中,GC Roots包括以下几类元素:

1、虚拟机栈中引用的对象:比如各个线程中被调用的方法中使用的参数或者局部变量等

2、本地方法栈JNI(native方法)引用的对象

3、方法区中类的静态属性引用的对象:比如Java类的引用类型静态变量

4、方法区中常量引用的对象:比如字符串常量池中的引用

5、被同步锁synchronized持有的对象:同步监视器

6、Java虚拟机内部引用的对象:基本数据类型对应的Class对象,一些常驻的异常对象,系统类加载器等

7、反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

假如一个指针指向堆内存中的对象,但是自己又不放到堆内存中,那么它就是一个root

其实这种可达性分析是一种临时的状态,你没办法确保下一刻会有哪些对象不可达,哪些对象可达,所以针对这种情况,我们必须让整个程序停止,也就是Stop The World,才能顺利让垃圾回收

即使是超低延迟的CMS收集器中,枚举根节点时也要停顿

对象的finalization机制

Java语言提供了对象终止机制来允许开发人员在对象终止之前自定义一些逻辑处理机制

当垃圾收集器发现没有一个引用指向一个对象,也就是在回收此对象之前,总会先调用这个对象的finalize()方法

finalize()允许在子类中进行重写,一般用于在对象被回收之前进行资源的释放,比如关闭文件,套接字和数据连接等

永远不要主动调用finalize(),应该交给垃圾回收机制,理由是:

1、在调用finalize()可能会导致对象免死

2、finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,如果不发生GC则永远不会调用

3、一个糟糕的finalize()会严重影响GC性能,STW的时间可能会非常长

如果从所有的根节点都无法到达这个对象,那么说明这个对象已经没有作用了,但是因为finalize()的存在,也算是存在理论上复活的状态,只要在finalize()中被重新引用,那么对象就会被复活,但是这种复活完全是没有任何意义的

由于finalize(),对象可能有三种状态:

1、可触及的:从根节点开始,可以到达这个对象,说明这个对象本来就不是垃圾

2、可复活的:对象的所有引用都被释放,但是有可能会在finalize()中复活,说明原来是垃圾但是在finalize()中复活

3、不可触及的:对象的finalize()被调用,而且没有复活,那么就会进入不可触及阶段。不可触及阶段中的对象不可能被复活,因为finalize()只会调用一次

清除阶段

当成功区分出内存中的垃圾之后,就进入了清除阶段。

清除阶段的三种垃圾收集算法比较常见:

1、标记-清除算法:Mark-Sweep算法

2、复制算法:Copying

3、标记-压缩算法:Mark-Compact

清除阶段:标记-清除算法

这个是一个比较常见的垃圾收集算法,这个算法被J.McCarthy等人在1960年提出并且应用在Lisp语言。

执行过程:当内存被耗尽之后,就会停止整个程序,然后进行两项工作

1、标记:Collctor从引用根节点开始遍历,标记所有被引用的对象。一般是在对象Header中记录为可达对象

2、清除:Collector对堆内存从头到尾进行线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收

注意,我们标记的不是垃圾,而是正常的对象:非垃圾对象,可达对象

优点:比较常见

缺点:

1、效率不高

2、进行GC的时候需要停止整个应用程序,导致用户体验比较差

3、这种方式清除出来的内存不是连续的,产生了内存碎片。只能维护一个空闲列表为对象分配内存,空间利用率低。

4、移动时需要暂停用户线程

清除:

注意了,清除并不是真的置空,而是将需要清除的对象的地址记录到空闲列表中,等到下一个数据分配之后将原来的数据覆盖

清除阶段:复制算法

为了解决标记-清除算法在垃圾收集效率的缺陷,那么我们出现了复制算法

他的思想是:

1、将活着的内存空间分为两块,每次只使用其中一块

2、在垃圾回收时将正在使用的内存中的存活对象复制到另一块未被使用的内存块中

注意这里的复制是复制一份新的对象,而不是将引用地址复制过去

3、之后清除正在使用的内存块中的所有对象
4、交换两个内存块的角色,完成垃圾回收

这个就是我们堆中的幸存者一区和零区的概念来源,我们的HotSpot虚拟机在这方面确实使用的是复制算法

优点:

1、没有经过标记-清除的过程,实现简单,效率高,速度比较快

2、不会出现内存碎片化问题,内存分配是指针碰撞方式,不需要维护列表

缺点:

1、内存空间始终有一部分是空闲状态

2、对于G1这种分拆成大量的region的GC,复制而不是移动,这意味着GC需要维护region之间对象引用地址,不管是内存还是时间开销都不小

栈中存放着堆中的引用地址,对象地址变动肯定需要维护地址值

3、移动时需要暂停用户线程

特别:

假如系统中的非垃圾对象很多,那么复制起来就会特别崩溃,所以复制算法要求垃圾对象越多越好

但是我们Java新生代中的对象大部分都是朝生夕死的,所以Java中使用复制算法是绝对漂亮的

那么老年代其实就不能使用这种复制算法,因为我们说过,老年代中的对象寿命周期都会比较长,换言之,基本都不是垃圾对象,所以老年代不使用复制算法

清除阶段:标记-压缩算法

标记-压缩算法也叫做标记-整理算法,Mark-Compact

之前我们说老年代使用复制算法效率会比较低,并且我们要尽量避免碎片化,那么我们就有了一个新的算法

我们在标记-清除算法的基础上进行了改进,改进成为了标记-压缩算法

过程:

1、标记阶段和标记-清除算法一样,都是标记出所有非垃圾对象

2、将所有的存活对象整理到内存中的一端,按照顺序摆放

3、清理掉所有边界之外的所有空间

标记-压缩算法有的时候也叫做标记-清除-压缩算法,再次分配内存也是使用的指针碰撞,而不是维护一个空闲列表

优点:

1、消除了标记-清除算法中碎片化的问题

2、解决了复制算法内存砍半的缺点

缺点:

1、从效率来说,标记-整理算法要低于复制算法

2、假如对象被引用,那么移动之后需要调整引用地址

3、移动时需要暂停用户线程

清除阶段:分代收集算法

在各自的场景下,各种算法都是不同的,所以没有最好的算法,而是只有最适合的算法

那么我们说分代收集算法是解决不同生命周期对象的问题的,所以他不是一个单纯的算法,而是某几个算法的集合

因为不同的对象有不同的生命周期,所以我们根据不同生命周期的对象来进行不同的收集方式,根据各个年代的特点使用不同的回收算法,以提高垃圾整体的回收效率

比如在Http的Session对象、线程、Socket连接,这种和业务直接挂钩,生命周期比较长 但是还有一些临时变量,生命周期比较短

现在几乎所有的垃圾回收器都是使用分代收集的算法执行垃圾回收的

在HotSpot中,根据分代的概念,GC所采用的内存分为新生代和老年代,然后根据各自的特点分别采用复制算法和标记压缩算法或者标记清除算法

我们对于老年代来说,以CMS回收器为例,它是基于标记-清除算法来实现的

那么CMS采用基于标记压缩算法的Serial Old回收器来回收那些碎片化的内存空间

也就是说当内存回收不佳时,就会采用Serial OLd执行Full GC来达到对老年代的内存整理

清除阶段:增量收集算法、分区算法

增量收集算法

上述的所有算法都需要让用户线程暂停,用户感觉会比较卡顿,但是能不能让系统不那么卡顿,又能顺利回收垃圾呢?

那么增量收集算法的基本思想就是:每次垃圾收集线程只收集一小块区域的内存空间,然后切换到应用程序线程。反复执行以上过程,直到垃圾收集完成。

总得来说,增量收集算法算法仍然是上面提到的三种基本算法,但是增量收集算法通过对线程之间的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清除、复制工作

缺点:系统线程切换和上下文的消耗,会令垃圾回收的总体成本上升,造成系统吞吐量下降

分区算法

分区算法的目的和增量收集算法的目的相同,都是降低用户感觉到的延迟

分区算法是将堆空间划分为很多小块,一个小块就是一个region,每一个小区间都是独立使用、独立回收的