Java垃圾回收

Java
311
0
0
2023-05-29

垃圾回收机制是 java 语言的一个显著特点,其可以有效防止内存泄露、管理内存的使用,使得Java应用程序不再需要考虑内存管理问题。(注:所谓内存泄露是指内存空间使用完毕之后未回收, Java 的内存泄露通常表现为一个对象的 生命周期 超出了应用程序需要它的时间长度。)

深入了解Java垃圾回收机制可以帮助我们写出更好的Java应用程序;其次如果涉及到一些性能调优,问题排查等,也是必须要深入了解Java垃圾回收机制的。此外,垃圾回收机制和算法,它的权衡与取舍,都很好地体现了软件设计或架构设计的理念,可以增长我们的知识,开阔我们的思路,作为学习和借鉴。

本文主要介绍Java垃圾收集相关概念,之后的文章会介绍Java垃圾收集问题分析以及优化。文章中垃圾回收简称GC(Garbage Collection)。

要理解垃圾回收机制,首先要知道垃圾回收主要回收的是哪些数据,对应 JVM 内存哪些区域。可以先回顾下之前文章介绍的JVM内存区域划分:

Java垃圾回收 JVM内存区域

  • 虚拟机栈 :线程私有区域,描述的是方法执行时的内存空间,生命周期与 线程 相同。每个方法被执行的同时会创建栈帧,主要保存执行方法时的 局部变量 表、操作数栈、动态链接和方法返回地址等信息,方法执行时入栈,方法执行完出栈,出栈就相当于清空了数据,所以这块区域不需要进行GC。
  • 本地方法栈 :本地方法栈为虚拟机执行本地方法(Native)时服务的。这块区域也不需要进行GC。
  • 程序计数器 :程序计数器是唯一一个在 Java虚拟机 规范中没有规定任何OOM情况的区域,所以这块区域也不需要进行GC。
  • 本地内存 :线程共享区域。在Java8以前有个“永久代”的概念,实现了JVM规范定义的方法区功能,主要存储类的信息、常量、静态变量、即时编译器编译后的代码等,这部分由于是在堆中实现的,受GC的管理。由于永久代有-XX:MaxPermSize的上限,如果将大量的类信息、 字符串常量 放入永久代,很容易造成OOM。从Java8开始,就把方法区的实现移到了本地内存中的元数据空间,这样方法区就不受JVM的控制了,也就不会进行GC。所以,在Java8及以后,这一区域也不需要进行GC。
  • :堆是GC发生的区域。对象实例和数组都是在堆上分配的,GC也主要对这两类数据进行回收。

垃圾回收算法

  • 复制算法

将原有的内存区域一分为二,在同一时间内只会使用其中一块内存区域用来分配对象。在发生GC时,首先通过根可达算法判断存活对象,并且将所有的存活对象移动到另一块未使用的内存区域,然后对于前面这块使用的内存区域中的对象进行统一回收。复制算法示意图如下:

Java垃圾回收

复制算法优点是:内存完整。缺点是:

①不适用于存活对象多的情况,移动对象对于空间/时间开销太大。

②内存利用率低,这种算法下无论何时永远有一半内存区域浪费。

  • 标记-清除算法

标记-清除算法分两阶段,标记阶段通过根可达算法标记出所有存活对象,然后在清除阶段对于所有未标记对象进行统一回收。标记-清除算法示意图如下:

Java垃圾回收

标记-清除算法优点是:简单。缺点是:

①两个阶段效率都不高。

②会产生大量的内存碎片。

  • 标记-压缩(整理)算法

标记-压缩(整理)算法也分两阶段,标记阶段通过根可达算法标记出所有存活对象,整理/压缩阶段将所有存活对象挪动到内存的一端,然后对于边界之外的所有对象进行统一回收。标记-压缩(整理)算法示意图如下:

Java垃圾回收

标记-压缩(整理)算法优点是:内存完整。缺点是:因为需要移动对象,效率也不是很高。

以上三种垃圾回收算法中,

内存完整性 看,优先级是:复制算法 → 标记-压缩(整理)算法 → 标记-清除算法

内存利用率 看,优先级是:标记-压缩(整理)算法 → 标记-清除算法 → 复制算法

  • 分代收集算法

分代收集算法整合了以上算法,综合了这些算法的优点,最大程度避免了它们的缺点,所以是现代虚拟机采用的首选算法。其更确切说是一种策略,因为它是把上述几种算法整合在一起了。主要思想为:根据对象存活周期的不同,将内存划分为几块,一般是把Java堆分为新生代和老年代,这样就可以根据不同代的特点采用合适的收集算法来提高JVM垃圾收集的执行效率:

①新生代:在新生代中,每次垃圾收集时都发现有大量对象可回收,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成回收。

②老年代:在老年代中,因为对象存活率高、没有额外空间对它进行分配 担保 ,就必须使用标记-清除算法或标记-压缩(整理)算法进行回收。

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

Minor GC :对新生代进行垃圾回收,不会影响到老年代。Minor GC发生非常频繁,执行效率高。

Full GC :对整个堆进行回收,包括新生代和老年代。Full GC比Minor GC要慢,应尽可能减少Full GC发生的次数。

分代收集工作原理


Java垃圾回收

对象在新生代的分配与回收

分代收集算法根据对象存活周期的不同将堆分成新生代和老年代(Java8以前还有个永久代),默认比例为1:2,新生代又分为Eden区,S0(from Survivor区),S1(to Survivor区),三者的比例为8:1:1。

①对象一般分配在Eden区,当Eden区满时,将触发Minor GC,大部分的对象在短时间内都会被回收,所以经过Minor GC后只有少部分对象会存活( IBM 专业研究表明,一般来说,98%的对象经过一次Minor GC后就会被回收),它们会被移到S0区,同时对象年龄+1(对象的年龄即发生Minor GC的次数),最后把Eden区对象全部清理以释放出空间。

②当触发下一次Minor GC时,会把Eden区的存活对象和S0中的存活对象一起移到S1(且对象年龄+1),同时清空Eden和S0的空间。

③若再触发下一次Minor GC,则重复上一步,只是此时变成从Eden区和S1区将存活对象复制到S0区,每次Minor GC,S0和S1角色互换。也就是说在Eden区的垃圾回收采用的是复制算法。

对象晋升老年代

①当对象的年龄达到了设定的 阈值 ,则会从S0(或S1)升级到老年代。假如年龄阈值设置为15,当发生某次Minor GC时,S0中有个对象年龄达到15,将会晋升到老年代。

②大对象,当某个对象分配需要大量的连续内存时,此时对象的创建不会分配在Eden区,会直接分配在老年代。因为如果把大对象分配在Eden区,Minor GC时再移动到S0或S1会有很大开销(复制耗时长,占用空间大),也很快会占满S0或S1区,所以干脆直接分配到老年代。

③还有一种情况,在S0(或S1)区相同年龄的对象大小之和大于S0(或S1)空间一半以上时,则年龄大于等于该年龄的对象也会晋升到老年代。

空间分配担保

在发生Minor GC之前,Java虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果大于,Minor GC可以确保是安全的,如果不大于,那么虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于则进行Minor GC,否则会进行一次Full GC。

如果老年代满了,会触发Full GC,Full GC会同时回收新生代和老年代(对整个Java堆进行垃圾回收),会导致STW,造成很大性能开销。一般Full GC会导致用户线程停顿时间过长(因为Full GC清理整个堆中垃圾对象,耗时较长),如果此时服务器收到很多请求,则会被拒绝服务。所以我们要尽量减少Full GC(Minor GC也会造成STW,但只会触发轻微的STW)。

几个相关概念:

STW(Stop-The-World) :在GC发生时,所有用户线程都需要挂起停止,对于用户而言,就是整个程序卡住不动了,直到等到GC完成知会用户线程才能重新恢复执行。至于为什么需要停止用户线程也很好理解,GC发生时,GC线程和用户线程操作的是同一块内存区域,如果不停止用户线程,GC线程前脚刚标记完一块区域,用户线程就在这块区域创建了对象,当回收的时候,可能这个对象还是存活的,那么就会导致该对象被清除,对于业务应用系统来说就会受到很大影响,存在线程安全问题。STW会在任何一种GC算法中发生。实际上,GC优化很多时候就是指减少STW发生的时间。从而使系统具有高吞吐、低停顿的特点。

安全点 :由于Full GC(或Minor GC)会影响性能,所以要在一个合适的时间点发起GC,这个时间点被称为Safe Point,这个时间点的选定既不能太少以让GC时间太长导致用户线程过长时间卡顿,也不能过于频繁以至于过分增大运行时的负荷。Safe Point主要指的是以下特定位置:

①循环的末尾

②方法返回前

③调用方法的call之后

④抛出异常的位置

记忆集(RememberSet) :在发生新生代GC时都会通过根可达算法先判断垃圾对象,之后再对非存活对象进行统一回收,但是如果有老年代对象引用了新生代对象,那么根据根可达算法的特性,老年代也会被加入扫描范围,这样下来一次新生代的GC代价太大。所以为了解决跨代引用的问题,在新生代引入了记录集的数据结构,记录从非收集区到收集区的引用指针集合,避免在通过根可达算法判断对象存活时把整个老年代加入扫描范围。GC时,GC器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,无需进行详细的根搜索过程。记忆集可根据不同的记忆粒度实现:

①字宽/字长精度:精确到每个字宽(32bit/64bit),每一个跨代引用指针;

②对象精度:精确到每个对象,对象的字段中包含跨代引用指针;

③卡精度:精确到每一块内存区域,内存区域中有对象存在跨代指针。

卡表 :上面第③种精度的实现被称为卡表(Card Table),也是HotSpot虚拟机中记忆集的实现方式,卡表中记录记忆集的记录精度、与堆内存区域的映射关系等。在HotSpot虚拟机中卡表是使用一个字节数组实现,数组中每个元素对应着其标识的内存区域,称为卡页,卡页大小为512字节,也就是说内存中每连续的512字节会被当作一个卡页,作为卡表的一个元素。如果有老年代的对象引用了新生代的对象,那么该新生代对象所在区域对应的卡页元素设置为1,反之则为0。

如何确定一个对象是否可以被回收

  • 引用计数算法:判断对象的引用数量

简单理解为给对象添加一个 计数器 ,每当有一个地方引用它的时候,计数器就+1;每当有一个引用失效的时候,计数器就-1。当计数器的值为0的时候,那么该对象就是可回收的垃圾对象了。引用计数算法有一个很大的缺点:很难解决对象之间循环引用的问题。即当A有B的引用,B又有A的引用的时候,这个时候,即使A和B都设置为null,引用计数算法也不会将他们进行垃圾回收。因此,目前使用已经不多。如下示意图:

Java垃圾回收

上图中,对象A、对象B互相引用,最后即使将指向它们的引用ObjectA、ObjectB设置为null,也就是说ObjectA、ObjectB指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为0,那么垃圾收集器就不会回收它们。

  • 可达性分析算法:判断对象的引用链是否可达

可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,通过一系列的名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时(用图论的话来说就是从GC Roots到这个对象不可达),则证明此对象是不可用的。

Java垃圾回收

上图中,根可达算法可以避免引用计数算法难以解决的循环引用问题,Object6、Object7、Object8彼此之间有引用关系,但是没有雨GC Roots相连,那么就会被当做垃圾回收。

在Java中,有固定的GC Roots对象和不固定的临时GC Roots对象。

固定的GC Roots

①在虚拟机栈(栈帧的本地变量表)中所引用的对象,比如各个线程被调用的方法中使用到的参数、局部变量、临时变量等。

②在方法区中类静态属性引用的对象,比如Java类的引用 静态变量 。

③在方法区中常量引用的对象,比如字符串常量池中的引用。

④在方法区栈中 JNI (Native方法)引用的对象。

⑤Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(空指针异常、OOM等),还有类加载器。

⑥所有被 Synchronized 持有的对象。

⑦反应Java虚拟机内部情况的JMXBean、JVMTI(Java Virtual Machine Tool Interface)中注册的 回调 、本地代码缓存等。

临时GC Roots

因为目前的垃圾回收大部分都是分代收集和局部回收,如果只针对某一部分区域进行局部回收,那么就必须要考虑“当前区域的对象有可能正被其他区域的对象所引用”,这时候就需要将这部分关联的对象也添加到GC Roots中去以确保根可达算法的准确性。

垃圾回收器

目前在HotSpot虚拟机中主要有分代收集和分区收集两大类,根据最新的垃圾收集器发展趋势,未来会逐渐向分区收集发展。如下图所示。

分代收集器 :Serial、ParNew、Parallel Scavenge、CMS、Serial Old、Parallel Old

分区收集器 :G1、ZGC、Shenandoah

其他:Epsilon,JDK11提供的一个不执行任何垃圾回收动作的回收器,可用作性能分析

Java垃圾回收

  • Serial

是一个单线程的垃圾回收器,采用复制算法负责新生代垃圾回收,可以与CMS垃圾回收器一起搭配工作。在STW的时候只会有一个线程去进行垃圾收集工作,所以效率会比较低。但它是所有垃圾回收器里面消耗额外内存最小的。

  • ParNew

是一个多线程的垃圾回收器,也是采用复制算法负责新生代垃圾回收,可以与CMS垃圾回收器一起搭配工作。它其实是Serial的多线程版本,主要区别就是在STW的时候可以用多个线程去清理垃圾。

  • Parallel Scavenge

是一个多线程的垃圾回收器,采用复制算法负责新生代的垃圾回收工作,可以与Serial Old,Parallel Old垃圾回收器一起搭配工作。是与ParNew类似,都是用于新生代回收的使用复制算法的并行收集器,与ParNew不同的是,Parallel Scavenge的目标是达到一个可控的吞吐量。

吞吐量 = 程序运行时间 / (程序运行时间 + GC时间)

Parallel Scavenge提供了两个参数用以精确控制吞吐量,分别是用以控制最大GC停顿时间的-XX:MaxGCPauseMillis及直接控制吞吐量的参数-XX:GCTimeRatio。

停顿时间越短越适合需要与用户交互的程序。良好的响应速度能提升用户体验,而高吞吐量主要适合在后台运算而不需要太多交互的任务。

  • Serial Old

是一个单线程垃圾回收器,采用标记-压缩(整理)算法负责老年代的垃圾回收工作。其实它就是Serial的老年代版本。

  • Parallel Old

是一个多线程的垃圾回收器,采用标记-压缩(整理)算法负责老年代的垃圾回收工作,可以与Parallel Scavenge垃圾收集器一起搭配工作。Parallel Old是Parallel Scavenge的老年代版本,它的设计思路也是以吞吐量优先的。

  • CMS

CMS全称Concurrent Mark Sweep,是在JDK5发布时,HotSpot推出的一款在强交互应用中可称为具有划时代意义的垃圾收集器。这款垃圾收集器是HotSpot虚拟机中第一款真正意义上支持并发的垃圾收集器,它首次实现了让垃圾收集线程与用户线程同时工作。它的工作过程如下:

①初始标记:标记出和GC Roots直接关联的对象,速度很快,为了保证标记的准确,这部分会在STW的状态下运行。

②并发标记:并发标记阶段会直接根据第①步关联的对象找到所有的引用关系,这一部分是和用户线程并发运行的。

③重新标记:重新标记是为了解决第②步并发标记所导致的标错情况。例如:并发标记时a没有被任何对象引用,此时垃圾回收器将该对象标为垃圾,在之后的标记过程中,a又被其他对象引用了,如果不进行重新标记就会发生“误清除”,这部分是在STW状态下运行。

④并发清除:这一步是最后的清除阶段,将之前真正确认为垃圾的对象回收。这部分是和用户线程并发运行的。

CMS的三个缺点:

①影响用户线程:CMS默认启动的回收线程数是 (处理器核数 + 3) / 4,由于是和用户线程并发执行,所以必然会影响用户线程的执行速度。

②会产生“浮动垃圾”:上面提到CMS清除垃圾阶段是和用户线程并发运行,所以在清理垃圾的同时用户线程会产生新的垃圾,这部分垃圾就叫做“浮动垃圾”,只能等着下一次垃圾回收再清除。

③产生碎片化的空间:CMS使用标记-清除的算法去清理垃圾,这种算法的缺点就是产生碎片化的内存空间,后续可能会导致大对象无法分配而触发和Serial Old配合使用来处理碎片化问题,而这是在STW状态下进行,所以某些情况下在STW状态处理碎片化的时间会比较长。

  • G1

为解决CMS算法产生内存空间碎片等问题缺陷,HotSpot提供了G1(Garbage First)垃圾收集器,可通过参数-XX:+UseG1GC来启用。G1垃圾收集器在JDK6u14实验性引入,JDK 7u4版本正式推出,JDK9开始成为HotSpot虚拟机的默认垃圾回收器。G1可以面向堆空间的任何空间来进行回收。G1的设计理念里,最小回收单元是Region,每次回收的空间大小都是Region的N倍。G1会跟踪各个Region区域内的垃圾价值(与回收空间大小、回收时间有关),然后维护一个优先级列表,来收集那些价值最高的 Region 区域。

  • ZGC

ZGC(The Z Garbage Collector)是JDK11实验性引入,JDK15正式推出的一款低延迟垃圾回收器,其设计目标为:

①停顿时间不超过10ms

②停顿时间不会随着堆的大小,或者活跃对象的大小而增加

③支持8MB—4TB级别的堆

  • Shenandoah

Shenandoah垃圾回收器是JDK12引入,JDK15作为产品特性正式推出的一款垃圾收集器,由 Red Hat 的一个团队负责开发。与G1类似,基于Region设计,不需要RememberSet和Card Table来记录跨Region引用,停顿时间和堆大小无关,与ZGC接近。

  • Epsilon

Epsilon垃圾收集器被称为“no-op”收集器,JDK11引入,它只处理内存分配而不进行垃圾回收。可以在执行短期任务或性能测试等特殊应用场景时使用。

Java对象引用级别与GC行为

Java对象存在四种引用类型,按强弱关系依次为:强引用→软引用→弱引用→虚引用。

  • 强引用

通常通过new关键字创建的对象都属于强引用类型,堆中的对象与栈中变量存在直接引用,对于这类存在强引用的对象,当堆内存不足时,GC机制会被强制触发,但是如果GC器发现堆内对象都存在强引用时,GC器不会强制回收,而是触发OOM异常。因此,通常在编码的时候如果确定一个对象不再使用之后可以显式的将对象引用清空:obj = null,这样能够方便GC在查找垃圾对象时直接发现它。

  • 软引用

软引用是指使用java.lang.ref.SoftReference<Object>(obj)类型修饰的对象,当一个对象只存在软引用时,在堆内存不足时,该引用级别的对象将被GC机制回收。如果在发生GC时堆内存还充足,就不会回收该引用级别的对象。基于这个特性,可以用来实现缓存。

  • 弱引用

弱引用是指使用java.lang.ref.WeakReference<Object>(obj)类型修饰的对象,与软引用的区别在于:弱引用类型对象的生命周期更短,因为弱引用类型的对象只要被GC发现,不管当前的堆内存资源是否紧张,都会被GC机制回收。不过因为GC线程的优先级比用户线程更低,所以一般不会立马发现弱引用类型对象,因此一般弱引用类型的对象也会有一定的存活周期。

  • 虚引用

虚引用是指使用java.lang.ref.PhantomReference<Object>(obj)类型修饰的对象,不过在使用虚引用的时候是需要配合ReferenceQueue引用队列才能联合使用。与其他几种引用类型不同的是,虚引用不会决定GC机制对一个对象的会收权,如果一个对象仅仅存在虚引用,那么GC机制将会把它当成一个没有任何引用类型的对象,随时可以回收。虚引用的使用场景:跟踪垃圾回收过程,对象销毁前的一些操作。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在垃圾回收后,销毁这个对象,将这个虚引用加入引用队列。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么可以在所引用的对象的内存被回收之前采取必要的行动。