2: JVM的垃圾回收与回收算法

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

1:什么是GC?

GC(Garbage Collection):即JAVA中的垃圾回收机制,是JVM的重要机制。在JVM中由守护线程线程运行。

2:为什么需要GC?

程序执行过程中每实例化一个对象或者定义一个变量,都需要在内存中分配一片空间用来存储,但是内存空间是有限的,所以对于无用的变量或者对象,就需要销毁掉,收回其占用的内存空间用来分配给新的对象或变量。相对于手动GC的繁琐以及遗漏可能导致的内存泄漏,GC可以在适当的时间去自动清理掉无用的对象回收内存空间。

3:如何进行GC?

3.1:确定垃圾

如果要进行垃圾回收,首先要确定哪些才是要回收的对象,哪些是要保留的对象。 在Java中通过引用来访问对象,如果一个对象有被引用,说明这个对象正在被使用,此对象就需要保留以继续使用,相反的如果一个对象已经没有被引用,就意味着已经没有地方需要它了,它也就可以被销毁了。

3.1.1:引用计数法

引用计数法通过判断对象的被引用次数来确定是否需要回收。 它为每一个对象设置一个引用计数器,当有新的引用指向它时引用计数器就+1,当有引用被销毁时引用计数器就-1.当引用计数器为0时就意味着这个对象没有地方引用了,就可以被回收了。但是这种方式有一个致命的弱点,那就是循环引用。例如a.instance=b;b.instance=a;,在这种情况下因为a对象被b引用,所以a的引用计数器需要等到b被释放时才能归0,但是b同时也被a引用着,它如果要释放,也需要等待a被销毁使得b的引用计数器归0.这就陷入了一个死循环。当外部没有任何引用指向a和b,也就是说它们已经属于无用的对象需要回收了,但是由于a与b的循环引用使得a和b都无法被识别为可回收对象,从而导致内存泄漏。

3.1.2:可达性分析法

要解决循环引用的问题,就需要知道那些是有效引用,那些是无效引用。可达性分析法就解决了这个问题。它也是目前JVM的主流回收算法。 程序执行过程中对直接使用对象的引用,以及这些直接使用的对象所产生的引用链即为有效引用。 可达性分析法就是以这些直接使用对象(被称为 "GC Roots" )作为起点集,从这些节点开始,通过引用关系向下搜索,搜索过程中所经过的路径被称为引用链(Reference Chain),能被引用链搜索到的对象即为可达对象,需要保留。而当一个对象无法通过任何 GC Roots搜索到时,则称这个对象是不可达的。 但是不可达并不代表着就需要被回收,是否需要回收还需要经历两次标记过程。

  • 第一次标记:判断不可达对象是否需要执行finalize方法,判断的依据是对象的finalize方法。重写了finalize方法并且finalize方法未被执行过的对象需要执行finalize。相反的,因为finalize只会执行一次,所以finalize未被重写以及finalize已经被执行过的对象的finalize就无需执行finalize了。如果是无需执行finalize的对象,那么就会被标记为可回收的。如果对象需要执行finalize,那么对象就会被放入一个名为F-Queue队列中。等待稍后的第二次标记。
  • 第二次标记:JVM会建立一个低优先级的线程finalizer线程来触发F-Queue队列中对象的finalize方法(注意是触发,而不是执行,这个线程只负责触发,而不会一条条的执行,这是为了避免某个对象的finalize执行缓慢或者陷入死循环或者更极端的情况,拖累后续对象的finalize,导致回收过程缓慢或阻塞甚至回收系统崩溃),finalize方法是对象最后一个避免死亡的机会,只需将其与引用链上的某个对象关联起来,比如赋值给某个引用链对象的变量。这时这个对象就会获得一个有效引用从而避免被回收。因为finalize方法只能被执行一次,所以一个对象也只能拯救自己一次。而finalize方法中未进行自我拯救的对象在对F-Queue队列中对象的二次标记过程中就会标记为可回收的。(F-Queue可以看以看一下3: JAVA中的四种引用类型 - 掘金 (juejin.cn)中有关ReferenceQueue的部分,利用了对象回收跟踪机制)

可以看到可达性分析法的关键就在于GC Roots和引用: 可达性分析法中可以做为GC Roots的对象有:

  • 虚拟机栈中引用的对象。
  • 本地方法栈中引用的对象。
  • 方法区中的常量和静态变量引用的对象。

如果对方法区和堆栈这些JVM内存的概念较为模糊的化可以看上一篇文章1: JVM内存区域 - 掘金 (juejin.cn) 而对于引用,不同的引用GC也会采用不同的策略处理。确定垃圾这一部分的引用指强引用(大家所熟知的一般意义上的引用)以及内存充足时的软引用(关于引用的分类,可以参考3: JAVA中的四种引用类型 - 掘金 (juejin.cn)这篇博文)。

3.2:进行垃圾回收(常见的垃圾回收算法)

3.2.1:标记-清除算法(Mark-Sweep)

标记清除法是最基础的垃圾回收算法。此方法包含两个阶段,第一阶段标记需要回收的对象,第二阶段销毁回收对象,回收内存空间。

标记清除算法

可以看到,此种方法清除后的内存空间碎片化严重,而且后续有大对像生成的话可能会出现找不到可利用空间,使得JVM触发GC进行内存回收以获得足够的连续内存,而GC是会影响到JVM的性能的,因此碎片化严重的内存引发的频繁GC会使得JVM的性能受到很大的影响。更严重的情况是GC之后依然没有足够的连续空间导致OOM的产生(这里只考虑单纯的标记清除算法,不考虑分代收集算法的Survivor To Space内存不足时直接将对象纳入Old区的机制)。 还有一个问题那就是为什么要先进行标记,然后再进行清除,而不是直接清除呢,这样不是节省了标记的过程了吗?这是因为业务线程的执行会改变对象的可达性,所以在确定垃圾的过程中是需要Stop The World的,也就是需要暂停所有业务线程。所以要尽可能的减少停顿时间避免影响业务线程。而对内存的操作是一个相对来说比较耗时的过程,所以就采用标记的方法,快速的搜索完成标记。然后就可以恢复业务线程的执行,较为耗时的内存回收过程就和业务线程并行运行。这样就减轻了GC对业务线程的影响。

  • 优点:实现简单,不需要移动对象,耗时较少
  • 缺点:清理后的内存空间碎片化,一定程度上提高了GC频率

3.2.2:复制算法(Copying)

复制算法解决了标记清除算法的清理后的内存空间碎片化的缺陷。它将内存空间划分为等大小的两块活动区和空闲区,每次只使用其中一块(活动区),当活动区内存满了时就将存活对象排列移动到另一块空闲内存(空闲区)上面,更新存活对象的引用地址,然后直接清除当前块,然后以另一块作为活动区。如图:
复制算法
复制算法也是需要经历标记的,因为只有经过二次标记对象才能回收,但是不同于标记清除算法需要对整个内存空间都标记一遍然后才开始清理。复制算法因为涉及对象的复制移动,所以整个执行过程都是需要Stop The World的。因此先全局标记再进行扫描清理就显得没有必要了,复制算法经过二次标记被认为是可回收对象之后,会直接将对象复制到另一区域,也就是边标记边复制。最后再一次性清空整个活动区,将其转为空闲区。

  • 优点:实现简单,不会产生内存碎片,不用经历二次扫描,效率较高
  • 缺点:在使用的内存空间被压缩到原来的一半,使用空间的减少也会导致GC频率的上升。而且如果对象存活率高的话会导致复制过程花费大量的时间。

3.2.3:标记-整理算法(Mark-Compact)

标记整理算法综合了标记清除算法与复制算法。标记整理算法份两步。第一步与标记清除算法一样,扫描内存中的所有对象,对其进行存活性标记。第二步则是将内存中的存活对象复制移动到内存的同一端,更新存活对象的引用地址。然后将内存剩余区域清除。这样就解决了标记清除算法的内存碎片化问题。
标记整理算法
标记整理算法在整个回收过程中因为涉及对象的复制与重新引用,也是需要Stop The World的。但是为什么不能像复制法一样边标记边复制,而是先全局标记呢。原因在于标记整理算法用的是整一个块内存,而对象的遍历顺序与对象在内存中的摆放顺序是不一致的。有可能一个摆放着内存首端的对象最后才被遍历到,如果边遍历边整理,那么首端这个对象即使复合存活条件,也会被先遍历到的存活对象覆盖掉,这种情况自然是不允许发生的。

  • 优点:不会产生内存碎片,也避免了复制算法的内存浪费问题
  • 缺点:先标记再移动,执行效率上比标记清除算法与复制算法都要低

3.2.4:分代收集算法

分代收集算法是目前绝大多数JVM采用的GC算法。其核心思想是根据对象的生命周期的差异,将内存划分为不同区域。然后对不同区域采用不同的回收算法,发挥不同回收算法的优势。要理解分代收集算法,首先要明白分代收集算法是怎么划分内存区域的。 根据对象生命周期的特性,分代收集算法将堆内存分为新生代和老年代。新生代一般暂居堆内存的1/3。老年代占据堆内存的2/3。
堆内存
新生代
新生代用来存储较新的对象,对新生代的GC被称为Minor GC。因为大部分对象都是朝生夕死的。也就是说大部分对象被创建使用后都会在第一次经历GC时间被回收掉。针对这种特性,Minor GC采用复制算法(存活对象少使得复制算法因移动对象产生的弊端大大减轻,还可以通过内存分配比例降低内存利用率的问题,同时还兼顾了复制算法的高效率)。将新生代划分为Eden区,Survivor From区和Survivor To区。他们之间的内存分配关系是Eden:Survivor From:Survivor To = 8:1:1.

  • Eden区:新对象的出生地。当Eden区的内存不够时就会触发Minor GC.
  • Survivor From区:上一次扫描的幸存者,再次GC时会被扫描。
  • Survivor To区:Minor GC过程中的幸存者。

新生代每次只使用Eden区与Survivor From区。所以新生代运行中只有90%的内存被使用。Minor GC的过程分为三步:

  1. 对Eden区与Survivor From区的对象进行扫描,将幸存的对象中年龄达到老年代标准的对象复制到老年代,未达到老年代标准的复制到Survivor To区,并将对象的年龄+1。(如果Survivor To区的内存不够的话幸存对象会被直接放入老年代,无论它年龄是多少)。

默认年龄达到15即符合进入老年代的标准,可以通过-XX:maxtenuringThreshold来设置这个标准 2. 清空Eden区与Survivor From区。 3. 将Survivor From区Survivor To区互换。这次的Survivor From区将成为下次的Survivor To区。

老年代
老年代主要存放生命周期比较长的对象,这类对象比较稳定。也就意味回收比率较低。针对这种特性,复制算法并不适合,因此老年代一般采用标记清除算法或者标记整理算法(取决于使用的哪一款GC收集器,HotSpot JVM有7款垃圾收集器,关于垃圾收集器,后面会单独列一篇博文来写,欢迎关注)。针对老年代的垃圾回收被称为MajorGC。老年代空间不足时间会触发一次Major GC,不过一般MajorGC之前都会经历一次Minor GC(新生代晋升为老年代导致空间不足)。

3.2.5:分区收集算法

分区收集算法将整个堆分为连续的不同小区间,每个小区间独立使用,这样GC时可以不用对整个堆回收而是对若干个小区间回收,通过控制回收的小区间数量来控制GC的时间(程序停顿的时间)。

4:GC什么时间进行?

分代回收算法是目前JVM-GC的主流回收算法,所以GC时间这一块也针对分代回收算法。 分代回收算法针对不同的区域采用了不同的回收算法,同样的不同区域触发GC的时间也不一样:

  • MinorGC:Eden空间不足时或者执行System.gc之后JVM在合适的时间,触发Minor GC。而导致Eden空间不足的原因有:新的对象被创建,要分配到Eden区。

MinorGC回收对象为新生代。回收区域为Eden和Survivor From区。因为是对于新生代的回收,也被称为Young GC。

  • MajorGC:MajorGC回收对象针对老年代。老年代空间不足时或者执行System.gc之后JVM在合适的时间,触发Major GC。导致老年代空间不足的原因有:1.MinorGC执行之后,符合年老的对象需要放入老年代。2.MinorGC执行时,Survivor To空间不足,会有一部分对象直接放入老年代。
  • Full GC:Full GC和Major GC是否有区别目前存在争议。一部分观点认为Full GC与Major GC是一个概念,都是针对老年代的回收。一部分观点认为Full GC不同于Major GC,Major GC针对老年代,而Full GC则是针对整个堆空间(包括新生代,老年代)。个人认为造成这种定义差异的区别是因为看待问题的角度不一样。因为MajorGC之前通常都会有一次Minor GC,只有进行了Minor GC,将符合条件的对象移动入老年代,老年代的空间发生变动,才会出现内存不足进而执行Major GC,可以看做是Minor GC触发了Major GC。这时间整个堆都被进行GC。关键就在于是否把导致Major GC的Minor GC看做此次回收的一部分,如果认为Minor GC和Major GC是整体的,那么Full GC就被定义为对整个堆回收,包含Minor GC和Major GC。但是如果认为Minor GC和Major GC是两部分,那么Full GC的定义就和Major GC的定义就一样了,表示着回收老年代。所以当和别人讨论起Full GC和Major GC时,最好先确认一下对方所表达的是那种意义上的Full GC和Major GC。
    注释:本文将Full GC与Major GC区分来看。下文中的Full GC表示是整个堆的回收,Major GC认为是对老年代的回收。
    复制代码
  • System.gc:通过System.gc来触发的Full GC(对整个堆的回收),并不会立即执行,整个方法只是告诉虚拟机,我需要一次对整个堆的GC,但是具体什么时间GC则由虚拟机决定。原因是执行GC需要stop the world(即JVM中所有的用户线程都暂停执行),所以GC的时间需要JVM来确定在一个安全的位置来执行,而不是执行了System.gc就立刻stop the world来执行GC.
    注释:无论是Minor GC,Major GC,还是Full GC都会触发stop the world。因为Minor GC针对新生代,大部
    分对象都会被当做垃圾回收,所以Minor GC导致的stop the world时间很短。而Major GC和Full GC因为一般分
    析大量的对象,其导致的stop the world时间则会长很多。
    复制代码

5:特别的方法区

方法的回收主要针对常量的回收以及类型卸载,收益比较低。它确认垃圾的方法也不同于堆内存。 常量:常量池中的常量没有任何地方引用。 无用的类的卸载需要同时满足以下三个条件:

  1. 该类所有实列均被回收,也就是堆中没有该类的实例
  2. 加载该类的ClassLoader已经被回收
  3. 该类对应的java.lang.Class对象没有被任何地方引用,无法在任何地方通过反射访问该类。

因为HotSpot JVM不同版本对方法区有不同的实现,因此不同的版本方法区的回收也被略有不同。

  • JDK1.6版本及其之前方法区满时则触发方法区的GC。
  • JDK1.7版本字符串常量池被移入堆中,因此字符串常量的回收也就归堆GC来进行了(但是这并不意味着堆GC会像对待堆内存对象一样对待方法区,一般情况下的堆GC也不会涉及到方法区)。类型卸载则和1.6一样。
  • JDK1.8开始永久代被移除,类信息被放入元空间(MetaSpace),元空间直接使用本地内存,不再受JVM的内存限制。也就很难达到元空间内存不足的情况了。

关于方法区的变迁,具体可见1: JVM内存区域 - 掘金 (juejin.cn)

本文已参与「新人创作礼」活动,一起开启掘金创作之路
PS:
开发成长之旅 [持续更新中...]
上篇导航:1: JVM内存区域 - 掘金 (juejin.cn)
下篇导航:3: JAVA中的四种引用类型 - 掘金 (juejin.cn)
欢迎关注...

参考资料:
百度百科:GC
java GC是在什么时候,对什么东西,做了什么事情
Minor GC、Major GC和Full GC之间的区别
聊聊JVM(四)深入理解Major GC, Full GC, CMS
MinorGC、MajorGC、FullGC差异
可达性算法、Java引用 详解
java中垃圾回收机制中的引用计数法和可达性分析法(最详细)
标记清除算法,为什么不直接清除?
聊聊JAVA GC系列(7) - 标记整理算法
JVM - 方法区(永久代)的垃圾回收
JVM内存管理------GC算法精解(复制算法与标记/整理算法)

猜你喜欢

转载自juejin.im/post/7076058354170216462