概述:GC的作用域是堆和方法区(即永久区)
这里所谓的垃圾指的是在系统运行过程当中所产生的一些无用的对象,这些对象占据着一定的内存空间,如果长期不被释放,可能导致OOM。
GC算法要解决三件事:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
对于程序计数器、虚拟机栈、本地方法栈来说,由于他们是跟随当前线程的生命周期,当线程销毁时其占用的内存自然回收。
而 Java 堆和方法区则不一定,一个接口的多个实现类需要的内存可能不一样,一个方法中多个分支所占内存也可能不一样。所以就需要在动态分配与内存回收的基础上实施监控和内存回收。
哪些内存需要回收
引用计数法(无法解决循环引用的问题,不被java采纳)
概念:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
java虚拟机并没有选用引用计数算法来管理内存,其中最主要的原因是:它很难解决对象之间相互循环引用的问题。
对于最右边的那张图而言:循环引用的计数器都不为0,但是他们对于根对象都已经不可达了,但是无法释放。
可达性分析法
从根(GC Roots)的对象作为起始点,开始向下搜索,搜索所走过的路径称为“**引用链**”,当一个对象到GC Roots没有任何引用链相连(用图论的概念来讲,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。<br />在JAVA语言中,可以当做GC roots的对象有以下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中 JNI(Native 方法)引用的对象。
备注:第一和第四种都是指的方法的本地变量表
在根搜索算法的基础上,现代虚拟机的实现当中,垃圾搜集的算法主要有三种,分别是标记-清除算法、复制算法、标记-整理算法。这三种算法都扩充了根搜索算法
可达性分析需要考虑下面两个点:
- 如果方法区大小就有数百兆,如果逐一检查引用,则肯定消耗性能,所以不可能这么做,在执行可达性分析时,必须要保证这个过程期间对象的引用关系不能再变化,否则不能保证分析结果正确性
- 必须要停止所有线程去执行枚举根节点,被称为Stop the World
【上面两个点反映出来的性能问题,解决方法】:
- OopMap数据结构: 保存GC Roots 节点,避免全局扫描去一一查找。(目前主流java虚拟机都是准确式GC)
- 安全点: 精简指令,为特定位置(安全点: Safepoint)上的指令生成对应的OopMap,暂停进行GC的位置也是在安全点;
- 安全区域 :在一段代码中,引用关系不会发生变化,在这个区域中的任意地方开始GC都是安全的。处理没有被分配CPU时间的线程。
OopMap会在类加载时进行计算,并在JIT也会进行记录。
安全点设置原则:
- 不能太少,太少会导致GC等待时间过长
- 不能太过于频繁,以致于过分增加运行时的负荷
安全点的选定基本都是以“是否具有让程序长时间执行的特征”为标准进行选定–因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行” 的最明显特征就是指令程序复用,例如方法调用,循环调转 ,异常跳转等。所以具有这些功能的指令才会产生SafePoint
GC发生时,让所有线程(不包括执行JNI调用的线程) 都“跑”到最近的安全点上再停顿
- 抢先式中断(Preemptive Suspension) : GC发生时,中断全部线程,如果发现线程不在安全点,则恢复让其”跑” 到安全点
- 主动式中断(Voluntary Suspension ): 设置一个标志,然后采用轮询触发。
安全区域(Safe Region)
主要针对没有分配CPU时间的线程,如线程处于Sleep状态或者Blocked状态。这个时候线程无法响应JVM的中断请求。所以需要安全区域来解决。
所有的算法,需要能够识别一个垃圾对象,因此需要给出一个可触及性的定义。
可触及的:
从根节点可以触及到这个对象。
其实就是从根节点扫描,只要这个对象在引用链中,那就是可触及的。
可复活的:
一旦所有引用被释放,就是可复活状态,因为在finalize()中可能复活该对象
不可触及的:
在finalize()后,可能会进入不可触及状态,不可触及的对象不可能复活要被回收。
finalize方法复活对象的代码举例:
public class CanReliveTest {
public static CanReliveTest obj;
//当执行GC时,会执行finalize方法,并且只会执行一次
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("CanReliveTest finalize called");
obj = this; //当执行GC时,会执行finalize方法,然后这一行代码的作用是将null的object复活一下,然后变成了可触及性
}
@Override
public String toString() {
return "------CanReliveTest";
}
public static void main(String[] args) throws
InterruptedException {
obj = new CanReliveTest();
obj = null; //可复活
System.out.println("第一次gc");
System.gc();
Thread.sleep(1000);
if (obj == null) {
System.out.println("obj 是 null");
} else {
System.out.println("obj 可用");
}
obj = null; //不可复活
System.out.println("第二次gc");
System.gc();
Thread.sleep(1000);
if (obj == null) {
System.out.println("GC后 obj 是 null");
} else {
System.out.println("GC后 obj 可用");
}
}
}
一开始,我们在第21行将obj设置为null,然后执行一次GC,本以为obj会被回收掉,其实并没有,因为GC的时候会调用7行的finalize方法,然后obj在第10行被复活了。紧接着又在第30行设置obj设置为null,然后执行一次GC,此时obj就被回收掉了,因为finalize方法只会执行一次。
运行结果:
第一次gc
CanReliveTest finalize called
obj 可用
第二次gc
GC后 obj 是 null
Process finished with exit code 0
finalize方法的使用总结:
- 经验:避免使用finalize(),操作不慎可能导致错误。
- 优先级低,何时被调用,不确定
由于不确定何时发生GC,自然也就不知道finalize方法什么时候执行
- 如果要使用finalize去释放资源,我们可以使用try-catch-finally来替代它
什么时候回收
当 JVM 经过可达性分析法筛选出实效对象时,并不是马上清除,而是进行标记并判断是否回收:
判断对象是否覆盖了 finalize() 方法
- 如果覆盖了 finalize() 方法,那么将 finalize() 放到 F-Queue 队列中
- 如果未覆盖该方法,则直接回收
执行 F-Queue 队列中的 finalize() 方法
由虚拟机自动建立一个优先级较低的线程去执行 F-Queue 中的 finalize() 方法,这里的执行只是触发这些方法并不保证会等待它执行完毕。如果 finalize() 方法作了耗时操作,虚拟机会停止执行并将该对象清除。- 对象销毁或重生
在 finalize() 方法中,将 this 赋值给某一个引用,那么该对象就重生了。如果没有引用,该对象会被回收。
方法区的内存回收(补充)
Java 虚拟机规范中说不需要方法区实现垃圾收集,因为方法区中存放的都是一些生命周期较长的类信息、常量、静态变量。方法区就像是堆的老年代,每次垃圾回收只有少量垃圾被清除:
- 废弃的常量:
当前系统中没有任何对象引用常量池中的该常量,则是废弃常量- 废弃的类判断规则:
该类所有实例都被回收;
加载该类的 ClassLoader 已经被回收;
该类对应的 Class 对象没有引用,也无法通过反射访问该类的方法。
如何回收
通过上面的介绍我们了解到垃圾收集、内存回收的主要区域是 Java 堆,JVM 回收的对象是那些没有引用的对象、常量、类等。要注意的是 JVM 筛选出需要清除的对象时并不是马上进行回收,而是进行标记并判断是否覆写 finalize() 方法,然后再依据一定规则进行 GC。
算法分类:
常见的GC算法目前主要有以下四种
- 复制
- 标记清除
- 标记整理
- 分代收集算法
复制
复制算法为了解决效率问题,将可用内存按容量划分为大小相同的两块,每次使用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另一块上面。然后将已经使用过的内存空间一次清理掉。<br />**好处:**<br /> 内存分配时不用考虑内存碎片等复杂情况,运行高效。<br />**弊端:**
- 是将内存缩小到原来的一半。
- 如果对象存活率高,需要复制的对象比较多,产生效率问题。
优化:
由于新生代对象98%是朝生夕死的,故将内存空间氛围一块比较大的Eden空间和两块较小的Survivor空间。Hotspot虚拟机默认比例为8:1:1 。当这里的内存将满时,JVM 会出发一次 MinorGC,清除掉废弃对象,并将存活对象复制到另一块 Survior2 中。那么接下来就使用 Eden + Survior2 进行内存分配。通过这种方式只需浪费10% 的内存空间即可实现复制清除算法,同时避免了内存碎片的问题。
标记清除
分为标记和清除两个阶段,先标记出要回收的对象,然后统一回收这些对象。
回收前
绿色:存活的对象
黑色:可回收
灰色:未使用
回收后
之所以说标记-清除算法是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改造而得到的。
主要有两点不足:
- 效率问题,标记和清除两个过程的效率都不高。
-
标记整理
标记部分与标记-清除一样 ;后续不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉一端边界外的内存。
优点:无需复制,保证效率。内存规整。
- 缺点:效率不如复制算法。
整理的顺序
不同算法中,堆遍历的次数,整理的顺序,对象的迁移方式都有所不同。而整理顺序又会影响到程序的局部性。主要有以下3种顺序:
- 任意顺序:对象的移动方式和它们初始的对象排列及引用关系无关任意顺序整理实现简单,且执行速度快,但任意顺序可能会将原本相邻的对象打乱到不同的高速缓存行或者是虚拟内存页中,会降低赋值器的局部性。任意顺序算法只能处理单一大小的对象,或者针对大小不同的对象需要分批处理。
- 线性顺序:将具有关联关系的对象排列在一起。
- 滑动顺序:将对象“滑动”到堆的一端,从而“挤出”垃圾,可以保持对象在堆中原有的顺序。
所有现代的标记-整理回收器均使用滑动整理,它不会改变对象的相对顺序,也就不会影响赋值器的空间局部性。复制式回收器甚至可以通过改变对象布局的方式,将对象与其父节点或者兄弟节点排列的更近以提高赋值器的空间局部性。
整理算法的限制:如整理过程需要2次或者3次遍历堆空间;对象头部可能需要一个额外的槽来保存迁移的信息。
部分整理算法:
- 双指针回收算法:实现简单且速度快,但会打乱对象的原有布局。
- Lisp2算法(滑动回收算法):需要在对象头用一个额外的槽来保存迁移完的地址。
- 引线整理算法:可以在不引入额外空间开销的情况下实现滑动整理,但需要2次遍历堆,且遍历成本较高。
- 单次遍历算法:滑动回收,实时计算出对象的转发地址而不需要额外的开销。
分代收集算法
原理:把 Java 堆分为新生代和老年代,根据各个对象的年代采用最合适的收集算法。
针对新生代的对象,采取灵活比例的复制算法,只需要复制少量存活对象就可以完成收集。
针对老年代的对象,因为这些对象存活率高,没有额外空间进行分配担保,必须使用 标记 - 清除 或 标记 - 整理 算法。