浅谈JVM的垃圾收集(一)

前言

本文主要介绍Java中的有关的垃圾收集算法,以及实现算法的垃圾收集器

背景

我们经常可以听到垃圾收集这个词,那Java中

”垃圾“是指什么呢?
为什么要收集“垃圾”呢?
怎么知道哪些是“垃圾”呢?
怎么收集“垃圾”呢?
什么时候收集“垃圾”呢?

带着这些问题来看这篇文章

垃圾的定义

百度百科的关于垃圾的定义

垃圾是失去使用价值、 无法利用的废弃物品,是物质循环的重要环节。

在Java中,万物皆对象。类似的,”垃圾“在Java中的定义即是失去使用价值、无法利用的对象,是垃圾收集的重要环节。

为什么要收集“垃圾”呢?

我们知道资源是有限的,所以要尽可能充分利用资源。类似的,操作系统分配给Java进程的内存资源也是有限的,若JVM不进行垃圾收集从而回收内存,当内存不足时就会抛出OutOfMemory(OOM)错误了。

怎么知道哪些是“垃圾”呢?

我们知道Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。因此这几个区域的内存分配和回收都具备确定性,当方法结束或者线程结束时,内存自然就跟随着回收了。

Java堆方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。

如何判断对象是否存活

Java中的”垃圾“收集,即是收集“死亡”的对象(”死亡”即不可能再被任何途径使用的对象)。而Java堆中,存放着几乎所有的对象实例。垃圾收集是垃圾收集器的工作,垃圾收集器对Java堆进行回收工作前,是怎么判断哪些对象是否存活呢?
主要有两种判断对象是否存活的方法:引用计数算法可达性分析算法

引用计数算法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

主流的Java虚拟机没有选用引用计数算法来管理内存,因为这个简单的算法要配合大量的额外处理才能正确的工作。例如单纯的引用计数就很难解决对象之间相互循环引用的问题

可达性分析算法(主流的判断对象存活的算法)

这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

例如,图中object5,6,7就不可达。

在这里插入图片描述

GC ROOT

在Java中,固定可作为GC ROOT的对象的包括以下几种:

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

除了这些固定的GC ROOT以外,还可以有其他对象“临时性”的加入。当使用分代收集和局部回收的垃圾收集器时,例如只针对新生代的垃圾回收,新生代的某些对象完全有可能被老年代的对象所引用,这时候就要把老年代的对象临时的加入到GC ROOT集合中去,这样才能保证可达性分析的正确性。像这种跨代引用的对象,它具体是怎么判断的呢

  • GC ROOT扫描遇上跨代引用的对象的知识看这篇文章——记忆集和卡表
  • 实际上虚拟机扫描根节点时是通过OopMap来获知根节点位置的,更多细节可看这篇文章——OopMap与安全点
对象的自救——finalize()方法

即使在可达性分析中被判定为不可达的对象,也不是”非死不可“,这时候它们暂时还处于“缓刑”阶段。要真正宣告一个对象死亡,至少要经历两次标记过程:

-如果对象在进行可达性分析后发现没 有与GC Roots相连接的引用链,那它将会被第一次标记,随后虚拟机判断此对象是否有必要执行finalize()方法

假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

如果对象有必要执行finalize()方法,那么对象将会被放置在一个名为F-Queue队列中,并由虚拟机创建低调度优先级的Finalizer线程去触发它们的finalize() 方法开始执行,但不承诺会等待它们的finalize() 方法执行结束。

finalize()方法是对象逃脱死亡命运的最后一次机会,并且任何一个对象的finalize()方法都只会被系统自动调用一次

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

对象自救总结:对象可以通过重写finalize()方法,并将自己重新与引用链上的任何一个对象建立关联,即可实现自救。finalize()方法只会被系统自动调用一次。

回收方法区

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

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就 比较苛刻了。需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方 法。

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是 和对象一样,没有引用了就必然会回收。

怎么收集“垃圾”呢?

上面说了怎么判断对象是否存活,现在说的才是真正干活的事——JVM如何进行垃圾收集。垃圾收集主要是垃圾收集器干的活,而垃圾收集算法又定义了是怎么去收集垃圾的,即垃圾收集器是垃圾收集算法的实现

理论和算法

垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作“直接垃圾收集”和“间接垃圾收集”。主流的虚拟机都是使用“追踪式垃圾收集”。HotSpot虚拟机的垃圾收集器主要采用的算法有标记-清除算法、标记-复制算法和标记-整理算法。目前大多数垃圾收集器都是遵循“分代收集”理论进行设计。

分代收集理论

分代收集理论包括三条假说:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
  • 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极 少数。

前两条假说共同奠定了多款收集器设计的原则:即将Java堆划分为不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活的对象。如果剩下的都是难以消灭的对象,那就把他们集中到一块,虚拟机以较低的频率来回收这一区域。

根据分代收集理论设计的虚拟机,一般会把Java堆划分为新生代老年代两个区域。根据不同区域的收集行为,有了不同回收类型的划分。一般分为部分收集和整堆收集

部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:

  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。不过有些资料上也指Major GC是整堆收集。
  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。

整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集

而依据第三条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称 为“记忆集”,Remembered Set)。

具体实现可见这篇文章——记忆集和卡表

除了回收类型,还有针对不同区域进行回收的回收算法,即“标记-复制算法”,“标记-清除算法”,“标记-整理算法”等。

标记-清除算法

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

它的主要缺点有两个:第一个是执行效率不稳定,如果Java堆中包含大量对 象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过 程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

总结

  • 过程:分为标记,清除两个阶段。标记阶段即可达性分析阶段,判断对象是否属于垃圾。
  • 缺点:执行效率不稳定,效率随着对象数量的增长而降低。会产生内存碎片,会导致分配大对象时没有足够内存,提前触发GC动作。
标记-复制算法

标记-复制算法常被简称为复制算法。新生代使用复制算法。HotSpot实现的复制算法是把新生代分为一块较大的Eden空间两块较小的 Survivor空间(From Survivor、To Survivor),每次分配内存只使用Eden和From Survivor。发生垃圾搜集时,将Eden和From Survivor中仍然存活的对象一次性复制到To Survivor空间上,然后直接清理掉Eden和From Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上From Survivor的10%),即To Survivor作为保留空间占用10%。但是不能保证每次回收都只有不多于10%的对象存货,如果To Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过空间分配担保机制直接进入老年代。

GC进行时,Eden区所有存活的对象都被复制到To Survivor区。而From Survivor区中,仍存活的对象会根据它们的年龄值决定去向,年龄值达到年龄阈值(默认15是因为对象头中年龄占4bit,新生代每熬过一次垃圾回收,年龄+1),则移到老年代,没有达到则复制到To Survivor。然后清空From Survivor,并交换From Survivor和To Survivor的指针。

空间分配担保机制

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

老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,也就是说假如某次Minor GC存活后的对象突增,远远高于历史平均值的话,依然会导致担保失败。担保失败会触发触发Handle Promotion Failure错误,并进行Full GC。另外,在JDK 6 Update24之后,实际虚拟机中已经不再使用-XX:HandlePromotionFailure参数了。规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行Full GC

废话不多说,直接上图

标记-整理算法

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

标记整理算法和标记清除算法的本质差异:前者是移动式算法,后者是非移动式算法。由于老年代存活率高,没有额外空间给他做担保,所以老年代只能使用标记-清除算法或标记-整理算法。两种算法各有优缺点

对于标记整理算法,对于老年代这种每次收集都有大量对象存活的区域,移动存活对象并更新 所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用 程序才能进行,这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿也叫“Stop The World”。

但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过“分区空闲分配链表”来解决内存分配问题。内存的访问是用户程序最频繁的操作,甚至都没有之 一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。

简单的说就是,

  • 标记-整理算法要移动并更新对象的引用,会导致用户线程停顿时间更长,即增加延迟
  • 标记清除算法会产生内存碎片,需要通过分区空闲分配链表来解决内存分配问题,所以会降低吞吐量

所以,要么关注吞吐量,要么关注延迟。二者不可兼得。

还有“和稀泥”使用两种算法的解决方案,让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经 大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法

还有更屌的,最新的ZGC和Shenandoah收集器使用读屏障(Read Barrier)技术实现了整理过程与用户线程的并发执行。

总结

这篇文章主要分析了怎么判断对象存活,以及垃圾收集算法(其实还有很多很重要的内容在文中的拓展连接)。下一篇文章才是重中之重,主要介绍垃圾收集器。主要讲CMS、G1、ZGC和Shenandoah收集器实现的细节,也是面试中的一大亮点。这几个收集器特定是垃圾收集能和用户线程并发执行,例如并发的执行可达性分析等等。可先了解前置知识——并发的可达性分析

判断对象是否可回收/存活的方法主要有:引用计数法和可达性分析法。

引用计数法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。 但是会产生对象间循环引用的问题。

可达性分析法

通过GC ROOT向下搜索引用,走过的路径称为引用链,当一个对象到GC ROOT没有任何引用链,说明对象可以被回收。

可作为GC ROOT的对象

  • 虚拟机栈中引用的对象
  • 方法区中常量和静态属性引用的对象
  • 所有被同步锁(synchronized关键字)持有的对象
  • Java虚拟机内部的引用(如系统类加载器,常驻的异常对象等等)
  • 本地方法栈中引用的对象等等

判定为不可达的对象,一定会被回收吗?
不一定,如果虚拟机判定有必要执行对象的finalize()方法,且对象在finalize()方法中实现自救(把自身引用赋值给其他对象),则逃脱垃圾回收。

方法区的垃圾回收
方法区只回收废弃的常量和无用的类。
如果没有任何地方对此常量进行引用,则此常量就会被回收。

方法区可以回收无用的类
(1)java堆中不存在该类的任何实例。
(2)加载该类的ClassLoader已经被回收。
(3)该类的class对象没有任何地方被引用。
满足以上三个条件的类可以被回收,而不是和java堆中的对象一样必然会被回收。

对象什么时候会进入老年代?

  • 每个对象的对象头都有年龄计数器(age)。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15,因为age占4bit),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX: MaxTenuringThreshold设置

  • 分配对象时会在Eden分配,首先查看Eden区域够不够,不够的话,会执行一次Young GC,若GC后存活的对象无法放入To Survivor中,则将存活对象通过分配担保机制放到老年代中。然后再次分配刚创建的对象到Eden中。

  • 即survivor中存活的对象要进入到老年代,要么满足对象的年龄达到MaxTenuringThreshold要求的年龄;要么满足相同年龄对象大小的总和达到To Survivor空间的一半,则所有年龄大于等于该年龄的对象都能进入老年代。例如,To Survivor大小为10M,有两个对象age为1,总大小为6M,还有一个对象age为2。则这3个对象都可以进入老年代。

  • 大对象直接进入老年代,HotSpot虚拟机提供了==-XX:PretenureSizeThreshold ==参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区 之间来回复制,产生大量的内存复制操作。但是 -XX:PretenureSizeThreshold参数只对SerialParNew两款新生代收集器有效。

分配担保机制

三大垃圾收集算法

  • 标记-复制算法:将新生代分为Eden和From Survivor,To Survivor区,复制存活对象到To Survivor区,需要额外空间作为担保
  • 标记-整理算法:要移动并更新对象的引用,会导致用户线程停顿时间更长,即增加延迟
  • 标记-清除算法:会产生内存碎片,需要通过分区空闲分配链表来解决内存分配问题,所以会降低吞吐量

新生代因为存活对象比较少,使用标记-复制算法,需要老年代空间作为担保。
老年代因为没有额外空间作为担保,只能使用标记整理算法或标记清除算法

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值