垃圾回收篇:垃圾回收算法
垃圾回收篇:垃圾回收算法
小吴顶呱呱什么是垃圾?
什么是垃圾(Garbage)呢?
垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要回收的垃圾。
如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间一直保留到应用程序结束,被保留的空间无法被其他对象使用,甚至可能导致内存溢出。
为什么需要GC
- 对高级语言来说,一个基本的认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断的分配内存空间而不进行回收,就好像不停地生产生活垃圾而不打扫一样。
- 除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占的堆内存移动到堆的一端,以便jvm将整理出的内存分配给新的对象。
- 现在应用程序所对应的业务,用户群体日益强大,没有GC就不能保证应用程序的正常进行
垃圾回收算法
垃圾判别阶段算法
在堆里存放着几乎所有的java实例对象,在GC执行垃圾回收之前,首需要区分内存中哪些对象是活的,哪些对象是已死亡只有被标记了已死亡的对象,GC才会在执行垃圾回收时,释放掉其所占有的内存空间,因此这个过程可以称为垃圾标记阶段。
1.引用计数算法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的,这时就是可以回收的。
优点:实现简单,垃圾对象便于辨识:判定效率高,回收没有延迟性。
缺点:1.它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。(这缺点可忽略)
2.每次赋值都需要更新计数器,伴随着加法和减法的操作,这增加了时间开销。(也可忽略)
3.引用计数器有一个严重的问题,即无法处理循环引用的问题。这是一条致命缺点,这也导致Java的垃圾回收器中没有使用这类算法。
循环引用代码实例:
1 | /** |
运行结果:
从运行结果中可以清楚看到内存回收日志中包含“4603K->210K”,意味着虚拟机并没有因为这两个对象互相引用就放弃回收它们,这也从侧面说明了 Java 虚拟机并不是通过引用计数算法来判断对象是否存活的。
2.可达性分析算法
可达性分析(或根搜索算法、追踪性垃圾收集)
相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的该算法可以解决循环引用问题,防止内存泄露的发生。
原理:就是将对象及其引用关系看做一个图,选定活动的对象作为GC ROOTS,然后跟踪引用链条,如果一个对象和GC ROOTS之间不可达,也就是不存在引用链条,那么即可认为是可回收对象。
基本思路:
- 可达性分析算法是以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
- 使用可达性分析算法后,内存中的存活对象就会被根对象集合直接或者间接连接着,搜索所走过的路径称为引用链(Reference Chain)
- 如果对象没有任何引用链相连,则是不可达的,就意味着对象已死亡,可以标记为垃圾对象。
- 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是活对象。
在 Java 技术体系里面,固定可作为 GC Roots 的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量。
- 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
- 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。
- Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized 关键字)持有的对象。
- 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等
除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整 GC Roots 集合。譬如分代收集和局部回收(Partial GC)。
如果只针对 Java 堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入 GC Roots 集合中去,才能保证可达性分析的正确性。
小技巧:由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不是存放在堆内存里面,那它就是一个Root。
注意点:
- 如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行,这点如果不满足的话分析结果的准确性就无法保证。通俗的讲就是这一时刻判断的GC Roots在下一时刻某些就不存在了。正因为此GC进行时候必须暂停用户线程(Stop The World),否侧GC Roots就会变化。即是是号称不会停顿的CMS收集器,枚举根节点也是必须要停顿的。
垃圾清除阶段算法
1.标记清除算法
标记:Collector 从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的 Header 中记录为可达对象。标记的是引用的对象,不是垃圾!!
清除:Collector 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header中 没有标记为可达对象,则将其回收。
它的主要缺点有两个:第一个是执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2.标记复制算法
标记-复制算法常被简称为复制算法。为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
**优点:**没有标记和清除过程,实现简单,运行高效;复制过去以后保证空间的连续性,不会出现“碎片”问题。
**缺点:**1.需要俩倍的内存空间;2.对于G1这种分拆成为大量region的GC,复制而并不是移动,意味着GC需要维护region之间的对象引用关系,不管是内存占用或者时间开销也不小。3.如果系统中存活的对象有很多,复制算法不会很理想。因为复制算法要求复制存活的对象数量并不会很大,或者说非常低也行。所以这也是为什么在新生代应用这个算法。
3.标记整理算法(标记压缩)
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费 50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征,1974 年 Edward Lueders 提出了另外一种有针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,
标记整理算法的最终效果等同于标记清除算法执行完成之后,再进行一次内存碎片整理,因此也可以把它称作标记-清除-整理算法。
二者的本质差异在于标记清除算法是一种非移动式的回收算法,标记整理是移动式的。是否移动回收后的存活对象是一项优缺点并存的分险决策。
可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清除掉如此一来,当我们需要给新对象分配新内存时,jvm只需要有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
优点:
- 消除了标记清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,jvm只需要持有一个内存的起始地址即可
- 消除了复制算法当中内存减半的高额代价
缺点:
- 从效率上来说,标记整理算法要低于复制算法。效率不高,不仅要标记所有存活对象,还要整理所有存活对象的引用地址。对于老年代每次大量对象存活的区域来说,极为负重。
- 移动对象的同时,如果被其他对象引用,则还需要调整引用的地址
- 移动过程中,需要全程暂停用户应用程序线程(Stop The World)
4.分代收集算法
三种算法的对比:
效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存。
而为了兼顾上面提到的三个指标,标记整理算法相对来说更平滑一些,但是效率是却不尽人意,它比复制算法多了一个标记阶段,比标记清除多了一个整理内存的阶段。
分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般把java堆分为新生代和老年代,这样就可以根据各个2年代的特点使用不同的回收算法,以提高垃圾回收的效率。新生代里面的对象存活周期一般都比较短,每次垃圾回收的时候都会发现有大量的对象死去,所以新生代可以使用复制算法来完成垃圾收集。而老年代里的对象存活率比较高,所以就采用标记清除或者标记整理进行回收。
5.增量收集算法(了解即可)
上述现有的算法,在垃圾回收过程中,应用软件将处于一种Stop the World的状态。在Stop The World状态下,应用程序所有的线程都会挂起,暂停一切的正常工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集(Incremental Collecting)算法的诞生。
基本思想:
如果一次性将所有垃圾进行处理,需要造成系统长时间的停顿,那么我们可以让垃圾收集线程饿应用线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。
总的来说,增量垃圾收集算法的基础仍然是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。
缺点:
使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。
finalize()方法
即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行 **finalize()**方法。假如对象没有覆盖 finalize()方法,或者 finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为确有必要执行 finalize()方法,那么该对象将会被放置在一个名为 F-Queue 的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer 线程去执行它们的 finalize() 方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致 F-Queue 队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this 关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
⚠️用此方法来拯救对象是不建议使用的!!!
正如周志明老师的《深入理解java虚拟机:第三版》中说道: