前言
本笔记作为jvm学习系列的第五篇,讲解与垃圾对象相关的算法内容,如标记某个对象是否为“垃圾”的算法,还有垃圾回收算法。
说起GC,大部分人都把这项技术当做java的伴生产物。实际上,早在1960年的时候,MIT的Lisp就已经真正使用内存动态分配和垃圾回收技术。java、C#等其他语言是借鉴这种思想,毫不夸张的说是GC成就了这些语言。但其实这都是顺应了时代的发展而已,人总是朝着越来越来“懒”的方式去生活,技术也就朝着越来越“自动化“的方向去发展,看这几年如雨后春笋般涌现的持续交付,自动化部署,AI等都是自动化技术,这也就意味着,人工将慢慢被自动化技术所替代。所以,人,还是要居安思危啊!作为一个攻城狮,自动化技术不可不学。
垃圾回收标记算法
在前面的章节,我们知道了 jvm 总共分为五个区域,其中三个是线程独占区:程序计数器,虚拟机栈,本地方法栈,两个是线程共享区:堆,方法区。程序计数器、虚拟机栈、本地方法栈这 3 个区域是随线程而生而灭的,内存分配和回收都具备确定性。而堆和方法区则不一样,各线程共享,在运行时内存的分配与回收都是动态的,垃圾收集器所关注的是这部分内存。
堆和方法区主要存放各种类型的对象(方法区中也存储一些静态变量和全局常量等信息),那么 GC 对其进行回收的时候首先要考虑的就是 如何判断一个对象是否应该被回收 。也就是要判断一个对象是否还有其他的引用或关联,使得这个对象处于 存活 的状态。我们需要将不在存活状态的所有对象标记出,以便于 GC 进行回收。
判断对象是否存活有两种比较常见的方法:引用计数法与可达性分析算法。
引用计数法
引用计数法
的逻辑是:在堆中存储对象时,在对象头处维护一个counter计数器:
- 如果一个对象增加了一个引用与之相连,则将counter++;
- 如果一个引用关系失效则counter–-;
- 如果一个对象的counter变为0,则说明该对象已经被废弃,不处于存活状态。
引用计数法
的实现简单,判定效率也很高,也有一些著名的应用案例,如 微软的COM技术
、 ActionScript3的FlashPlayer
和 Python
等,但是 jvm 并不采用这种方式判断对象是否存活,其中最主要的原因是它很难解决对象之间循环引用的问题,并且开销较大,频繁且大量的引用变化,带来大量的额外运算。
比如说,一个对象 A 持有对象 B,而对象 B 也持有一个对象 A,那发生了类似操作系统中死锁的循环持有,这种情况下 A 与 B 的 counter 恒大于1,会使得 GC 永远无法回收这两个对象。
可达性分析法
可达性分析法
也称为 传递跟踪算法
。这个算法的基本思路,是通过一系列名为 GC Roots
的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链 (Reference Chain),当一个对象到GC Roots
没有任何引用链相连时,则证明此对象是不可用的。
哪些可以作为 GC Roots 呢?一般来说,如下情况的对象可以作为GC Roots:
- 虚拟机栈(栈桢中的本地变量表)中的引用的对象
- 方法区中的类静态属性引用的对象
- 方法区中的常量引用的对象
- 本地方法栈中 JNI (Native方法)的引用的对象
可达性分析算法更加精确和严谨,可以分析出循环数据结构相互引用的情况,但是实现比较复杂,需要分析大量数据,消耗大量时间,且分析过程需要 GC 停顿(引用关系不能发生变化),即停顿所有 java 执行线程(称为"Stop The World",全局停顿,所有 java 代码停止,native代码可以执行,但不能和 jvm 交互)。
垃圾回收算法
通过可达性分析算法,判断出对象是否为垃圾之后,接下来就是要回收这些垃圾对象所占用的空间了,所以这一章就是讲解常用的 垃圾回收算法
。
标记清除法
标记清除算法
是现代垃圾回收算法的思想基础。
标记清除算法将垃圾回收分为两个阶段: 标记阶段
和 清除阶段
。一种可行的实现是,在标记阶段,首先通过 GC Roots
,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象。然后,在清除阶段,清除所有未被标记的对象。
但是现在的垃圾收集器一般在使用中都不会用这种算法,因为这种算法会产生 空间碎片
,比如,在连续的内存空间中创建对象,然后垃圾回收完之后,剩下的空间可能是不连续的,如果有大对象要创建,发现没有连续的空间去放得下该对象,此时可能会重新进行垃圾回收,影响性能。
复制算法
与标记清除算法相比,复制算法
是一种相对高效的回收方法。
复制算法的将原有的内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。
这样就使得每次都是对整个半区进行内存回收,内存分配时也不用考虑空间碎片问题,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。但是这样的话,可用空间就缩小成原来的一半。
现在的商业虚拟机都采用了这种算法去收集新生代的内存。但是并不是用一半的空间留作复制用,因为大部分的对象都是“朝生夕死”的,实际的应用中并不会有这么多的对象存活。
在jvm第三篇笔记里面,讲堆的时候就有说过,堆内存会分代成新生代与老年代,然后在新生代里面,又会分成 Eden(占比80%) 与 两块 Surivivor(各占10%)。
对象创建时,一般会在新生代中创建,然后经过多次GC以后还存活的对象会晋升到老年代。关于对象的分配原则后面会讲到,这里是为了介绍新生代与老年代的区别,就简单介绍一下。
IBM公司的专门研究表明,新生代中的对象 98%
是 “朝生夕死” 的,所以在新生代使用复制算法的时候,也并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden空间
和两块较小的Survivor空间
,当需要在新生代中创建对象时,就使用 Eden 和其中一块 Survivor 。当回收时,将使用的 Eden 和Survivor 中还存活着的对象一次性地复制到另外一块 Survivor空间 上,最后清理掉 Eden 和 刚才用过的 Survivor空间。HotSpot虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。
当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖老年代内存进行分配担保(Handle Promotion)。
内存的分配担保就好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了。内存的分配担保也一样,如果另外一块Survivor空间没有足够的空间存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代。
复制算法适用于回收效率很高的新生代区域。
标记压缩算法
标记压缩算法
在标记清除算法的基础上做了一些优化。和标记清除算法一样,标记压缩算法也首先需要从 GC Roots
开始,对所有可达对象做一次标记。但之后,它并不简单的清理未标记的对象,而是将所有的存活对象压缩到内存的一端。最后,清理边界外所有的空间。
此算法是针对回收效率不高的老年代区域。
分代收集算法
分代收集算法
其实就是根据对象存活周期的不同,将内存划分为几块,然后分而治之。一般是把 java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
在 新生代
中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用 复制算法
,只需要付出少量存活对象的复制成本就可以完成收集。而 老年代
中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用 标记清除
或者 标记压缩
算法来进行回收。