JVM 垃圾回收

116 阅读10分钟

JVM垃圾回收

垃圾回收在哪里发生?那其他地方不用垃圾回收吗?

  • 1、垃圾回收发生在堆区和元数据区
  • 2、因为JVM 的堆区是内存共享的区域,而在虚拟机栈,本地方法栈,程序计数器,这些区域属于线程私有区,会伴随着我们线程的结束而销毁,因而不需要垃圾回收(根本就没有垃圾了)

哪些对象是垃圾?

==垃圾对象指的是在我们程序的运行过程中,已经没有任何指针指向的对象==

判断对象存活的方法

  1. 引用计数法

    1. 说白了就是通过 + 1 和 - 1 的机制来对对象进行计数
    2. 对象创建出来后,计数器初始化为 1
    3. 后续执行过程中,又有另外一个变量引用该实例时,该对象的引用计数器会+1
    4. 而当方法执行结束,栈帧中局部变量表中引用该对象的指针随之销毁时,当前对象的引用计数器会-1
    5. 当一个对象的计数器为0时,代表当前对象已经没有指针引用它了(垃圾
    6. !问题 !对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们
  2. 可达性分析法

    1. 被GC-Roots 搜索链搜索下可达的对象称之为有用对象,否则则为垃圾

    2. 可以被作为GC Roots的对象有如下四大类:

      1. 虚拟机栈中引用的对象
      2. 元数据空间中类静态属性引用的对象
      3. 元空间运行时常量池中常量引用的对象
      4. 本地方法栈中JNI(native方法)中引用的对象
    3. 下图中存在两个根节点,而通过这两个根节点向下搜索,object1、2、3、4对象都是可达的,那代表着这四个对象都为存活对象。而object5、6、7三者之间虽然存在引用关系,但实际上已经没有Roots节点可以到达了,那最终这三个对象会被一起判定为 “垃圾”

image.png


我们上面讲的都是对象,但是有没有考虑过什么样的类是无用的呢?

类需要满足以下三个条件才有可能被回收

  1. 该类的所有实例对象已经被回收

  2. 加载该类的ClassLoader 已经被回收

  3. 该类没有在任何地方被引用

垃圾回收的算法

1、标记清除法

在标记阶段会根据可达性分析算法,通过根节点标记堆中所有的可达对象,而这些对象则被称为堆中存活对象,反之,未被标记的则为垃圾对象。然后在清除阶段,会对于所有未标记的对象进行清除

程序运行期间所有对象的状态,初始GC标志位都为0,也就是未标记状态

假设此时系统堆内存出现不足,那么最终会触发GC机制。GC开始时,在标记阶段首先会停下整个程序,然后GC线程开始遍历所有GC Roots节点,根据可达性分析算法找出所有的存活对象并标记为1

所有未被标记的对象会被清除回收掉,留下的则是前面被标记的存活对象。同时为了方便下次GC,在清除操作完成之后,会将前面存活对象的GC标志位复位,也就是会将标记从1为还原成未标记的0

标记-清除算法是最初的GC算法,因为在标记阶段需要停下所有用户线程,也就是发生STW,而标记的时候又需要遍历整个堆空间中的所有GcRoots,所以耗时比较长,对于客户端而言,可能会导致GC发生时,造成很长一段时间内无响应。 还有,因为堆空间中的垃圾对象是会分散在内存的各个角落,所以一次GC之后,会造成大量的内存碎片

GC 标记究竟标记在哪里?

在对象头中存在一个markword字段,而GC标志位就存在其内部

2、复制算法

图示说明即可:

image.png 在发生GC时,首先会将左侧这块内存区域中的存活对象移动到右侧这块空闲内存

image.png 然后会对于左侧这块内存所有区域进行统一回收

最终左侧空出来用作下次GC发生时转移存活对象,而右侧则成为新的对象分配区域,原本的两块区域角色互相转换

每次GC都是直接对半边区域进行回收,所以回收之后不需要考虑内存碎片的复杂情况

但这种算法最大的问题在于对内存的浪费,因为在实际内存分配时只会使用一块内存,所以在实际分配时,内存直接缩水一半,这是比较头疼的事情。同时,存活的对象在GC发生时,还需要复制到另一块内存区域,因此对象移动的开销也需要考虑在内,所以想要使用这种算法,最起码对象的存活率要非常低才行

3、标记整理算法

标记-整理算法也被称为标记-压缩算法,标-整算法适用于存活率较高的场景,它是建立在标-清算法的基础上做了优化。标-整算法也会分为两个阶段,分别为标记阶段、整理阶段:

  • ①标记阶段:和标-清算法一样。在标记阶段时也会基于GcRoots节点遍历整个内存中的所有对象,然后对所有存活对象做一次标记。
  • ②整理阶段:在整理阶段该算法并不会和标-清算法一样简单的清理内存,而是会将所有存活对象移动(压缩)到内存的一端,然后对于存活对象边界之外的内存进行统一回收

image.png

当所有存活对象全部被压缩到内存的一端后,GC机制会开始对于存活对象边界之外的内存区域进行统一回收,回收掉这些内存区域之后,最后再把存活对象的GC标志复位,然后GC结束

经过标-整算法之后的堆空间会变成整齐的内存,因为被标记为存活的对象都会被压缩到内存的一端。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可

但是标-整算法唯一的美中不足在于:它的整体收集效率并不高。因为标-整算法不仅仅要标记对象,同时还要移动存活对象,所以整个GC过程下来,它所需要耗费的时间资源开销必然是不小的。

垃圾收集算法总结

这又回到了我们最经典的时间与空间的关系(鱼和熊掌不可兼得)

  • 收集速度:复制算法 > 标-清算法 > 标-整算法
  • 内存整齐度:复制算法 = 标-整算法 > 标-清算法
  • 内存利用率:标-整算法 > 标-清算法 > 复制算法

在GC算法中,速度快的需要用空间来换,空间利用率高且整齐度高的,则需要牺牲时间来换取,所以相对而言,在绝大部分算法中,时间与空间不可兼得

分代收集策略

现代商用虚拟机中的GC机制一般都会采用“分代收集策略”

根据对象不同的生命周期将堆空间划分为不同的区域,然后在不同区域中采用不同的垃圾收集算法进行回收工作

  • 新生代:一般使用复制算法,因为在新生代中的对象几乎绝大部分都是朝生夕死的,每次GC发生后只会有少量对象存活,这种情况下采用复制算法无疑是个不错的选择,付出一定的内存空间开销以及少量存活对象的移动开销,换取内存的整齐度以及可观收集效率,这很明显是个“划得来的买卖”。

  • 年老代:一般采用标-整算法,绝大多数年老代GC器都会选择采用标-整算法,因为毕竟标-清算法会导致大量的内存碎片产生,在年老代对象分配时,内存不完整可能会导致大对象分配不下而持续触发GC。而标-整算法虽然效率较低,但胜在GC后内存足够整齐,再加上年老代的GC并没有新生代频繁,所以年老代空间采用标-整算法无疑也是个不错的选择

分区收集策略

在JDK1.8及之前的JVM中,堆中间一般会按照对象的生命周期长短划分为新生代、年老代两个空间,分别用于存储不同周期的对象。

而在新版本的GC器,如G1、ZGC中,则摒弃了之前物理内存上分代的思想,在运行时并不会直接将堆空间切分为两块区域,而是将整个堆划分为连续且不同的小区间,每一个小区间都独立使用,独立回收,这种回收策略带来的好处是:可以控制一次回收多少个小区间

GC类型划分

JVM在发生GC时,主要作用的区域有三个:新生代、年老代以及元数据空间,当然,程序运行期间,绝对多数GC都是在回收新生代。一般而言,GC可以分为四种类型,如下:

  1. 新生代收集(MinorGC):只针对新生代的GC,当Eden区满了时触发,Survivor满了并不会触发。
  2. 年老代收集(MajorGC):针对年老代空间的GC,不过目前只有CMS存在单独回收年老代的行为。
  3. 混合收集(MixedGC):指收集范围覆盖整个新生代空间及部分年老代空间的GC(目前只有G1存在该行为)。
  4. 全面收集(FullGC):覆盖新生代、年老代以及元数据空间的GC,会对于所有可发生GC的内存进行收集。

内存分配以及回收规则

  • 对象优先在Eden 分配

    • 在程序运行的大多数情况下,对象都在Eden 分配,当Eden 空间不足,才会发起一次MinorGC
  • 大对象进入老年代

    • 指的是需要连续内存空间的对象,比如字符串或者数组
  • 长期存活对象进入老年代

    • 默认情况下,每熬过一次MinorGC 就会增加一岁,到十五岁就会进入老年代

参考资料:JVM成神路 - 竹子爱熊猫的专栏 - 掘金 (juejin.cn)