天天看点

JVM垃圾回收算法JVM如何判断对象"已死"?垃圾回收算法

Java是一门不用程序员手动管理内存的语言,全靠JVM自动管理内存,既然是自动管理,那必然有一个垃圾内存的回收机制或者回收算法。

在Java堆上分配一个内存给实例对象时,此时在虚拟机栈上引用型变量就会存放这个实例对象地址。

JVM垃圾回收算法JVM如何判断对象"已死"?垃圾回收算法

 当执行Object obj = null 时,栈中的obj变量就不在指向堆中的实例对象了,也就是Java堆上的实例对象无法再次引用它,那么它就是被GC的对象,我们称之为对象“已死”。那虚拟机栈上的obj变量呢? JVM内存结构提到过,虚拟机栈是线程独占的,也就是说随着线程初始而初始,消亡而消亡,当线程被销毁后,虚拟机栈上的内存自然会被回收,也就是说虚拟机栈上的这块内存空间不在虚拟机GC范围。下图展示了垃圾回收的内存范围:

JVM垃圾回收算法JVM如何判断对象"已死"?垃圾回收算法

JVM如何判断对象"已死"?

        在堆里面存放着Java 世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还 存活着,哪些已经死去(“死去”即不可能再被任何途径使用的对象)了。

引用计数算法

思想:

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

        看似简单的一个算法,实际上在大部分Java虚拟机中并没有采用这种算法,因为它会带来一个致命的问题——对象循环引用。对象A指向B,对象B反过来指向A,此时它们的引用计数器都不为0,但它们俩实际上已经没有意义因为没有任何地方指向它们,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。

demo:

JVM垃圾回收算法JVM如何判断对象"已死"?垃圾回收算法

 运行结果:

JVM垃圾回收算法JVM如何判断对象"已死"?垃圾回收算法

从运行结果中可以清楚看到内存回收日志中包含 “4603K->210K” ,意味着虚拟机并没有因为这两 个对象互相引用就放弃回收它们,这也从侧面 说明了Java虚拟机并不是通过引用计数算法来判断对象是否存活的。

可达性分析算法

        当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是通过 可达性分析(Reachability Analysis)算法 来 判定 对象是否存活 的。 算法的基本思路:

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

如图所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的, 因此它们将会被判定为可回收的对象。

JVM垃圾回收算法JVM如何判断对象"已死"?垃圾回收算法

 在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

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

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

垃圾回收算法

分代收集理论

         当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

          这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

        在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——----因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。这里笔者提前提及了一些新的名词,它们都是本章的重要角色,稍后都会逐一登场,现在读者只需要知道,这一切的出现都始于分代收集理论。

        把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代 (Young Generation)和老年代(Old Generation)两个区域。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。如果读者有兴趣阅读HotSpot虚拟机源码的话,会发现里面存在着一些名为“*Generation”的实现, 如“DefNewGeneration”和“ParNewGeneration”等,这些就是HotSpot的“分代式垃圾收集器框架”。原本HotSpot鼓励开发者尽量在这个框架内开发新的垃圾收集器,但除了最早期的两组四款收集器之外,后来的开发者并没有继续遵循。导致此事的原因有很多,最根本的是分代收集理论仍在不断发展之中, 如何实现也有许多细节可以改进,被既定的代码框架约束反而不便。其实我们只要仔细思考一下,也很容易发现分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用。

        假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样[3]。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:

  • 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

        这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。                  依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。 注意 刚才我们已经提到了“Minor GC”,后续文中还会出现其他针对不同分代的类似名词, 为避免读者产生混淆,在这里统一定义:

  • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
  1. 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
  2. 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指, 读者需按上下文区分到底是指老年代的收集还是整堆收集。
  3. 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

值得注意的是,分代收集理论也有其缺陷,最新出现(或在实验中)的几款垃圾收集器都展现出了面向全区域收集设计的思想,或者可以支持全区域不分代的收集的工作模式。新生代(Young)、老年代(Old)是HotSpot虚拟机,也是现在业界主流的命名方式。在IBM J9虚拟机中对应称为婴儿区(Nursery)和长存区(Tenured),名字不同但其含义是一样的。通常能单独发生收集行为的只是新生代,所以这里“反过来”的情况只是理论上允许,实际上除了CMS收集器,其他都不存在只针对老年代的收集

以上摘自 深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) (华章原创精品) - 周志明

标记-清除 算法

标记清除算法是最早也是最基础的垃圾收集算法,算法分为“ 标记 ” 和 “ 清除 ” 两个阶段:

        首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程,这在前面讲述垃圾对象标记判定算法时其实已经介绍过了。

        之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。 标记- 清除算法的执行过程如图 所示:

JVM垃圾回收算法JVM如何判断对象"已死"?垃圾回收算法

标记-清除算法的缺点:

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

标记-复制 算法

          标记-复制算法常被简称为复制算法。为了解决标记-清除算法面对大量可回收对象时执行效率低和内存空间碎片化的问题         1969年 Fenichel 提出了一种称为 “ 半区复制 ” ( Semispace Copying )的垃圾收集算法, 它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。         如果内存中多数对象都是存活的,这种算法将会 产生大量的内存间复制的开销 ,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一 点。

复制算法主要 针对新生代的垃圾回收 。

Java堆中被分为了新生代和老年代,这样的划分是方便GC。Java堆中的新生代就使用了GC复制算法。在新生代中又分为了三个区域:Eden 空间、To Survivor空间、From Survivor空间。

JVM垃圾回收算法JVM如何判断对象"已死"?垃圾回收算法

 新的对象实例被创建的时候几乎都放在Eden空间,发生在Eden空间上的GC称为Minor GC,当在新生代发生一次GC后,会将Eden和其中一个Survivor空间的内存复制到另外一个Survivor中,如果反复几次有对象一直存活,此时内存对象将会被移至老年代(此连接的文章中提到年龄计数器)。可以看到新生代中Eden占了大部分,而两个Survivor实际上占了很小一部分。这是因为大部分的对象被创建过后很快就会被GC(这里也许运用了是二八原则)。

再重申下复制算法的思想:

        每次将一块Survivor的空间保留,将另一块Survivor与Eden一起拿来使用。进行垃圾回收时,将所有存活的对象复制被到保留的那块Survivor的空间上,然后将Eden和之前使用的Survivor的空间清理掉。两块Survivor交替着与Eden一起使用。

原理图:

初始的新生代中的Eden、s0、s1

JVM垃圾回收算法JVM如何判断对象"已死"?垃圾回收算法

 GC前新生代中的Eden、s0、s1

JVM垃圾回收算法JVM如何判断对象"已死"?垃圾回收算法

GC后新生代中的Eden、s0、s1

JVM垃圾回收算法JVM如何判断对象"已死"?垃圾回收算法

 IBM的一项研究表明,新生代中98%的对象是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存划分为一块较大的名为Eden的空间,两块较小的名为Survivor的空间。HotSpot虚拟机默认Eden和Survivor的大小比例为8:1:1。

大多数情况下,只有2%的对象存活,一块Survivor(占据10%内存)完全够用,但是超过10%的对象存活的情况还是存在的,届时一块Survivor的空间就不够用了,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

如果保留的Survivor没有足够的空间存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代。

标记-整理 算法

标记-整理算法又名标记-压缩 算法

对于新生代,大部分对象都不会存活,所以在新生代中使用复制算法较为高效,而对于老年代来讲,大部分对象可能会继续存活下去,如果此时还是利用复制算法,效率则会降低。标记-压缩算法首先还是先“标记”,标记过后,将不用回收的内存对象压缩到内存一端,此时即可直接清除边界处的内存,这样就能避免复制算法带来的效率问题,同时也能避免内存碎片化的问题。老年代的垃圾回收称为“Major GC”。

原理图示:

JVM垃圾回收算法JVM如何判断对象"已死"?垃圾回收算法