Java·垃圾回收(GC)

1. java的引用类型

⚠️"强->软->弱->虚" 强度依次递减

1. 强引用(Strong Reference)

是Java程序中最普遍的一种。只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。

Object obj = new Object();  // 创建强引用
2. 软引用(Soft Reference)

用来描述一些可能还有用,但并非必需的对象。只在系统内存不够用时,这类引用关联的对象将被垃圾收集器回收。
JDK1.2之后提供了SoftReference类来实现软引用。

Object obj = new Object();  // 创建强引用
SoftReference sf = new SoftReference(obj);  // 创建软引用
obj = null;  // 强引用失效,但仍然存在软引用
 
 
3. 弱引用(Weak Reference)

被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
在JDK1.2之后,提供了WeakReference类来实现弱引用。

Object obj = new Object();  // 创建强引用
WeakReference wf = new WeakReference(obj);  // 创建弱引用
obj = null;  // 强引用失效,但仍然存在弱引用
 
 
4. 虚引用(Phantom Reference)

又称为幽灵引用或者幻影引用,是最弱的一种引用关系,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。
设置虚引用的唯一目的:能在这个对象被收集器回收时收到一个系统通知
JDK1.2之后提供了PhantomReference类来实现虚引用。

Object obj = new Object();  // 创建强引用
PhantomReference pf = new PhantomReference(obj, null);  // 创建虚引用
obj = null;  // 强引用失效,虚引用存在但不影响对象的生存时间
 
 

2. 如何找到"垃圾"

1. 引用计数法(Reference Counting Collector)

堆中每个对象(不是引用)都有一个引用计数器。当一个对象被创建并初始化赋值后,该变量计数设置为1。每当有一个地方引用它时,计数器值就+1(a = b, b被引用,则b引用的对象计数+1)。当引用失效时(一个对象的某个引用超过了生命周期(出作用域后)或者被设置为一个新值时),计数器值就-1。任何引用计数为0的对象可以被当作垃圾收集。
优点
效率高、实现简单。
缺点
在两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。同时,引用计数器增加了程序执行的开销。
所以Java 虚拟机不使用引用计数算法。

public class Test {
    public Object instance = null;

    public static void main(String[] args) {
        Test a = new Test();
        Test b = new Test();
        a.instance = b;
        b.instance = a;
        a = null;
        b = null;
    }
}
// a 与 b 引用的对象实例互相持有了对象的引用
// 当把对 a 对象与 b 对象的引用去除之后
// 因为两个对象还存在互相之间的引用
// 所以两个 Test 对象无法被回收。
2. 可达性分析法(Tracing Collector)

(Java和C#中都采用此算法来判定对象是否存活)
以 GC Roots 为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。
GC Roots
GC Root是可以从堆外部访问到的对象。因为方法区、虚拟机栈和本地方法栈一般不被GC所管理,所以选择这些区域内的对象作为GC Roots。
GC Roots可以包含:

  • 虚拟机栈中,栈帧中的局部变量表中引用的对象(local变量或参数)
  • 本地方法栈中JNI(Native方法)中引用的对象(local变量或参数)
  • 方法区中类静态属性引用的对象
  • 方法区中的常量引用的对象
  • 活跃的线程

可达性分析/根搜索基本思路
(此处的引用指的是⚠️强引用

  1. 通过一系列名为“GC Roots”的对象作为起始点,寻找对应的引用节点。
  2. 找到这些引用节点后,从这些节点开始向下继续寻找它们的引用节点。
  3. 重复第2步。
  4. 搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,就证明此对象是不可用的。

标记阶段的关键点

  1. Stop The World
    开始进行标记前,需要先暂停应用线程,否则如果对象引用链一直在变化的话是无法真正去遍历它的。暂停应用线程以便JVM可以尽情地收拾家务的这种情况又被称之为安全点(Safe Point),这会触发一次Stop The World(STW)暂停。触发安全点的原因有许多,但最常见的应该就是垃圾回收了。暂停时间的长短取决于存活对象的多少。因此,改变堆的大小并不会影响标记阶段的时间长短。
  2. 两次标记过程
    (1)如果对象在进行根搜索后发现没有与GC Roots相连接的引用链,那它会被第一次标记并且进行一次筛选。筛选的条件是此对象是否有必要执行finalize()方法。如果对象没有覆盖/重写finalize()方法,或finalize()方法已经被虚拟机调用过,都视为没有必要执行。
    (2)如果该对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为F-Queue队列中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程去执行finalize()方法。稍后GC将对F-Queue中的对象进行第二次小规模的标记。如果对象执行finalize()方法后,还没有关联到任何链上的引用,那它就会被回收掉。
3. finalize()方法

Java允许在类中定义finalize()方法,类似 C++ 的析构函数,用于关闭外部资源。它使得在GC回收该对象内存之前先调用finalize()方法,并在下一次GC回收发生时,真正回收对象内存。

相比用finalize()方法来进行"善后"工作, try-finally 等方式可以做得更好。并且finalize()方法运行代价很高,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。

当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能在该方法中让对象重新被引用,从而实现自救——只要在finalize()方法中让该对象重新引用GC Roots引用链上的任何一个对象建立关联即可。一个对象的finalize()方法最多只会被系统自动调用一次。

4. 方法区的回收

主要是对常量池的回收(废弃常量)和对类的卸载(无用的类)
相对而言,垃圾收集行为在这个区域是比较少出现的。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说,这个区域的回收"成绩"比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是必要的。

废弃常量的回收与回收Java堆中的对象类似。
为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能。
类的卸载需要满足以下三个条件,但满足了条件也不一定会被卸载:

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

3. 垃圾回收算法

1. 标记-清除(mark-sweep)
  1. 标记阶段
    程序会检查每个对象是否为活动对象,如果是活动对象,则程序会在对象头部打上标记。标记过程:可达性分析算法。
  2. 清除阶段
    进行对象回收并取消标志位。另外,还会判断回收后的分块与前一个空闲分块是否连续,若连续,会合并这两个分块。回收对象就是把对象作为分块,连接到被称为 “空闲链表” 的单向链表,之后进行分配时只需要遍历这个空闲链表,就可以找到分块。

缺点
(1)标记和清除过程效率都不高;
(2)会产生大量不连续的内存碎片,导致无法给较大的对象分配内存。

标记-清除

2. 标记-整理(mark-compact)

和"标记-清除"算法类似,区别是会让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。在基于此算法的垃圾收集器的实现中,一般增加句柄和句柄表。
优点
(1)不会产生内存碎片;
(2)新对象的分配只需要通过指针碰撞便能完成,相当简单。
缺点
(1)因需要移动大量对象,处理效率比较低,GC暂停的时间会增长

标记-整理

3. 复制(copying)

将内存按容量分为大小相等的两块,每次只使用其中的一块(对象面),当这一块的内存用完了,就将还存活着的对象复制到另外一块内存上面(空闲面),然后再把已使用过的内存空间一次清理掉。
一种典型的基于Coping算法的垃圾回收是stop-and-copy(停止-复制)算法,在对象区与空闲区的切换过程中,程序暂停执行。
优点
(1)标记阶段和复制阶段可以同时进行;
(2)每次只对一块内存进行回收,运行高效;
(3)只需移动栈顶指针,按顺序分配内存即可,实现简单;
(4)不会产生内存碎片。
缺点
(1)只使用了内存的一半空间
(2)当程序进入稳定状态之后,可能只产生少量垃圾,此时进行复制操作会造成无意义的消耗

复制
4. 自适应技术

JVM会监控当前堆的使用情况,并自动选择适当算法的垃圾收集器。
如果所有对象都很稳定,GC的效率降低的话,就切换到"标记-清除"方式;若堆空间出现很多碎片,就会切换回"停止-复制"方式。

拓展:为对象分配内存

选择哪种分配方式由堆内存块是否规整决定。

  1. 指针碰撞法
    当堆的内存空间是工整的,令一个指针作为分界点的指示器,指针的一边是已经分配的内存,指针的另外一边是空闲内存。需要分配内存时,只需要把指针往空闲的一端移动与对象大小相等的距离即可。
  2. 空闲列表法
    如果堆的内存空间不是绝对规整的,已分配的内存和空闲内存相互交错,就需要维护一个列表来记录哪些内存块是可以用的。当分配操作发生时,从列表中找到一个足够大的内存块分配给对象实例,并更新列表上的记录。

4. 分代垃圾回收

1. java堆的分代

根据对象的生命周期分代,使不同的代采取不同的回收算法进行垃圾回收,以提高回收效率。

  • Java的堆内存基于Generation算法划分为新生代、年老代和永久代
  • 新生代进一步划分为Eden和Survivor区
  • 大部分对象在Eden区中生成,经历一次垃圾回收后放到Survivor区
  • Survivor区由Survivor0(S0)和Survivor1(S1)组成
  • S0和S1都有可能成为FromSpace或ToSpace,依据GC情况而变,分别用指针from和指针to来表示,to指针总是指向空的那一个Survivor区

内存大小比例
Eden : S1 : S0 = 8 : 1 : 1
Young : Old = 1 : 2

堆内存分代

永久代与元空间
曾经还有一个永久代(Permanent Generation),永久代是方法区的一种实现方式。但在Java 8中被移出 HotSpot JVM 了,其原有的数据迁移至 Java Heap 或 Native Heap (元空间 Metaspace)。

不论是永久代还是元空间,都一样有可能发生垃圾回收。对应前文所述的"方法区的回收"。

2. 详述各代的垃圾回收

0. 垃圾回收模式
针对 HotSpot VM的实现,它里面的GC其实准确分类有两种:

  1. Partial GC: 并不收集整个 GC 堆
  • Young GC/Minor GC: 只收集新生代的GC
  • Old GC: 只收集年老代的GC。只有垃圾收集器CMS的concurrent collection 是这个模式
  • Mixed GC: 收集整个新生代以及部分年老代的GC。只有垃圾收集器 G1有这个模式
  1. Full GC: 收集整个 GC 堆,包括新生代、老年代、永久代/元空间 等所有部分

1. 新生代 - Young/Minor/Scavenge GC (复制算法)
Eden 区和 from 指针指向的 Survivor 区中的存活对象会被复制到 to 指向的 Survivor区中。
最后需要交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区是空的。
新生代采用空闲指针的方式来控制GC触发,指针保持最后一个分配的对象在新生代区的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发GC。

⚠️空间分配担保
在发生Minor GC前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间。

  • 如果大于,那么Minor GC可以确保是安全的。
  • 如果小于,虚拟机会查看HandlePromotionFailure设置值是否允许担任失败。
    • 如果允许,那么会继续检查老年代最大可用连续空间是否大于历次晋升老年代对象的平均大小
      • 如果大于,试着进行一次Minor GC,尽管这次Minor GC是有风险的
      • 如果小于,进行一次Full GC
    • 如果不允许,则进行一次Full GC

取平均值进行比较其实仍然是一种动态概率的手段,也就是说如果某次Minor GC后存活的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。

⚠️Survivor区对象晋升为老年代对象

  • Java虚拟机会记录 Survivor区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15 (对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升为至老年代
    (为什么是 15次:HotSpot会在对象头的中的标记字段里记录年龄,分配到的空间只有4位,所以最多只能记录到15)
  • 如果单个 Survivor 区中,相同年龄所有对象大小的总和大于Survivor区的50% (对应虚拟机参数: -XX:TargetSurvivorRatio),那么年龄大于或等于该年龄的对象可以直接进入老年代
  • 若Survivor区不足以容纳Eden和另一个Survivor中的存活对象。则多余的对象将被移到老年代,这称为过早提升(Premature Promotion),这会导致老年代中短期存活对象的增长,可能会引发严重的性能问题
  • 再进一步,如果老年代满了而无法容纳更多的对象,Minor GC 之后通常就会进行Full GC,这将导致遍历整个Java堆,这称为提升失败(Promotion Failure)
  • [非晋升,出生即在年老代]对于大对象(需要大量连续内存空间的Java对象),虚拟机提供-XX:PretenureSizeThreshold参数,大于这个设置值的新对象将直接分配在老年代。避免新生代中发生大量内存复制

2. 年老代 - Old/Major GC(标记-清除/标记-整理)

3. 新生代+部分年老代 - Mixed GC
(仅G1垃圾收集器中)
Mixed GC中有一个阈值参数-XX:InitiatingHeapOccupancyPercent,当老年代大小占整个堆大小百分比达到该阈值时,会触发一次Mixed GC。

4. 整个堆 - Full GC

3. 总结:什么时候进行垃圾回收

Minor GC 触发条件:

  • Eden区已满,无法为新对象分配内存
  • 不一定等Eden区满才触发

Full GC 触发条件:

  • 年老代将满:不允许空间分配担保或担保失败
  • 要在 永久代/元空间 分配空间但已经没有足够空间时
  • System.gc():不建议使用这种方式,而是让虚拟机管理内存
  • Heap Dump带GC(Heap Dump 是 Java进程所使用的内存情况在某一时间的一次快照。以文件的形式持久化到磁盘中。参见:生成 Heap Dump 的几种方式)
  • Concurrent Mode Failure:执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC

6. 垃圾收集器

HotSpot虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用
垃圾收集器/垃圾回收器 的评价维度

单线程与多线程:单线程指的是垃圾收集器只使用一个线程,而多线程使用多个线程。

独占与非独占:独占式垃圾收集器一旦运行,就停止应用程序中的其他所有线程,直到垃圾回收过程完全结束(Stop-The-World);非独占的垃圾收集器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。

垃圾收集器负载:指在应用程序的生命周期内,垃圾收集器耗时与系统运行总时间的比值。垃圾收集器负载=GC线程时间/(用户线程时间+GC线程时间)

吞吐量:指在应用程序的生命周期内,应用程序耗时和系统总运行时间的比值。吞吐量=用户线程时间/(用户线程时间+GC线程时间)。高吞吐量可以高效率地利用 CPU 时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。

停顿时间:指垃圾回收器正在运行时,应用程序的暂停时间。停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。对于独占回收器而言,停顿时间可能会比较长。使用并发的回收器时,由于垃圾回收器和应用程序交替运行,程序的停顿时间会变短,但是,由于其效率很可能不如独占垃圾回收器,故系统的吞吐量可能会较低。

垃圾回收频率:指垃圾回收器多长时间会运行一次。一般来说,对于固定的应用而言,垃圾回收器的频率应该是越低越好。通常增大堆空间可以有效降低垃圾回收发生的频率,但是可能会增加回收产生的停顿时间。

HotSpot虚拟机提供的垃圾收集器
收集器 作用区域 线程 是否独占 算法
Serial 收集器 新生代 单线程 独占 停止-复制
ParNew 收集器 新生代 多线程 独占 停止-复制
Parallel Scavenge 收集器 新生代 多线程 独占 停止-复制
Serial Old 收集器 年老代 单线程 独占 标记-整理
Parallel Old 收集器 年老代 多线程 独占 标记-整理
CMS 收集器 年老代 多线程 非独占 标记-清除
G1 收集器 新生代/年老代 多线程 非独占 标记-整理+复制

查看默认垃圾收集器(一般是G1),在命令行中输入:

java -XX:+PrintCommandLineFlags -version
1. Serial 收集器

拥有最高的单线程收集效率。当 JVM 在 Client 模式下运行时,它是默认的垃圾收集器。

2. ParNew 收集器

注意,在单 CPU 或者并发能力较弱的系统中,ParNew 收集器的效果不会比Serial 收集器好。

3. Parallel Scavenge 收集器

“吞吐量优先”收集器,它的目标是达到一个可控制的吞吐量。
缩短停顿时间是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。

可以通过一个开关参数打开 GC 自适应的调节策略(GC Ergonomics),就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

4. Serial Old 收集器

可以和多种新生代回收器配合使用,同时也可以作为 CMS 回收器的备用回收器。

5. Parallel Old 收集器

是 Parallel Scavenge 收集器的老年代版本。
在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

6. CMS 收集器(Concurrent Mark Sweep)
CMS收集器流程

四个流程

  1. 初始标记(initial mark)(需要停顿):仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快。
  2. 并发标记(concurrent mark)(不需要停顿):进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长。
  3. 重新标记(remark)(需要停顿):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。
  4. 并发清除(concurrent sweep)(不需要停顿):与并发标记一样耗时较长。

缺点

  • 吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
  • 对CPU资源非常敏感:CMS默认启动的回收线程数是(CPU数量 + 3)/4,并发回收时垃圾收集线程所占CPU资源随着CPU数量的增加而下降,而且在CPU不足4个时,CMS对用户程序的影响就可能变得很大,导致执行速度降低。
  • 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
  • 标记-清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
7. G1 收集器(Garbage First)
G1 把堆内存划分为多个大小相等的内存块(Region),新生代和老年代不再物理隔离

G1(Garbage First)是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot开发团队赋予它的使命是未来可以替换掉 CMS 收集器。

Region

  • 通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个相等大小的Region,使得每个Region可以单独进行垃圾回收。
  • H代表Humongous,这表示这些Region存储的是巨型对象(Humongous object),当新对象大小超过Region大小的50%时,直接在新的一个或多个连续Region中分配,并标记为H。
  • 默认是2048个Region。一个Region的大小可以通过-XX:G1HeapRegionSize参数指定,大小从1M到32M不等,必须是2的幂次方。若为默认,则把堆内存按照2048份均分,得到一个合理的大小。

这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。

每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。

G1收集器流程

如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

  1. 初始标记(initial mark):操作和CMS一样,也需要停顿。不同的是,这个阶段是跟Minor GC一同发生的。也就是说,在G1中,你不用像在CMS那样,单独暂停应用程序的执行来运行Initial Mark阶段,而是在G1触发Minor GC的时候一并将年老代上的Initial Mark给做了。
  2. 并发标记(concurrent mark):操作和CMS一样。但G1还多做了一件事情,如果在Concurrent Mark阶段中,发现哪些Tenured region中对象的存活率很小或者基本没有对象存活,那么G1就会在这个阶段将其回收掉,而不用等到后面的clean up阶段。这也是Garbage First名字的由来。同时,在该阶段,G1会计算每个 region的对象存活率,方便后面的clean up阶段使用 。
  3. 最终标记(final mark):操作和CMS一样,但使用不同的算法。虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
  4. 筛选回收(clean up/copy):首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

特点

  • 空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
  • 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。

7. GC优化

(未完待续)

参考资料:
浅析JAVA的垃圾回收机制(GC)
CS-Notes:Java虚拟机
一个面试官对面试问题的分析
从实际案例聊聊Java应用的GC优化
Java Hotspot G1 GC的一些关键技术
深入学习Java(更新中)
JAVA对象的创建与内存布局
深入探究 JVM | 探秘 Metaspace
JVM 系列文章之 Full GC 和 Minor GC
Java垃圾回收机制
JVM 垃圾回收器工作原理及使用实例介绍

图片来源:
CS-Notes:Java虚拟机
深入理解Java虚拟机 &GC分代年龄
G1垃圾收集器介绍

你可能感兴趣的:(Java·垃圾回收(GC))