JVM - 内功修炼之垃圾回收算法
文章目录
- JVM - 内功修炼之垃圾回收
- 1.垃圾回收概述
- 1.1 为什么要进行垃圾回收?
- 1.2 为什么要去了解垃圾回收机制?
- 1.3 垃圾回收的过程是怎样的?
- 2.对象存活算法
- 2.1 引用计数算法(Reference Counting)
- 2.1.1 引用计数算法可能面临的问题
- 2.1.2 引用计数算法优缺点
- 2.2可达性分析算法(Reachability Analysis)
- 2.2.1 GC ROOT大家庭
- 3.垃圾回收算法
- 3.1 引用计数算法
- 3.2 标记-清除算法(Mark-Sweep)
- 3.3 复制算法
- 3.3.1 为什么会有复制算法?
- 3.3.2 复制算法的缺点
- 3.3.3 复制算法的应用场景
- 3.4 标记-整理算法(Mark-Compact)
- 3.4.1 为什么会有标记-整理算法?
- 3.4.2 标记-整理算法核心思想
- 3.5 分代收集算法
- 3.6 分区收集算法
JVM - 内功修炼之垃圾回收
1.垃圾回收概述
1.1 为什么要进行垃圾回收?
Java语言有一个显著的特点就是引入了垃圾回收机制,了解C++的同学应该知道,内存管理的问题总是让开发者头痛不已。Java与C++之间存在着一堵高墙,一堵由
内存动态分配
和垃圾收集机制
所围成的墙,墙外面的人想进来而里面的人却想出去。
当程序去申请新的内存空间但是空闲内存不够,或者已分配内存达到一定比例时就会触发垃圾回收机制。垃圾回收可以有效的防止内存的泄露以及更合理有效地使用空闲内存。正是因为存在垃圾回收机制,所以使用Java开发者可以更自由、轻松地去发挥。
1.2 为什么要去了解垃圾回收机制?
在了解垃圾回收机制之前我们先思考一个问题,我们为什么要去了解JVM的
垃圾回收机制
?
Java语言在经历了这么长时间的发展,其实针对内存动态分配
和垃圾回收
的技术已经相当成熟了,在我们实际开发过程中几乎都不需要我们去触及到关于垃圾回收机制的层面。但接触过墨菲定律
的同学应该听过一句话:如果事情有变坏的可能,不管这种可能性有多小,它总会发生
。
常在河边走,哪有不湿鞋。当我们某天真正需要排查各种内存溢出、内存泄漏等问题或者当垃圾收集成为系统性能瓶颈时,我们就需要自己对其有充分的了解去实施必要的监控和调整。
1.3 垃圾回收的过程是怎样的?
我们可以试想一下,假设我们自己去实现垃圾回收,应该考虑哪几个方面?
- 哪些内存应该被回收?
- 什么时候回收这些内存?
- 怎么去回收这些内存?
垃圾回收的整个过程其实大部分情况下都是围绕着上面三个问题展开的。我们大家都知道程序计数器、虚拟机栈、本地方法栈三块内存区域的生命周期都是依附线程的,栈帧会随着方法的执行和结束有条不紊地去进行入栈和出栈的操作。每个栈帧中所需分配多少内存在类结构确定后就是已知的,线程结束内存自然就会回收,所以这几个内存空间中的内存分配和回收无需过多考虑。
但是Java堆以及方法区则不同,由于一个接口的多个实现类以及一个方法中条件分支不同都有可能造成所需内存不一。只有当服务在运行时才能够确定哪些对象会被创建,也就是说这部分内存的分配和回收都是动态的,而我们所需要研究的垃圾回收也就是针对这一部分的。
2.对象存活算法
我们要进一步去了解GC,那就要能够了解GC存在的意义。GC的目的是通过特定算法将不再被使用的对象内存空间进行回收。垃圾回收的算法有很多种,但是在这之前我们需要先学会如何判定一个对象是否存活。
2.1 引用计数算法(Reference Counting)
当一个对象被创建时都会初始化一个引用计数器,当该对象实例被引用时,该计数器的值就会+1(例:a = b,此时b实例的引用计数器就会+1),而当一个对象实例的引用失效(超过生命周期或者引用重定向)时,这个引用计数器就会-1。当垃圾回收机制执行时,所有引用计数器为0的对象实例都会被标记为不可用对象从而被当做垃圾回收。而当一个对象实例被回收后,它引用的其他对象实例的引用计数器也会-1(例:a = b,当a被回收时,b对应的引用计数器也会-1)。
2.1.1 引用计数算法可能面临的问题
总的来说,引用计数算法的实现比较简单,并且判定效率也很高。在大部分情况下都是一个不错的算法,但是目前主流的Java虚拟机并没有选用引用计数算法来管理内存,最主要的原因就是它很难解决对象循环引用的问题。
我们先看下面这段代码:
package com.ithzk.gc;
/** * @author hzk * @date 2019/11/28 */
public class ReferenceCounter {
public Object instance;
public static void main(String[] args){
ReferenceCounter referenceCounterA = new ReferenceCounter();
ReferenceCounter referenceCounterB = new ReferenceCounter();
referenceCounterA.instance = referenceCounterB;
referenceCounterB.instance = referenceCounterA;
referenceCounterA = null;
referenceCounterB = null;
System.gc();
}
}
上面这段代码的运行情况也就是我们上图中的情况,最初我们使用
referenceCounterA
和referenceCounterB
变量去接收两个新创建的对象时,这两个对象的引用计数器都会+1。然后我们使两个对象实例进行循环引用,此时这两个对象实例的引用计数器则会再次+1变为2。然后我们将变量referenceCounterA
和referenceCounterB
的引用清除,这时两个对象实例的引用计数器-1变为1。此时因为两个对象实例的引用计数器都不为0,所以使用引用计数算法则GC是无法回收这两个对象实例的。
我们在运行程序时给其加上-verbose:gc -XX:+PrintGCDetails
虚拟机参数打印GC详情日志。
这里可以看到,GC其实是有进行比较大比例内存回收的,这也验证了上面我们所说的目前主流的Java虚拟机并没有采用引用计数算法来管理内存。
2.1.2 引用计数算法优缺点
优点
:实现简单,效率高。缺点
:很难解决对象之间相互循环引用时的对象存活判断,例如对象A和B相互引用对方,即使A和B永远都不会再被访问,但是因为AB彼此持有对方的引用导致,AB的计数器永远不会为0,也就不会死亡,引用计数器无法通知GC收集器回收它们,正是因为这一点,主流的JVM都没有选用引用计数法来管理内存。
2.2可达性分析算法(Reachability Analysis)
可达性分析算法又称为根搜索法,引用的是离散数学中图论的思想。把所有引用关系看作一张图,通过将一系列的
GC ROOT
对象作为起点,从这些起点出发向下搜索经过的路线称为引用链
。当一个对象与GC ROOT
之间不存在任何引用链时,则说明该对象为不可用。
如上图所示,绿色的对象为仍然存活的对象,红色的为可以回收的对象。以我们已致的两个reference
作为GC ROOT
可以通过1、2、3、4
四条路线作为引用链搜索到A、B、C
三个对象。而D
和E
两个节点虽然之间存在关联,但是没有任何可以到达GC ROOT
的引用链,即不可达,也就是说这两个节点会被判定为可回收的对象。
其实当一个对象是不可达时,也并非一定就被立即回收了,在整个回收的过程中其实还会根据一定的策略对其进行标记,最后整个筛选结束才会确定哪些属于要被回收的对象。具体详细的包括方法区内的回收后面有机会可以作为扩展给大家稍微讲解下,这里不多提。
2.2.1 GC ROOT大家庭
既然我们一直在以
GC ROOT
为起点去搜索可达的对象,那么我们就要弄清楚GC ROOT
到底包含哪些对象?
- 虚拟机栈中引用的对象(局部变量表)
- 本地方法栈中引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
3.垃圾回收算法
上面我们提到了JVM虚拟机中如果判定对象的存活状态,这一切都是为了我们接下来的GC做准备的。因为每个虚拟机管理内存的策略不同,所以垃圾算法其实有很多,例:引用计数、标记清除、标记整理、复制、分代等。这里我们针对这几种常用的垃圾回收算法做一个介绍。
3.1 引用计数算法
引用计数是一个比较古老的算法,它的核心思想也就是我们上面提到的引用计数:当对象被引用时计数器加1,引用失效时则减1。垃圾回收时,只会收集计数为0的对象。此算法最致命的是无法处理循环引用的问题,并且每次进行计数器加减操作比较浪费系统性能。
3.2 标记-清除算法(Mark-Sweep)
标记-清除算法
也正如同其名一样,算法分为“标记”和“清除”两个阶段:
- 第一阶段:标记所有需要回收的对象。
- 第二阶段:统一回收所有被标记对象。
标记-清除算法所存在的问题
这种算法其实是存在一定缺陷的,正如上图所示:
- 第一个问题就是空间问题,通过该算法会产生大量不连续的内存空间。在往后程序运行过程需要分配较大对象时,由于无法提供足够的连续内存而导致被迫需要提前触发另一次GC。
- 第二个问题就是效率问题,其实标记和清除这两个过程效率都是不高的。而且由于上面所说会造成的内存碎片过多的问题,过多不连续的内存空间的工作效率其实是低于连续内存空间的。
3.3 复制算法
3.3.1 为什么会有复制算法?
复制算法
的出现主要是为了接解决标记-清除算法
造成内存碎片过多所产生的效率问题。
其核心思想就是将可用内存空间划分为两块大小相等的区域,每次只使用其中一块。当垃圾回收时,把正在使用这块内存区域中仍然存活的对象复制到另一块内存区域中,然后将已使用过的内存空间完全清除掉,整个过程两块内存区域反复交替角色。复制算法使得每次垃圾回收都是针对半区的,内存分配时也就不用去考虑内存碎片等问题,只需按顺序分配内存即可,十分简单高效。
3.3.2 复制算法的缺点
从上图我们可以看出,复制算法虽然已经不存在内存碎片的问题,使得所有可用内存空间都是连续的。但同时也引发了另一个很明显的缺点,那就是我们真正使用的内存只占到了总内存的一半,对于内存空间的使用率就会有点低。
3.3.3 复制算法的应用场景
我们思考一个问题,由于复制算法需要将存活对象复制到新的内存区域中,如果当一个内存块中对象存活率很高的话,那效率其实就变得十分低了。所以这种算法主要用在
新生代
。
目前商业虚拟机都采用这种算法来回收新生代。有专门研究表明,新生代中的对象90%以上是“朝生夕死”的,所以并不需要按照1:1的比例去划分内存空间,而是将内存划分为一块较大的Eden
空间和两块较小的Survivor
空间,每次使用Eden
和其中一块Survivor From
。Eden
和Survivor
空间内存比例8
。1
当进行GC时,会将Eden
和Survivor From
区域中仍然存活的对象复制到Survivor To
上,然后将Eden
和Survivor From
清空。上面我们知道了Eden
和Survivor
空间内存比例8
,所以其实对于上面我们提出内存过度浪费的缺点可以忽略的。也就是说新生代中可用内存占整个新生代的90%,只有10%被用来作为保留内存,浪费其实已经十分小了。但其实还是需要依赖另外内存空间的支持,并不单纯是我们表面看到10%内存的浪费。1
对于98%对象都是可回收的这个研究,其实也仅仅局限于一般场景,并不能够保证每次回收都只有10%以内的对象存活。当Survivor To
区域不够时,JVM会依赖老年代去进行分配担保将存活的对象直接移至老年代中。
3.4 标记-整理算法(Mark-Compact)
3.4.1 为什么会有标记-整理算法?
上面我们知道了当对象存活率较高的情况下需要进行大量的复制操作,复制算法效率就会变得很低。更关键的是,如果不想浪费过多的保留空间,就需要有额外的空间进行分配担保去应对使用内存对象存活率极高的极端情况,所以老年代中一般不能直接选用复制算法。
3.4.2 标记-整理算法核心思想
根据老年代的特点,
标记-整理算法
就出现了,该算法在标记-清除算法
和复制算法
的基础上进行优点的结合。标记过程仍然和标记-清除算法
一样,但是在标记后不会直接对需要回收的内存进行清理,而是将存活对象整理集中到一块,然后将存活对象边界以外的内存清理掉,整个过程如上图所示。这种方法对于标记-清除算法来说,有效地解决了空间碎片的问题,而对于复制算法来说同样也解决了内存浪费的问题。
3.5 分代收集算法
分代收集是一种基于对象生命周期分析得到的一种算法,由于JVM会将堆分为新生代和老年代,这种算法的核心就是根据不同内存区域的特点会采用不同的算法。
对于新生代来说,每次GC都会有大量对象由于死亡而被回收,只有少量对象是存活的,这种情况采用复制算法最为妥当,只需要对少量存活对象进行复制就可以完成收集。而老年代由于对象存活率较高,并且缺乏额外空间对其进行分配担保,所以必须选择标记-清除算法
或标记-整理算法
进行回收。
3.6 分区收集算法
这种算法大家可能听得不是特别多,
分区收集算法
和分代收集算法
思路类似,只不过分区收集算法
是将整个内存分为N个小的独立空间,每个空间独立使用。由于这种分区的是细粒度的,所以主要是控制一次回收某一些小空间,而不是整个空间都进行GC。从而提升性能,并且可以有效地减少GC所造成的停顿时间。
还没有评论,来说两句吧...