JVM垃圾回收算法与垃圾收集器

Posted 爱写Bug的王六六

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM垃圾回收算法与垃圾收集器相关的知识,希望对你有一定的参考价值。

垃圾回收算法与垃圾收集器

1.垃圾收集算法

目前最基本的垃圾收集算法有四种:

  • 标记-清除算法(mark-sweep)
  • 标记-整理算法(mark-compact)
  • 复制算法(copying)
  • 分代收集算法(Generational Collection)

而现代流行的垃圾收集算法一般是由这四种中的其中几种算法相互组合而成,比如说,对堆(heap)的一部分采用标记-清除算法,对堆(heap)的另外一部分则采用复制算法等等。

1.1 标记清除算法

最基础的收集算法,后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。

具体内容参考:标记-清除算法

1.1.1 基本概念

1.1.1.1 mutatorcollector

首先是mutatorcollector,这两个名词经常在垃圾收集算法中出现。
collector指的就是垃圾收集器。
mutator是指除了垃圾收集器之外的部分,比如说应用程序本身。

职责:
mutator

  • NEW(分配内存)
  • READ(从内存中读取内容)
  • WRITE(将内容写入内存)

collector

  • 回收不再使用的内存来供mutator进行NEW操作的使用。
1.1.1.2 mutator roots(mutator根对象)

mutator根对象一般指的是分配在堆内存之外,可以被mutator直接访问到的对象。
一般是指静态/全局变量以及Thread Local变量(在Java中,存储在java.lang.ThreadLocal中的变量和分配在栈上的变量 ,方法内部的临时变量等都属于此类).

1.1.1.3 可达对象

从mutator根对象开始进行遍历,可以被访问到的对象。
这些对象也是mutator(应用程序)正在使用的对象。

1.1.2 垃圾回收过程

1.1.2.1 标记(mark)

总体概括:遍历内存,找到需要回收的对象。
在标记阶段,collectormutator根对象开始进行遍历,对mutator根对象可以访问到的对象都打上一个标识(标识所在位置一般是在对象的header中),将其记录为可达对象


在Mark阶段,从根对象1可以访问到B对象,从B对象又可以访问到E对象,所以B,E对象都是可达的。同理,F,G,J,K也都是可达对象。

1.1.2.2 清除(sweep)

总体概括:将要清除的空间记录到空闲列表中。在标记完成后统一回收掉所有被标记的对象。
清除阶段,collector堆内存(heap memory)从头到尾进行线性遍历,如果发现某个对象没有标记为可达对象(通过读取对象的header信息,判断该对象是否为可达对象),就将其回收。
从堆内存起始位置开始,线性遍历所有对象直到堆内存末尾,如果该对象是可达对象(在mark阶段被标记过的),就直接去除标记位(为下一次的mark做准备),如果该对象是不可达的,直接释放内存。

到了Sweep阶段,所有非可达对象都会被collector回收。同时,Collector在进行标记和清除阶段时会将整个应用程序暂停(mutator),等待标记清除结束后才会恢复应用程序的运行,这也是Stop-The-World这个单词的来历。

缺点:
垃圾收集后有可能会造成大量的内存碎片,像上面清除阶段的图片所示,垃圾收集后内存中存在三个内存碎片,假设每个碎片中一个方格代表1个单位的内存,如果有一个对象需要占用3个内存单位(目前只有连续的两个内存单位)的话,那么就会导致Mutator一直处于暂停状态,而Collector一直在尝试进行垃圾收集,直到Out of Memory

1.1.2.3 整体流程

1.1.3 缺点

  • 效率问题:标记和清除过程的效率都不高;
  • 空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致导致大对象无法分配到足够的连续内存,从而不得不提前触发GC,甚至Stop The World。

使用此算法的收集器: CMS收集器

1.2 复制算法

为了更好的缓解内存碎片问题,“复制”算法出现了。

1.2.1 算法原理

将堆内存对半分为两个半区,只用其中一个半区来进行对象内存的分配。如果在这个半区内存不够给新的对象分配了,开始进行垃圾收集,将这个半区中的所有可达对象都复制到另外一个半区中去,然后继续在另外那个半区进行新对象的内存分配。

这样使得每次都是对半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可。

相比于标记-清除算法,可用堆内存减少了一半。同时对于大对象,复制比标记的代价更大。所以复制算法更一般适合回收小的,存活期短的对象。

1.2.2 大致流程

1.2.3 具体流程

参考:https://blog.csdn.net/yw_1207/article/details/100017889

假设A,B对象是根对象。


1.首先交换左右半区(To, From), 同时设置free指针和top指针。


2. 遍历处理根对象A、B。先将A对象复制到free指针指向的位置,同时将A对象复制后的地址(迁移地址)写到原先A对象所在的位置(图中虚线的箭头表示)。可以看到A对象已经被collector访问过了,但是还没有访问其孩子节点,所以将其标为了灰色。紧接着scan-遍历,free指针继续向前移动。


3. 由于是深度遍历算法,紧接collector会先遍历处理A对象所引用的对象C,当发现对象C没有迁移地址时,说明它还没有被复制,由于它又是可达对象,所以接着collector会将它复制到当前free指针指向的位置,即对象A后面。对象C复制完后,会用其复制后的地址来更新A原先对C的引用(紫色实线),同时也写到原先C对象所在的地址上(黑色虚线)。

4. 接着collector会处理对象C的孩子节点(深度遍历算法),由于对象C没有引用任何对象,于是对象C的处理结束,将其标记为黑色。然后collector接着处理A对象的另外一个孩子节点E对象,处理方式跟处理对象C一致。

5. 对象E也没有孩子节点,collector也将其标识为黑色。

6. 到目前为此,A对象也全部处理结束了,于是collector将其标识为黑色,然后接着去处理对象B。当复制B对象结束后,发现B对象所引用的对象C有迁移地址,于是就更新其对对象C的引用,使其指向FromSpace半区中对象C的迁移地址 - 即C对象复制后所在ToSpace的地址(紫色实线)。这个情况下就不需要再次复制对象C了。

7. 当所有的可达对象都从FromSpace半区复制到ToSpace半区后,垃圾收集结束。新对象的内存分配从free指针指向的位置开始进行分配。

从垃圾收集过程中对象的移动顺序来看,collector将相邻的对象都复制在相近的位置上

1.2.4 年轻代的垃圾回收算法

参考:https://blog.csdn.net/g6U8W7p06dCO99fQ3/article/details/121154504

IBM公司的专门研究表明,新生代中的对象98%是“朝生夕死”的。
新生代内存区域划分为三块:1个Eden区,2个Survivor区,默认情况下Eden区占80%内存空间,每一块Survivor区各占10%内存空间。

Survivor:Survivor:Eden =1:1:8

比如说新生代1G的内存,Eden区就有800MB内存,每一块Survivor区就有100MB内存。
如下图所示:

  • 刚开始对象都是分配在Eden区内的,如果Eden区快满了,此时就会触发垃圾回收。
    新生代使用的复制算法,此时就会把Eden区中的存活对象都一次性转移到一块空着的Survivor区。survivor的中文意思是存活,顾名思义,就是放垃圾回收后存活的对象的。

  • 接着Eden区就会被清空,然后再次分配新对象就会继续分配到Eden区里,这样只有Eden区和一块Survivor区里是有对象的,其中Survivor区里放的是上一次Minor GC后存活的对象。新生代的垃圾回收就是这样来回倒腾的。

  • 所以1G内存的新生代,平时可以使用的,就是Eden区和其中一块Survivor区,那么相当于有900MB的内存是可以使用。

这么做最大的好处,就是只有10%的内存空间是被闲置的,90%的内存都被用上了。新生代对象存活期很短,存活的对象也很少,所以survivor分配的区域也比较小。

每次使用Eden和其中一块Survivor。只有10%的内存会被“浪费”。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。由于Eden可能无法容下所有存活的对象,因此需要老年代进行内存担保。

From Survivor, To Survivor使用的就是复制算法。老年代不使用这种算法。

1.2.5 优缺点

优点:
保持内存的规整,不会产生内存碎片
缺点:
效率问题:在对象存活率较高时,复制操作次数多,效率降低;
空间问题:內存缩小了一半;需要额外空间做分配担保(老年代)。

使用此算法的收集器: serial收集器,ParNew收集器,Parallel Scavenge收集器。

1.3 标记-整理算法

内存碎片一直是非移动垃圾回收器(指在垃圾回收时不进行对象的移动)的一个问题,比如说在前面的标记-清除垃圾回收器就有这样的问题。
而标记-压缩垃圾回收算法能够有效的缓解这一问题。

复制算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。
更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法。

1.3.1 算法原理

1.3.1.1 标记(mark)

标记过程仍然与“标记-清除”算法一样,标记存活对象。
但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

1.3.1.2 整理(compact)

移动所有的可达对象到堆内存的一个区域中,使其紧凑的排列在一起,从而将所有非可达对象释放出来的空闲内存都集中在一起,通过这样的方式来达到减少内存碎片的目的。

在整理阶段,由于要移动可达对象,那么需要考虑移动对象时的顺序,一般分为下面三种:

  • 任意顺序 - 即不考虑原先对象的排列顺序,也不考虑对象间的引用关系,随意的移动可达对象,这样可能会有内存访问的局部性问题。
  • 线性顺序 - 在重新排列对象时,会考虑对象间的引用关系,比如A对象引用了B对象,那么就会尽可能的将A,B对象排列在一起。
  • 滑动顺序 - 顾名思义,就是在重新排列对象时,将对象按照原先堆内存中的排列顺序滑动到堆的一端。

现在大多数的垃圾收集算法都是按照任意顺序滑动顺序去实现的。

1.3.1.3 Two-Finger 算法(2指)

参考:https://blog.csdn.net/yw_1207/article/details/100017702
Two-Finger 算法-----任意顺序移动 ------ 适用于处理包含固定大小对象的内存区域

需要遍历堆内存两次:

  • 第一次遍历是将堆末尾的可达对象移动到堆开始的空闲内存单元去
  • 第二次遍历则需要修改可达对象的引用,因为一些可达对象已经被移动到别的地址,而原先引用它们的对象还指向着它们移动前的地址。

在这两次遍历过程中,首尾两个指针分别从堆的头尾两个位置向中间移动,直至两个指针相遇。

1.3.1.4 LISP2 算法

参考:https://blog.csdn.net/yw_1207/article/details/100017702
LISP2 算法------滑动顺序----可以处理不同大小的对象

计算出来的可达对象的迁移地址需要额外的空间进行存储而不再是复写原先对象所在的位置。最后,Lips2算法需要进行3次堆内存的遍历。

1.3.2 回收过程


缺点:
标记-压缩算法虽然缓解的内存碎片问题,但是它也引用了额外的开销,比如说额外的空间来保存迁移地址,需要遍历多次堆内存等。

使用此算法的收集器: Serial Old,Parallel Old

1.4 分代回收

根据对象存活周期的不同将内存划分为几块。把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

  • 在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
  • 老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。

1.4.1 年轻代的垃圾回收算法

参考:https://blog.csdn.net/g6U8W7p06dCO99fQ3/article/details/121154504

IBM公司的专门研究表明,新生代中的对象98%是“朝生夕死”的。
新生代内存区域划分为三块:1个Eden区,2个Survivor区,默认情况下Eden区占80%内存空间,每一块Survivor区各占10%内存空间。

Survivor:Survivor:Eden =1:1:8

比如说新生代1G的内存,Eden区就有800MB内存,每一块Survivor区就有100MB内存。
如下图所示:

  • 刚开始对象都是分配在Eden区内的,如果Eden区快满了,此时就会触发垃圾回收。
    新生代使用的复制算法,此时就会把Eden区中的存活对象都一次性转移到一块空着的Survivor区。survivor的中文意思是存活,顾名思义,就是放垃圾回收后存活的对象的。

  • 接着Eden区就会被清空,然后再次分配新对象就会继续分配到Eden区里,这样只有Eden区和一块Survivor区里是有对象的,其中Survivor区里放的是上一次Minor GC后存活的对象。新生代的垃圾回收就是这样来回倒腾的。

  • 所以1G内存的新生代,平时可以使用的,就是Eden区和其中一块Survivor区,那么相当于有900MB的内存是可以使用。

这么做最大的好处,就是只有10%的内存空间是被闲置的,90%的内存都被用上了。新生代对象存活期很短,存活的对象也很少,所以survivor分配的区域也比较小。

每次使用Eden和其中一块Survivor。只有10%的内存会被“浪费”。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。由于Eden可能无法容下所有存活的对象,因此需要老年代进行内存担保。

2. 总结

参考:JVM 对象分代、垃圾回收机制

  • Mark-Sweep(标记-清除)算法:产生内存碎片
  • Copying(复制)算法:不会产生内存碎片
  • Mark-Compact(标记-整理)算法:不会产生内存碎片
  • Generational Collection(分代收集)算法:新生代 用 复制算法 和 老年代 用 标记整理算法

算法性能对比:

  • 效率:复制算法>标记/整理算法>标记/清除算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。
  • 内存整齐度:复制算法=标记/整理算法>标记/清除算法。
  • 内存利用率:标记/整理算法=标记/清除算法>复制算法。

垃圾回收器补充

JVM(四)分代回收机制及垃圾回收算法
ParNew: 是Serial收集器的多线程版本,使用多个线程进行垃圾收集。
Serial/Serial Old: Serial/Serial Old收集器是最基本最古老的收集器,是单线程收集器,并且在进行垃圾收集时,必须暂停所有用户线程。

  • Serial收集器是针对新生代的收集器,采用的是Copying算法。
  • Serial Old收集器是针对老年代的收集器,采用的是Mark-Compact算法。优点是实现简单高效,但是缺点是会给用户带来停顿。

以上是关于JVM垃圾回收算法与垃圾收集器的主要内容,如果未能解决你的问题,请参考以下文章

JVM专题--垃圾回收算法, 垃圾回收器

JVM高级特性-垃圾收集之判断对象存活算法

JVM垃圾收集

jvm 05-JVM垃圾收集策略

JVM高级特性与实践:对象存活判定算法(引用) 与 回收

JVM垃圾回收算法与垃圾收集器