1.概述
GC的历史比Java久远,1960年诞生于MIT的Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言。经过半个多世纪的发展,目前内存的动态分配与内存回收技术已经相当成熟,一切看起来都进入了“自动化”的时代,那么我们为什么还要去了解GC和内存分配呢?答案很简单:当我们需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。
我们知道JVM运行时数据区包括程序计数器、Java栈、本地方法栈、方法区和Java堆组成。
其中,程序计数器、Java栈和本地方法栈3个区域是线程私有的,随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈的操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的。因此,这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。
而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,故垃圾收集器GC所关注的就是这部分的内存。
2.对象存活判断
GC在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)。那么判断的方法是什么呢?
2.1 引用计数法
引用计数法: 给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器值为0的对象就是不可能再被使用的。
引用计数法实现简单,判定效率也很高。但是很多主流的JVM都没有选用引用计数法来管理内存,最主要的原因是它很难解决对象之间相互循环引用的问题。
2.2 可达性分析
可达性分析: 通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象就是不可用的。
如下图示,Object5、Object6和Object7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们会被判定为可回收的对象。
在Java语言中,可作为GC Roots的对象包括下面几种:
- 1.Java栈(栈帧中的本地变量表)中引用的对象
- 2.方法区中类静态属性引用的对象
- 3.方法区中常量引用的对象
- 4.本地方法栈中JNI(即一般说的Native方法)引用的对象
3.垃圾收集算法
这里介绍几种垃圾收集算法的思想。
3.1 标记-清除算法
最基础的垃圾收集算法是“标记-清除”算法,算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它的标记过程就是通过上述的对象存活判定算法来标记。
该算法的执行过程如下:
该方法之所以是最基础的算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。它的不足主要有两个:
- (1)效率问题,标记和清除两个过程的效率都不高;
- (2)空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
3.2 复制算法
为了解决效率问题,一种称为“复制”的收集算法实现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
这样使得每次都是对整个半区进行内存回收,内存分配时也不用考虑内存碎片等复杂情况,实现简单,运行高效。但是,这种算法的代价是将内存缩小为原来的一半,未免太高了一点。
复制算法的执行过程如下:
现在的商用虚拟机都是采用这种收集算法来回收新生代。IBM公司的专门研究表明,新生代的对象98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。 HotSpot虚拟机默认Eden和两个Survivor的大小比例为:8:1:1,也就是说每次新生代中可用内存为整个新生代的90%,只有10%会被浪费。
但是,我们没有办法保证每次回收都只有不多于10%的对象存活,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。
3.3 标记-整理算法
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了另外一种“标记-整理”算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
标记-整理算法的示意图如下:
3.4 分代收集算法
当前商用虚拟机的垃圾收集都采用分代收集算法,分代收集算法其实就是对前面三种算法的综合运用。
根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
而老年代中,因为对象存活率高、没有额外的空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。
4.HotSpot虚拟机的GC策略
4.1 GC分代划分
JVM内存模型中Java堆区分两大块,一块是新生代(Young Generation),另一块是老年代(Old Generation);另外习惯上将方法区称为永久代。如下图示:
虽然我们常说的GC是针对Java堆区,但是永久代(也就是方法区)也是存在垃圾回收的。永久代的垃圾回收主要回收两部分内容:废弃常量和无用的类。
4.2 Minor GC
大多数情况下,对象在新生代Eden区分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
这里说一下Minor GC 和 Full GC的区别:
1.新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
2.老年代GC(Major GC / Full GC):指发生在老年代的GC。通常出现Full GC的时候,会伴随至少一次Minor GC。也就是说Full GC是对整个Java堆的收集。Full GC的速度一般会比Minor GC慢10倍以上。
我们知道新生代由一个Eden区,和两个Survivor区组成,所有对象在初始创建时都会分配在新生代中,并且优先在Eden区进行分配。对象在这三个区域的存活过程如下:
- 1.大多数新生对象都被分配在Eden区
- 2.第一次GC过后Eden中还存活的对象被移到其中一个Survivor区
- 3.再次GC过程中,Eden中还存活的对象会被移到之前已移入对象的Survivor区
- 4.一旦该Survivor区域无空间可用时,还存活的对象会从当前Survivor区移到另一个空的Survivor区。而当前Survivor区就会再次置为空状态
- 5.经过数次在两个Survivor区域移动后还存活的对象最后会被移动到老年代
如上所述,两个Survivor区域在任何时候必定有一个保持空白。参考上述讲到的“复制”算法,不难理解这样做的目的。
下图展示了经过Minor GC把数据迁移到老年代的过程:
4.3 Full GC
当准备要触发一次Minor GC时,如果发现统计数据说之前Minor GC的平均晋升大小比目前老年代剩余的空间大,则不会触发Minor GC而是转为触发Full GC。Full GC会清理整个堆空间。
这里所说的晋升是指一个对象从新生代转存到老年代的过程。
4.4 对象的晋升
一个对象最初是在新生代进行分配的,那么它是怎么进入老年代的呢?
(1)大对象直接进入老年代
因为新生代采用的是标记-复制策略,在Eden中分配大对象将会导致Eden区和两个Survivor区之间大量的内存拷贝。所以虚拟机可以设置-XX:PretenureSizeThreshold参数,大于这个参数值的对象将直接分配到
老年代中。
(2)长期存活的对象将进入老年代
对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁)时,就会晋升到老年代中。