一张图看懂JVM之垃圾回收算法详解
导读
在之前的内容中,我们通过一张图的方式(图👆),从总体上对JVM的结构特别是内存结构有了比较清晰的认识,虽然在JDK1.8+的版本中,JVM内存管理结构有了一定的优化调整。主要是方法区(持久代)取消变成了直接使用元数据区(直接内存)的方式,但是整体上JVM的结构并没有大改,特别是我们最为关心的堆内存管理方式并没有在JDK1.8+的版本中有什么变化,所以图中的结构整体上是没有什么不准确的,之所以将方法区以及持久代标注出来,主要还是为了起到对比认识的作用,大家知道就可以了。
关于持久代元数据区的使用问题,目前可以理解就是使用的物理内存,理论上是不受JVM自动内存回收机制管理的,如果不设置参数大小默认最大使用限制就是操作系统可用物理内存的大小,设置了-XX:MetaspaceSize参数的话,JVM就会在使用物理内存空间时自己进行限制。
至于直接内存与物理内存到底是不是一回事,我认为对于我们理解上没有区别,只是概念的区别,另外就是对这块内存使用细节上的区别,如果不受JVM的自动回收管理,那么怎么管理呢?说到底还是JVM本身在直接使用物理内存或者说是直接内存(用时直接“malloc”物理内存区域,而不再是JVM进程启动时初始化的内存区域),还有一种概念叫native memory,说实话我暂时还不理解他们到底有啥区别,如果大家对这些概念有更好的认识,也可以给我留言哦!之所以对这几个问题做一些笔墨的说明,主要是在之前的文章中大家对此提出了疑问,所以正好在这节的内容中进行下阐述。
回到今天的主题,我们知道JAVA最大的优点就是可以实现自动内存管理,这极大的便利了JAVA程序员,降低了使用成本。但这也使得平时我们在使用JAVA编程时不太关注JVM到底是怎样进行内存回收的,只有在需要实际对JVM进行系统性能调优,这里的场景可能是在系统面临极致性能优化要求时,我们才发现需要对JAVA的整体内存结构以及内存回收机制要有一定的认识和了解才行。
在👆的图中,我们也大致对整个垃圾回收系统进行了标注,这里主要涉及回收策略、回收算法、垃圾回收器这几个部分。形象一点表述,就是JVM需要知道那些内存可以被回收,要有一套识别机制,在知道那些内存可以回收以后具体采用什么样的回收方式,这就需要设计一些回收算法,而具体的垃圾回收器就是根据不同内存区域的使用特点,采用相应地回收策略和算法的具体实现了。
在👆图中,我们也标注了不同垃圾回收器所适用的特定内存区域,对于JVM垃圾回收这块的优化,就是我们需要在了解这些垃圾回收算法、垃圾回收器特点后能够根据自己应用的场景选择合适的垃圾收集器,以及各区域垃圾收集器的搭配关系。下面我们就从这几个方面给大家介绍,JVM的垃圾回收相关的知识点。
回收策略
我们知道,JVM进行内存回收的主要目的是为了回收不再使用的内存,因为在进行JAVA程序编写时,我们只有new的操作,而不需要收工释放不再使用的空间,如果这些空闲内存不能及时被回收,很快我们的JVM内存空间就会泄露(新申请内存空间的操作失败,导致程序报错),所以回收不再使用的内存的目的则是为了及时释放空间,腾笼换鸟,以防止内存泄漏。
那么问题来了,JAVA程序申请了那么多的内存空间,那些内存才能被认定是不再使用的内存呢?搞错了,如果把正在被程序使用的内存给释放了,程序逻辑就空指针异常了!
我们知道在JVM中内存分配的基本粒度主要是对象、基本类型。而基本类型的使用主要是包括在对象中的局部变量,所以回收对象所占用的内存是JAVA垃圾回收的主要目标。
那么如何判断对象是处于可回收状态的呢?在主流的JVM中是采用“可达性分析算法”来进行判断的。
这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,并从这些节点开始往下进行搜索,搜索走过的路径我们称之为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,我们就称之为对象引用不可达,则证明这个对象是不可用的,就可以暂时判定这个对象为可回收对象。示意图如下:
在图中虽然Obj F与Obj J之间互相有关联但是它们到GC Roots是不可达的,所以将会被判定为可回收对象。既然如此,什么样的对象可以作为GC Roots对象呢?
在JAVA中可以被作为GC Roots的对象主要是:虚拟机栈-栈帧中的本地变量表所引用的对象、方法区(<JDK1.8)中类静态属性所引用的对象/常量属性所引用的对象、本地方法栈中引用的对象。
这里还需要注意一个小的细节,就是被判定为对象不可达的对象也并非会被立刻回收,在学习JAVA语法是我们应该学习过finalize()方法,如果对象重写了finalize方法,并重新把this关键字赋值给了某个类变量或对象的成员变量的话,该对象就会被"救活",具体过程可参考上图所示,只是这种方式并不鼓励大家使用,了解下就行。
在关于如何判定对象是否属于不再使用的内存时,还有个通常会被大家错误认为是JVM使用的方式-“引用计数法”,事实上引用计数法的实现比较简单,判定效率也比较高,在Python语言中就使用了这种算法进行内存管理,但是它有一个比较难解决的对象之间循环引用的问题,所以在JAVA虚拟机里并没有选用“引用计数法”来管理内存。这个问题很多人都会搞错,包括有很多年开发经验的程序员,需要大家注意下!
回收算法
在JVM中主要的垃圾收集算法有:标记-清除、标记-清除-压缩(简称“标记-整理”)、标记-复制-清除(简称“复制”)、分代收集算法。这几种收集算法互相配合,针对不同的内存区域采取对应的收集算法实现(这里具体是由相应的垃圾收集器实现)。
下面我们就分别来看下这几种收集算法的特点:
1)、标记-清除
标记-清除算法是最为基础的一种收集算法,算法分为:“标记”和“清除”两个阶段。首先标记出所有需要回收的对象(标记的过程就是上面介绍过的根节点可达算法),在标记完后统一回收所有被标记对象占用的内存空间。
示意图如下:
这种收集算法的优点是简单直接,不会影响JVM进程的正常运行。而其缺点也是非常明显,首先,这样的回收方式会产生大量不连续的内存碎片,不利于后续连续内存的分配;其次,这种方式的效率也不高。
2)、标记-复制-清除
这种算法的思路是将可用的内存空间按容量划分为大小相等的两块,每次只使用其中一块。当这一块使用完了,就将还存活着的对象复制到另外一块上面(移动堆顶指针,按顺序分配内存),然后再把已使用过的内存空间一次清理掉。
示意图如下:
这种收集方式比较好的解决了效率和内存碎片的问题,但是会浪费掉一般的内存空间。目前此种算法主要用于新生代回收(文顶的图中有标注)。
因为新生代的中98%的对象都是很快就需要被回收的对象,这一点大家在编程时可以体会到,所以并不需要1:1的比例来划分内存空间,在新生代中JVM是按照“8:1:1”的比例(文顶图中有标注)来将整个新生代内存划分为一块较大的Eden区和两块较小的Survivor区(S0、S1)。
每次使用Eden区和其中一个Survivor区,当发生回收时将Eden区和Survivor区中还存活的对象一次性复制到另一块Survivor区上,最后清理掉Eden区和刚才使用过的Survivor区。理想情况下,每次新生代中的可用空间是整个新生代容量的90%(80%+10%),只会有10%的内存会被浪费。实际情况中,如果另外一个10%的Survivor区无法装下所有还存活的对象时,就会将这些对象直接放入老年代空间中(这块在后面的分代回收算法会说到,这里先了解下)。
3)、标记-清除-压缩
如果在对象存活率较高的情况下,仍然采用复制算法的话,因为要进行较多的复制操作,效率就会变得很低,而且如果不想浪费50%的内存空间的话,就还需要额外的空间进行分配担保,以应对存活对象超额的情况。显然老年代不能采用2)中的复制算法。、
根据老年代的特点,标记-清除-压缩(简称标记-整理)算法应运而生,这种算法的标记过程仍然与“标记-清除”算法一样,只是后续的步骤不再是直接清除可以回收的对象,而是将所有存活的对象都向一端移动后,再直接清理掉端边界以外的内存。
示意图如下:
4)、分代回收算法
实际上在讲解复制算法时已经涉及到了分代回收的内容,这种算法根据对象存活周期的不同将内存划分为几块,Java中主要是新生代、年老代。这样就可以根据各个年代的特点,采用合适的收集算法了,在文顶的图中已经标示,新生代采用了复制算法,而老年代采用了整理算法,这里就不再赘述。
摘自原文作者:无敌码农