【进阶之路】攻克JVM——JVM的垃圾回收机制(二)

1,162 阅读14分钟

导言

大家好,我是南橘,从接触java到现在也有差不多两年时间了,两年时间,从一名连java有几种数据结构都不懂超级小白,到现在懂了一点点的进阶小白,学到了不少的东西。知识越分享越值钱,我这段时间总结(包括从别的大佬那边学习,引用)了一些平常学习和面试中的重点(自我认为),希望给大家带来一些帮助

这是之前的几篇文章,有兴趣的同学可以看看(暗搓搓给自己打广告)

这一片文章的思路来自于猿人谷大佬,大佬技术非常好,写的文章也很硬,吃起来非常满足。(^_^)

有需要的同学可以加我的公众号,以后的最新的文章第一时间都在里面,也可以找我要思维导图

上篇文章讲了JVM对象及对象的访问定位,多多少少的提到了垃圾回收。与c++相比,java放弃了很多东西(比如指针,我最喜欢指针了),但是也有c++所没有的东西(比如垃圾回收)。这篇文章,主要就讲讲JVM中的垃圾回收。

一、确认对象死亡

要回收对象,首先要确定对象已经死亡。上回章说道:一个对象的死亡,至少要经历两次标记过程,那么,如何,JVM给我们提供了哪些方法来标记并确定对象的死亡呢?

1、引用计数法

给对象中添加一个引用计数器去截获所有的引用更新操作,每当有其他地方引用这个对象,那么计数器+1,当引用失效时候,计数器-1,当计数器归0的时候,代表着对象不可能再被使用。

但是,引用计数器也有不少缺点:

  • 1、需要额外的空间来存储计数器,并且每次引用都需要更新,非常的繁琐。
  • 2、如果出现循环引用,那么就GG(比如A引用B,B又引用A)

2、可达性分析法

可达性是指,如果一个对象会被至少一个在程序中的变量通过直接或间接的方式被其他可达的对象引用,则称该对象是可达的(reachable)。更准确的说,一个对象只有满足下述两个条件之一,就会被判断为可达的:

  • 1、对象本身是根对象。根(Root)是指由堆以外空间访问的对象。JVM中会将一组对象标记为根,包括全局变量、部分系统类,以及栈中引用的对象,如当前栈帧中的局部变量和参数。
  • 2、对象被一个可达的对象引用。

GC Roots对象(由堆外指向堆内的引用)如下:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象。
  • 已启动且未停止的Java线程。

这个算法的基本思路在上回的最后对象的回收这一块有讲过,有兴趣的同学可以翻到上一章去回顾一下。

不过,可达性分析法还是存在一些问题的。比如在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为null)或者漏报(将引用设置为未被访问过的对象)。误报情况下Java虚拟机至多损失了部分垃圾回收的机会。漏报就问题大了,因为垃圾回收器可能回收事实上仍被引用的对象内存。一旦从原引用访问已经被回收了的对象,则很有可能会直接导致Java虚拟机奔溃。

二、垃圾回收算法

接下来,我们介绍一下如何快速回收之前发现的垃圾。

1、标记-清除算法

标记(标记出所有可回收的对象)——清除(回收所有已被标记的对象,释放这部分空间。)

标记清除算法的优点是可以解决循环引用的问题,同时简单实用,一般来说GC数量不多,作用于CMS垃圾收集器的老年代收集

标记清除算法的缺点:

  • 1、会产生大量内存碎片内存碎片:由于Java虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。无法找到足够的连续内存,而不得不提前触发一次垃圾收集动作。
  • 2、分配效率较低。如果是一块连续的内存空间,那么我们可以通过指针加法(pointer bumping)来做分配。而对于空闲列表,Java虚拟机则需要逐个访问列表中的项,来查询能够放入新建对象的空闲内存。

2、标记-复制算法

标记-复制算法倒是解决了内存碎片的问题,其流程如下:

  • 1、划分区域:将内存区域按比例划分为1个Eden区作为分配对象的“主战场”和2个幸存区(即Survivor空间,划分为2个等比例的from区和to区)
  • 2、复制:收集时,打扫“战场”,将Eden区中仍存活的对象复制到某一块幸存区中。
  • 3、清除:由于上一阶段已确保仍存活的对象已被妥善安置,现在可以“清理战场”了,释放Eden区和另一块幸存区。
  • 4、晋升:如在“复制”阶段,一块幸存区接纳不了所有的“幸存”对象。则直接晋升到老年代。

光从这几个步骤来看,我们就能发现标记-复制算法堆空间的使用效率极其低下。特别是在对象存活率较高时,需要进行较多的标记-复制操作,效率会变得很低。

3、标记-整理算法

标记(标记出所有可回收的对象)——整理(将标记阶段的对象移动到空间的一端,释放剩余的空间。)

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

标记-整理算法解决了内存碎片的问题,也规避了复制算法只能利用一半内存区域的弊端。但它对内存变动更频繁,需要整理所有存活对象的引用地址,在效率上比复制算法要差很多

4、分代收集算法

JVM内存对象基本上都是分代收集的,就是根据对象存活周期的不同将内存划分为几块,这样就可以根据各个年代的特点采用最适当的收集算法。

  • 在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用标记-复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
  • 而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记-清理算法或标记-整理算法来进行回收。

三、GC分类

刚刚提了一下分代收集,现在就聊一下在JVM中如何定义分代收集的。

1、Minor GC

当 JVM 无法为一个新的对象分配空间时会触发 Minor GC,比如当 Eden 区满了。所以分配率越高,越频繁执行 Minor GC。

所有的 Minor GC 都会触发stop-the-world,停止应用程序的线程。对于大部分应用程序,停顿导致的延迟都是可以忽略不计的。因为大部分 Eden 区中的对象都能被认为是垃圾,永远也不会被复制到 Survivor 区或者老年代空间。如果正好相反,Eden 区大部分新生对象不符合 GC 条件,Minor GC 执行时暂停的时间将会长很多。

触发条件:Eden空间满时

2、Major GC

指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge 收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。

触发条件:Minor GC 会将对象移到老年代中,如果此时老年代空间不够,那么触发 Major GC。

3、Full GC

清理整个堆空间。一定意义上Full GC 可以说是 Minor GC 和 Major GC 的结合。

触发条件:调用System.gc();老年代空间不足;空间分配担保失败。

Stop-the-world

等等,我们多次提到了Stop-the-world,世界暂停,到底什么是Stop-the-world呢?

GC进行时必须停顿所有Java执行线程,这就是Stop-the-world。

对象的可达性分析时必须在一个能确保一致性的快照中进行,这里“一致性”的意思是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,这一点不满足的话分析结果准确性就无法得到保证。

Stop-the-world是通过安全点机制来实现的。当Java虚拟机接收到Stop-the-world请求,它便会等待所有的线程都到达安全点,才允许请求Stop-the-world的线程进行独占的工作.

四、HotSpot的算法实现

Stop-the-world的解释中,我们聊到了安全点,那么什么是安全点?我们通过HotSpot虚拟机对垃圾回收算法的实现来聊一聊这个话题。

1、安全点(Safepoint)

安全点,即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停**。Safepoint的选定既不能太少以至于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷**。

安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下,Java虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性分析。只要不离开这个安全点,Java虚拟机便能够在垃圾回收的同时,继续运行这段本地代码。

程序运行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的。“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生Safepoint。

对于安全点,另一个需要考虑的问题就是如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。

两种解决方案:

抢先式中断(Preemptive Suspension)

抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机采用这种方式来暂停线程从而响应GC事件。

主动式中断(Voluntary Suspension)

主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志地地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

2、安全区域

指在一段代码片段中,引用关系不会发生变化。在这个区域中任意地方开始GC都是安全的。也可以把Safe Region看作是被扩展了的Safepoint。

3、卡表

因为某次GC,导致一部分新生代的对象去了老年代,那标记扫描存活对象的时候,需要扫描老年代的对象。同时,因为这个对象拥有新生代的引用,所以要扫描就得要全堆扫描。。。古尔丹,这个代价太高了吧。

HotSpot给出的解决方案是一项叫做卡表(Card Table),该技术将整个堆划分为一个个大小为512字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。

在进行Minor GC的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到Minor GC的GC Roots里。当完成所有脏卡的扫描之后,Java虚拟机便会将所有脏卡的标识位清零。

想要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么Java虚拟机需要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。

卡表能用于减少老年代的全堆空间扫描,这能很大的提升GC效率。

之外的枚举根节点在上文已经提及,这里就不再反复横跳了。

五、关于特殊对象的回收

回收方法区对象

之前我们说的都是堆GC,那么什么时候回去回收方法区里的对象呢?什么时候又会去回收静态对象呢?

方法区的类需要同时满足以下三个条件才能被回收:

  • 1、该类所有的实例都已经被回收,也就是说Java堆中不存在该类的任何实例;

  • 2、加载该类的类加载器已经被回收;

  • 3、该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

回收静态成员

静态成员一般也可以分为静态基本类型静态引用类型

静态基本类型存储在在静态变量区域;

静态引用类型的引用存储在静态变量区域,而实例(具体内容)存储在堆上。静态成员只存在一份,静态成员加载时机:类加载的时候(第一次访问),这个类中所有静态成员就会被加载在静态存储区,同时存储在静态变量区域的成员一旦创建,直到程序退出才会被回收。

当然,如果我们让静态实例=null,虽然静态存储区里的变量会一直存在,但是在堆里实例对象因为没有变量指向它,依然会被回收。因此如果不用的静态引用类型可以通过设置=null方式让GC可以回收其堆上的空间。

妄图回收单例

方法区是jvm的一块内存区域,用来存放类相关的信息。很明显,java中单例模式创建的对象被自己类中的静态属性所引用,符合GC Roots的引用,因此,单例对象不会被jvm垃圾收集。

但是,单例还是会在回收方法区对象的情况下被回收。

结语

JVM第二章也写完了。因为是清明期间,要去扫墓祭祖之类的事情比较多,所以学习、写文的时间也少了不少,本想一口气写完垃圾回收,想到还有那么多垃圾回收器。。。还是多分几章吧。大家觉得还看得过去的话,动动小手点个赞什么的就是对我最大的支持了see you!

同时需要思维导图的话,可以联系我,毕竟知识越分享越香!