java中的gc即垃圾处理器(Gargage Collection),是java和C++/C的主要区别之一。对于C开发人员来说内存是自己分配的,同时还要对内存进行维护和释放。然而对于java程序员来说,内存是由JVM自动分配的,同时垃圾的回收是由gc自动进行回收的,不太容易出现内存溢出和内存泄露的问题。
gc是用来回收垃圾的它主要对java堆进行回收,也是就是回收对象。那么我们很容易想到几个问题:什么样的对象才能被认为可以回收?gc是通过什么样的方式进行回收的?gc是什么时候进行回收的?我们也主要针对这几个问题进行阐述。
这个内存分配和上一篇的内存分配有点不同,这里的内存分配指的是堆上的分配,也可以说是对象的内存分配。根据分代算法一般将堆分为新生代和老年代。新生代又进一步分为Eden区和Survivor区,其中Survivor区也称为FromSpace区和ToSpace区,JVM对两个区的默认分配是8:1:1,Eden在新生代中占用相当大的一部分。先介绍两个概念Minor GC和Full GC:
Minor GC(新生代GC):指发生在新生代的垃圾回收,因新生代上的对象大多存活时间短,所以Minor GC发生得特别频繁。
Major GC/Full GC(老年代GC):指发生在老年代的垃圾回收。出现一次Major GC,经常会伴随至少一次Minor GC。Major GC的速度一般会比Minor GC慢10倍。
1、对象优先在Eden分配
大多数情况下,对象在新生代的Eden区上分配。当Eden没有足够空间进行分配时,就再发起一次Minor GC。
2、大对象直接进入老年代
大对象即需要大量连续存储空间的对象。
3、长期存活的对象将进入老年代
虚拟机给每个对象都定义了一个年龄(Age)计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设定为1。对象在Survivor中没经过一次Minor GC,年龄就增加1岁,当年龄增加到一定程度的时候(默认15岁),就会晋升到老年代。
4、动态对象年龄判定
为了能更好地适应不同程序地内存状况,虚拟机并不是永远地要求对象地年龄必须到达某个设定的值才能进入老年代。如果在Survivor空间中相同年龄的所有对象大小大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。
当我们判定是否回收一个对象时,最简单的想法就是“活”着的对象不回收,“死”了的对象回收。判定策略主要有两个:
1、引用计数算法
给对象添加一个引用计数器,每当有一个地方引用它的时候,计数器就加1,当引用失效时计数器减1,若计数器的值为0的话,就认为该对象已死,是可以被回收的对象。计数器初始化为1。
引用计数算法易实现,同时判定效率很高。但主流的Java虚拟机并没有选用这种算法,原因在于它没有办法处理循环引用的情况,举个例子:
public Class A{
B test;
}
public Class B{
A test;
public static void main(String[] args){
A a = new A();
B b = new B();
a.test = b;
b.test = a;
a = null;
b = null;
System.gc();
}
}
考虑上面这段代码在用引用计数算法的时候是否能被回收。答案当然是不行的,将a和b都赋为null前,a和b相互引用,a,b对象的引用计数器都为2。
当将a和b赋为null后,即栈中的引用类型不再指向堆中的对象,但因它们彼此相互引用,a和b的引用计数器为1,不会为0,也就永远不会被清理掉。
2、可达性分析算法
在主流的商用程序语言的主流实现中,都是通过可达性分析来判定对象的存活情况。这个算法的基本思路是一个树形结构,以一个称为GC Roots的对象作为根节点,由该根节点对整个数进行搜索,走过的路径称为引用链(Reference Chain),当一个对象没有引用链的时候就认为该对象已经死亡,可以被回收。
大家一定会有疑问,可以作为GC Roots的对象有哪些呢?
1、虚拟机栈中引用的对象。
2、方法区中类静态属性引用的对象。
3、方法区中常量引用的对象。
4、本地方法栈中JNI(Native方法)引用的对象。
关于引用
上面两种策略判定对象存活都是与引用有关,那到底什么是引用呢?引用《深入理解Java虚拟机》中的话:
“在JDK1.2以前,Java中引用的定义很传统:如果引用类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。这种定义很纯粹,但是太过狭隘,一个对象在这种定义下只有引用和被引用两种状态。但事实上我们希望能描述这样一类对象:当内存空间还足够时,则能保存在内存中;而内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。”
在JDK1.2以后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用。
强引用:类似“Object o = new Object()”这种在局部变量表中的引用类型就是强引用,它可以作为GC Roots,只要它存在,它所指向的对象就永远不可能被回收掉。
软引用:软引用是用来描述一些还有用但并非必须的对象。对于软引用关联的对象,在系统将要发生内存溢出之前,将会把这些对象列进回收范围内进行第二次回收。即软引用所指示的对象在一般情况下是不会被回收掉的,但当要发生内存溢出之前进行两次垃圾清理的时候,第二次清理会将软引用所指示的对象也进行回收来释放内存。只有当这次回收还没有足够的内存,才会抛出内存溢出异常。
弱引用:弱引用也是用以描述非必须的对象的。但相对于软引用更加弱,只要发生垃圾回收弱引用所只是的对象就会被回收掉。
虚引用:是最弱的一种引用关系。一个对象是否有需引用与它的生存时间没有关系,也不能通过虚引用定位对象。为对象设置虚引用的唯一目的就是在该对象被垃圾回收的时候收到一个系统通知。
3、是否可以拯救一下
在可达性分析算法中不可达的对象其实也不一定非死不可,它有拯救自己的机会。
要宣告一个对象为死亡对象,要经过两次标记过程:第一次是发现该对象到GC Roots没有引用链的时候标记一次并进行一次筛选,筛选条件是此对象是否有必要执行finalize()方法。当对象没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,这两种情况被视为没有必要执行。
如果对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程区执行它。但这个执行是指虚拟机会找机会触发这个方法,并不承诺会等待它运行结束,放止运行时间过长或进入死循环造成队列阻塞。finalize()方法是在垃圾回收之前执行的,是对象拯救自己的最后机会,稍后GC会对F-Queue中的对象进行第二次标记,如果要拯救自己只要在重写的finalize()中将自己与引用链上的一个对象相关联即可,那第二次标记后就不会被回收了。
public class FinalizeEscapeGC{
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive(){
System.out.println("yes,i am saved!");
}
//重写了finalize方法,因此虚拟机会判定为有必要执行finalize()方法
public void finalize() throws Throwable{
super.finalize();
SAVE_HOOK = this;
}
public static void main(String[] args){
SAVE_HOOK = new FinalizeEscapeGC();
SAVE_HOOK = null;
System.gc();
try {
Thread.sleep(500);//因finalize方法优先级很低,所以暂停0.5秒等待执行
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
if(SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}
else{
System.out.println("no,i am dead?");
}
SAVE_HOOK = null;
System.gc();
try {
Thread.sleep(500);//因finalize方法优先级很低,所以暂停0.5秒等待执行
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
if(SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}
else{
System.out.println("no,i am dead?");
}
}
}
运行结果:
我们可以看到上述代码重写了finalize()方法,将属性对象SAVE_HOOK赋为当前对象。从结果上来看,是拯救成功了。但有趣的是我们用完全一样的代码第二此执行的时候确拯救失败,这是因为finalize()方法只会被系统自动调用一次。
4、回收方法区
方法区(永久代)的垃圾收集效率较低,一般新生代一次垃圾收集能回收70%~95%的空间,而永久代比这个数字要低的多。一般永久代回收的是废弃常量和无用的类。
废弃常量的回收类似于对象的回收,只要没有对象对某常量有引用该常量就会被回收。以字面量为例,假如一个字符串"abc"进入常量池,而没有一个String对象叫做"abc"的,即没有String对象对"abc"有引用,其他地方也对"abc"没有引用的话,在发生垃圾回收的时候就有可能被清理掉。
而无用的类相对来说判断就要复杂多了,需同时满足三个条件:
1、该类的所有实例都已经被回收。
2、加载该类的ClassLoader已经被回收。
3、该类对应的java.lang.Class对象没有在任何地方被引用,无妨在任何地方通过反射访问该方法。
1、标记-清除算法
标记-清除算法是最基础的垃圾收集算法,分为标记和清除两个阶段。算法非常简单,就是对所有需要回收的对象进行标记,然后统一回收所有标记的对象。这种方法的缺陷有两个:一是效率问题,标记和清除两个过程效率都不高。二是很容易再清除后产生大量不联系的空间,当需要分配大对象的时候可能会没有空间进行分配,而迫使提前触发下一次回收。
2、复制算法
为了解决效率问题,复制算法便出现了。复制算法是先将内存对半分,每次只使用其中的一半,当这一半内存用完了,就将还存活着的对象复制到另外一半,然后将原本那一半内存清空。这种算法不用考虑内存锁片的问题,同时只要移动堆顶指针,按顺序分配内存。但缺陷也明显,相当于将内存缩小了一半,同时存活对象较多时,需要的复制操作会很多。
因为考虑到复制算法对内存的浪费,为了减小这种浪费,应该选择存活对象少,回收对象多的区域使用即新生代中使用。因为新生代在一般情况有98%的对象是会被回收,所以最后活下来的只是一小部分对象,这一小部分对象所占用的空间自然不用给很大。之前我们说过新生代分为Eden区和两个Survivor区,它们的占空间比例是8:1:1,在用复制算法的时候,使用的是Eden区和一个Survivor区,然后将存活的对象复制到另一个Survivor区来提高效率。当用于存储复制后的Survivor区不够用的时候需要依赖其他内存(老年区)进行分配担保。
3、标记-整理算法
我们都知道老年代的存活对象较多,每次垃圾收集的效率远低于新生代,同时没有额外空间作担保,所以如果用复制算法的话在时间和空间上的浪费都较多,因此根据老年代的特点,提出了另一种标记-整理算法。
标记过程与标记-清除算法一样,但后续过程不是让标记对象被清除,而是让存活对象向一端移动,然后直接清理掉端边界以外的内存。
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
该图取自《深入理解Java虚拟机》,展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。
Serial收集器:新生代收集器,单线程,它在垃圾收集的同时会暂停其他所有的工作线程(Stop The World)。但是它没有线程交互的开销,效率高。
ParNew收集器:新生代收集器,是Serial收集器的多线程版本,多个线程并行进行垃圾收集,但同样也会发生Stop The World。除了Serial收集器,ParNew收集器是唯一可与CMS配合的新生代收集器。
Parrallel Scavenge收集器:新生代收集器,多线程,相比其他收集器,它更关注吞吐量的控制,所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗的时间的比值(运行用户代码时间/(运行用户代码时间+垃圾收集时间))。高吞吐量可以高效利用CPU时间,适合后台运算而不需要太多交互的任务。
Serial Old收集器:是Serial收集器的老年代版本,单线程,会发生Stop The World。
Parallel Old收集器:是Parrallel Scavenge收集器的老年代版本,多线程。Parallel Old在多核计算中很有用。Parallel Old出现后(JDK 1.6),与Parallel Scavenge配合有很好的提高吞吐量效果。
CMS收集器:老年代收集器,多线程,是一种以获取最短回收停顿时间为目标的收集器,它几乎不会发生Stop The World。这里具体解释一下为什么会有回收停顿时间,即Stop The World消耗的时间:在进行可达性分析的时候,不可以出现对象引用关系还在不断变化的情况,有点像线程同步,如果这点不满足,会造称结果无法预测。所有在进行GC时必须暂停所有Java执行线程,使得整个执行过程看起来就像停止在了某个时间点上。CMS时基于标记-清除算法实现的,整个过程分为4步:初始标记、并发标记、重新标记、并发清除。
初始标记:会发生Stop The World,初始标记仅仅只是标记一下GC Roots可以直接关联到的对象,时间极短。
并发标记:需要标记出 GC roots 关联到的对象的引用对象有哪些,此阶段是和应用线程并发执行的,所谓并发收集器指的就是这个,主要作用是标记可达的对象,此阶段不需要用户停顿。
重新标记:这个阶段主要是为了修正并发标记期间因用户程序同时在运作让一部分对象标记产生变动的标记记录。这个阶段也会发生Stop The World,时间比初始标记稍微长一点,但并发标记短的多。
并发清除:该阶段进行并发的垃圾清除。清理完后就要为下一次gc重置相关数据结构。
CMS是一款非常优秀的收集器,但并不完美,也有它的缺陷:
1、CMS收集器对CPU资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但是因为占用一部分线程(或者说CPU资源)造成应用程序变慢,总吞吐量降低。
2、CMS是基于标记-清除算法的,之前我们说过这种算法会造成大量空间碎片的产生,会给大对象的分配带来麻烦,造成不得不提前触发Full GC(老年代GC)。为了解决这个问题,CMS收集器提供了一个开关参数(默认开启),用于CMS收集器顶不住要进行Full GC的时开启内存碎片的合并整理过程,此过程无法并发,会造成停顿时间变长。
3、无法处理浮动垃圾。所谓浮动垃圾:CMS在并发清理阶段用户线程还在运行,自然就会有新的垃圾产生,这部分垃圾出现在标记过后,只有等下一次收集后进行处理,这部分垃圾就叫浮动垃圾。
G1收集器:G1是一款面向服务端的垃圾收集器。实现并发和并行,同时能够独自管理整个GC堆,同时能建立可预测的停顿时间模型,目标是未来能替换CMS收集器。
主要参考书籍:
《深入理解Java虚拟机:JVM高级特效与最佳实现》,第三章。