JVM笔记(GC特别篇)

其实我特别不想直接跳到GC,JVM的学习应该还是按顺序进行能够深入(避免跳过内存结构直接学习内存分配和回收),但是最近还是经常遇到这个问题,所以决定跳到这部分从底层完整学习一下。

笔记来源视频:JVM-Java虚拟机-从入门到精通-尚硅谷-134集开始

笔记顺序结构有修改整理,所有示意图全部重做。

垃圾回收概述

什么是垃圾?

垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要回收的垃圾。

如果不及时对内存中的垃圾进行清理,那么这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用,可能导致内存溢出。

为什么需要GC?

GC帮助回收分配出去却不再使用的内存资源。

除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片,碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象。

随着应用程序的业务更加复杂,没有GC就很难保证应用程序的正常运行。

早期垃圾回收

早期的C/C++时代,垃圾回收基本由手工进行。开发人员使用new关键字申请内存,并使用delete关键字进行内存释放。

这种方式虽然灵活控制内存释放的时间,但会给开发人员带来频繁申请和释放内存的管理负担,容易产生人为问题上的内存泄漏。

Java垃圾回收机制

Java会对内存进行自动管理,无需开发人员手动参与内存的分配和回收,这样降低内存泄露和内存溢出的风险。但是过度依赖自动会弱化开发人员在程序出现内存溢出时定位问题和解决问题的能力。


垃圾回收相关算法

首先明确一点,GC行为存在于方法区和堆中。主要存在于堆。

垃圾标记阶段:引用计数算法(Java未选用)

引用计数器算法简单,对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。

只要有任意对象引用了对象X,则X的引用计数器就加1,当引用失效时,引用计数器就减1。只要对象X的引用计数器值为0,即表示对象X不可能再被使用,就可以进行回收。

优点:

  • 实现简单
  • 垃圾对象便于识别
  • 效率高,回收无延迟

缺点:

  • 需要单独的字段储存计数器,增加了储存空间的开销
  • 每次赋值都要更新计数器,增加时间开销
  • 无法处理循环引用问题(致命缺陷,因此java中没有使用引用计数算法

垃圾标记阶段:可达性分析(Java使用)

可达性分析算法不仅同样具备实现简单和执行高效的特点,更重要的是该算法可以有效地解决在引用技术算法中循环引用的问题,防止内存泄漏发生。

实现思路:

  • 以根对象集合GC Roots(一组必须活跃的引用)为起点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达
  • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链
  • 如果目标对象没有任何引用链相连,则不可达,就意味着该对象可被标记为垃圾对象
  • 可达性分析算法中,只有能够被根对象集合直接或间接连接的对象才是存活对象
上图中对象4、5应被标记为可回收

如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保证一致性的快照中进行。不满足这点分析结果的准确性就无法保证。

因此GC进行时必须“Stop the world”,在号称几乎不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。

补充:GC Roots

GC Roots包含一下几类元素:

  • 虚拟机栈中引用的对象(如各个线程被调用方法中使用到的参数、局部变量等)
  • 本地方法栈内JNI(本地方法)引用的对象
  • 方法区中类静态属性引用的对象(如Java类的引用类型静态变量)
  • 方法区中常量引用的对象(如字符串常量池里的引用)
  • 所有被同步锁(synchronized)持有的对象
  • Java虚拟机内部的引用(基本数据类型对应的Class对象,一些常驻的异常对象,系统类加载器)
  • 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还有其他对象可以“临时性”加入,共同构成完整GC Roots集合。比如:

分代收集和局部回收。

如果只针对Java堆中的某一块区域进行垃圾回收,必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GC Roots集合中去考虑,才能保证可达性分析的准确性。

小技巧:由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里,那它就是一个Root。

对象的finalization机制

Java语言提供了对象终止机制来允许开发人员提供对象被销毁之前的自定义处理逻辑

当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize() 方法。

finalize() 方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。(这点和安卓中activity的onDestroy()方法很像)

永远不要主动调用某个对象的finalize() ,应当交由垃圾回收机制调用。

因为:

  • finalize() 时可能会导致对象复活
  • finalize() 方法的执行时间没有保障,完全由GC线程决定,极端情况下,若不发生GC,则finalize() 方法将没有执行机会
  • 一个糟糕的finalize() 方法会严重影响GC的性能

通常来说,所有根节点都无法访问到的对象,说明已经不再使用了,但也并不是一定会死亡,一个无法触及的对象有可能在某个条件下“复活”自己,那样对它进行回收就是不合理的。

由于finalize() 的存在,虚拟机中的对象一般处于三种可能的状态,如下:

  1. 可触及的:从根节点开始,可以到达这个对象
  2. 可复活的:对象的所有引用都被释放,但是对象有可能在finalize() 中复活
  3. 不可触及的:对象的finalize() 被调用,且没有复活,那么进入不可触及状态。不可触及的对象不可能被复活,因为finalize() 只会被调用一次

只有在对象不可触及时才可以被回收

事实上,判定一个对象X是否可回收,至少要经过两次标记过程:

  1. 如果对象X到GC Roots没有引用链,进行第一次标记
  2. 进行筛选,判断此对象是否有必要执行finalize()方法
    1. 如果对象X没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,X被判定为不可触及的
    2. 如果对象X重写了finalize(),且并未执行过,那么X会被插入到F-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法执行
    3. finalize()方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记。如果X在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,X会被移出“即将回收”集合。之后,在对象再次出现没有引用的存在的情况下,finalize()方法不再被调用,对象会直接变成不可触及。(免死金牌只用一次)

垃圾清除阶段:标记-清除算法

标记-清除算法是一种非常基础常见的垃圾收集算法。

执行过程:

当堆中的有效内存空间被耗尽的时候,就会停止整个程序,然后进行两项工作,第一项为标记,第二项为清除。

  • 标记:Collector从引用根节点开始遍历,标记所有被引用的对象标记可达的对象)。一般是在对象的Header中记录为可达对象。
  • 清除:Collector对堆内存从头到尾进行线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。

缺点:

  • 效率不高
  • GC时需要停止整个应用程序
  • 清理出来的空间是不连续的,产生内存碎片,需要维护一个空闲列表

清除的过程并非置空,而是把需要清除的对象地址保存在空闲的地址列表里,下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够就存放(覆盖)(windows电脑也是通过这种方式)。

垃圾清除阶段:复制算法

核心思想:将激活的内存空间分为两块,每次只使用其中一块,当垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。

优点:

  • 没有标记清除过程,简单高效
  • 复制过去后保证空间的连续性,不会出现碎片问题

缺点:

  • 需要两倍的内存空间
  • 对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,内存占用和时间开销都不小

特别备注:如果内存中的存活对象很多,垃圾很少,复制算法效果会不理想。因此,复制算法通常用于在新生代的垃圾回收。

垃圾清除阶段:标记-压缩算法

复制算法更加适用于新生代的垃圾回收,因此基于老年代垃圾回收的特性,需要使用其他算法。

执行过程:

  1. 第一阶段和标记-清除算法相同,从根节点开始标记所有被引用的对象
  2. 第二阶段将所有存活的对象压缩到内存的一端,按顺序排放

标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩算法。

二者本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩算法是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。

可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表开销更小。

优点:

  • 消除了标记-清除算法中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可
  • 消除了复制算法当中,内存减半的高额代价

缺点:

  • 从效率上来说,标记-压缩算法要低于复制算法
  • 移动对象的同时,如果对象被其他对象引用,则还需调整引用的地址
  • 移动过程中,需要全程暂停用户应用程序,即STW

备注:速度上 复制算法(最快)>标记清除算法(中等)>标记压缩算法(最慢)

分代收集算法

分代收集算法基于这样一个事实:不同对象的生命周期不一样。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。

在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如HTTP请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。还有一些对象如String,生命周期很短。

新生代:

新生代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。

这种情况下使用复制算法回收整理,速度最快。复制算法内存利用率不高的问题,则通过hotspot中的两个survivor设计缓解。

老年代:

老年代特点:区域较大,对象生命周期长、存活率高,回收没有新生代频繁。

这种情况下存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或是标记-压缩的混合实现。

  • 标记阶段的开销与存活对象的数量成正比
  • 清除阶段的开销与所管理区域的大小成正比
  • 压缩阶段的开销与存活对象的数据成正比

增量收集算法

上述现有算法在垃圾回收中,应用软件将处于停止状态,如果垃圾回收时间过长,应用程序被挂起太久,将严重影响用户体验和系统的稳定性。增量收集算法就是为了实现实时垃圾收集。

基本思想:

如果一次性将所有的垃圾进行处理,需呀造成系统长时间的停顿, 那么可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。

总的来说,增量收集算法的基础仍然是传统的标记-清除算法和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。

缺点:

使用这种方式,由于在垃圾回收的过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是线程切换和上下文转换的消耗,会使得垃圾回收的总成本上升,造成系统吞吐量的下降

分区算法

一般来说,在相同条件下,堆空间越大,一次GC所需要的时间就越长,有关GC产生的停顿也越长。为了更好的控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理的回收若干个小区间,从而减少一次GC所产生的停顿。

分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。每个小区间都可以独立使用,独立回收。


垃圾回收相关概念

System.gc()的理解

在默认情况下,通过System.gc() 或者Runtime.getRuntime().gc() 的调用,会显式触发Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。

然而System.gc() 调用附带一个免责声明,无法保证对垃圾收集器的调用(仅仅是提醒JVM希望进行一次垃圾回收)。

JVM实现者可以通过System.gc() 调用来决定JVM的GC行为。而一般情况下,垃圾回收应该是自动进行的,无需手动触发。在一些特殊情况下,如正在编写一个性能基准,我们可以在运行之间调用System.gc() 。

安全点和安全区域

程序执行时并非在所有地方都可以停顿下来开始GC,只有在特定的位置才能停顿下来,这些位置称为“安全点”。

安全点的选择很重要,如果太少可能会导致GC等待的时间过长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为安全点,如方法调用、循环跳转、异常跳转等。

目前虚拟机使用主动式中断:设置一个中断标志,各个线程运行到安全点的时候主动轮询这个标志,如果中断标志为真,则将自己的进行中断挂起。

安全点机制保证了程序执行时,在不太长的时间就会遇到可进入GC的安全点。但在线程睡眠或阻塞状态,线程无法响应JVM的中断请求,JVM也不太可能等待线程被唤醒。就需要安全区域来解决。

安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域的任何位置开始GC都是安全的。(可以理解为扩展了的安全点)

实际执行时:

  1. 当线程运行到安全区域的代码时,首先标识已经进入了安全区域,如果这段时间发生GC,JVM会忽略标识为安全区域状态的线程
  2. 当线程即将离开安全区域时,会检测JVM是否已经完成GC,如果完成了,则继续进行,否则线程必须等待直到收到可以安全离开安全区域的信号为止

评估GC的性能指标

  • 吞吐量:运行用户代码的时间占总运行时间(程序运行时间+内存回收时间)的比例
  • 垃圾收集开销:吞吐量的补数,垃圾收集时间与总运行时间的比例
  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间
  • 收集频率:相对于应用程序的执行,收集操作发生的频率
  • 内存占用:Java堆区所占的内存大小
  • 快速:一个对象从诞生到被回收所经历的时间

引用

为什么要区分引用,因为我们希望一些内容在内存空间还足够时,能保留在内存中。如果内存空间在进行垃圾收集之后还是很紧张,则可以抛弃这些对象。

  • 强引用:最传统的引用定义。指在程序代码中普遍存在的引用赋值,任何情况下,只要强引用的关系还存在,垃圾收集器就不会回收掉被引用的对象
  • 软引用:在系统要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存异常
  • 弱引用:被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象
  • 虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知

垃圾回收器

串行回收器:Serial、Serial Old;

并行回收器:ParNew、Parrallel Scavenge、Parrallel Old;

并发回收器:CMS、G1;

新生代收集器:Serial、ParNew、Parallel Scavenge;

老年代收集器:Serial Old、Parrallel Old、CMS;

整堆收集器:G1;

Serial收集器:串行回收

Serial收集器是最基本、历史最悠久的垃圾收集器。在HotSpot中的Client模式下默认的新生代垃圾收集器。

采用复制算法、串行回收和STW机制的方式执行内存回收。

Serial收集器还提供用于执行老年代垃圾收集的Serial Old收集器。Serial Old收集器同样也采用了串行回收和STW,只不过内存回收算法使用的是标记-压缩算法。

Serial Old在Client模式下默认为老年代垃圾回收器。

Serial Old在Server模式下用途有:

  1. 与新生代Parrallel Scavenge收集器配合使用
  2. 作为老年代CMS收集器的后备垃圾收集方案

优点:

  • 简单高效,由于没有线程交互的开销,专心做垃圾收集可以获得最高的效率。
  • 在用户的桌面场景中,可用内存不大,可以在较短时间内完成垃圾收集。
  • 在HotSpot虚拟机中,使用 -XX:+UseSerialGC 参数可以指定新生代和老年代都使用串行收集器。

ParNew收集器:并行回收

ParNew收集器是Serial收集器的多线程版本。Par表示并行,New表示只能处理新生代。

除了采用并行回收的方式,其他与Serial几乎无区别,同样的复制算法+STW机制。

ParNew是很多JVM运行在Server模式下新生代的默认垃圾收集器

  • ParNew收集器运行在多CPU环境下,可以充分利用多CPU多核优势,提升程序吞吐量
  • 在单CPU环境下,不比Serial收集器高效

除Serial外,目前只有ParNew GC能与CMS收集器配合工作。

在程序中,使用-XX:+UseParNewGC 手动指定使用ParNew执行新生代内存回收任务。

-XX:ParrallelGCThreads 限制线程数量,默认开启和CPU数量相同的线程数。

Parrallel Scavenge收集器:吞吐量优先

Java8 默认的垃圾收集器。

ParNew和Parrallel都用于新生代。

和ParNew不同的是,Parrallel收集器的目标是达到一个可控制的吞吐量,也被称为吞吐量优先的垃圾收集器。

自适应调节策略也是Parrallel Scavenge与ParNew一个重要的区别。

高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。,常见在服务器环境中使用。(吞吐量优先环境下使用Parrallel在Server模式下性能不错)

Parrallel Old收集器采用了标记-压缩算法,也同样基于并行回收和STW机制。

CMS收集器:低延迟

老年代收集器。

在强交互应用中划时代意义的垃圾收集器。因为实现了垃圾收集器和用户线程同时工作(并发)。

CMS收集器致力于尽可能缩短垃圾收集时用户线程的停顿时间,提升用户的体验。

采用标记-清除算法和STW机制。由于采用标记-清除算法,会产生内存碎片,无法使用指针碰撞技术,只能使用空闲列表执行内存分配。恰恰是因为要保证和用户线程并发执行,所以不能使用标记-压缩算法(会产生移动)

运行过程为四个阶段:

  1. 初始标记(STW):程序中所有的工作线程都将会因为STW机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出GC Roots能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快。
  2. 并发标记从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  3. 重新标记(STW):由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
  4. 并发清除:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure“失败,这时虚拟机将启动后备预案:临时启用Serial Old 收集器来重新进行老年代的垃圾收集。

优点:

  • 并发收集
  • 低延迟

缺点:

  • 会产生内存碎片,导致并发清除后用户线程可用空间不足,触发Full GC
  • 对CPU资源敏感,占用一部分线程导致程序变慢,降低总吞吐量
  • 无法处理浮动垃圾,可能出现”Concurrent Mode Failure“导致Full GC产生。在并发标记阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,延迟到下一次GC时释放。

[重点]G1收集器:区域化分代式

JDK 9以后的默认垃圾收集器。

G1的目标是在延迟可控的情况下获得尽可能高的吞吐量。

G1是一个并行回收器,它把堆内存分割为很多不相关的区域(Region) (物理上不连续的)。使用不同的Region来表示Eden、幸存者0区,幸存者1区,老年代等。

G1 GC有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。由于这种方式的侧重点在于回收垃圾最大量的区间(Region),所以我们给G1一个名字:垃圾优先(Garbage First)。

G1是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器。

优点:

  • 并行:回收期间多个GC线程同时工作。
  • 并发:能与应用程序交替执行,部分工作可以和应用程序同时执行。
  • 分代收集:不要求整个Eden区、新生代或者老年代都是连续的,也不再坚持固定大小和固定数量。将堆空间分为若干个区域,这些区域中包含了逻辑上的新生代和老年代
  • 空间整合:Region之间使用复制算法,但整体上可看作标记-压缩算法,可以避免内存碎片。
  • 可预测的停顿时间模型:能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不超过N毫秒。

G1在用户程序运行过程中的内存占用和额外执行负载都大于CMS。