垃圾回收简介
垃圾回收英文全称为Garbage Collection,简称GC。Java进程在启动后会创建垃圾回收线程,来对内存中无用的对象进行回收,释放内存空间。
垃圾回收的区域
Java运行时内存区域主要划分为:程序计数器,Java虚拟机栈,本地方法栈,方法区和堆五部分。
其中程序计数器、Java虚拟机栈、本地方法栈随线程的生命周期或生或灭,栈中的栈帧也随着方法的进入和退出有条不紊地执行着出栈和入栈操作,在方法结束或者线程结束时,内存自然就跟随着回收了。
所以垃圾回收的主要区域为方法区和堆。
而在方法区(1.7为永久代,1.8为元空间)的垃圾收集主要针对:废弃常量和无用的类。然后此区域进行垃圾收集的“性价比”一般比较低。
所以Java堆才是垃圾收集器管理的主要区域,因此很多时候也被称做 “GC堆”。
如何识别垃圾
Object o = new Object();
o = null;
观察上面代码可以发现,我们先创建了一个对象,并让o指向这个对象,然后再把null赋给o。
这样做会发生一个什么事情呢?虽然堆中有一个new 出来的Object对象,但是已经没有引用指向它了。此时再也无法通过任何变量去调用这个对象,所以这个对象继续占用着内存就是白白浪费资源,这种对象就是需要被回收的对象。
那么如何让jvm来判断一个对象该不该被回收呢,一般有下面两种算法:
1、引用计数算法(Reference Counting)
这个算法就是给每个对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的时候,这个对象就是不可能再被使用的。
引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法。比如,Python、ActionScript等语言使用的都是引用计数法。
但是这种算法它很难解决对象之间相互循环引用的问题,所以Java中的垃圾回收器基本上不使用这个算法。
2、可达性分析算法
通过一系列的称为“GC Roots”(所谓“GC roots”,或者说tracing GC的“根集合”,就是一组必须活跃的引用)的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为GC Roots引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。
上图中Obj 5,Obj6,Obj 7虽然相互引用,但是由于他们到GC Roots都不可达,因此会被判定为可回收的对象。
此算法解决了对象间循环引用的问题,所以Java、C#等语言都是使用可达性分析算法进行垃圾回收。
常见的垃圾回收算法
常见的垃圾回收算法主要有:标记—清除算法、复制算法、标记—整理算法这三种。
1、标记-清除算法(Mark-Sweep算法)
标记-清除算法是最基础的收集算法。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。
这个算法如同它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。如下图:
从上图可以发现这个算法有两个的问题:
- 一是效率较低,标记和清除这两个步骤的效率都比较低。清除的效率低是因为需要扫描整个内存空间,逐个释放对象所占内存;
- 二是空间问题,标记清除之后会产生大量不连续的内存碎片。空间碎片太多可能会导致在后面程序运行过程中,需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2、复制算法(Copying算法)
由于标记-清除算法效率较低以及产生内存碎片的问题,于是就产生了这个新的算法——复制算法。
这种算法原理就是将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块保留区上面,然后再把已使用过的内存空间一次清理掉。这样一来,仍然存活的对象被放进保留区,而垃圾对象也被释放了。同时,之前被使用的空间被清空后,成了新的保留区,而之前的保留区成了被使用的空间,就这样不断循环使用两个空间。
复制算法每次都是对整个半区进行内存回收,直接释放被使用的空间的全部内存,比一段一段释放的效率要高很多。同时,对象被复制到另外一个区域时,只要移动堆顶指针,按顺序分配内存对象即可被整齐地摆放,所以不会出现内存碎片。实现简单,运行高效。所以,复制算法的效率要远远高于标记—清除算法。
但是这诸多好处的代价就是每次都有一半的空间无法被使用,使得空间利用率很低。所以我们在实际应用复制算法时会对其进行改进。
3、标记—整理算法(Mark-Compact算法)
标记-整理算法的标记过程仍与"标记-清除"算法一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,直至这些对象相互靠拢,整齐排列,然后直接清理掉这之外的全部内存。
这个算法的关键就是整理步骤,正是由于这一步骤,所以解决了内存碎片的问题。
分代回收算法
当前JVM垃圾收集都采用的都是"分代收集(Generational Collection)" 算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为新生代和老年代两部分。
1、新生代部分
新生代中98%的对象都是"朝生夕死"的,并不需要按照复制算法所要求1 : 1的比例来划分内存空间。所以在实际实现中是将新生代内存分为一块较大的Eden(伊甸园)区空间和两块较小的Survivor(幸存者)区,每次使用Eden区和其中的一块Survivor区。HotSpot默认Eden与Survivor的大小比例是8 : 1,也就是说Eden :Survivor0 : Survivor1 = 8 : 1 : 1。所以每次新生代可用内存空间为整个新生代容量的90%,只有10%的内存会被”浪费“。
由于在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法非常划算。
但是我们还得考虑一个小问题,就是如果在某次垃圾回收过后,仍然有大量的对象存活,此时一个另一个Survivor空间不够存放这些存活对象怎么办?
这时候就需要有另一个空间来做担保了,当这种情况发生时,会将这些对象放入另一个空间中,那个空间就叫做担保空间。以上算法是用在新生代中,所以这个所谓的担保空间,实际上就是老年代。老年代为这个算法提供了担保,在Survivor空间不够存放新生代垃圾回收后存活对象的时候,这些对象会直接进入老年代。但是在大部分情况下,Survivor区都是能够满足需求的。
2、老年代部分
老年代中对象存活率高、没有额外空间对它进行分配担保,所以就必须采用"标记-清理"或者"标记-整理"算法。
3、为什么复制算法只需要有一块保留区,而新生代中却要划分两个Survivor区呢?
因为在上面复制算法中,我们知道了必须要有一个保留区。假设现在就只有一个survivor区,那么我们可以想象一下过程:首先新建的对象会优先进入Eden区中,一旦Eden区满了,便会触发新生代垃圾回收,Eden区中的存活对象就会被移动到Survivor区。那么在下一次Eden区满了,再进行垃圾回收的时候,Eden区和Survivor区各有一些存活对象。回收后Survivor区也将产生许多内存碎片,此时如果将Eden区的存活对象硬放到Survivor区,将会导致更严重的内存碎片化问题。
但是如果建立两块Survivor区,问题便可以迎刃而解。还是上面的问题,第二次Eden区满了,触发新生代垃圾回收后,Eden区和S0中的存活对象复制送入 S1中。此时再将S0和Eden区清空,然后下一轮S0与S1交换角色。如此循环往复,便不会发生内存碎片化的问题。