什么是垃圾回收?
垃圾回收实际上就是一种自动内存管理机制,它的核心工作是回收程序中不再使用的对象所占用的内存,防止内存泄漏,减轻程序员手动管理内存的负担。
垃圾回收机制需要考虑三个问题:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
回顾JVM内存的几个区域:
-
线程私有:
- 程序计数器
- 虚拟机栈
- 本地方法栈
-
线程共有:
- 堆
- 方法区
对于线程私有的区域,它们随着线程而生,随着线程而灭,栈中的栈帧随着方法的进入和退出有条不紊地执行着出栈和入栈操作,而每一个栈帧分配的大小在类的结构上就确定下来,因此这些区域不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。
对于堆和方法区,就有着很明显的不确定性,这部分内存的分配和回收是动态的,因此垃圾收集器所关注的正是这部分内存该如何管理。
死亡的对象
堆的回收
在堆中存放着几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事就是要确定这些对象之中哪些对象“死”了。
引用计数法
一种教科书方法是:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器就加一,当引用失效时,计数器值就减一,任何时刻计数器为零的对象就是不可 能再被使用的。
但是在主流的JVM中,并没有使用引用计数法来管理内存,因此有很多例外需要考虑:当两个对象循环引用的时候,而再没有别的对象引用它们,就会导致这两个对象永远无法释放,当然这可以通过环来判断,但是这又需要额外的工作。
可达性分析算法
主流的商用语言,都是通过可达性分析算法来判定对象是否存活的。
这个算法的基本思路就是通过一系列称为GC Roots的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为引用链,如果某个对象到GC Roots间没有任何引用链相连, 或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
再谈引用
在JDK 1.2之前,Java中的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。
在这种定义之下,对象只有引用和被引用两种状态,对于一些中间状态没法描述:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象——很多系统的缓存功能都符合这样的应用场景。
因此之后java对引用的概念进行了扩充:
- 强引用:传统的引用定义,只要强引用还存在,垃圾收集器永远不会回收它
- 软引用:描述一些目前还有用,但是非必须的对象。在系统即将发送内存溢出前,会将这些对象回收。
- 弱引用:描述一些非必须的对象,下一次垃圾收集会将其回收。
- 虚引用:一个对象是否有虚引用的 存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
这四种引用的强度逐渐减弱。
生存和死亡
即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。
什么是finalize()方法
finalize()是定义在java.lang.Object类中的一个 protected 方法。这意味着所有Java对象都继承了这个方法。 它的原始设计目的是:在垃圾回收器(Garbage Collector, GC)确定这个对象没有任何引用(即成为垃圾)之后,但在实际回收其内存空间之前,给对象一个“最后的机会”来释放其占用的非Java资源(如文件句柄、Socket连接、数据库连接、本地方法分配的内存等)。资深游戏老玩家应该知道这个就是“亡语”,现在已经不怎么用了,因为问题太大了。
假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用 过,那么虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定需要执行finalize()方法,那么这个对象就会被放置在一个名为F-Queue的队列之中,之后会有一个由虚拟机创建的、低调度优先级的Finalizer线程去执行它们的finalize()方法。但不一定会等待它允许完毕。
finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK=null;
public void isAlive(){
System.out.println("仍然存活");
}
/**
* 在对象的finalize方法中,将自己赋给静态变量,使得收集器对F-Queue的对象进行第二次标记的时候逃脱
* @throws Throwable
*/
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize方法执行");
//将自身赋给SAVE_HOOK静态变量
FinalizeEscapeGC.SAVE_HOOK=this;
}
public static void main(String[]args) throws InterruptedException {
//先创建一个对象
SAVE_HOOK=new FinalizeEscapeGC();
//消除对这个对象的引用
SAVE_HOOK=null;
//触发gc,gc第一次标记这个对象,对象被放入F-Queue中,等待Finalizer线程启动它的finalize方法
System.gc();
TimeUnit.SECONDS.sleep(1);
if(SAVE_HOOK!=null){
//结局,对象仍然存在
SAVE_HOOK.isAlive();
}else {
System.out.println("对象已经死亡");
}
//第二次消除对象的引用
SAVE_HOOK=null;
//再次触发gc,收集器第一次标记对象,判定该对象已经执行过一次finalize,再第二次标记对象的时候移除回收它
System.gc();
TimeUnit.SECONDS.sleep(1);
if(SAVE_HOOK!=null){
SAVE_HOOK.isAlive();
}else {
//这次对象没有再执行finalize方法来挽救自己
System.out.println("对象已经死亡");
}
}
}
方法区的回收
在方法区进行垃圾回收的性价比很低:在Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收70%至99%的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。
方法区的垃圾回收主要回收两个部分:
- 废弃常量
- 不再使用的类型
回收常量和Java堆中的对象非常类似,比如说:
假如一个字符串java曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是java,换句话说,已经没有任何字符串对象引用常量池中的java常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个java常量就将会被系统清理出常量池。常量池中其他类(接 口)、方法、字段的符号引用也与此类似。
但是回收类型的条件就非常苛刻:
- 该类的所有实例都已经被回收,堆中不存在该类和其派生子类的实例。
- 加载该类的类加载器已经回收,除非用了其他替换的类加载器,否则很难达成。
- 该类的
Class对象没有被引用
JVM被允许对满足这三个条件的无用类进行回收。
垃圾回收算法
从如何判定对象消亡的角度,垃圾回收算法被划分为“引用计数式垃圾回收”和“追踪式垃圾回收”两大类。主流的JVM都是后者,因此下面讨论“追踪式垃圾回收”。
分代收集理论
目前的虚拟机都是遵循了“分代收集”的经验法则:
- 弱分代假说:绝大多数对象都是朝生夕灭的
- 强分代假说:熬过越多次垃圾回收的对象越是难以消灭
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间。
如果剩下的都是难以消亡的对象,那把它们集中放在一块, 虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
JVM的堆一般都会被设计至少划分为“新生代”和“老年代”,但是有一个问题:对象不是孤立的,对象之间会存在跨代引用。
假如要现在进行一次只局限于新生代区域内的收集,但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:
- 跨代引用假说:跨代引用相对于通同代引用来说占比极少
这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。
因此不应该为了少量的跨代引用去扫描整个老年代,只需要在新生代上建立一个全局的数据结构(记忆集)。这个结构把老年代划分为若干小块,标识出老年区的哪一块内存会存在跨代引用。这样,当新时代进行回收的时候,只需要把包含跨代引用的老年区的小块对象加入GC roots中扫描就绪。这种方法需要在对象改变引用关系时维护记录数据的正确性,增加一些运行时开销。
标记-清除算法
最早最基础的算法是“标记-清除算法”,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
它有两个缺点:
- 执行效率不稳定,标记和清除两个过程的执行效率都随对象数量增长而降低。
- 内存空间碎片化,标记清除之后会产生大量不连续的内存碎片。后续分配大对象的时候,可能没有足够的连续空间,导致另一次
GC
标记-复制算法
标记-复制算法将内存划分为两个大小一样的内存,每次只使用其中的一块,当这块内存用完了,将存活的对象复制到另一半上,然后清理这一块内存。
因为大部分对象都是可回收的,因此复制的对象仅仅只是少部分存活对象,因此执行效率高,同时每次都是针对半区进行内存回收,也不用考虑空间碎片。
但是缺点是可用内存缩小为原本的一半,空间浪费太多。
由上述的特点可以知道,这种方法特别适合去回收新生代,而新生代中做过研究,98%的对象熬不过第一轮收集,因此没有必要按照1:1的比例来划分新生代的空间。
因此有另一种更优化的算法“Appel式回收”,Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的 Survivor空间,每次分配内存只使用Eden和其中一块Survivor。
在发生垃圾收集的时候,将Eden区和Survivor区仍然存活的对象一次性复制到另一块Survivor区,然后清理掉Eden和用过的那块Survivor区。
HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。
但是没有任何人可以保证,一轮GC过后,存活的对象不会将Survivor区占满,甚至溢出,因此Appel式回收当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就会依赖其他区域(老年代)进行分配担保:如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。
标记-整理算法
标记-复制在对象存活数量较多的成本很高,因此它只适合于新生代,不适合于老年代。
根据老年代的存亡特点,另外有一个算法,即标记-整理算法,其中标记过程和标记清除算法一样,然后让所有的存活对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
但是是否移动存活的对象是一项优缺点并存的风险决策:
- 如果移动存活对象,尤其是老年代这种存活对象十分多的区域,移动对象并更新所有的引用是一种十分繁重的任务,并且需要全程暂停用户应用程序才能进行。
- 如果不移动存活对象,那么就会导致空间碎片化,导致内存分配复杂。
但是从整个程序的吞吐量来看,移动对象会更加划算。HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的。