在之前的文章中,我们介绍了对象的创建过程,以及运行期的相关优化手段。本文主要介绍对象回收的判定方式以及垃圾对象的回收算法等相关知识。
下面我们一起来了解一下。
当一个对象被创建时,虚拟机会优先分配到堆空间中,当对象不再被使用了,虚拟机会对其进行回收处理,以便释放内存空间,这个过程也被称为垃圾对象回收。
那么如何找到对象是否可以进行回收呢?一般有两种方式。
下面我们一起来了解下相关知识。
这个方法的实现思路是:在对象中维护一个引用计数器,每当一个地方引用这个对象时,计数器值+1;当引用失效时,计数器值-1。当对象的计数器值为 0,表示这个对象不再被使用了,可以被回收。
这种方法使用场景很多,但很少有垃圾收集器会使用这种方式。
原因在于:这种方式存在一个致命的缺陷,比如堆中的两个对象相互引用,此时他们的计数器值是 1,但这两个对象并没有被外部使用,因此不会被回收,容易造成内存泄露。
这个方法的实现思路是:从“GC Roots”(这个 GC Roots 可以是栈中的引用变量,也可以是方法区的引用变量或常量)开始扫描堆中的对象,沿着 GC Roots 一路扫描,被扫描的所有对象全部标记为存活对象;扫描完成之后,没有被标记的视为垃圾对象,可以被回收。
比如对象 A 被线程占中的变量 a 引用着,对象 A 中引用着对象 B,对象 B 又引用着 C 等,沿着 a 开始扫描,会扫描到对象 A,B,C 等,并把它们标记为存活对象。全部扫描完成之后,当一个对象到 GC Roots 没有任何引用链时,表示此对象是不可用的,等待被 GC 回收。
在 JVM 中,可以作为 GC Roots 的对象包括:
当一个对象被判定为垃圾对象之后,剩下的工作就是如何进行回收了。
下面我们一起来看看常见的几种垃圾回收算法的思想。
标记-清除算法如同它的名字一样,分为“标记”和“清除”两个阶段,也是最基础的算法。
首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。
这个算法也有很多的不足,主要体现在效率和空间。
标记-清除算法执行过程,可以用如下图来概括:
复制算法是为了解决效率问题而出现的,它将可用的内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
这样每次只需要对整个半区进行内存回收,内存分配时也不需要考虑内存碎片等复杂情况,只需要移动指针,按照顺序分配即可。
这个算法也有缺点,操作的时候内存会缩小为了原来的一半,代价很高;其次,持续复制长生存期的对象会导致回收效果不佳,效率较低。
一般的商用虚拟机会采用这种算法来回收新生代(也称为年轻代)的对象,不过研究表明1:1
的比例不是很科学,因此新生代的内存空间被细划分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor;每次回收时,将 Eden 和 Survivor 空间中还存活的对象一次性复制到另外一块 Survivor 空间上,最后清理掉之前的 Eden 和 Survivor 空间。
HotSpot 虚拟机默认 Eden 和 Survivor 区的比例是8 : 1 : 1
,期望每次回收后只有不到 10% 的对象存活,如果出现 Survivor 空间不够用时,需要依赖老年代进行分配担保。
复制算法执行过程,可以用如下图来概括:
在上面我们提到了复制算法的优点和缺点,针对对象存活率较高的场景,进行大量的复制操作时,效率很低下。如果不想浪费 50% 的空间,当对象 100% 存活时,那么需要有额外的空间进行分配担保。
在 HotSpot 虚拟机中,堆空间划分成两个不同的区域:新生代和老年代,目的是为了更有效率的回收对象。新生代的对象存活率低,会优先被回收,如果多次执行依然没有被回收,就会转移到老年代。老年代都是不易被回收的对象,对象存活率高,因此一般不能直接选用复制算法。
根据老年代的特点,有人提出了另外一种标记-整理算法,也称为标记-压缩算法,过程与标记-清除算法一样,不过不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉边界外的内存。
标记-整理算法执行过程,可以用如下图来概括:
分代收集算法,可以看成以上内容的延伸。它的实现思路是根据对象的生命周期的不同,将内存划分为几块,比如把堆空间划分为新生代和老年代,然后根据各块的特点采用最适当的收集算法。
在新生代中,存在大批对象死去、少量对象存活的特点,会采用“复制算法”,只需要付出少量存活对象的复制成本就可以完成垃圾对象收集,效率高;在老年代中,存在对象存活率高、没有额外空间对它进行分配担保的特点,会采用“标记-清理”或者“标记-整理”算法来进行回收。
可以用如下图来概括堆内存的空间布局:
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
不同的虚拟机所提供的垃圾收集器可能会有很大差异,以 HotSpot 虚拟机为例,所包含的垃圾收集器可以用如下图来概括。
上图中的连线表示,不同分代的收集器可以搭配使用。
在虚拟机中,没有所谓的万能收集器,只有根据具体的业务场景,选择最合适的收集器。这也是为什么 HotSpot 实现了这么多收集器的原因。
下面我们一起来看看相关的具体实现。
Serial 系列的垃圾收集器是 JVM 的第一款收集器,它的设计思路很简单,在新生代,使用单线程采用复制算法进行收集对象;在老年代,使用单线程采用标记整理算法进行收集对象;垃圾收集的过程中会暂停用户线程,直到垃圾收集完毕。
因为当时的硬件环境配置都不高,内存都是几十兆,CPU 也都是单核的,不像现在这样处处都是高并发的应用场景。限于当时的硬件资源和应用场景,这个收集器优势很突出,简单高效、消耗资源也很少。
唯一的不足在于,在用户不可见的情况下要把用户正常工作的线程全部停掉,这对很多应用比较难以接受。不过实际上到目前为止,Serial 收集器依然是虚拟机在 Client 模式下运行的默认新生代收集器,因为它简单而高效。客户端应用模型下,分配给虚拟机管理的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代对象,停顿时间平均在几十毫秒,只要不是频繁收集,完全可以接受。
整个流程,可以用如下图来概括。
总结下来,收集器特点如下:
ParNew 收集器,可以看成是 Serial 收集器的多线程版本。除了使用多线程进行垃圾收集外,其余行为和 Serial 收集器完全一样,包括使用的也是复制算法,垃圾收集时暂停用户线程。在多核 CPU 资源环境下,可以显著提升整个垃圾收集的性能,也是虚拟机在 Server 模式下运行的首选新生代收集器。
能让 ParNew 出名的一个核心因素是,它是除了 Serial 收集器外,目前唯一一个能与 CMS 收集器配合一起使用的新生代收集器,因为 CMS 优秀所以 ParNew 也出名了,有点类似碰上了大款的感觉,其中 CMS 收集器是一款几乎可以认为有划时代意义的垃圾收集器,下文我们再讲。
其次,ParNew 收集器在单个 CPU 的环境中绝对不会有比 Serial 收集器更好的效果,甚至由于线程交互的开销,该收集器在两个 CPU 的环境中都不能百分之百保证可以超越 Serial 收集器。当然,随着可用 CPU 数量的增加,它对于垃圾收集的效率提升还是很有帮助的。
整个流程,可以用如下图来概括。
总结下来,收集器特点如下:
Parallel Scavenge 和 ParNew 收集器很类似,也是一款使用多线程采用复制算法的新生代收集器;Parallel Old 收集器是一款使用多线程采用标记整理算法的老年代收集器;垃圾收集过程中也会暂停用户线程,直到整个垃圾收集过程结束。
不同的是,Parallel 收集器更关注系统的吞吐量,也被称为“吞吐量优先收集器”。
所谓吞吐量的意思就是 CPU 用于运行用户代码时间与 CPU 总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),比如虚拟机总运行 100 分钟,垃圾收集 1 分钟,那吞吐量就是 99%。高吞吐量可以高效率的利用 CPU 资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
自适应调节策略也是 Parallel Scavenge 与 ParNew 的一个重要区别,用户可以通过参数来打开自适应调节策略,比如-XX:+UseAdaptiveSizePolicy
参数,打开之后就不需要手动指定新生代大小、Eden 区和 Survivor 参数等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量。如果对于垃圾收集器运作原理不太了解,优化比较困难的情况下,使用 Parallel 收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成也是一个不错的选择。
另外,Parallel 收集器是虚拟机在 Server 模式下运行的默认垃圾收集器。
整个执行流程,跟 ParNew 收集器类似。
总结下来,收集器特点如下:
CMS 收集器是一种以获取最短回收停顿时间为目标的老年代收集器。
与前面几个收集器不同,它采用了一种全新的策略可以在垃圾回收过程中的某些阶段用户线程和垃圾回收线程一起工作,从而避免了因为长时间的垃圾回收而使用户线程一直处于等待之中。
目前很大一部分 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其注重服务的响应速度,希望系统停顿时间最短,比如在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过多少毫秒,以期给用户带来较好的体验,其中 CMS 收集器就非常符合这类应用的需求。
CMS 的英文全程是:Concurrent Mark-Sweep Collector,从名字上就能看出 CMS 收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为如下 4 个步骤:
CMS 会根据每个阶段不同的特性来决定是否停顿用户线程。整个流程,可以用如下图来概括。(图片来自于勤劳的小手 - 垃圾收集器文章)
初始标记阶段的工作主要是标记一下 GC Roots 能直接关联到的对象,这个过程会短暂的停顿用户线程,因为并不会对整个 GC Roots 的引用进行遍历,因此速度很快。
并发标记阶段的工作主要是把阶段一标记好的 GC Roots 对象进行深度的遍历,找到所有与 GC Roots 关联的对象并进行标记,这个过程会采用多线程的方式进行遍历标记,因为非常耗时,CMS 考虑到为了尽量不停顿用户线程,因此这个阶段不会暂停用户线程,也就是说,此时 JVM 会分配一些资源给用户线程执行任务,通过这样的方式减少用户线程的停顿时间。
重新标记阶段的工作主要是修补阶段二用户线程运行期间产生新的垃圾对象,进行重新标记,同样也是采用多线程方式进行,此阶段数量不会很多,会短暂的停顿用户线程,速度也很快。
并发清除阶段的工作主要是对那些被标记为可回收的对象进行清理,在一般情况下,并发清除阶段是使用的是“标记-清除”算法,因为这个过程不会牵扯到对象的地址变更,所以 CMS 在并发清除阶段是不需要停止用户线程的,对象回收效率非常高。
与此同时,正因为并发清除阶段用户线程也可以同时运行,所以在用户线程运行的过程中自然也会产生新的垃圾对象,这也就是导致 CMS 收集器会产生“浮动垃圾”的原因,此时也会产生很多的空间碎片,当空间碎片到达了一定程度时,此时 CMS 就会使用“标记-整理”算法来解决空间碎片的问题。
在上文的垃圾回收算法中我们有说到,“标记-整理”算法会将对象的位置进行挪动并更新对象的引用的指向地址,在这个过程中,如果用户线程同时运行的话会产生并发问题,因此当 CMS 进行碎片整理的时候必须得停止用户线程。所以,在某些情况下,并发清除阶段 CMS 也会停顿用户线程。
CMS 收集器作为一个全新思路的垃圾收集器,虽然很优秀,但一直没有被 Hospot 虚拟机纳入为默认的垃圾收集器。时至今日,JDK1.8 使用的默认收集器都还是 Parallel scavenge 和 Parallel old 收集器,主要原因在于 CMS 存在一些比较头疼的问题,比如浮动垃圾、空间碎片整理时会造成系统卡顿、在并发清除阶段可能会出现系统长时间的假死。
总结下来,收集器特点如下:
G1(Garbage-First)收集器是当今收集器技术发展的最前沿成果之一,从 JDK 7 Update 4 后开始进入商用。
在 G1 收集器出现之前,不管是 Serial 系列,Parallel 系列,还是 CMS 收集器,它们都是基于把内存进行物理分区的形式将 JVM 内存分成新生代、老年代、永久代或 MetaSpace,这种分区模式下进行垃圾收集时必须对某个区域进行整体性的收集,比如整个新生代、整个老年代收集或者整个堆,当内存空间不大的时候,比如几个 G,通过参数优化能取得不错的收集性能。但是,随着硬件资源的发展,JVM 可用内存从几十 G 到几百 G 甚至上 T 时,这种采用传统模式下的物理分区进行收集时,每次扫描内存的区域自然就变大了,进行垃圾清理的时间自然就变得更长了,此时传统的收集器即时再怎么优化,也难以取得令人满意的收集效果,因此需要一款全新的垃圾收集器。
G1 收集器就是在这样的环境下诞生的,它摒弃了原来的物理分区,把整个 Java 堆分成若干个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离,它们都是一部分 Region 的集合。从结构上看,G1 收集器不要求整个新生代或者老年代都是连续的,也不再坚持固定大小和固定数量,它会跟踪各个 Region 里面的垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。这种通过 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。
G1 收集器内存划分,可以用如下图来概括。(图片来自于勤劳的小手 - 垃圾收集器文章)
在 G1 收集器里面维护了一个 Collect Set 集合,这个集合里面记录了待回收的 Region 区域信息,同时也包括了每个 Region 区域可回收的大小空间。通过 Collect Set 里面的信息,G1 在进行垃圾收集时,可以根据用户设定的可接受停顿时间来进行分析,在设定的时间范围内优先收集垃圾最多的 Region 区域,以实现高吞吐、低停顿的收集效果。
在工作流程上,G1 收集器也吸收了 CMS 很多优秀的收集思路,整个垃圾收集过程,可以分为如下 4 个步骤:
G1 收集器的垃圾回收流程和 CMS 逻辑大致相同,主要的区别在最后一个阶段,G1 不会直接进行清除,而是会根据设置的停顿时间进行智能的筛选和局部的回收,采用“标记复制”算法来实现。
整个流程,可以用如下图来概括。
此阶段的工作内容与上文介绍的 CMS 收集器一样,会先把所有 GC Roots 直接引用的对象进行标记,同时会短暂的停止用户线程,因为不会对整个 GC Roots 的引用进行遍历,因此速度比较快。
此阶段的工作内容与上文介绍的 CMS 收集器也一样,找到所有与 GC Roots 关联的对象并进行深度遍历标记,会采用多线程的方式进行遍历标记,因为比较耗时,为了尽量不停顿用户线程,这个阶段 GC 线程会和用户线程同时运行,通过这样的方式减少用户线程的停顿时间。
此阶段的工作内容与上文介绍的 CMS 收集器也是一样,针对阶段二用户线程运行的过程中产生新的垃圾,采用多线程方式进行重新标记,为了避免这个过程再次产生新的垃圾对象,会短暂的停止用户线程,因为数量不会很多,因此速度比较快。
筛选回收阶段的工作主要是把存活的对象复制到 Region 空闲区域,同时会根据 Collect Set 记录的可回收 Region 信息进行筛选,计算 Region 回收成本,接着根据用户设定的停顿时间值制定回收计划,最后根据回收计划筛选合适的 Region 区域进行垃圾回收。
从局部来看,G1 使用的是复制算法,将存活对象从一个 Region 区域复制到另一个 Region 空闲区域;但从整个堆来看,G1 使用的逻辑又相当于标记整理算法,每次垃圾收集时会把存活的对象整理到对应可用的 Region 区域,再把原来的 Region 区域标记为可回收区域并记录到 Collect Set 中,因此 G1 的每一次回收都可以看作是一次标记整理过程,两者都不会产生空间碎片问题。
总结下来,收集器特点如下:
最后我们对以上介绍的垃圾收集器进行一次汇总,同时介绍一下服务器端常用的组合模式,内容如下。
服务器组合 | 新生代收集器 | 老年代收集器 | 备注 |
---|---|---|---|
组合一 | Serial | Serial Old | Serial 是一个使用单线程采用复制算法的新生代收集器;Serial Old 是一个使用单线程采用标记整理算法的老年代收集器,GC 时会暂停所有应用线程,可以使用-XX:+UseSerialGC 选项来开启 |
组合二 | ParNew | Serial Old | ParNew 是一个使用多线程采用复制算法的新生代收集器,GC 时会暂停所有应用线程,可以使用-XX:+UseParNewGC 选项来开启 |
组合三 | Parallel Scavenge | Serial Old | Parallel Scavenge 是一个使用多线程采用复制算法的新生代收集器,GC 时会暂停所有应用线程,可以使用-XX:+UseParallelGC 选项来开启;需要注意的是,在jdk1.7及之前的版本中,这个参数默认采用 Serial Old 作为老年代收集器;在jdk1.8及之后的版本中,默认采用 Parallel Old 作为老年代收集器 |
组合四 | Parallel Scavenge | Parallel Old | Parallel Old是 Serial Old 的多线程版收集器,可以使用-XX:+UseParallelOldGC 选项来开启 |
组合五 | Serial | CMS + Serial Old | CMS 是一个使用多线程采用标记清楚算法的老年代收集器,可以实现 GC 线程和用户线程并发工作,不需要暂停所有用户线程;另外,可以将 Serial Old 收集器作为备选,当 CMS 进行 GC 失败时,会自动使用 Serial Old 进行 GC;可以使用-XX:+UseConcMarkSweepGC 选项来开启 |
组合六 | ParNew | CMS + Serial Old | ParNew 是除了 Serial 以外,唯一一个能搭配 CMS 的新生代收集器;可以使用-XX:+UseConcMarkSweepGC 开启,默认使用 ParNew 作为新生代收集器,也可以通过-XX:+UseParNewGC 强制指定 ParNew |
组合七 | G1 | G1 | G1 是一个新一代的垃圾收集器,摒弃了原来的物理分区,把整个 Java 堆分成若干个大小相等的独立区域(Region),针对局部区域使用多线程采用复制算法进行筛选回收,可以使用-XX:+UseG1GC 选项来开启 |
以上介绍的都是对象的回收过程,在之前的 JVM 内存结构的文章中我们介绍到,Java 应用程序运行时,除了堆空间会存在垃圾数据以外,方法区同样也存在。
虽然虚拟机规范中没有明确要求方法区一定要实现垃圾回收,主要原因在于这个区域的垃圾回收效率非常低,但是 HotSpot 虚拟机对方法区也会进行回收的,主要回收的是废弃常量和无用的类两部分。
如何判断一个常量是否为“废弃常量”呢?其实很简单,只要当前系统中没有任何一处引用该常量,就会被判定为废弃常量。
如何判断一个类是否为“无用的类”呢?条件非常苛刻,需要同时满足以下三点。
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法满足以上三个条件则表示这个类再也无用了,HotSpot 虚拟机会对此类进行回收。例如在大量使用反射、动态代理、CGLib 等 ByteCode 框架,并自定义 ClassLoader 创建的类,为了保证方法区不会溢出,虚拟机会在适当的情况下对无用的类进行回收。
在 JDK1.7 及以前的版本中,用永久代来作为方法区的实现,当永久代的空间不足时会触发 Full GC。
在 JDK1.8 及之后的版本中,用元空间来作为方法区的实现,元空间的内存空间默认使用的是操作系统的内存空间,它的垃圾回收不再由 Java 来控制,元空间的内存管理由元空间虚拟机来完成。
本文主要围绕对象的回收判断方式,垃圾回收算法以及垃圾收集器,做了一次知识内容的整理和总结,如果有描述不当的地方,欢迎大家留言指出,不胜感激。
1.https://zhuanlan.zhihu.com/p/267223891
2.https://www.cnblogs.com/xrq730/p/4836700.html
3.https://zhuanlan.zhihu.com/p/248709769
4.http://www.ityouknow.com/jvm/2017/09/28/jvm-overview.html
最后的最后,说点更重要的,当下人工智能大火,每个人都应该关注到,我们在 ChatGPT 推出的第一时间就开始关注,我们就将整个公司的精力投入到了人工智能+变现的领域。
可以说,去年我们所做的一切都与人工智能+有关,所以当 Sora 出现时,我们也第一时间开始关注。
第一时间整理了市面上最全的 Sora 学习资料,覆盖介绍、未来变现玩法、各路观点、技术解读等等,还创建了交流群。
大家如果想领取这份资料,扫描下方二维码加我微信,发送给大家。
备注:sora
需要提醒大家的是,Sora 目前还没有完全开放,任何市面上付费的一些课程,都请大家谨慎选择。
可以确定的是:未来10-20年,一定是 AI 的大风口、大时代,如果错过了公众号、错过了抖音、错过了小红书。
请一定不要错过 AI +。
在这个史诗级别的超级大风口下,普通人只需要抓住其中的一个小点,即可实现人生自由、财富自由。
希望,大家都能够抓住这一波。