一、 如何定位垃圾
- 垃圾回收的核心工作就是回收垃圾,在JVM 的视角来看:垃圾就是无用的对象占用的堆内存空间。那么如何定位垃圾便是垃圾回收的重中之重。
1. 引用计数算法(reference counting)
- 算法思想:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器就加 1;当引用失效时,计数器值就减 1;一旦某个对象的引用计数器为 0,则说明该对象已经死亡,便可被回收。
- 缺陷:无法处理循环引用对象,如下:
/**
* testGC() 方法执行后,objA 和 objB是否会被GC?
* ClassName: ReferenceCountingGC
*/
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024 * 1024;
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
System.gc();
}
}
对象 a, b 相互引用,除此之外没有其他引用指向a 或者 b,在这种情况下,a 和 b 实际已经死亡,但是由于他们的引用计数器皆不为 0 ,在引用计数算法的心中,这两个对象还活着。因此,这些循环引用对象所占据的空间将不可回收,从而造成内存泄漏。
-
GC Roots: 可以理解为由堆外指向堆内的引用,包括(但不限于)如下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中JNI(即Native 方法)引用的对象;
- 已启动且未停止的 Java 线程。
2. 可达性分析算法
- 算法思想:以“GC Roots”的对象作为起始点,若无法到达对象 a 或者 b,则可达性分析便不会将它们加入存活对象合集中。其中搜索走过的路径称为引用链(Reference Chain)
- 缺陷:在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为 null)或漏报(将引用设置为被访问的对象)
- 误报只会使JVM 损失了部分垃圾回收的机会,即当GC标记完成,还未开始回收,你更新了其中一个引用,使之指向 null,那么原来的指向对象本可以被回收(但没有被GC 标记为可回收,只能等待下次标记)。
- 漏报是已经被 GC 标记为可回收的对象,更新为被其他对象指向,垃圾回收器直接给回收掉了,则可能会直接导致JVM 崩溃。
3. Stop-the-world 以及安全点
- 目的是为了解决上述标记过程中堆栈的状态发生改变,JVM 采取安全点机制来实现 Stop-the-world 操作,暂停其他非垃圾回收线程。因此老年代回收有卡顿现象。
4. Java的四种引用:强软弱虚
5. 对象的最后一次自我拯救
- 即使在可达性算法中不可达的对象,仍有一次自我拯救的机会。因为宣告一个对象的死亡至少要经历两次标记过程:对象在可达性算法标记为不可达后进行一次筛选,判断此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都是为“没有必要执行”。
- 在重写的 finalize() 方法中,只要重新与引用链上的任何一个对象建立关联即可,如下:
public class FinalizeEscapeGC {
public static FinalizeEscapeGC 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 method executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
// 对象第一次成功自我拯救
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 method executed!
yes, i am still alive:)
no, i am dead :(
执行结果一次成功自救,一次失败,这是因为任何一个对象的 finalize() 方法都只会被系统自动调用一次,如果对象面临下一次回收,它的 finalize() 方法不会再次执行。
二、如何回收垃圾
- 当标记完所有的存活对象是,便可以对死亡的对象进行回收了
1. 标记 - 清除(Mark-Sweep)
-
算法思想:把死亡对象所占据的内存标记为空闲内存,并记录在空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。
- 缺点:
- 造成内存碎片。由于JVM 的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。
- 分配效率较低。如果是一块连续的内存空间,那么可以通过指针加法(pointer bumping)来做分配。而对于空闲列表,JVM 则需要逐个访问列表的项,来查找能够放入新建对象的空闲内存。
2. 压缩(compact)也叫标记 - 整理(Mark-Compact)
-
算法思想:把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。
- 优缺点:能够解决内存碎片化的问题,代价就是压缩算法的性能开销。
3. 复制(copy)
-
算法思想: 把内存区域分为两等分,分别用两个指针 from 和 to 来 维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到to 指针指向的内存区域中,并且交换 from 指针 和 to 指针的内容。
优缺点: 能够解决内存碎片化的问题,但堆空间的使用效率极其低下。
4. 分代收集(Generational Collection)
- 算法思想:根据对象存活周期的不同将内存划分为几块。一般为新生代和 老年代,便可根据各个年代的特点采用最适合的收集算法。在新生代中,每次垃圾收集时都会有大批对象死去,只有少量存活,便采用复制算法,而老年代中因为对象存活率高、没有额外空间对它进行分配担保,则使用 “标记 - 清除” 或者 “标记 -整理” 算法。
5. JVM 的堆划分
- 前面提到了新生代 和 老年代,这是 JVM 对堆的划分,其中新生代又被划分为 Eden 区,以及两个大小相同的 Survivor 区。
- 默认情况下,JVM采取的是一种动态分配的策略(对应JVM参数 -XX:UsePSAdaptiveSurvivorSizePolicy),根据生成对象的速率,以及 Survivor 区使用情况动态调整 Eden区 和 Survivor 却的比例。
-
当然,也可以通过参数 -XX:SurvivorRatio 来固定这个比例。不过Survivor区会一直为空,因此比例越低浪费的堆空间将越高。
- 当 调用new 指令时,它会在 Eden 区中划出一块作为存储对象的内存。由于堆空间是线程共享的,因此直接在这里划空间是需要进行同步的。不然可能出现两个对象公用一段内存的事故。
- JVM 的解决方法就是:每个线程都可以向JVM 申请一段连续的内存,作为线程私有的TLAB(线程私有缓冲区 Thread Local Allocation Buffer,对应虚拟机参数 -XX:+UseTLAB,默认开启)。这个操作需要加锁,线程需要维护两个指针(可能更多,主要的就两个),一个指向TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。
- 接下来的new 指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,TLAB 已经没有足够的空间来满足本次新建操作。此时,便需要当前线程重新申请新的 TLAB。
- 如果 Eden 区的空间耗尽,此时JVM 会触发一次 Minor GC,来收集新生代的垃圾。存活下来的对象,则会被送到 Survivor 区。新生代有两个 Survivor 区,我们分别用 from 和 to来指代。其中 to 指向 Survivor 区是空的。
- 当发生 Minor GC时,Eden 区和 from指向的 Survivor 区中的存活对象会被复制到 to指向的 Survivor 区中,然后交换 from 和 to 指针,以保证下一次 Minor GC时,to 指向的Survivor 区还是空的。
- 新生代和老年代的划分: JVM会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。另外,如果 单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。
注:为什么是15 而不是其他?原因是 HotSpot会在对象头中的标记字段里记录年龄,分配到的空间只有4位,因此只能记录到15
- 优缺点:Minor GC 是不用对整个堆进行垃圾回收,此时有一个问题就是老年代的对象可能引用新生代的对象。也就是说,在标记存活对象的时候,我们需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,你们这个引用也会被作为 GC Roots。如此,岂不是又做了一次全堆扫描?
6. 卡表(Card Table)
HotSpot为了解决Minor GC的时候不用进行全堆扫描而提供的方案
- 原理:该技术将整个堆划分为一个个大小为512 字节的卡,并维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果存在,你们这张卡就是脏的。
- 在进行 Minor GC的时候,便可不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots中。当完成所有脏卡的扫描后,JVM便可将所有的脏卡的标识位清零。
三、 垃圾回收器
- 针对新生代的垃圾回收器有三个:Serial、Parallel Scavenge 和 Parallel New。都是采用标记 - 复制算法。其中,Serial 是单线程的,Parallel New 可以看成 Serial 的多线程版本。Parallel Scavenge 和 Parallel New 类似,但更加注重吞吐量(Throughput),吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。此外,Parallel Scavenge 不能与 CMS 一起使用。
- 针对老年代的垃圾回收器也有三个:Serial Old 和 Parallel Old,以及 CMS。Serial Old 和 Paralled Old 都是标记 - 压缩算法。前者为单线程的,后者可以看成前者的多线程版本。
- CMS 采用的是标记 - 清除算法,并发收集、低停顿。但是对CPU敏感、而且会产生空间碎片。
- G1(Garbage First)是一个横跨新生代和老年代的垃圾回收器。它打乱了前面所说的堆结构,直接将堆分为极多个区域。每个区域都可以充当 Eden 区、Survivor 区或者老年代中的一个。采用的是“标记 - 压缩”算法,而且和 CMS 一样都能在应用程序运行过程中并发的进行垃圾回收。