一、概述
目前内存的动态分配与内存回收技术已经相当成熟,可能平时开发不用关注这些,当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些技术实施必要的监控和调节。
前面介绍了Java内存运行时区域,其中程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,这些区域的内存分配和回收都是确定的,不用考虑回收问题。而Java堆和方法区则不一样,只有在程序运行期间才知道对象的情况,这部分内存的分配和回收都是动态的,垃圾收集器关注的就是这部分内存。
1. 引用计数算法
引用计数是一种判断对象是否存活的简单算法,给对象添加一个引用计数器,每有一个地方引用它时,计数器值加1,当引用失效时减1,任何计数器为0的对象就是没使用的。这种算法实现简单,判断效率高,但主流虚拟机没有选用它来管理内存,主要原因是它很难解决对象之间相互循环引用的问题。
2. 可达性分析算法
可达性分析算法的基本思路就是通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链时,则证明此对象是不可用的。在Java语言中,可作为GC Roots的对象包括以下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
3. 引用类型
无论使用什么算法,判定对象是否存活都与“引用”有关,传统定义下的的对象只有引用或者被引用两种状态,为了更细地管理引用,根据内存空间紧张程度来确定某些对象是否被回收,Java对引用进行了扩充,将引用分为强引用、软引用、弱引用和虚引用四种。
强引用是在程序代码中普遍存在的,类似 Object obj = new Object()
这类的引用,存在强引用的对象垃圾收集器永远不会回收。
软引用用来描述有用但并非必需的对象,只有在系统将要内存溢出之前,会第二次回收这类对象,如果这次回收后还没有足够的内存,才会抛出内存溢出异常,可以使用 SoftReference
类来实现。
弱引用也是用来描述非必需对象的,但它的强度比软引用更弱一些,这类对象只能生存到下一次垃圾收集发生之前,就是无论当前内存是否足够,都会被回收,可以用 WeakReference
类来实现。
虚引用是最弱的一种引用关系。对象是否有存在虚引用,完全不会对其生存构成影响,也无法通过虚引用来取得对象实例。对象设置虚引用关联的唯一目的就是能在对象被收集器回收时收到一个系统通知,可以根据这个特性来跟踪对象被垃圾收集器回收的活动,它可以用 PhantomReference
类来实现。
4. 垃圾回收
可达性分析算法中不可达的对象,也并不一定会回收,对象被回收至少要经历两次标记过程:如果对象在进行可达性分析后与GC Roots没有引用链,它将会被第一次标记并且进行一次筛选,筛选条件是此对象是否有必要执行 finalize()
方法。
当对象没有覆盖 finalize()
方法或 finalize()
方法已经被虚拟机调用过,都没有必要执行。如果这个对象被判定有必要执行 finalize()
方法,那么它将会放置在 F-Queue 队列中,稍后虚拟机会自动建立一个低优先级的 Finalizer 线程去执行它。这个“执行”是指虚拟机会触发这个方法,并不会等待它运行结束,所以一个对象的回收不会阻塞其他对象的回收。
GC将对 F-Queue 中的对象进行第二次小规模的标记,如果对象在 finalize()
中重新与引用链上的对象建立了关联,那它就不会被回收。
在方法区(HotSpot虚拟机中的永久代)中主要回收两部分内容:废弃常量和无用的类。回收废弃常量与回收Java堆中的对象非常类似,例如判定是否存在 String
对象引用常量池中的字符串常量。判断无用的类条件比较苛刻,需要满足以下三个条件:
- 该类所有的实例都已经被回收(Java堆中不存在该类的任何实例)
- 加载该类的 ClassLoader 已经被回收
- 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
虚拟机可以对满足上述三个条件的无用类进行回收,但并不是一定会回收,是否对类进行回收,HotSpot虚拟机提供了 -Xnoclassgc
参数进行控制,在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
二、垃圾收集算法
1. 标记 - 清除算法
标记 - 清除(Mark-Sweep)算法是最基础的算法,算法分为“标记”和“清除”两个阶段:首先标记所有要回收的对象,在标记完成后统一回收。但它不足的是,标记和清除两个过程的效率都不高,而且标记清除后会产生大量不连续的内存碎片。空间碎片太多会导致程序运行过程中需要分配大对象时,无法找到足够的连续内存而提前触发另一次垃圾收集动作。
2. 复制算法
复制(Copying)算法是将可用内存按容量分为大小相等的两块,每次只使用其中的一块,当这一块内存用完了,就将还存活的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉,这样内存分配时不用考虑内存碎片等复杂情况。但不足的是,将内存缩小为原来的一半,内存投资比较大。
现在商用虚拟机都采用这种收集算法来回收新生代,新生代中对象回收率可达到98%,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活的对象一次性地复制到另外一个Survivor上,最后清理掉Eden和用过的Survivor空间,但没办法保证每次回收后Survivor空间够用,需要依赖其他内存(老年代)进行分配担保。
3. 标记 - 整理算法
复制算法在对象存活率较高时就要进行较多的复制操作,效率将会变低,如果不想浪费50%的空间,就需要额外的空间进行分配担保,以应对被使用的内存中所有对象100%存活的极端情况,所以在老年代一般不直接使用这种算法。标记 - 整理(Mark-Compact)算法中的标记过程与标记 - 清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
4. 分代收集算法
当前商用虚拟机的垃圾收集都采用分代收集算法,它根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代。在新生代中,每次垃圾回收时都会发现大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代因为对象存活率高,没有额外空间对它进行分配担保,就必须使用标记 - 清除或者标记 - 整理算法来进行回收。
5. 枚举根节点
在HotSpot虚拟机中,可达性分析中从GC Roots节点找引用链,GC Roots主要是在全局性的引用(常量和静态属性)与执行上下文(栈帧中的本地变量表)中,逐个检查会消耗很多的时间。另外,可达性分析的GC停顿也会增加执行时间,因为它必须在一个能确保一致性的快照中进行,也就是在整个分析期间整个执行系统在某个时间点上,不可以出现对象引用关系还在不断变化的情况。所以在GC进行时会停顿所有Java执行线程。目前主流虚拟机都使用准确式GC,停顿后并不需要检查所有执行上下文和全局的引用位置,虚拟机可以直接得知哪些地方存放哪些对象引用,在HotSpot中是使用一组OopMap的数据结构来达到这个目的的。
6. 安全点和安全区域
HotSpot虚拟机不会为每条指令都生成OopMap,只是在特定的位置记录这些信息,这些位置称为安全点(Safepoint),即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点才能暂停。Safepoint的选定既不能太少也不能过于频繁,所以,安全点的选定基本是以程序是否长时间执行的特征为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”的明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这这些功能的指令都会产生Safepoint。
对于Safepoint,另一个需要考虑的问题是如何在GC发生时让所有线程(不包括执行JNI调用的线程)都到最近的安全点再停顿下来。这里有两种方案:抢先式中断和主动式中断,现在几乎没有虚拟机采用抢先式中断。
- 抢先式中断:虚拟机需要GC时,中断所有线程,让没有到达SafePoint的线程继续执行至SafePoint并中断
- 主动式中断:虚拟机不直接中断线程,而是在内存中设置标志位,线程检查到标志位被设置,运行至SafePoint时主动中断
在线程处于Sleep状态或者Blocked状态时,无法响应JVM的中断请求,JVM也不会等待线程重新被分配CPU时间,对于这种情况需要安全区域(Safe Region)来解决。安全区域是指在一段代码片段之中,线程执行与否不会影响对象引用的状态。线程进入Safe Region会给自己加标记,告诉虚拟机可以进行GC,线程准备离开Safe Region前会询问虚拟机GC是否完成。
三、垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java虚拟机规范对垃圾收集器如何实现没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器可能大不一样。下图展示了7种不同分代的收集器,两个收集器之间有连线,说明它们可以搭配使用。每个收集器都有各自的特性,没有哪个是最好的。
1. Serial 收集器
Serial收集器是最基本、发展历史最悠久的收集器,它是一个单线程收集器,在进行垃圾收集时,会暂停其他所有工作线程。但它相比其他收集器,简单而高效,适用于运行在Client模式下的虚拟机。
2. ParNew 收集器
ParNew收集器是Serial收集器的多线程版本,它是Server模式下虚拟机首选的新生代收集器,因为除了Serial收集器外,目前只有它能与CMS收集器搭配使用。因为存在线程交互的开销,ParNew收集器在单CPU环境中比不上Serial收集器。
3. Parallel Scavenge 收集器
Parallel Scavenge收集器是一个新生代收集器,也使用复制算法,又是并行的多线程收集器。它与其他收集器不同的是,不关注垃圾收集的停顿时间,只需达到一个可控的吞吐量(CPU运行用户代码的时间与CPU总消耗时间的比值)。停顿时间越短就适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则是可以高效地利用CPU时间,尽快完成程序的运行任务,主要适合在后台运算而不需要太多交互的任务。除此之外,Parallel Scavenge收集器提供一个参数 -XX:+UseAdaptiveSizePolicy
来打开GC自适应调节策略,打开之后,就不需要手工指定新生代大小、Eden与Survivor比例、晋升老年代对象年龄等细节参数,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。
4. Serial Old 收集器
Serial Old是Serial收集器的老年代版本,同样是一个单线程收集器,使用标记 - 整理算法,它的主要意义也是给Client模式下的虚拟机使用。
5. Parallel Old 收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和标记 - 整理算法,这在JDK 1.6中才开始提供,在此之前,新生代的Parallel Scavenge收集器只能与老年代的Serial Old收集器搭配使用,因此老年代在服务端应用性能上会受到限制,直到Parallel Old收集器出现后,才有了吞吐量优先的应用组合。
6. CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,目前大部分Java应用集中在互联网站或者B/S系统的服务端上,为给用户带来较好的体验,非常重视系统停顿时间。它是基于标记 - 清除算法实现的,运行过程比前几种收集器复杂,分为4个步骤:
- 初始标记:停止用户线程,标记GC Roots能直接关联到的对象,速度很快
- 并发标记:进行GC Roots Tracing,用时长,可以与用户线程并发执行
- 重新标记:停止用户线程,修正并发标记期间产生变动的对象的标记记录,可并行执行
- 并发清除:用时长,可以与用户线程并发执行
CMS收集器拥有并发收集、低停顿的特性,但还远达不到完美的程序,有以下3个明显的缺点:
- 对CPU资源非常敏感:并发收集虽然不会导致用户线程停顿,但会占用资源而使程序变慢。
- 无法处理浮动垃圾:无法处理并发清理阶段用户线程产生的新垃圾。
- 产生大量空间碎片:标记 - 清除算法的缺点。
7. G1 收集器
G1收集器收集器是当今收集器技术发展的最前沿成果之一,它是一款面向服务端应用的垃圾收集器,具有以下特点:
- 并行与并发:充分利用多CPU多核环境硬件优势,缩短停顿时间。
- 分代收集:虽然G1不需要其他收集器配合,但依然支持分代回收。
- 空间整合:从整体上看基于标记 - 整理算法,从局部(Region)上看是复制算法,不会产生内存空间碎片。
- 可预测停顿:除了追求低停顿外,还能建立可预测的停顿时间模型。
使用G1收集器时,Java堆为划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但它们不再是物理隔离的,都是一部分Region(不连续)的集合。G1之所以建立可预测模型,是因为它能有计划地避免在整个Java堆中进行垃圾收集,跟踪各个Region里面的垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。一个对象分配在某个Region中,可以与Java堆任意的对象发生引用关系,所以Region之间的对象引用以及其他收集器中新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。如果不计算维护Remembered Set的操作,G1收集器有以下运行步骤
- 初始标记:标记GC Roots能直接关联到的对象,速度很快
- 并发标记:进行GC Roots Tracing,用时长,可以与用户线程并发执行
- 最终标记:修正并发标记期间产生变动的对象的标记记录,可并行执行
- 筛选回收:对各个Region的回收价值和成本进行排序,制定回收计划,可并行执行
四、内存分配与回收策略
Java技术体系中所提倡的自动内存管理最终可以归结为自动化解决两个问题:给对象分配内存以及回收分配给对象的内存。下面是几条普遍的内存分配规则:
对象优先在 Eden 分配:大多数情况下,对象在新生代Eden区中分配,当Eden区没有足够空间分配时,虚拟机会发起一次Minor GC。
大对象直接进入老年代:大量连续内存空间的Java对象(比如长字符串及数组),直接在老年代分配,所以在写程序的时候要避免短命大对象。
长期存活的对象将进入老年代:多次未被回收的对象,年龄增加到一定程度会晋升到老年代中。
动态对象年龄判定:晋升到老年代的年龄标准并不是固定的,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就会直接进入老年代。
空间分配担保:新生代使用复制收集算法,为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况,就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。
新生代GC(Minor GC):指发生在新生代的GC,执行非常频繁,回收速度快。
老年代GC(Major GC/Full GC):指发生在老年代的GC,一般比Minor GC慢10倍以上。