GC(垃圾收集)是一个早于 Java 的概念,诞生于 1960 年的 Lisp 语言当时就使用了内存动态分配和垃圾收集技术,经过半个世纪发展,这项技术已经发展很成熟。之所以还要去探究 GC 和内存分配,是因为当程序万一出现了内存溢出、内存泄漏等问题的时候,当垃圾收集成为系统高并发性能瓶颈的时候,我们需要有针对性的对 JVM 自动化的内存分配和垃圾回收策略实施必要的监控和调整。
要学习 GC 必须思考以下三个问题:
- 哪些内存需要回收
- 什么时候回收
- 怎么回收
- 都有哪些垃圾收集器,各有什么特点
哪些内存需要回收
通过前面的介绍,我们知道 JVM 管理的内存中,线程私有的虚拟机栈、本地方法栈以及程序计数器都是随着线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出有条不紊的进行入栈和出栈。这部分内存区域随着线程结束或者方法退出自然的就被释放回收了,因此这部分不需要过多考虑回收问题。而 Java 堆及方法区则不一样,这部分内存的分配和回收都是动态的。垃圾收集器关注的区域主要指的是这部分内存。
判断对象是否存活的方法
GC 在垃圾回收的时候首先需要判断哪些对象时 “存活” 的,哪些是已经 “死亡” 了(不能够再被使用到的对象)可以回收,判断的方法主要有 引用计数法 及 可达性分析法。
引用计数法: 给对象添加一个引用计数器,每当有一个地方引用计数器就加 1,引用失效时计数器就减 1,因此哪些计数器为 0 的对象都是不再被引用需要回收的对象。优点是实现简单、效率也高,但是缺点就是无法解决对象之间相互引用的情况。
可达性分析法(HotSpot 默认): 通过一系列称为 GC Roots 的对象作为起点开始向下进行搜索,搜索走过的路径叫做引用链,如果一个对象没有任何引用链与其相连时说明该对象不可达,即不可能再被使用到,这时便被判定为可回收的对象。在 Java 中 可作为 GC Roots 的对象 有以下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 本地方法栈中 Native 方法引用的对象
- 方法区中的常量引用的对象
需要注意的一点是在可达性分析方中不可达的对象也并非就一定是 “非死不可” 的,GC 在第一次可达性分析发现对象不可达时进行一次标记,同时会判断该对象是否有必要执行 finalize() 方法(当对象覆盖了 finalize()方法并且没有被调用过时才会被认为有必要),如果判断有必要则将其加入一个叫作 F-Queue 的队列,稍后虚拟机会启动一个专门的、低优先级的线程去依次执行队列中对象的 finalize 方法,并且不保证一定执行成功。在对象的 finalize 方法中如果对象将自己(this 关键字)与引用链上的任意对象建立关联,那么在下一次可达性标记的时候这个对象就会被从 “即将回收” 的集合中移除。代码举例见 p67。注意 finalize() 方法不被建议在代码中使用,因为其能做的事情 try-finally 都能够做的更好!
垃圾怎么回收(垃圾回收算法)
标记 - 清除算法(Mark-Sweep)
这时最基础的收集算法,分为 “标记” 和“清除”两个阶段:首先按照上面介绍的方法标记出需要回收的对象,标记完成后统一对被标记的对象内存进行回收。显然这种方式会导致产生大量不连续的内存碎片,从而导致后面再需要分配较大对象时无法找到足够的连续内存,从而提前触发另一次垃圾收集。
复制算法(Coping)
这种算法将可用内存按照容量划分为大小相等的两部分,每次只使用其中一半,当这一半使用完了就将其中还存活的对象复制到另一块内存上,然后对这一块内存进行回收,循环往复。优点是实现简单、运行高效。缺点就是可用内存缩小到了原来的一半,这个代价稍微有点高!
这种算法主要被用来回收新生代,因为新生代中的对象百分之九十八都是“朝生夕死”,也就是说大部分内存都会被回收掉,那就没有必要按照 1:1 的比例划分内存空间,而是将内存分为较大的一块 Eden 空间和两块较小的 Survivor 空间。每次使用 Eden 和其中一块 Survivor 区(from),当回收时将其中的存活对象复制到另外一块 Survivor 区,把 Eden 和刚才用过的 Survivor 区清理掉。HotSpot 虚拟机默认的 Eden 和 Survivor 内存比例是 8:1,也就是说每次新生代的可用空间为整个新生代容量的 90%,这样内存的利用率很高,一定程度上避免了上面提到的可用内存折半的缺点。但是我们并没有办法保证每次回收都只有不到 10% 的对象存活(因为存活的对象会被复制到 survivor to 区,这部分只占了 10%),这样就有可能出现 Survivor 内存不够用,需要依赖其它内存(老年代)进行分配担保。
显然在对象存活率较高的情况下这种算法效率就会降低。
标记整理算法(Mark-Compact)
老年代由于存活率比较高(想想为什么),因此并不适合上面提到的复制算法,针对其特点,“标记 - 整理”的算法被提出来。其标记过程与 “标记 - 清除” 算法的过程一样,但后续并不是直接对标记对象进行清理,而是让存活的对象都向一端移动,然后直接清理掉边界以外的区域。
当代虚拟机都采用 “分代收集” 的思想,一般根据对象存活周期将 Java 堆分为新生代和老年代,分别根据其特点选择相应的收集算法:新生代对象存活率低,则采用 复制算法 只需要对极少比例的存活对象进行复制即可完成收集;而老年代因为存活率高,没有额外空间对其进行分配担保,就必须使用 “标记 - 清理”或者 “标记 - 整理” 算法 来回收。
如何高效的在堆上分配对象空间
Java 在堆上分配对象,在堆栈上分配指向对象的引用及常量。Java 从堆分配空间的速度可以和其它程序在堆栈上分配空间的速度相媲美。只是因为在分配堆空间时 java 只是简单的将 “堆指针” 移动到尚未分配的区域,他是如何做到的呢? 这得益于 java 的垃圾回收器的工作原理,gc 在工作的时候一边回收空间,一边将堆中的对象重新紧凑的排列,这样 “堆指针” 就可以更容易的移到空闲区域的开始处,也就尽量避免了页面错误。通过 gc 对对象重新排列,实现了一种高速的、有无限空间可供分配的堆模型。
垃圾回收的思想
对任何 “活” 的对象,一定能最终追溯到其存活在堆栈或静态存储区中的引用。
基于此从堆栈和静态存储区开始遍历所有的引用,就能找到所有 “活” 的对象,对这些对象进行标记,将其余的对象回收。不同的虚拟机对这个过程有不同的具体实现。
停止 - 复制模式 先暂停程序运行(不属于后台回收模式),将所有活得对象从当前堆复制到另一个堆,没有复制的对象都当作垃圾回收,复制到新堆时对象会被一个挨着一个整齐的排列,这样便可以按照前面说的移动 “堆指针” 的方式直接分配新空间了。当然这种 “复制移动” 式的回收方法效率较低,通常做法是按需从堆中分配几块较大的内存,复制动作发生在这几块较大的内存之间。
标记 - 清扫模式 前一种 “停止 - 复制” 模式在垃圾较少的情况下效率仍然很低下,因为这时大量的复制行为其实没有必要,于是另一种新的方法:遍历所有引用进而找到所有存活的对象并对其标记,标记完成以后将没有标记的对象清理,这个过程中并不做任何复制。当然这样的话剩下的堆空间并不是连续的。
两种方式个有利弊,一般 java 虚拟机会采用一种自适应的方式,即如果监控到所有对象都很稳定垃圾回收器效率较低时,切换到 “标记 - 清扫模式”,同样监控发现堆空间出现很多碎片时又切回“停止 - 复制” 模式。
什么时候回收
首先来看看 HotSpot VM 都有哪些类型的 GC:
- Partial GC(局部 GC): 并不收集整个 GC 堆的模式
- Young GC: 只收集 young gen 的 GC,Young GC 还有种说法就叫做 "Minor GC"
- Old GC: 只收集old gen的GC。只有垃圾收集器CMS的concurrent collection 是这个模式
- Mixed GC: 收集整个young gen 以及部分old gen的GC。只有垃圾收集器 G1有这个模式
- Full GC: 收集整个堆,包括 新生代,老年代,永久代(在 JDK 1.8及以后,永久代被移除,换为metaspace 元空间)等所有部分的模式
Full GC 的触发条件如下,注意不同的虚拟机实现各有不同,基本
- 显式的调用 System.gc():尽量避免在代码中显式调用此方法,让虚拟机自己去管理它的内存
- serial GC 中,老年代内存剩余已经小于之前年轻代晋升老年代的平均大小,则进行 Full GC;而在 CMS 等并发收集器中则是每隔一段时间检查一下老年代内存的使用量,超过一定比例时进行 Full GC 回收。
Yong GC / Minor GC 的触发条件:
当 Eden 区的空间耗尽时 Java 虚拟机便会触发一次 Minor GC 来收集新生代的垃圾,存活下来的对象,则会被送到 Survivor 区。简单说就是当新生代的Eden区满的时候触发 Minor GC。
Yong GC 过程
新生代共有 两个 Survivor 区,我们分别用 from 和 to 来指代。其中 to 指向的 Survivor 区是空的。当发生 Minor GC时,Eden 区和 from 指向的 Survivor 区中的存活对象会被复制(此处采用标记 - 复制算法)到 to 指向的 Survivor 区中,然后交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区还是空的。from 与 to 只是两个指针,它们变动的,to 指针指向的 Survivor 区是空的。
Survivor 区对象晋升位老年代对象的条件
Java 虚拟机会记录 Survivor 区中的对象在 from 和 to 之间一共被来回复制了几次。如果一个对象被复制的次数为 15 (对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升为至老年代;另外,如果单个 Survivor 区已经被占用了 50% (对应虚拟机参数: -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。
hotspot 都有哪些垃圾收集器
上图收集器是基于 JDK1.7 Update 14 之后的 HotSpot 虚拟机(在此版本中正式提供了商用的 G1 收集器,之前 G1 仍处于实验状态),该虚拟机包含的所有收集器,如图所示 7 种作用于不同分代的收集器,连线表示相互之间可以搭配使用
首先解释一些概念
- 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
- 并发(Concurrent):指 用户线程与垃圾收集线程 同时执行,用户程序在继续运行。
新生代收集器
新生代对象一般都是朝生夕灭,每次都会有超过 95% 的对象被回收,因此更适合使用复制算法进行回收,下面介绍的几种新生代收集器都是如此。
Serial 收集器
Serial(串行)收集器是最基本、最悠久的采用复制算法的新生代收集器,它在进行垃圾收集时会 Stop The World。是 HotSpot 虚拟机运行在 Client 模式下的默认的新生代收集器。
ParNew 收集器
ParNew 收集器是 Serial 收集器的多线程版本,也是一个新生代收集器。除了使用多线程进行垃圾收集外,其余行为与 Serial 收集器完全相同。需要注意的是这里的并行并不是与用户线程并行,而是会有多个线程执行 GC,此时仍然是 Stop The World 的状态。
ParNew 是 Server 模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关的重要原因是,除了 Serial 收集器外,目前只有它能和 CMS 收集器(Concurrent Mark Sweep)配合工作,具体内容将在稍后进行介绍。需要注意的是 ParNew 收集器在单 CPU 的环境中绝对不会有比 Serial 收集器有更好的效果,因为存在线程切换的开销。
Parallel Scavenge 收集器
Parallel Scavenge 收集器也是一个并行的采用复制算法的多线程新生代收集器。它的特点是会根据当前系统的运行情况收集性能监控信息,动态调整新生代的大小(-Xmn)、Eden 和 Survivor 区的比例(-XX:SurvivorRatio)等参数以提供最合适的停顿时间或者最大的吞吐量,即 GC 自适应的调节策略(GC Ergonomics)。自适应调节策略也是 Parallel Scavenge 收集器与 ParNew 收集器的一个重要区别。
注意 Parallel Scavenge 收集器无法与 CMS 收集器配合使用,所以在 JDK 1.6 推出 Parallel Old 之前,如果新生代选择 Parallel Scavenge 收集器,老年代只有 Serial Old 收集器能与之配合使用。
老年代收集器
老年代一般需要回收的对象不会占到太大的比例,因此基本都是用标记 - 整理的方式。
Serial Old 收集器
Serial 收集器的老年代版本,单线程采用 “标记 - 整理”(Mark-Compact)算法。也主要是用于 Client 模式下的虚拟机。
Parallel Old 收集器
Parallel Scavenge 收集器的老年代版本,使用多线程和 “标记 - 整理” 算法。在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。
CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,它非常符合那些集中在互联网站或者 B/S 系统的服务端上的 Java 应用,这些应用都非常重视服务的响应速度。从名字上(“Mark Sweep”)就可以看出它是基于 “标记 - 清除” 算法实现的。
CMS 收集器工作的整个流程分为以下 4 个步骤:
- 初始标记(CMS initial mark):仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要 “Stop The World”。
- 并发标记(CMS concurrent mark):进行 GC Roots Tracing 的过程,在整个过程中耗时最长。
- 重新标记(CMS remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要 “Stop The World”。
- 并发清除(CMS concurrent sweep)
可以看到过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。
因此 CMS 的优点是:并发收集、低停顿。它的缺点是由于采用并发所以对 CPU 资源非常敏感,其次它采用标记 - 清除的算法会导致的空间碎片
G1 收集器
G1(Garbage-First)收集器是当今收集器技术发展最前沿的成果之一,它是一款面向服务端应用的垃圾收集器,HotSpot 开发团队赋予它的使命是(在比较长期的)未来可以替换掉 JDK 1.5 中发布的 CMS 收集器。与其他 GC 收集器相比,G1 具备如下特点:
- 并行与并发 G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU 来缩短 “Stop The World” 停顿时间。
- 分代收集 分代概念在 G1 中依然得以保留。虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同方式去处理新创建的对象和已存活一段时间、熬过多次 GC 的旧对象来获取更好的收集效果。
- 空间整合 G1 从整体来看是基于 “标记 - 整理” 算法实现的收集器,从局部(两个 Region 之间)上来看是基于 “复制” 算法实现的。
- 可预测的停顿 降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了降低停顿外,还能建立可预测的停顿时间模型