Java底层篇:垃圾回收

本篇文章参考《深入了解Java虚拟机 JVM高级特性与最佳实践》。

一、概述

说起垃圾回收(GC),大部分人都把这项技术当作 Java 语言的伴生物。事实上,GC 的历史比 Java 久远,1960年诞生于 MIT 的 Lisp 是第一门真正使用内存动态分配和垃圾收集技术的语言。当 Lisp 还在胚胎时期时,人们就在思考 GC 需要完成的三件事情:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

目前内存的动态分配和内存回收技术已经相当成熟,一切看起来都进入了“自动化”时代,那为什么我们还要去了解 GC 和内存分配呢?答案就是当需要排查各种内存溢出、内存泄漏问题时,当垃圾回收成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。

我们介绍过了 JVM 的内存结构,其中程序计数器、虚拟机栈、本地方法栈 3 个区域的生命周期和线程绑定。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的。因此这几个区域的内存分配和回收都具备确定性。而 Java 堆和方法区则不一样,一个接口中多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创键哪些对象,这部分内存的分配和回收都是动态的,垃圾回收器所关注的是这部分内存。

二、对象生死簿

在堆里面存放着 Java 世界中几乎所有的对象实例,垃圾回收器在对堆进行回收前,第一件事情就是要确定这些对象中哪些还“存活”,哪些已经“死去”(即不可能再被任何途径使用的对象)。

1. 引用计数算法

引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象实例都有一个引用计数。当一个对象被创建时,就将该对象实例分配给一个变量,该变量计数设置为1。当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则b引用的对象实例的计数器+1),但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减1。任何引用计数器为0的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减1。

客观地说,引用计数算法的实现简单,判定效率也高,在大部分情况下它都是一个不错的算法。但是至少主流的 JVM 中没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。

举个简单的例子,请看以下代码,对象object1和object2 都有字段instance,令“ object1.instance = object2; ”以及“ object2.instance = object1; ”,除此之外,这两个对象没有任何引用,实际上这两个对象已经不可能再被访问了,但是它们因为互相引用着对方,导致他们的引用计数都不为 0,于是引用计数算法无法通知 GC 回收他们。

public class Test{

    public Object instance = null

    public static void main(String[] args) {
        // TODO Auto-generated method stub
        
        Test object1=new Test();
        Test object2=new Test();
        
        object1.instance=object2;
        object2.instance=object1;
        
        object1=null;
        object2=null;
        

    }

}

2. 可达性分析算法

子啊主流的商用程序语言的主流实现中,都是称通过可达性分析来判断对象是否存活的。这个算法的基本思路就是通过一系列成为“GC Roots”的对象作为起始点,从这些结点向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说,就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。如下图所示,obj5、6、7虽然相互有关联,但是它们到 GC Roots 是不可达的,所以他们会判定为是可回收对象。

在 Java 中,可作为 GC Roots 的对象包括下面几种:

  • 虚拟机栈(帧栈中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中 JNI (Natvice 方法) 引用的对象。

3. 深探引用

无论是通过以上哪种方法判定对象存活都与“引用”有关。在JDK1.2以前,Java 中的引用的定义很传统: 如果 reference 类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,也很狭隘。我们通常会有一些希望在内存空间还足够的情况下,保留某些对象,如果内存告急的话抛弃这些对象。在 JDK 1.2 后,Java 对引用的概念进行了扩充,将引用分为:强引用、软引用、弱引用、虚引用4种,这四种引用强度依次逐渐减弱。

强引用

  在程序代码中普遍存在的,类似 Object obj = new Object() 这类引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

软引用

  用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。

弱引用

  也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

虚引用

  也叫幽灵引用或幻影引用(名字真会取,很魔幻的样子),是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。它的作用是能在这个对象被收集器回收时收到一个系统通知。

  不要被概念吓到,也别担心,还没跑题,再深入,可就不好说了。小编罗列这四个概念的目的是为了说明,无论引用计数算法还是可达性分析算法都是基于强引用而言的。

4. 一线生机

即使在可达性分析算法种不可达的对象,也并非是“非死不可”的,这个时候它们暂时处于“拘留”阶段,在真正宣告一个对象死亡,至少需要经历两次审判(标记)过程:

第一次标记

如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,第一次标记后紧接着会进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。

如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象就会放置在一个叫做 F-Queue 的队列中,并在稍后由一个虚拟机自动建立的,低优先级的 Finalizer 线程去执行它。这个“执行”是指会触发这个方法,但不成承诺会等待它运行结束,这样做的原因是,如果一个对象在 finalize() 方法种执行缓慢,或者发生了死循环,将可能导致 F-Queue 队列种其他对象处于永久等待,甚至整个 GC 系统崩溃。

第二次标记

在 finalize() 方法中没有重新与引用链建立关联关系的,将被进行第二次标记。

finalize() 方法是对象越狱的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模标记,如果对象想要在 finalize() 中成功拯救自己,只要重新与这个引用链上的任意对象建立关联即可,那么在第二次标记时它将被移除出“即将回收”的集合。如果这个对象这个时候还没有越狱,它们基本上它就真的非死不可了。

 

5. 回收方法区

很多人认为方法区(或者 HotSpot 虚拟机中的永久代)是没有 GC 的,JMM 中也确实说过可以不要求虚拟机在方法区实现 GC,而且在方法区中进行 GC 的“性价比”一般比较低。在堆中,尤其是在新生代中,常规应用进行一次垃圾回收一般可以回收70%-95%的空间,而永久代的效率远低于此。

永久代的垃圾回收主要回收两部分内容:废弃常量和无用的类。回收废弃常量与回收堆中的对象非常类似,以常量池中字面量的回收为例,加入一个字符串“abc”已经进入了常量池中,但是没有任何String 对象或其他地方引用这个常量,如果这时发生垃圾回收,这个常量就会被系统清理出常量池。其他类(接口)、方法、字段的符号引用与此类似。

判定一个常量是否是“废弃常量”比较简单,但是判断一个类是否是“无用的类”就比较苛刻,对于无用的类则需要同时满足下面3个条件:

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

三、 垃圾回收算法

由于垃圾回收算法涉及大量的实现细节,而且各平台虚拟机操作内存方法不一致,下面仅介绍几种算法的思想。

1. 标记-清除算法(Mark-Sweep)

最基础的算法是“标记-清除算法”,就如同名字一样,分为“标记”和“清除”两个阶段。首先标记所有需要回收的对象,然后统一回收被标记的对象。它主要有两个不足:一是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,清除后会残留大量的内存碎片。

2. 复制算法(Copying)

为了解决效率问题,一种称为“复制”的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活的对象赋值到另一块上,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行垃圾回收,内存分配时也不用考虑内存碎片的复杂情况。实现简单、运行高效。代价却比较高,将内存缩小为原来的一般。

现在的商用虚拟机都采用这种方法,IBM 公司的专门研究表明,新生代中的对象 98% 都是 “朝生夕死”,所以并不需要按照 1:1的比例来划分内存空间,而是将内存划分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor 。当回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另一块 Survivor 中,然后清理 Eden 和刚才用过的 Survivor 。HotSpot 默认比例是8:1 ,也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%会被"浪费"。当然如果 Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保。

3.标记-整理算法(Mark-compact)

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

  根据老年代特点,有人提出了“标记-整理算法”,标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。

4.分代回收算法

分代收集算法是目前大部分 JVM 的垃圾回收器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久代(Permanet Generation)。老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。

年轻代一般选择复制算法,老年代一般选择标记整理算法。

四、 垃圾回收器

下面一张图是HotSpot虚拟机包含的所有收集器:

  • Serial 收集器(复制算法)
    新生代单线程回收器,标记和清理都是单线程,优点是简单高效。是client级别默认的GC方式,可以通过-XX:+UseSerialGC来强制指定。
  • Parallel Scavenge 回收器(停止-复制算法)
    并行回收器,追求高吞吐量,高效利用CPU。吞吐量一般为99%, 吞吐量= 用户线程时间/(用户线程时间+GC线程时间)。适合后台应用等对交互相应要求不高的场景。是server级别默认采用的GC方式,可用-XX:+UseParallelGC来强制指定,用-XX:ParallelGCThreads=4来指定线程数。
  • ParNew 回收器(停止-复制算法) 
    新生代收集器,可以认为是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现。
  • Serial Old 回收器(标记-整理算法)
    老年代单线程收集器,Serial收集器的老年代版本。
  • Parallel Old 回收器(停止-复制算法)
    Parallel Scavenge收集器的老年代版本,并行回收器,吞吐量优先。
  • CMS(Concurrent Mark Sweep) 回收器(标记-清理算法)
    高并发、低停顿,追求最短GC回收停顿时间,cpu占用比较高,响应时间快,停顿时间短,多核cpu 追求高响应时间的选择。
  • G1 回收器
    高并发、低停顿,可预测停顿时间模型,比CMS要更好

五、GC是什么时候触发的(面试最常见的问题之一)

  由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。GC有两种类型:MinorGC、MajorGC和Full GC。

5.1 Minor GC

  一般情况下,当新对象生成,并且在Eden申请空间失败时,就会触发MinorGC,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。这种方式的GC是对年轻代的Eden区进行,不会影响到年老代。因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使Eden去能尽快空闲出来。

5.2 MajorGC

    当老年代内存达到一定比例之后,会触发MajorGC,对老年代进行回收。

5.3 Full GC

  对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个堆进行回收,所以比Scavenge GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于Full GC的调节。有如下原因可能导致Full GC:

a) 年老代(Tenured)被写满;

b) 持久代(Perm)被写满;

c) System.gc()被显示调用;

d) 上一次GC之后Heap的各域分配策略动态变化;

当FullGC之后,内存空间还不足以分配资源,就会抛出OOM错误。

发布了77 篇原创文章 · 获赞 62 · 访问量 8万+

猜你喜欢

转载自blog.csdn.net/weixin_42236404/article/details/96473179