JVM(三)—— 垃圾回收机制

167 阅读16分钟

JVM系列文章:

  1. JVM(一)—— JVM内存管理
  2. JVM(二)—— 对象的创建、内存布局以及访问定位
  3. JVM(三)—— 垃圾回收机制

1. 对象是否存活

垃圾收集(Garbage Collection,简称GC),垃圾收集器主要关注java堆方法区这两个区域,因为这部分内存的分配和回收是动态的,只有在运行时,我们才知道程序究竟会创建哪些对象。

之前说过,其他几个区域的内存分配和回收具有确定性(比如每一个栈帧分配多少内存基本上在类结构确定下来就是已知的),当方法结束时或线程结束时,内存自然就回收了。

垃圾收集器在对堆进行回收前,首先要判断哪些对象是存活的。判断存活的方法:引用计数算法可达性分析算法

1.1 引用计数算法

算法描述

给Java对象添加一个引用计数器,每当有一个地方引用它时,计数器 +1;引用失效则 -1。任何时刻计数器为零的对象就是不可能再被使用的。

优点

  • 原理简单,判定效率高。

缺点

  • 额外空间计数。
  • 存在循环引用问题,还需要进行额外的处理。

1.2 可达性分析算法

算法描述

通过一系列称为“GC Roots(GC Root Set)”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连, 则此对象是不可能再被使用的。

image.png 图中对象5、对象6和对象7为可回收对象,它们到GC Roots是不可达的。

可作为GC Roots的对象

在java技术体系中,可作为GC Roots的对象包括以下几种:

  1. 虚拟机栈中引用的对象。
  2. 在方法区中类静态属性引用的对象。
  3. 在方法区中常量引用的对象。
  4. 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  5. Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  6. 所有被同步锁(synchronized关键字)持有的对象。
  7. 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
  8. 临时对象:跨代引用。

2. 引用

Java将引用分为四类:引用强度依次减弱强引用 > 软引用 > 弱引用 > 虚引用

2.1 强引用(StronglyReference)

最普通的引用赋值,类似于Object obj=new Object()

只要强引用关系存在,垃圾收集器就不会回收被引用的对象。

2.2 软引用(SoftReference)

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

2.3 弱引用(WeakReference)

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

2.4 虚引用(PhantomReference)

虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。

3. finalize()

即使在可达性算法中判定为不可达的对象,也不是“非死不可”的。

一个对象真正的死亡,至少要经历两次两次标记过程:

第一次标记

对象在可达性分析时没有与GC Roots相连接的引用链,此时该对象被第一次标记。

第二次标记

第一次标记后进行一次筛选,筛选条件:是否有必要执行finalize()方法。以下两种情况不需要执行finalize()方法:

  1. 对象没有覆盖finalize()方法。
  2. finalize()方法已经被虚拟机调用过。

筛选后如果对象判定为有必要执行finalize()方法,该对象被放入F-Queue队列中。稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。

这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。

稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

4. 方法区回收

方法区的垃圾收集主要回收两部分内容:废弃的常量不再使用的类型

4.1 回收废弃的常量

回收废弃常量与回收Java堆中对象类似。以常量池中字面量回收为例,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

4.2 回收不再使用的类型

相比于回收废弃的常量,回收不再使用的类型条件较为苛刻。需同时满足以下三个条件:

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

满足以上三个条件,Java虚拟机被允许对无用类进行回收,但不是必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制。

image.png

5. 分代收集

收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。

一般至少会把Java堆划分为新生代(Young Generation)老年代(Old Generation) 两个区域。

  • 部分收集(Partial GC):目标不是完整收集整个Java堆的垃圾收集。
    • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集。
    • 老年代垃圾收集(Major GC/Old GC):只是老年代的垃圾收集。
  • 混合收集(Mixed GC):目标收集整个新生代以及部分老年代的垃圾收集。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

6. 垃圾收集算法

6.1 标记-清除算法

算法描述

最基础的收集算法,分为标记清除两个阶段,首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。

image.png

标记-清除算法也是需要停顿用户线程来标记、清理可回收对象的,只是停顿时间相对而言要来的短而已。

缺点

  • 执行效率不稳定。标记和清除两个过程的执行效率都随对象数量增长而降低。
  • 内存空间碎片化。标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

6.2 标记-复制算法

算法描述

标记-复制算法简称为复制算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

image.png

优点

  • 实现简单,运行高效。解决了标记-清除算法面对大量可回收对象时执行效率低的问题。
  • 没有内存碎片

缺点

  • 空间利用率只有一半

如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

大多数虚拟机采用这种方式回收新生代,因为新生代中的对象大部分都熬不过第一轮收集。因此并不需要按照1:1的比例来划分新生代的内存空间,提出了一种优化的半区复制分代策略,Appel式回收

Appel式回收 —— 优化的复制算法

算法描述

Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。

image.png

优点

提高了空间利用率。 HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。

空间分配担保

当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。

6.3 标记-整理算法

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

算法描述

其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

image.png

缺点

对象需要移动需暂停用户线程。标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行。

7. 内存分配和回收策略

image.png

7.1 对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

7.2 大对象直接进入老年代

大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组,大对象对虚拟机的内存分配来说就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,我们写程序的时候应注意避免

在Java虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销。HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。

7.3 长期存活的对象将进入老年代

HotSpot虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

7.4 动态对象年龄判定

为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。

7.5 空间分配担保

在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。

7.6 虚拟机优化技术

逃逸分析

逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术。

逃逸分析的基本原理:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。

如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化。

栈上分配

几乎所有对象都在Java堆中分配内存,Java堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问到堆中存储的对象数据。

虚拟机回收和整理堆中内存,都要耗费大量资源。

如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。若对象触发了JIT(热点数据)并且做了逃逸分析,那么这样的对象可能被分配在栈中。 栈上分配可以支持方法逃逸,但不能支持线程逃逸。

可以使用参数-XX:+DoEscapeAnalysis来手动开启逃逸分析。