Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。
概述
GC这项技术不是Java语言的伴生物,其历史远比Java久远。1960年诞生于MIT的Lisp是第一门真正使用动态内存分配和垃圾收集技术的语言。
Lisp还处于胚胎时期,人们就在思考GC需要完成的三件事情:
- 哪些内存需要回收
- 什么时候回收
- 如何回收
Java内存运行区域中的程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程而死。这几个区域的内存分配和回收都具备确定性,不需要过度考虑回收的问题,因为随着方法结束或者线程结束时,内存自然就跟着回收了。
Java堆和方法区则不一样,一个接口中多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,只有在运行期间才知道会创建哪些对象,这部分内存的分配和回收都是动态。GC所关注的就是这部分内存。
判断对象存活的算法
引用计数算法
给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器都为0的对象就是不可能再被使用的。
但是,Java语言没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间的循环引用问题。
根搜索算法(GC Roots Tracing)
Java和C#,甚至包括之前的Lisp都是使用此算法来判断对象是否存活。
基本思路:通过一系列名为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(GC Roots到这个对象不可达)时,则证明此对象是不可用的。
Java语言里,可作为GC Roots的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中的引用的对象。
- 方法区的类静态属性引用的对象
- 方法区中的常量引用的对象
- 本地方法栈中JNI的引用的对象
引用分类
JDK 1.2之前,Java中对引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。 这种定义很狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态。对于如何描述一些“食之无味,弃之可惜”的对象就显得无能为力。我们希望能描述这样的一些对象:当内存空间还足够时,则能保留在内存中,如果内存在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。
JDK 1.2后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)四种,这四种引用强度依次逐渐减弱。
- 强引用就是指在程序代码中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还存在,GC永远不会回收掉被引用的对象。
- 软引用用来描述一些还有用,但并非必须的对象。对于软引用关联的对象,系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中并进行第二次回收。如果这次回收还是没有足够内存,才会抛出内存溢出异常。
- 弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存道下一次GC收集发生之前。当GC工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
- 虚引用也被称为幽灵引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实。为一个对象设置虚引用关联的唯一目的是希望能在这个对象被回收时收到一个系统通知。
对象什么时候判定为死亡
根搜索算法中不可达的对象,会被第一次标记并且进行一次筛选,筛选条件是此对象是否有必要执行finalize()方法,当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
如果对象被判定为有必要执行finalize()方法,则会被放置到一个名为F-Queue队列中,并在稍后由虚拟机自动建立的、低优先级的Finalizer线程去执行。这里“执行”是指虚拟机会触发这个方法,但是不保证会等到它运行结束。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记。
回收方法区
方法区(HotSpot虚拟机中的永久代)进行垃圾收集的性价比比较低,主要回收两部分内容:废弃常量和无用的类。
判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多,类需要同时满足下面3个条件才能算是“无用的类”:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
- 加载该类的ClassLoader已经被回收
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述3个条件的无用类进行回收,仅仅是“可以”,而不是和对象一样,不使用了就必然会回收。
垃圾收集算法
标记-清除算法
最基础的收集算法是“标记-清除”(Mark-Sweep)算法。分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。之所以说是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。
主要有两个缺点:一个是效率问题,标记和清除过程的效率都不高;二是空间问题,标记清除后会产生大量不连续的内存碎片,碎片太多后会导致,当程序在以后运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
复制算法
为了解决效率问题,一种被称为“复制”的收集算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中一块,当这块内存用完了,就将还存活的对象依次复制到另一块上面,然后把已使用的内存空间一次清理掉。
这种算法缺点是内存使用率不高,只有原来的一半。
现代商业虚拟机都采用这种收集算法来回收新生代,但并不按照1:1来划分空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地拷贝到另外一块Survivor空间,最后清理掉Eden和刚才用过的Survivor的空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1。
标记-整理算法
复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。在老年代一般不能直接选用这种方法。
根据年老代的特点,有人提出了“标记-整理”(Mark-Compact)算法,标记后,不是直接对可回收的对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
分代收集算法
当前商业虚拟机都采用“分代收集”(GenerationalCollection)算法,根据对象的存活周期的不同将内存划分为几块。一般Java堆分为新生代和老年代,根据各个年代的特点采用最适当的收集算法。
新生代中,存活率较低,采用复制算法,而老年代因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。
垃圾收集器
垃圾收集器是内存回收的具体实现。不同厂商、不同版本的虚拟机所提供的垃圾收集器可能会有很大的差别,并且一般会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。
展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。
Serial 收集器
最基本、历史最悠久的收集器,曾经(JDK 1.3.1之前)是虚拟机新生代唯一的选择。
这个收集器是一个单线程的收集器,“单线程”的意义并不仅仅是说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。
ParNew 收集器
ParNew收集器其实就是Serial收集器的多线程版本(并行收集器),除了使用多线程进行垃圾收集之外,其余行为都与Serial收集器完全一样。
是许多运行在Server模式下的虚拟机中的首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作。
ParNew收集器在单CPU环境中绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境下中都不能百分之百地保证能超越Serial收集器。
注意并行收集器和并发收集器。
- 并行(Parallel):指多条垃圾收集线程并行工作,但此时用于线程仍然处于等待状态
- 并发(Concurrent):指用于线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序继续运行,而垃圾收集程序运行于另一个CPU上。
Parallel Scavenge 收集器
Parallel Scavenge收集器也是一个新生代收集器,也是使用复制算法的收集器,又是并行的多线程收集器,看上去和ParNew都一样。
Parallel Scavenge收集器的特点是它的关注点和其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户的体验;而高吞吐量则可以最高效率地利用CPU时间,尽快地完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
由于与吞吐量关系密切,Parallel Scavenge收集器也经常被称为“吞吐量优先”收集器。
Serial Old 收集器
是Serial收集器的老年代版本,同样是一个单线程收集器,使用“标记-整理”算法。主要意义也是被Client模式下的虚拟机使用。在Servier模式下可以作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。
Parallel Old 收集器
是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。在注重吞吐量及CPU资源敏感的场合,都可以有效考虑Parallel Scavenge加Parallel Old收集器
CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。从名字上就可以看出其是基于“标记-清除”算法实现的,运作过程相对于其他收集器来说要更复杂一些,整个过程分为4个步骤:
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
其中,初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运行而导致标记产生变动的那部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
CMS最主要有点是:并发收集、低停顿。但是还存在三个显著的缺点:
- CMS收集器对CPU资源非常敏感。
- CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败后而导致另一次Full GC的产生。浮动垃圾是由于CMS并发清理过程用户线程同时运行而出现在标记过程之后,CMS无法在本次收集中处理掉的部分。
- CMS是基于“标记-清除”算法,在收集结束后可能产生大量空间碎片。
G1收集器
G1(Garbage First)收集器
G1收集器是垃圾收集器理论进一步发展的产物,与CMS收集器相比有连个显著的改进:一是G1收集器是基于“标记-整理”算法实现的收集器,也就是说它不会产生空间碎片。二是她可以非常精确地控制停顿,既能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。