本篇内容包括:JAVA 垃圾回收机制概述、有哪些内存需要回收、如何回收(标记-清除、标记-整理(标记-清除-压缩)、复制(标记-复制-清除)、分代收集等算法) 以及 何时进行垃圾回收等内容!
一、概述
Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的"高墙",墙外面的人想进去,墙里面的人却想出来
说起垃圾收集(Garbage Collection,下文简称GC),有不少人把这项技术当作Java语言的伴生产物。事实上,垃圾收集的历史远远比Java久远,在1960年诞生于麻省理工学院的Lisp是第一门开始使用内存动态分配和垃圾收集技术的语言。
当 Lisp 还在胚胎时期是,其作者 John McCarthy 就思考过垃圾收集器需要完成的三件事情:
- 有哪些内存需要回收?
- 什么时候回收?
- 如何回收?
二、有哪些内存需要回收?——对象已死?
在堆里面存放着 Java 世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径 使用的对象)。
1、引用计数算法
引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象实例都有一个引用计数。当一个对象被创建时,就将该对象实例分配给一个变量,该变量计数设置为 1。当任何其它变量被赋值为这个对象的引用时,计数加 1(a = b,则b引用的对象实例的计数器+1),但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减 1。任何引用计数器为 0 的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减 1。
优缺点:
-
优点:引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。
-
缺点:无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0。
2、可达性分析算法
可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点 GC ROOT 开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。
在Java语言中,可作为 GC Roots 的对象包括下面几种:
- 栈帧中的局部变量表中的 reference 引用所引用的对象;
- 方法区中 static 静态引用的对象;
- 方法区中 final 常量引用的对象;
- 本地方法栈中 JNI(Native方法)引用的对象;
- Java 虚拟机内部的引用, 如基本数据类型对应的 Class 对象, 一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError) 等, 还有系统类加载器;
- 所有被同步锁(synchronized关键字) 持有的对象;
- 反映 Java 虚拟机内部情况的 JMXBean、 JVMTI 中注册的回调、 本地代码缓存等。
这个算法的基本思路就是通过一系列的称为 GC Roots 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots 没有任何引用链相连(用图论的话来说,就是从GC Roots 到这个对象不可达)时,则证明此对象是不可用的。
三、如何回收?
在 JVM 中主要的垃圾收集算法有:标记-清除、标记-整理(标记-清除-压缩)、复制(标记-复制-清除)、分代收集等算法。这几种收集算法互相配合,针对不同的内存区域采取对应的收集算法实现(这里具体是由相应的垃圾收集器实现)。
垃圾回收涉及到大量的程序细节,而且各个平台的虚拟机操作内存的方式也不一样,但是他们进行垃圾回收的算法是通用的,所以这里我们也只介绍几种通用算法。
1、标记-清除算法
算法实现:分为标记-清除两个阶段,首先根据上面的根搜索算法标记出所有需要回收的对象,在标记完成后,然后在统一回收掉所有被标记的对象。
缺点:
- 效率低:标记和清除这两个过程的效率都不高。
- 容易产生内存碎片:因为内存的申请通常不是连续的,那么清除一些对象后,那么就会产生大量不连续的内存碎片,而碎片太多时,当有个大对象需要分配内存时,便会造成没有足够的连续内存分配而提前触发垃圾回收,甚至直接抛出 OutOfMemoryExecption。
2、复制算法
为了解决标记-清除算法的两个缺点,复制算法诞生了。
算法实现:将可用内存按容量划分为大小相等的两块区域,每次只使用其中一块,当这一块的内存用完了,就将还活着的对象复制到另一块区域上,然后再把已使用过的内存空间一次性清理掉。
优缺点:
-
优点:每次都是只对其中一块内存进行回收,不用考虑内存碎片的问题,而且分配内存时,只需要移动堆顶指针,按顺序进行分配即可,简单高效。
-
缺点:将内存分为两块,但是每次只能使用一块,也就是说,机器的一半内存是闲置的,这资源浪费有点严重。并且如果对象存活率较高,每次都需要复制大量的对象,效率也会变得很低。
3、标记-整理算法
上面我们说过复制算法会浪费一半的内存,并且对象存活率较高时,会有过多的复制操作,效率低下。如果对象存活率很高,基本上不会进行垃圾回收时,标记-整理算法诞生了。
算法实现:首先标记出所有存活的对象,然后让所有存活对象向一端进行移动,最后直接清理到端边界以外的内存。
局限性:只有对象存活率很高的情况下,使用该算法才会效率较高。
4、分代收集算法
当前商业虚拟机都是采用此算法,但是其实这不是什么新的算法,而是上面几种算法的合集。
算法实现:根据对象的存活周期不同将内存分为几块,然后不同的区域采用不同的回收算法。
对于 HotSpot 虚拟机,它将堆空间分为老年代和新生代两块区域
-
对于存活周期较短,每次都有大批对象死亡,只有少量存活的区域,采用复制算法,因为只需要付出少量存活对象的复制成本即可完成收集;
-
对于存活周期较长,没有额外空间进行分配担保的区域,采用标记-整理算法,或者标记-清除算法。
堆有新生代和老年代两块区域组成,而新生代区域又分为三个部分,分别是 Eden、From Surivor、To Survivor,比例是8:1:1。
新生代采用复制算法,每次使用一块 Eden 区和一块 Survivor 区,当进行垃圾回收时,将 Eden 和一块 Survivor 区域的所有存活对象复制到另一块 Survivor 区域,然后清理到刚存放对象的区域,依次循环。
老年代采用标记-清除或者标记-整理算法,根据使用的垃圾回收器来进行判断。
至于为什么要这样,这是由于内存分配的机制导致的,新生代存的基本上都是朝生夕死的对象,而老年代存放的都是存活率很高的对象。
四、何时进行垃圾回收
理清了什么是垃圾,怎么回收垃圾,最后一点就是Java虚拟机何时进行垃圾回收呢?
程序员可以调用 System.gc()
方法,手动回收,但是调用此方法表示希望进行一次垃圾回收。但是它不能保证垃圾回收一定会进行,而且具体什么时候进行是取决于具体的虚拟机的,不同的虚拟机有不同的对策。
其次虚拟机会自行根据当前内存大小,判断何时进行垃圾回收,比如前面所说的,新生代满了,新产生的对象无法分配内存时,便会触发垃圾回收机制。
这里需要说明的是宣告一个对象死亡,至少要经历两次标记,前面我们说过,如果对象与 GC Roots 不可达,那么此对象会被第一次标记并进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法,当对象没有覆盖 finalize() 方法,或者该方法已经执行了一次,那么虚拟机都将视为没有必要执行 finalize() 方法。
如果这个对象有必要执行 finalize() 方法,那么该对象将会被放置在一个有虚拟机自动建立、低优先级,名为 F-Queue 队列中,GC 会对 F-Queue进行第二次标记,如果对象在 finalize() 方法中成功拯救了自己(比如重新与GC Roots建立连接),那么第二次标记时,就会将该对象移除即将回收的集合,否则就会被回收。