接着上一篇,介绍完了 JVM 中识别需要回收的垃圾对象之后,这一篇我们来说说 JVM 是如何进行垃圾回收。

首先要在这里介绍一下80/20 法则:

约仅有20%的变因操纵着80%的局面。也就是说:所有变量中,最重要的仅有20%,虽然剩余的80%占了多数,控制的范围却远低于“关键的少数”。

Java 对象的生命周期也满足也这样的定律,即大部分的 Java 对象只存活一小段时间,而存活下来的小部分 Java 对象则会存活很长一段时间。

因此,这也就造就了 JVM 中分代回收的思想。简单来说,就是将堆空间划分为两代,分别叫做新生代老年代。新生代用来存储新建的对象。当对象存活时间够长时,则将其移动到老年代。

这样也就可以让 JVM 给不同代使用不同的回收算法。

对于新生代,我们猜测大部分的 Java 对象只存活一小段时间,那么便可以频繁地采用耗时较短的垃圾回收算法,让大部分的垃圾都能够在新生代被回收掉。

对于老年代,我们猜测大部分的垃圾已经在新生代中被回收了,而在老年代中的对象有大概率会继续存活。当真正触发针对老年代的回收时,则代表这个假设出错了,或者堆的空间已经耗尽了。此时,JVM 往往需要做一次全堆扫描,耗时也将不计成本。(当然,现代的垃圾回收器都在并发收集的道路上发展,来避免这种全堆扫描的情况。)

那么,我们先来看看 JVM 中堆究竟是如何划分的。

堆划分

按照上文所述,JVM 将堆划分为新生代和老年代,其中,新生代又被划分为 Eden 区,以及两个大小相同的 Survivor 区。

通常来说,当我们调用 new 指令时,它会在 Eden 区中划出一块作为存储对象的内存。由于堆空间是线程共享的,因此直接在这里边划空间是需要进行同步的。否则,将有可能出现两个对象共用一段内存的事故。

JVM 的解决方法是为每个线程预先申请一段连续的堆空间,并且只允许每个线程在自己申请过的堆空间中创建对象,如果申请的堆空间被用完了,那么再继续申请即可,这也就是 TLAB(Thread Local Allocation Buffer,对应虚拟机参数 -XX:+UseTLAB,默认开启)。

此时,如果线程操作涉及到加锁,则该线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向 TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。

接下来的 new 指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。

如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,TLAB 已经没有足够的空间来满足本次新建操作。这个时候,便需要当前线程重新申请新的 TLAB。

那有没有可能出现申请不到的情况呢?有的,这个时候就会触发Minor GC了。

Minor GC

所谓 Minor GC,就是指:

当 Eden 区的空间耗尽时,JVM 会进行一次 Minor GC,来收集新生代的垃圾。存活下来的对象,则会被送到 Survivor 区。

上文提到,新生代共有两个 Survivor 区,我们分别用 from 和 to 来指代。其中 to 指向的 Survivior 区是空的。

当发生 Minor GC 时,Eden 区和 from 指向的 Survivor 区中的存活对象会被复制到 to 指向的 Survivor 区中,然后交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区还是空的。

JVM 会记录 Survivor 区中每个对象一共被来回复制了几次。如果一个对象被复制的次数为 15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。

另外,如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。

总而言之,当发生 Minor GC 时,我们应用了标记 - 复制算法,将 Survivor 区中的老存活对象晋升到老年代,然后将剩下的存活对象和 Eden 区的存活对象复制到另一个 Survivor 区中。理想情况下,Eden 区中的对象基本都死亡了,那么需要复制的数据将非常少,因此采用这种标记 - 复制算法的效果极好。

Minor GC 的另外一个好处是不用对整个堆进行垃圾回收。但是,它却有一个问题,那就是老年代中的对象可能引用新生代的对象。也就是说,在标记存活对象的时候,我们需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为 GC Roots。这样一来,岂不是又做了一次全堆扫描呢?

为了避免扫描全堆,JVM 引入了名为卡表的技术,大致地标出可能存在老年代到新生代引用的内存区域。有兴趣的朋友可以去详细了解一下,这里限于篇幅,就不具体介绍了。

Full GC

那什么时候会发生Full GC呢?针对不同的垃圾收集器,Full GC 的触发条件可能不都一样。按 HotSpot VM 的 serial GC 的实现来看,触发条件是:

当准备要触发一次 Minor GC 时,如果发现统计数据说之前 Minor GC 的平均晋升大小比目前老年代剩余的空间大,则不会触发 Minor GC 而是转为触发 Full GC。

因为 HotSpot VM 的 GC 里,除了垃圾回收器 CMS 能单独收集老年代之外,其他的 GC 都会同时收集整个堆,所以不需要事先准备一次单独的 Minor GC。

垃圾回收

基础的回收方式有三种:清除压缩复制,接下来让我们来一一了解一下。

清除

所谓清除,就是把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。

其原理十分简单,但是有两个缺点:

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

压缩

所谓压缩,就是把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。

这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销,因此分配效率问题依旧没有解决。

复制

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

这种回收方式同样能够解决内存碎片化的问题,但是它的缺点也极其明显,即堆空间的使用效率极其低下。

具体垃圾收集器

针对新生代的垃圾回收器共有三个:Serial ,Parallel Scavenge 和 Parallel New。这三个采用的都是标记 - 复制算法。

其中,Serial 是一个单线程的,Parallel New 可以看成是 Serial 的多线程版本,Parallel Scavenge 和 Parallel New 类似,但更加注重吞吐率。此外,Parallel Scavenge 不能与 CMS 一起使用。

针对老年代的垃圾回收器也有三个:Serial Old ,Parallel Old 和 CMS。

Serial Old 和 Parallel Old 都是标记 - 压缩算法。同样,前者是单线程的,而后者可以看成前者的多线程版本。

CMS 采用的是标记 - 清除算法,并且是并发的。除了少数几个操作需要 STW(Stop the world) 之外,它可以在应用程序运行过程中进行垃圾回收。在并发收集失败的情况下,JVM 会使用其他两个压缩型垃圾回收器进行一次垃圾回收。由于 G1 的出现,CMS 在 Java 9 中已被废弃。

G1(Garbage First)是一个横跨新生代和老年代的垃圾回收器。实际上,它已经打乱了前面所说的堆结构,直接将堆分成极其多个区域。每个区域都可以充当 Eden 区、Survivor 区或者老年代中的一个。它采用的是标记 - 压缩算法,而且和 CMS 一样都能够在应用程序运行过程中并发地进行垃圾回收。

G1 能够针对每个细分的区域来进行垃圾回收。在选择进行垃圾回收的区域时,它会优先回收死亡对象较多的区域。这也是 G1 名字的由来。

总结

这篇文章主要讲述的是 JVM 中具体的垃圾回收方法,从对象的生存规律,引出回收方法,结合多线程的特点,逐步优化,最终产生了我们现在所能知道各种垃圾收集器。

有兴趣的话可以访问我的博客或者关注我的公众号、头条号,说不定会有意外的惊喜。

https://death00.github.io/

版权声明:本文为death00原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/death00/p/11706742.html