概述

垃圾收集技术在1960年的Lisp语言就开始使用了。

程序计数器、虚拟机栈、本地方法栈3个区域随线程而生灭,不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然也就跟着回收了。

但Java堆和方法区不同,一个接口的多个实现类的内存可能不一样,一个方法所执行的不同条件所需要的内存也可能不一样,这部分内存分配和回收是动态的。

对象存活判断

引用计数算法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器值为零的对象就是不可能再被使用的。

这种算法虽然占用了额外的内存空间,但原理简单,效率高。但在Java领域,主流虚拟机都没采用它来管理内存,因为这个算法要考虑很多例外情况,比如循环引用:

1
2
objA.instence = objB;
objB.instence = objA

他们互相引用对方,导致它们的引用计数不为零,无法回收。

可达性分析算法

主流虚拟机都是用这个算法来判定对象是否存活的。这个算法的思路是通过一系列称为‘GC Roots’的根对象作为起始节点集,从这些节点根据引用向下搜索,走过的路径叫引用链,如果某个对象到GC Roots间没有引用链相连,那就证明这个对象不再使用。

可以作为GC Roots的对象包括:

  • 虚拟机栈中引用的变量(方法执行完毕后弹栈,这些变量就不存在了,正好gc回收)。
  • 方法区中类静态属性引用的对象(类的静态成员变量,存在于方法区中,每个线程共享)。
  • 方法区中常量引用的对象(被final修饰,不能更改)。
  • 本地方法栈中Native方法(JNI)引用的对象。

引用

判断对象是否存活与引用离不开关系。

在JDK1.2之前,Java中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。但这种引用描述不了那种内存足够时可以保存在内存中,不够时可以抛弃的对象,就无能为力了。

在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种,这四种引用强度依次逐渐减弱。

  • 强引用就是程序代码中普遍存在的,类似“Object object = new Object()”这类的引用,只要强引用存在,垃圾收集器永远不会回收掉被引用的对象
  • 软引用用来描述一些还有用,但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中,并进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类来试下你软引用。
  • 弱引用也是用来描述非必须对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用。
  • 虚引用也称为幽灵引用或者幻影应引用,它是最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生成时间构成影响,也无法通过虚引用来取得一个实例对象。为一个对象设置虚引用关联的唯一目的就是希望能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚应引用。

生存还是死亡

在可达性分析算法中判定为不可达对象也不是马上死亡的,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将被第一次标记,然后筛选重写了finalize()方法并且finalize()方法没有被调用过的对象,将他们放置到一个叫F-Queue的队列里,并在稍后由一条虚拟机自动建立的、低调度优先级的Finalizer线程去执行他们的finalized() 方法,这里的执行是虚拟机会触发这个方法开始运行,但不一定会等他运行结束,因为它可能会死循环导致阻塞。

回收方法区

很多人认为方法区是没有垃圾收集的,Java虚拟机规范中确实说过不要求虚拟机在方法区实现垃圾收集,而且在方法区进行垃圾收集的性价比很低:在堆中,尤其是新生代,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,而方法区的垃圾收集效率远低于此。

方法区的垃圾收集主要回收两部分内容:废弃常量和无用的类型。回收废弃常量与回收Java堆中的对象非常类似。假如当前系统中没有一个对象引用了当前常量池中的某个常量,也没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个常量就会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。

判断一个类是否无用的条件比判断常量苛刻的多,类需要满足以下三个条件,才会被判定为“无用的类”

  • 该类的所有实例都已经被回收,也就是Java堆中不存在该类的任何实例。
  • 加载该类的ClassLoader已经被回收
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类方法。

垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集算法可划分为“引用计数式垃圾收集”和“追踪式垃圾收集”两大类,这两类也常被称作“直接垃圾收集”和“间接垃圾收集”。

分代收集理论

商业虚拟机的垃圾收集器大多遵循了分代收集的理论,它建立在两个分代假说上:

  1. 弱分代假说:绝大多数对象都是朝生夕灭的。

  2. 强分代假说:熬过越多次垃圾收集过程的对象越难以消亡。

收集器将Java堆划分出不同的区域,然后将回收对象依据起年龄(即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。这种把它们集中到一块的方式,使虚拟机可以使用较低的频率来回收集这个区域,兼顾时间开销和内存空间的有效利用。

分代收集并不是简单划分内存区域那么容易,它存在一个问题,对象之间可能存在跨代引用。假如收集局限于新生代区域,但新生代的对象完全可能被老年代引用,那就要多遍历一遍老年代来防止存活对象被收集。这时就为分代收集理论添加第三条经验法则:

  1. 跨代引用假说:跨代引用相对于同代引用来说只占极少数。

有了这个假说,只需在新生代建立一个全局的数据结构(叫做记忆集),这个结构把老年代划分成若干小块,标识出那一块内存存在跨代引用。此后当发生Minor GC(新生代收集)时,只有包含跨代引用的小块内存里的对象才会加入GC Root里进行扫描。

比如E,可以被回收,但YGC时因为被D引用,导致无法被回收。

img

标记-清除算法

最早最基础的算法就是标记-清除算法。 算法分为“标记”和”清除”(Mark-Sweep)两个阶段,首先标记出需要回收的对象,在标记完成后统一回收。后续的收集算法都基于这种思路并对其不足进行改进。

缺点:一是执行效率不稳定,当Java堆包含大量对象并且大部分都要回收时,必须进行大量标记清除动作,导致标记和清除两个过程的执行效率随对象数量增长而降低;另一个是空间碎片化问题,标记清楚后会产生大量不连续的内存碎片,空间碎片太多可能导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集。

标记-复制算法

标记-复制算法常被称为复制算法。为了解决效率问题,它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这块内存用完了,就将还存活着的对象复制到另外一块上面,然后把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,也不会出现内存碎片等复杂情况,只要移动堆顶指针,按顺序分配即可,实现简单、高效。但代价是将内存缩小为了原来的1/2

商业虚拟机都采用这种手机算法来回收新生代。IBM研究表明,新生代中98%的对象都是“朝生夕死”,所以不需要1:1的比例来划分内存,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中和存活的对象一次性复制到另外一块Survivor上,最后清理Eden和用过的Survivor空间。HotSpot默认Eden和Survivor大小比例为8:1。也就是新生代中可用空间为整个新生代的90%,只有10%会被“浪费”。当Survivor空间不足以容纳一次Minor GC后存活的对象时,就需要依赖其他内存(大多就是老年代)来进行分配担保。

标记-整理算法

标记-复制算法在对象存活率较高时要进行较多的复制操作,效率将会降低,更关键的在于如果不想浪费一半的内存空间,就需要有额外空间进行分配担保,以应对被使用的内存中所有对象都是100%存活的极端情况,所以在老年代一般不能直接使用这种算法。

根据老年代的特点,研究出了“标记-整理”(Mark-Compact)算法,标记过程与“标记-清除”一样,但后续步骤不是进行清理,而是让所有存活的对象都向一端移动,然后清理掉端边界以外的内存。

20161101222342412

移动存活对象有一个缺点,就是在老年代这种每次回收都有大量对象存活区域,移动对象将会是一个极为负重的操作。但如果跟标记-清除算法完全不考虑移动和整理内存的话,将会导致空间内存碎片化问题,那只能依赖更复杂的内存分配器和访问器来解决。内存的访问是用户最频繁的操作,如果这个环节增加负担,肯定影响程序的吞吐量。

基于以上两点,从垃圾回收的停顿时间卡,不移动对象停顿时间短,但从整个程序吞吐量看,移动对象更划算。虽然不移动对象会使收集器效率提升一些,但内存分配和访问相比垃圾收集频率高,这部分耗时增加,总吞吐量肯定下降。

HotSpot算法实现

根节点枚举

可作为GC Roots的节点主要在全局性的引用和执行上下文中,但查找过程要做到高效不容易,因为现在Java应用越来越大,方法区的大小就几百上千兆,逐个检查以这里为起源的引用要消耗不少时间。

现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举始终还是要在一个能保障一致性的快照里进行,这就会导致垃圾收集过程必须停顿所有用户线程。

目前主流Java虚拟机都使用准确式垃圾收集,所以虚拟机是可以直接得到那些地方存放对象引用的。在HotSpot中,是使用一组叫OopMap的数据结构来达到这个目的。

安全点

在OopMap的帮助下,HotSpot虚拟机可以快速的完成GC Roots的枚举。但可能导致引用关系变化的指令非常多,如果为每一条指令都生成OopMap,那将会需要大量的额外空间,这样GC的空间成本会变的很高。

实际上HotSpot只在特定的位置记录了这些信息,这些位置被称为安全点。安全点就是强制要求用户程序必须执行到安全点才能够暂停。

对于SafePoint,另一个问题是如何在GC发生时让所有线程都跑到安全点在停顿下来。这里有两种方案:抢先式中断和主动式中断。抢先式中断不需要线程代码主动配合,当GC发生时,首先把所有线程中断,如果发现线程中断的地方不在安全点上,就恢复线程,让他跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程来响应GC。
而主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单的设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己在最近的安全点上主动中断挂起,轮询标志的地方和安全点是重合的,另外再加上创建对象和需要分配的内存的地方。

安全区域

使用安全点似乎已经完美解决了如何进入GC的问题,但实际情况却并不一定,安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入的GC的安全点。但程序不执行时(就是没有分分配处理器时间,比如线程处于Sleep或Block),就无法响应虚拟机中断请求,那就必须引入安全区域解决。

安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域的任何地方开始GC都是安全的,我们可以把安全区域看做是扩展了的安全点。

当线程执行到安全区域中的代码时,首先标识自己已经进入了安全区,那样当在这段时间里,JVM要发起GC时,就不用管标识自己为安全区域状态的线程了。当线程要离开安全区域时,他要检查系统是否完成了根节点枚举,如果完成了,那线程就继续执行,否则他就必须等待,直到收到可以安全离开安全区域的信号为止。

经典垃圾收集器

  1. Serial收集器:新生代的“单线程”的收集器

  2. ParNew收集器:Serial收集器的多线程版本

  3. Parallel Scavenge收集器:新生代收集器,也是使用复制算法的收集器,又是并行的多线程收集器。

  4. Serial Old收集器:Serial收集器的老年代版本

  5. Parallel Old收集器:Parallel Scavenge收集器的老年代版本。使用多线程和“标记-整理”算法,jdk1.6开始提供

  6. CMS收集器:一种获取最短回收停顿事件为目标的收集器。分为:初始标记,并发标记,重新标记,并发清除四个步骤,初始标记和重新标记需要Stop The World。基于“标记-清除”算法。

  7. G1收集器:当今收集器技术发展的最前沿成果,具有特点:并行与并发,分代收集,空间整合,可预测的停顿

    G1把连续的Java堆划分为多个大小相等的独立区域(Region),每个Region都可以根据需要扮演Eden、Survivor或者老年代空间。回收标准不再是它属于哪个分代,而是那块内存存放的垃圾最多,回收收益最大,这就是Mixed GC模式。Region还有一类特殊的Humongous区域,存放大对象,默认只要超过一个Region一般的对象就是大对象。对于超过Region容量的大对象,被放在N个连续的Humongous Region中,G1大多数行为把Humongous当作老年代的一部分对待。关于跨Region引用,每个Region都有自己的记忆集,记录别的Region指向自己的指针。

    G1收集分为四个步骤:

    • 初始标记:标记GC Roots能直接关联到的对象。需要停顿线程,但耗时很短
    • 并发标记:从GC Root开始对堆中的对象进行可达性分析,递归扫描堆的对象图,找出要回收的对象,可与用户线程并发执行。
    • 最终标记:堆用户线程做一个短暂的暂停,处理并发标记阶段用户线程对已经标记过的对象的修改。
    • 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本来排序,根据用户期望的停顿时间来制定回收计划,把决定回收的那部分Region的存活对象复制到空的Region,清理旧的Region。必须暂停用户线程,由多条收集线程并行完成。

内存分配和回收策略

对象内存分配应该都是在堆上分配(实际上有可能经过即时编译后被拆散成标量类型并间接在栈上分配)。

对象优先在Eden分配

大多数情况下,对象在新生代Eden区分配。当Eden区没有足够空间分配时,虚拟机发起一次Minor GC。

比如:

虚拟机配置:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8

-Xms 堆内存的最小大小,默认为物理内存的1/64

-Xmx 堆内存的最大大小,默认为物理内存的1/4

-Xmn 堆内新生代的大小。通过这个值也可以得到老生代的大小:-Xmx减去-Xmn

XX:SurvivorRatio定义了新生代中Eden区域和Survivor区域的比例,默认为8,也就是Eden占8/10。

1
2
3
4
5
6
7
8
9
10
public class FinalizeEscapeGC {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) {
byte[] a1, a2, a3, a4;
a1 = new byte[2 * _1MB];
a2 = new byte[2 * _1MB];
a3 = new byte[2 * _1MB];
a4 = new byte[4 * _1MB];
}
}
1
2
3
4
5
6
7
8
9
Heap
PSYoungGen total 9216K, used 8192K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 100% used [0x00000000ff600000,0x00000000ffe00000,0x00000000ffe00000)
from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
to space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
ParOldGen total 10240K, used 4096K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 40% used [0x00000000fec00000,0x00000000ff000010,0x00000000ff600000)
Metaspace used 3137K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 343K, capacity 388K, committed 512K, reserved 1048576K

可以看到前面3个大小为2MB的对象分配在Eden区内,但在分配4M大小的对象时,Eden区剩余空间不足,所以发生Minor GC,但没有对象能够回收,所以通过分配担保机制把4M对象分配到老年代去。(这里eden占用100%因为本来就26%被虚拟机占用)。

大对象直接进入老年代

大对象堆虚拟机的内存分配来说就是一个不折不扣的坏消息,尤其是一群朝生夕灭的短命大对象,我们写程序应该避免。因为在分配空间时,大对象容易导致还有不少内存空间时就触发垃圾收集,而且复制对象开销大。

HotSpot虚拟机的-XX:PretenureSizeThreshold参数可以指定大于该设置值的对象直接分配在老年代,防止在Eden区和两个Survivor区来回复制。但PretenureSizeThreshold参数只对Serial和ParNew两款收集器有效。

长期存活的对象进入老年代

HotSpot虚拟机多数收集器都采用了分代收集来管理内存,为了决定对象放在新生代还是老年代,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象在Eden区诞生,第一次Minor GC后仍然存活,并能被Survivor容纳,则移动到Survivor中,并把年龄设为1,对象在Survivor中每熬过一次,年龄加一,当年龄到一定程度(默认15),就会晋升到老年代中。==这个实验要使用-XX:+UseSerialGC,jdk8默认的不符合==

对象晋升老年代的阈值,可以通过-XX:MaxTenuringThreshold设置。

动态对象年龄判定

为了更好的适应不同程序的内存情况,HotSpot虚拟机并不是要求对象年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代,而是如果Survivor空间中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以进入老年代。

空间分配担保

在发生Minor GC前,虚拟机必须先检查老年代最大可用连续空间是否大于新生代所有对象总空间,如果大于,可以确保Minor GC是安全的。不成立则查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败,如果允许,那就会检查老年代的最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试一次Minor GC,如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那就改为进行一次Full GC。

但jdk6之后,虚拟机已经不管-XX:HandlePromotionFailure的设置了,规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行Minor GC,否则Full GC。

HotSpot的serial收集器触发GC条件

最简单的分代式GC策略,按HotSpot VM的serial GC的实现来看,触发条件是:

  • young GC:当young gen中的eden区分配满的时候触发。注意young GC中有部分存活对象会晋升到old gen,所以young GC后old gen的占用量通常会有所升高。
  • full GC:当准备要触发一次young GC时,如果发现统计数据说之前young GC的平均晋升大小比目前old gen剩余的空间大,则不会触发young GC而是转为触发full GC(因为HotSpot VM的GC里,除了CMS的concurrent collection之外,其它能收集old gen的GC都会同时收集整个GC堆,包括young gen,所以不需要事先触发一次单独的young GC);或者System.gc()、heap dump带GC,默认也是触发full GC。