3.1 概述
- 为什么要考虑GC和内存分配?
虽然目前内存的动态分配和内存回收技术已经相当成熟,一切看起来都进入了“自动化”时代。但当需要排查各种内存溢出,内存泄漏问题时,以及垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要去对这些“自动化”技术进行必要的监控和调节了。
- 哪些内存需要回收?
我们知道Java内存运行时区域分为方法区,堆,虚拟机栈,本地方法栈和程序计数器五部分。其中虚拟机栈,本地方法栈和程序计数器这3个区域随线程而生,随线程而灭,因此对这些区域的内存分配和回收都是确定的。而Java堆和方法区就不同,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也不一样,我们只有在程序运行期间才能知道会创建哪些对象,这部分的内存和回收都是动态的。因此,垃圾收集器所关注的部分在Java堆和方法区。
3.2 对象已死吗
在Java堆中存放着几乎所有的对象实例,垃圾收集器在堆中进行回收前,需要确定哪些对象是“存活”着,哪些对象是已经”死去“的。
判断对象是否存活的两种算法
- 引用计数算法
给对象中添加一个引用计数器,每当一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
评价:引用计数算法简单高效,但它存在一个致命的问题:很难解决对象之间相互引用的问题。
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024 * 1024;
//这个变量的唯一意义是占用一点内存,以便能在GC日志中查看是否被回收过
private byte[] bigSize = new byte[2 * _1MB];
public static void main(String[] args){
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
//进行对象之间的相互引用
objA.instance = objB;
objB.instance = objA;
//设置为null
objA = null;
objB = null;
System.gc();
}
}
如上,ObjectA和ObjectB之间相互引用着,导致两个对象的引用计数值都不为0,因此,无法通知GC收集器来回收它们。
- 可达性分析算法
通过一系列的”GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为“引用链”。当一个对象到GC Roots没有任何引用链相连时,则证明该对象是不可用的。
如上图,其中Object1-4是不可回收的,而Object5-7是可回收的,因为它们到GC Root是不可达的。
GC Root的对象:
- 虚拟机栈中引用的对象
- 方法区中类静态变量属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中引用的对象
3.2.3 再谈引用
在传统意义上,如果reference类型的数据中存储的数值代表是另一块内存的起始地址,就称为这块内存代表着一个引用。但在实际应用中,我们希望能描述这样一类对象:当内存空间足够时,则能保留在内存之中;如果内存空间在进行垃圾收集之后还是非常紧张,则可以抛弃这类对象。
引用的扩充
- 强引用:类似
Object obj = new Object()
这类引用,只要强引用还存在,垃圾收集器永远不会回收被引用的对象。 - 软引用:描述一些有用但并非必需的对象。对于软引用对象,在系统在发生内存溢出之前会将这些对象列入范围之内进行第二次回收。典型例子为SoftReference类。
- 弱引用:描述一些非必要对象。当垃圾收集器工作时,无论内存是否足够,都会回收被弱引用关联的对象。典型的例子为WeakReference类。
- 虚引用:为一个对象设置虚引用的唯一目的就是能在这个对象被收集器回收前收到一个系统通知。
3.2.4 对象的死亡
若一个对象在可达性算法中为不可达对象,该对象也并非被直接宣布”死亡“,而只是处于”缓刑“阶段。因为要真正宣告一个对象的死亡,至少要经历两次标记过程。
两次标记过程:
a. 第一次标记:若对象在进行可达性算法分析后发现没有与GC Root相连接的引用链,那么它将会被第一次标记。
b. 第二次标记:针对的是第一次标记的对象,若对象”没有必要执行”finalize()方法,那么将会进行第二次标记,若“有必要执行”finalize()方法,那么对象还有存活的机会,只要重新与引用链上的任何一个对象进行关联,就可以避免第二次标记。“没有必要执行”:表示的是对象没有覆盖finalize方法或者finalize方法已经被虚拟机调用过。
“有必要执行”:表示的是对象将会放置在一个叫做F-Queue队列中,并由一个虚拟机自动建立的,低优先级的Finalizer线程去执行它。要注意的是这里所谓的“执行”指的是虚拟机会触发这个方法,但不一定会等到它执行完毕。因为如果一个对象在finalizer方法中执行得很缓慢,或者发生了死循环,那么将会导致F-Queue队列中其他对象一直处于等待状态,甚至导致GC系统奔溃。
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive(){
System.out.println("yes, i'm still alive");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
//与GC Root引用链进行关联
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
//对象进行第一次自救
SAVE_HOOK = null;
System.gc();
//因为finalizer方法优先级很低,因此暂停一会等待它
Thread.sleep(500);
if (SAVE_HOOK != null){
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i'm dead.");
}
//进行第二次自救
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if (SAVE_HOOK != null){
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i'm dead.");
}
}
}
输出结果:
finalize method executed!
yes, i'm still alive
no, i'm dead.
我们可以看到同样的自救方法,第一次是成功的,而第二次却失败了。这是因为任何一个对象的finalizer方法只会被系统自动调用一次。
3.2.5 回收方法区
方法区(永久代)的垃圾收集主要回收废弃常量和无用的类。
判断一个常量是否废弃是非常简单的事,而对于判断一个类是否为“无用的类”的条件则相对苛刻,必须要满足以下三个条件:
- 该类所有的实例都已经被回收,即Java堆中不存在该类的实例对象
- 加载该类的ClassLoader已经被回收
- 该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
3.3 垃圾收集算法
3.3.1 标记-清除算法
首先标记所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
- 评价:存在两个不足之处
a. 效率问题,标记和清除两个过程的效率都不高。
b. 空间问题,标记清除后会产生大量不连续的内存碎片,空间碎片太多可能导致以后在程序运行过程中需要分配较大的对象时,无法找到足够连续内存而不得不提前触发另一次垃圾回收。
3.3.2 复制算法
它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另一块上,然后再把已使用的内存空间一次性清理掉。
评价
优点:每次都是对半区回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配即可。实现简单,高效。
缺点:将内存缩小为原来的一半,代价太高。针对”新生代“的复制算法:研究表明,新生代的对象98%都是”朝生夕死“的,因此并不需要按照1:1的比例划分内存空间。因此,将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活的对象一次性复制到另一块Survivor中,最后清理Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例为8:1。但要注意的是,并非每次回收存活的对象都能够在Survivor中存放,当Survivor空间不足时,需要依赖其他内存(这里指老年代)进行分配担保。
3.3.3 标记-整理算法
复制收集算法在对象存活率较高时就需要进行较多的复制操作,效率将会变低。因此,在老年代一般不会选用复制算法。根据老年代的特点,提出了”标记-整理“算法,标记过程和”标记-清除“算法一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理端边界以外的内存。
3.3.4 分代收集算法
一般将Java堆分为新生代和老年代。在新生代中,每次垃圾收集都会有大量的对象死去,只有少量的存活,因此选用复制算法。在老年代中,对象存活率高,没有额外空间对它进行分配担保,就必须使用”标记-清除“和”标记-整理“算法。