GC?垃圾回收?GCRoots?简单聊

发布时间:18-10-1602:59

前言

今天的文章,思来想后不知道该写点什么,最后决定写写垃圾回收吧。

正文

提到垃圾回收,顾名思义,就是把已经分配出去的,但却不再使用的内存回收回来。对于JVM来说,垃圾指的是在堆中死亡的对象所占据的内存空间。

那么自然而然的,我们就能够提出一个问题:怎么知道对象死没死?由这个问题让我们引出俩个比较有名的思路:

一、引用计数法

引用计数法是一个颇为古老的方式,原因它有致命的缺点。先不说缺点,咱们看一看它的思路。

它的做法是为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为0,则说明该对象已经死亡,便可以被回收了。

它的具体实现是这样子的:如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器+1。如果一个指向某一对象的引用,被赋值为其他值,那么将该对象的引用计数器 -1。

对于引用计数法来说,除了需要额外的空间来存储计数器,以及繁琐的更新计数器以外;引用计数法还有一个重大的漏洞:无法处理循环引用。

假设对象 a 与 b 相互引用,除此之外没有其他引用指向 a 或者 b。在这种情况下,a 和 b 实际上已经死了,但由于它们的引用计数器皆不为 0,在引用计数法的心中,这两个对象还活着。因此,这种情况下就造成了内存泄露。

所以目前 Java 虚拟机的主流垃圾回收器采取的是可达性分析算法

二、可达性分析

2.1、基本概念

这种算法的思路在于:将一系列被称为GC Roots的变量作为初始的存活对象合集,然后从该合集出发,所有能够被该集合引用到的对象,并将其加入到该集合中,而不能被该合集所引用到的对象,并可对其宣告死亡。

那么什么是 GC Roots 呢?

注意这句话:GC Roots是一些由堆外指向堆内的引用,

一般而言,GC Roots 包括(但不限于)如下几种:

Java 方法栈桢中的局部变量;已加载类的静态变量;JNI handles;已启动且未停止的 Java 线程。可达性分析可以解决引用计数法所不能解决的循环引用问题。举例来说,即便对象 a 和 b 相互引用,只要从 GC Roots 出发无法到达 a 或者 b,那么a和b就是死亡的对象。

虽然可达性分析的算法本身很简明,但是在实践中还是有不少其他问题需要解决的。

2.2、多线程环境存在问题

在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,而我们的可达性分析线程却没有同步到最新的内容。那么就会造成误报或者漏报。

对于JVM来说漏报顶多损失了部分垃圾回收的机会。漏报则比较麻烦,因为垃圾回收器可能回收了仍被引用的对象…

怎么解决这个问题呢?

2.2.1、Stop-the-world以及安全点

在 Java 虚拟机里,传统的垃圾回收算法采用的是一种简单粗暴的方式,那便是 Stop-the-world,停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)。

Java 虚拟机中的 Stop-the-world 是通过安全点(safepoint)机制来实现的。当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才会停止所有线程,并允许请求Stop-the-world的那个线程进行独占的工作。

当然也并非蛮横的强制停止,毕竟多线程情况下,啥事都可能发生。安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下,Java 虚拟机的堆栈不会发生变化。

三、垃圾回收的三种方式

通过上文我们聊到的可达性分析,我们可以对死亡对象宣判死刑。那么接下来我们便可以对死亡对象进行回收工作了。主流的基础回收方式可分为三种。

3.1、清除(sweep)

常见的一种叫法:标记清除

思想:把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。

清除这种回收方式的原理及其简单,但是有两个缺点:

1、造成内存碎片。由于 Java 虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。比如:总空间100M,此时我们需要申请100M的数组。但是由于内存不连续,因此我们就会申请失败。2、分配效率较低。如果是一块连续的内存空间,那么我们可以通过指针加法(pointer bumping)来做分配。而对于空闲列表,Java 虚拟机则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。

3.2、压缩(compact)

常见的一种叫法:标记整理

思想:把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。

这种做法优缺点都比较的明显:

优点:能够解决内存碎片化的问题缺点:压缩算法的性能开销

3.3、复制(copy)

思想:把内存区域分为两等分,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容。

这种做法的优缺点同样明显:

优点:能够解决内存碎片化的问题缺点:堆空间的使用效率极其低下(毕竟分成两半,一次只使用一半)

四、现代设计方案

经研究发现,大多数的对象其实死的很快。因此,现在的垃圾回收器采用上述三种方式并存的思路:

一般是把Java堆分作新生代和老年代,根据各个年代的特点采用最适当的收集算法:

新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就用复制算法,只要少量复制成本就可以完成收集。老年代中因为对象的存活率较高、周期长,就用标记-整理或标记-清除算法来回收。稍稍正式官方的描述:Java 虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为 Eden 区,以及两个大小相同的 Survivor 区。分别用 from 和 to 来指代。其中 to 指向的 Survivior 区是空的。

如果Eden区不够分配,那么就会触发Minor GC。而此时Eden 区和 from 指向的 Survivor 区中的存活对象会被复制到 to 指向的 Survivor 区中,然后交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区还是空的。

Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15,

可以通过虚拟机参数 -XX:+MaxTenuringThreshold进行设置。

那么该对象将被放到老年代。另外,如果单个 Survivor 区已经被占用了 50%,

可以通过虚拟机参数 -XX:TargetSurvivorRatio进行设置

那么较高复制次数的对象也会被晋升至老年代。

而以上的过程,可以用一个图,轻松的描述清楚:

此图来源:码出高效Java开发手册

尾声

关于垃圾回收的部分,暂时就涉及这么多吧。因为再往下就会涉及到很多JVM调优,或者很深入的设计原理了。那样的话,便不是简简单单一篇文章能够就是的清楚了。最主要:我也不会,哈哈~

返回顶部