深入JVM之 垃圾收集算法、垃圾收集器

一. 简述

前文列举了JVM自Java诞生起,多年来的大概发展以及内存布局,这次我们就来聊一聊JVM中常见的垃圾收集算法以及这些历史与现在的垃圾回收器。
深入JVM之JVM发展史、内存布局

二. 垃圾收集算法

要了解垃圾收集算法,还是要建立在分代理论的基础上才更容易明白它们的存在价值。
新生代(包括Survivor)、老年代、永久代是堆的主要组成部分,这是我们在之前就已经了解到的了。那么我们的实例对象又是如何在这之中分配的呢?

垃圾收集中的三个代

  1. 新生代。
    当我们使用new关键字或者其他方式实例化一个对象的时候,这个对象大部分情况下一开始都是被分配到新生代,当该对象经历过多次新生代的GC还没有被回收,就会被移动到老年代。
    当实例化的新对象在新生代放不下,或者这个对象所占内存大于我们设置的PretenureSizeThreshold参数时,都会被直接放入老年代(在Java8默认的G1收集器中,大对象会直接放到‘大对象区’)。
    更具体的说,新生代又分为Eden区、两个Survivor区。新对象都是先来到Eden区,若Eden区放不下新对象则会触发一次GC,将当前Eden区活下来的对象放置一个Survivor区中(两个Survivor区的作用见下方标记-复制算法),在Survivor区又活过了多次GC,被移动到老年代。
    上述GC指的是只发生在新生代的Minor GC。而对象进入老年代的触发条件也不只是“年龄大”这一种,其他还有:经过Minor GC之后仍放不下的对象、大于等于某一年龄的对象超过了Survivor区的一半,这些大于等于的会进入老年代。

  2. 老年代。
    上面已经介绍了新生代进入老年代、大对象直接进入老年代的方式,那么想必我们都会有一个问题:新生代的对象年龄大了会进入到老年代,那么老年代的对象不会很快被填满吗?还是到一定年龄就死亡了?又或是到一定年龄就进入所谓的永久代长生不死了?
    答案:放置老年代被填满确实是一个很重要的问题,一旦这里被填满且无垃圾可回收,就会导致OOM(Out Of Memory)异常,所以老年代是重点调优部位,同时老年代也有它的垃圾回收方式,一般被称为Major GC(也有说法是Full GC==Major GC)。另外,老年代的对象除了被回收之外,另外一种方式只有继续存在于老年代,想要进入永久代?我们作为凡人,不要想太多了,稍后介绍永久代。
    除了注意代码上的bug,不要产生内存泄漏(无用的对象因为引用被其他对象持有而导致该对象无法被回收)之外;还要注意不要让一些“朝生夕灭”的大对象直接进入老年代,比如文件缓存,这样本该用完就回收的对象很容易在占满老年代,频繁引发Full GC。
    频繁引发Full GC会有什么坏处?简单来首,Full GC会导致整个JVM会有一个相对而言较长的Stop The World时间(稍后在标记-清除算法中再做介绍),也就是停止jvm工作线程的时间。JVM频繁的长时间罢工,C端产品是无法接受的。

  3. 永久代。
    永久代听上去好像是和新生代、老年代是一伙的,实际上并不是。永久代也存在于堆内存中,但是它被用来实现方法区,也就如上篇文章提到的,它只会存储方法区该存有的东西:class文件解析之后的class信息,常量池等(Class的信息都存在于方法区,但是在堆内存中也会有一个java.lang.Class对象作为该class的数据访问入口)
    永久代,即方法区存储的主要数据是class信息,自然可能造成方法区OOM的情况就是 类加载 过多,应该考虑是自己的方法区配置太小还是自定义类加载器的使用有问题,又或是spring aop产生的代理对象太多。永久代也存在GC,在Full GC时触发
    == 方法区在JDK8改名元空间,且不再使用永久代实现,而是挪动到了直接内存(宿主机的内存)中。这样元空间的大小便不会守永久代、堆的限制,而是随着宿主机硬件资源而动态调整。 ==

垃圾收集的三种算法

上面我们介绍了堆内存的分代论,那么这之中反复提到的GC究竟是如何工作的?对象什么情况下被判定为可以回收?回收的方式又是什么?
引用计数法是最基础的判断对象是否可回收的算法,它会给对象一个计数器,每当被其他对象引用则加一,引用它的对象消亡一个则减一,当计数器为0该对象就可以被回收了。
另一种则是当前JVM中一直被使用的判断算法——GC roots:有一组GC roots元素,如果一个对象可以通过这些GC roots元素直接或间接被访问到,则该元素就还有用,反之则可以回收。GC roots显然是当前程序正在使用的这些对象,具体如何明确这个定义,大家可以自行百度。聊了如何判断对象,下面来聊聊如何回收对象。

  1. 标记-清除算法。
    当一个对象被认定可回收,那么该对象就会被标记,当所有对象都被标记一遍之后,就开始清除工作,将所有被标记的对象清除掉。
    说起来简单,但是我们需要考虑一个问题:如何标记-清除工作是与用户线程并发的,那么必然会有脏数据产生,即该回收的未被标记或不该回收的反被标记。为了防止这类问题,最简单的方式就是STW:暂时停止所有的用户线程,标记-清除结束之后再开始用户线程。
  2. 标记-复制算法。
    标记-清除算法还有一个问题:被清除的对象的大小、位置在内存中都是随机的,也就是说GC之后会导致内存空间变得十分零散。这样最直接的问题之一就是需要分配一个大对象的时,内存空间总和是足够的,但是没有一篇碎片空间足以容纳这个大对象,就会导致内存溢出。
    为了解决这个问题,提出了标记-复制算法。该算法会在GC之后将活下来的对象全部移动到另一片空白空间,反过来另一片空间被用完的时候就挪动会这片空白的空间。两个Survivor区就是该算法的应用,开始Eden和一个Survivor正常使用,另一个Survivor空着,当Eden需要GC时,他们的对象全部移动到空白的Survivor。接下来两个Survivor交换职责,空着的空着,有对象的正常使用直到下一次GC。因为新生代的对象往往生命周期短,一次GC会产生大量的碎片空间,所以应用这种算法可以解决碎片空间问题
  3. 标记-整理。
    对于老年代来说,标记-复制却不那么适用了,因为老年代本身都是一些稳定、老、大的对象,一次GC可能不会有很多对象被回收。但是碎片空间也是存在的,当老年代使用一段时间后也可能变成一个千疮百孔的蜂窝。
    为了解决这个问题,标记-整理算法登场,它和标记-清除算法的区别仅仅是在GC之后,对当前空间的对象进行内存位置的重整。
    JVM老年代一直都是使用标记-清除算法的,但是当老年代产生过多碎片空间的时候也会切换使用标记-整理算法。

三.垃圾收集器

垃圾收集器(如上面提到的G1收集器)顾名思义,是JVM中专门标记垃圾、处理垃圾的部分,而收集器使用的收集方法也都是以上述几种垃圾回收算法为基础的。
*收集器主要进行如何具体实行垃圾回收算法的事情,它们有专门处理新生代的、也有专门处理老年代的、更有当前两代通用的。传统的及目前常用的垃圾收集器有:Serials收集器、Serials Old收集器、ParNew收集器、Parallel Scavenge收集器、Parallel Old收集器、CMS收集器、G1收集器。

  1. Serials收集器
    串行收集器,最古老的新生代收集器,只能串行,即不能与用户线程(我们的程序线程)并发且自己只能一条线程处理垃圾。
    优点:单CPU单核情况下,使用Serials没有线程切换的开销。
  2. Serials Old收集器
    同上,Serials Old是单线程的专门处理老年代的垃圾收集器。Serials Old采用标记-整理回收算法。
  3. ParNew收集器
    ParNew是Serials的并行版本,可以进行多线程回收,用于新生代。
  4. Parallel Scavenge收集器
    Parallel Scavenge收集器是一个并发的收集器。和ParNew的并行不同,并行只是指垃圾回收可以都线程执行,用户线程还是要STW,而Parallel则可以和用户线程并发执行。
  5. CMS收集器
    CMS(Concurrent Mark Sweep),是G1收集器之前的主流老年代收集器,它的STW时间短,常常与ParNew收集器合作工作。(不和parallel配合是因为设计上的不匹配。)
    CMS一次GC主要分为四个阶段:
    初始标记:标记GC ROOTS直接关联的对象。
    并发标记:并发标记其他对象。
    重新标记:标记在并发标记期间,用户新产生的引用变动。
    并发清除:并发清除垃圾对象。
    主要耗时的是并发标记和并发清除阶段,但是这两个阶段可以与用户线程并发。
  6. G1收集器
    G1收集器是Java8中默认的新生代老年代通吃的收集器。通吃最主要的原因是它变更了之前的内存布局,将整个内存切割成许多小空间(Region),每个Region可以在一个GC周期内动态用做新生代/老年代/大对象区。
    G1收集器对我们来说最大的好处是 ‘可控GC的最大暂停时间’,也就是说我们可以指定一次GC的最大时间,垃圾收集器自己计算最高收益的Region进行回收。
发布了88 篇原创文章 · 获赞 28 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/qq_35946969/article/details/104737437
今日推荐