前言
垃圾收集器(GC)概念实际在Java语言出现之前就被提出来了。其主要解决的事情如下:
- 哪些内存需要回收?(如方法区可能不需要频繁回收)
- 什么时候回收?(牵扯到停止用户线程的事)
- 如何回收?
直至今天,GC技术已经相当成熟,按道理来说不需要关注这些东西,但是当系统变得复杂,存在这内存溢出和内存泄漏问题时,又或者GC成为系统高并发量的瓶颈时,就必须对“自动化”的技术进行监控和调节。
🌚我们知道,在Java的内存区域划分中,虚拟机栈
、本地方法栈
、程序计数器
是线程私有的,所以这里讨论的GC
不包括以上。但Java堆
和方法区
内存区域有着不确定性。即:对象实例的大小时不确定的,而且很有可能在运行期间动态改变(对应着堆的内存)。
对象已死
回到第一个问题:哪些内存需要回收?即“死去”的对象(不可能被任何途径使用的对象)。那么GC
第一步工作就是如何确定对象是否存活。
👉 常用判断方法有以下两种:
- 引用计数算法
- 可达性分析算法
下面分别看一下这两种算法的原理以及优缺点。
引用计数算法
原理:在对象中添加一个引用计数器,每当有一个地方引用它,计数器加1;当有引用失效时,计数器就减1。
优点如下:
- 原理简单
- 判定效率高
缺点是无法解决循环引用问题。举例如下:
public class ReferenceCountGC {
public ReferenceCountGC instance;
public static void main(String[] args) {
ReferenceCountGC objA = new ReferenceCountGC();
ReferenceCountGC objB = new ReferenceCountGC();
//循环引用
objA.instance = objB;
objB.instance = objA;
//两个对象的引用都置空
objA = null;
objB = null;
//发生GC
System.gc();
}
}
以上代码被执行后,objA
和objB
指向堆中的具体对象实例是没有被回收的。所以基于上述缺点,可能会导致意向不到的结果,所以在主流的虚拟机实现中都没有采用引用计数算法
。
可达性分析算法
原理:通过一系列GC ROOT
的根对象作为起始节点集(即多个GC ROOT
),从这些节点开始根据引用关系向下搜索,如果某个对象在搜索完成后都不可达(没有一条路径可以走到该对象)时,则证明该对象不可能再被使用了。
如下图所示,对象5、6、7虽然互相关联,但是不可达,所以这三个对象时可回收的。
可以作为GC ROOT
的对象包括以下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象,方法被调用时用到的
参数
、局部变量
、临时变量
等 - 方法区中类静态属性引用的对象,比如类的引用类型的静态变量。
- 方法区中常量引用的对象,比如字符串常量池里的引用。
- 本地方法栈中JNI引用的对象
- 虚拟机内部的引用,比如基本类型对应的Class对象,一些异常对象或系统加载器。
- 所有被同步锁(synchronized)持有的对象。
除了固定的对象外,还可以有其他对象“临时性”的加入,共同构成GC ROOTs
集合。比如分代收集
和局部回收
。
引用
以上介绍的两种算法,判断对象存活时都和引用
有关。
随着发展,有这么一种需求:希望能描述一种对象,当内存空间还足够时,能够保存在内存之中,如果内存空间在进行垃圾收集后依旧紧张,那就可以抛弃这些对象。显然在JDK1.2之前是做不到的。
JDK1.2之后,Java
将引用分为强引用、软引用、弱引用和虚引用,这4种引用强度逐渐减弱。
- 强引用:任何情况下,只有有关联则对象不能被回收
- 软引用:系统发生内存溢出异常前,会把这些对象列进回收范围之中进行二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。使用
SoftReference
类实现软引用。 - 弱引用:只能生存到下一次垃圾收集发生为止。使用
WeakReference
实现弱引用。 - 虚引用:当一个对象为虚引用时,完全不会对其生成时间构成影响。唯一目的是在
GC
时能收到一个系统通知。使用PhantomReference
实现虚引用。
生存或者死亡
即使可达性分析算法判定为不可达的对象,也不是“非死不可”。真正宣告一个对象“死活”至少要经历两次标记过程:
- 可达性分析算法判定不可达,将会第一次标记该对象
- 筛选:如果该对象没有覆盖
finalize
方法,或者finalize
方法已经被虚拟机调用过,这两种情况都视为“没有必要执行”。
如果该对象被判定为有必要执行finalize
方法, 则会将该对象放入F-Queue
队列中,并有虚拟机去执行它们的finalize
方法。但请注意:虚拟机只是执行该方法,不一定等待该方法结束。执行后虚拟机则会将这些对象再次标记,该对象就彻底死亡了。
❓如何拯救自己?
只需要在finalize
方法中将this
和引用链上任意一个对象关联即可,这里请注意:❗️这样的拯救方式只会生效一次。
/**
* 此代码演示了两点:
* 1.对象可以在被GC时自我拯救。
* 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
*
* @author zzm
*/
public class Test {
public static Test SAVE_HOOK = null;
public void isAlive() {
System.out.println("yes,i am still alive:)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize mehtod executed!");
Test.SAVE_HOOK = this;
}
public static void main(String[] args) throws Throwable {
SAVE_HOOK = new Test();
//对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
//因为finalize方法优先级很低,所以暂停0.5秒以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no,i am dead:(");
}
//下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK = null;
System.gc();
//因为finalize方法优先级很低,所以暂停0.5秒以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no,i am dead:(");
}
}
}
执行结果:
finalize mehtod executed!
yes,i am still alive:)
no,i am dead:(
回收方法区
方法区相对于堆中新生代的回收性价比是比较低的,一般来说,主要回收两部分:废弃常量
和无用的类
。常量的回收和回收不可达对象类似,比较简单。但Class回收是比较麻烦的,需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
- 加载该类的ClassLoader已经被回收
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
在大量使用反射、动态代理、CGLib等ByteCode(字节码)框架、动态生成JSP这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
垃圾收集算法
本节介绍垃圾收集常用的算法原理,不会涉及到具体的实现细节。
标记-清除算法
标记清除算法相对来说比较简单,算法分为“标记”和“清除”两个阶段:①标记出所有需要回收的对象,②统一回收标记过的对象。
- 标记:从引用根结点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。
- 清除: 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。
具体执行过程如下图所示:
缺点
- 效率不高,标记和清除两个过程效率都不高。
- 标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
复制算法
复制算法的出现是为了解决标记-清除
效率低下的问题。原理是将内存分成两个空间,假设为A和B,对象一开始只在A控件进行分配,B空间处于空闲状态。GC时将存活的对象从A空间复制到B空间,之后将A空间清理,A空间则变成了刚一开始的B空间,而B空间则变成了一开始的A空间。
这种算法实现也简单,运行效率比标记-清除
要高很多。下图为算法执行过程:
缺点
- 可用内存缩小为原来的一半
- 如果内存中多数对象都是存活的,那么这种算法将产生大量的内存间复制的损耗。
使用
大多数Java虚拟机都采用此算法来回收新生代
,因为新生代
对象生命周期都比较短暂。
对于新生代而言,还会分为Eden空间和两块比较小的Survivor空间,每次分配内存只使用Eden和一块Survivor。当发发生GC,会将Eden和Survivor中存活的对象一次性复制到另外一块Survivor空间上,然后直接将Eden和使用过的Survivor清理掉。这样就可能造成一个问题,就是Eden中的存活对象比较多(当然这种情况少),那么在复制到另一块Survivor内存空间时,内存不够了,这个时候就需要其他内存区域(老年代)进行
分配担保
。关于分配担保后续会提到
标记-整理算法
这个算法标记过程和标记-清除
算法相同,但在标记完成后,不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,移动完成后,直接清理掉边界以外的所有内存。
分代收集
为什么会存在分代收集呢?文章前面提到过这类词汇,譬如:新生代
、老年代
、永久代
这种词语。其实他们都建立在两个分代假说之上:
- 弱分代假说:绝大多数对象都是朝生夕灭的。
- 强分代假说:当一个对象熬过多次GC后,这个对象就越难消亡。
这两个假说就奠定了一条原则:收集器应该将Java堆划分为多个不同的区域,然后按照年龄(熬过GC的次数)分配到不同的区域之中存储,不同区域根据保存对象的特性采用不同的回收算法。
三种算法的应用
算法 | 常见内存区域的使用 |
---|---|
标记-清除 | 新生代 |
标记-复制 | 新生代 |
标记-整理 | 老年代 |
常见的垃圾收集器
总结
本文主要从垃圾收集器为起点,分别介绍了三种垃圾回收的算法,以及算法的优缺点,这些算法是垃圾收集器
的理论基础,有很多的垃圾收集器都遵循这些算法。