接着上一篇,介绍完了 JVM 中识别需要回收的垃圾对象之后,这一篇我们来说说 JVM 是如何进行垃圾回收。
<!– more –>
首先要在这里介绍一下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。
垃圾回收
基础的回收方式有三种:清除
、 压缩
、 复制
,接下来让我们来一一了解一下。
清除
所谓清除,就是把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。
其原理十分简单,但是有两个缺点:
- 会造成内存碎片。由于 JVM 的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。
- 分配效率较低。如果是一块连续的内存空间,那么我们可以通过指针加法(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/