本文总结自周志明的第 2 版《深入理解 Java 虚拟机——JVM 高级特性与最佳实践》
文章目录
JVM 垃圾回收
在开头先讲解一下 Minor GC 和 Full GC 的意思:
- 新生代 GC(Minor GC):指发生在新生代的垃圾回收动作,因为 Java 对象大部分的生存周期都较短,因此 Minor GC 非常频繁,回收速度也较快。
- 老年代 GC(Major GC / Full GC):指发生在老年代的 GC,出现一次 Full GC 一般会伴随至少一次的 Minor GC(但不是绝对的)。
一、对象死亡
哪些对象需要回收?在堆里面存放着 Java 中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事就是要确定这些对象之中哪些还 “存活” 着,哪些已经 “死去” (即不可能再被任何途径使用的对象)
1. 引用计数算法
算法思路:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1;任何时刻计数器为 0 的对象就是不可能再被使用的。
该算法实现简单,判定效率高,在一些情况下他也是一种不错的算法,但 Java 并不采用这种算法, 因为这个算法很难解决对象之间相互循环引用的问题。
2. 可达性分析算法
算法思路:通过一系列的称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,即 GC Roots 到这个对象不可达时,则证明此对象时不可用的。如下图所示:
在 Java 中,可作为 GC Roots 的对象包括以下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象
3. Java 中的引用
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与 “引用” 有关。对于对象的引用,我们希望可以通过引用描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。
JDK1.2 后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用,这四种引用强度依次逐渐减弱。
- 强引用:就是指在程序代码之中普遍存在的,类似
Object obj = new Object()
这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。 - 软引用:是用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。通过使用 SoftReference 类实现软引用。
- 弱引用:也是用来描述非必需对象的,但是它的强度比软引用更强一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。通过使用 WeakReference 类实现弱引用。
- 虚引用:也称为幽灵引用或者幻影引用,他是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。通过使用 PhantomReference 类实现虚引用。
4. 宣告对象死亡
要真正宣告一个对象死亡,需要经历两次标记过程:
- 经过可达性分析后发现不可达,进行第一次标记,并判断是否有必要执行 finalize() 方法,如果对象的 finalize() 没被调用过且覆盖了 Object 中的 finalize() 方法,则会将对象加入一个 F-Queue 队列中。
- 虚拟机会建立一个低优先级的 Finalizer 线程执行队列中的对象的 finalize() 方法,如果在 finalize() 方法中重新跟任意引用链建立管理关系,则取消标记,移出 “即将回收” 集合,该对象避免被回收。
5. 方法区回收
方法区也称为永久代,方法区的垃圾回收效率远比堆区的回收效率低。
方法区的垃圾收集主要回收两个内容:废弃常量和无用的类。
- 在常量池中的方法、字段等,只要没有对象引用这个常量,也没有其他地方引用了这个字面量,就会在垃圾回收时被清除。
- 无用类需要满足三个条件:
1. 该类的所有实例都已经被回收;
2. 加载该类的类加载器已经被回收;
3. 该类的 Class 对象没有被引用。
是否对类进行回收,HopSpot 虚拟机提供了 -Xnoclassgc 参数进行控制,还可以使用 -verbose:class 以及 -XX:+TraceClassLoading、-XX:+TraceClassUnLoading 查看类加载和卸载信息。
二、 垃圾回收算法
1. 标记—清除算法
算法思路:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
不足:
- 效率问题,标记和清除两个过程的效率不高;
- 空间问题,清除后会产生大量不连续的内存碎片,后期分配给大对象时找不到连续内存,不得不又触发一次垃圾回收。
2. 复制算法
算法思路:将内存分为大小相等的两块,每次只使用其中一块,当这一块内存使用完后,将存活对象移动到另一块上,并将用完的这一块完全清除。
不足:只使用内存的一半,代价有点大
3. 标记—整理算法
算法思路:标记过程与 “标记-清除” 算法一样,但后续步骤变更为:所有存活对象向一端移动,清理掉边界外的内存区域。
不足:效率较低
4. 分代收集算法
算法思路:将内存分为新生代和老年代,并根据对象的生存周期分配给新生代或老年代。
- 新生代存的是生存周期较短的对象,每次垃圾回收都会有大批对象死去,只有少量存活,每次清理只需要付出少量存活对象的复制成本就可以完成,因此一般选择复制算法;
- 老年代中的对象存活率较高,没有额外的空间对他进行分配担保,就需要使用 “标记-清理” 或 “标记-整理” 算法。
三、 垃圾收集器
垃圾收集器是内存回收的具体实现,每个收集器都有各自的特点,不同厂商、不同版本所提供的垃圾回收器都有所不同,用户一般也可以根据应用特点自己组合出各个年代所使用的收集器。
以下是目前较为主流的收集器:。
1. Serial 收集器
这个收集器是一个单线程的收集器,不仅仅是说他只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是说当他进行垃圾收集时,必须暂停其他所有工作线程,直到收集结束。
评价:效果较差,且需要完全暂停用户线程,但与其他收集器的单线程比更简单高效,对于限定单个 CPU 的环境来说,因为没有线程交互的开销,能获得最高的单线程垃圾回收效率。
2. ParNew 收集器
该收集器是 Serial 收集器的多线程版本,除了使用多线程进行垃圾回收外,控制参数、收集算法、分配规则等都与 Serial 收集器完全一样,两者共用了很多代码。
评价:在 CPU 数量较少,尤其是一两个的情况下,ParNew 收集器的效果不如 Serial 收集器,但随着 CPU 数量的增加,ParNew 收集器的效果也会加强,其中可以通过使用 -XX:ParallelGCThreads 参数来限制垃圾收集器的线程数。由于只有该收集器能与 CMS 收集器配合工作,因此是新生代收集器的首选。CMS 在 JDK1.5 发布时期推出,几乎被认为是有划时代意义的垃圾收集器。
3. Parallel Scavenge 收集器
Parallel Scavenge 收集器与 ParNew 收集器相似,是新生代的采用复制算法的并行的多线程收集器。大部分收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目的是达到一个可控制的吞吐量。
吞吐量: CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即:吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。比如,虚拟机总共运行 100 分钟,其中垃圾收集花掉 1 分钟,吞吐量就是 99%。
Parallel Scavenge 收集器提供两个参数控制吞吐量:
- -XX:MaxGCPauseMillis :控制最大垃圾回收停顿时间,参数是大于 0 的毫秒数
- -XX:GCTimeRatio :直接设置吞吐量大小
Parallel Scavenge 收集器还有一个开关参数,-XX:+UseAdaptiveSizePolicy 参数,开启后,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整新生代大小、晋升老年代对象年龄等细节参数,以提供最合适的停顿时间或者最大的吞吐量。
评价:停顿时间短适合要与用户交互的程序,提升用户体验;高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互地任务。
4. Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本,同样是一个单线程收集器,使用 “标记-整理” 算法。
评价:在 JDK1.5 以前,主要与 Parallel Scavenge 收集器搭配使用,现也作为 CMS 收集器的后备方案,在并发收集发生 Concurrent Mode Failure 时使用。
5. Parallel Old 收集器
Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本,也更注重吞吐量而不是注重停顿时间,是一个多线程、使用 “标记-整理” 算法的收集器。
评价:在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器组合。
6. CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。该收集器非常符合重视服务的响应速度,希望系统停顿时间更短的互联网站或者 B/S 系统的服务端上。
该收集器是基于 “标记-清除” 算法实现的,整个过程分为以下 4 个步骤:
- 初始标记(CMS initial mark):仅仅标记一下 GC Roots 能直接关联到的对象,速度快,不过需要暂停用户线程。
- 并发标记(CMS concurrent mark):进行 GC Roots Tracing 的过程。
- 重新标记(CMS remark):为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,也会暂停用户线程,但暂停时间较短。
- 并发清除(CMS concurrent sweep):垃圾清除,可以跟用户线程一起运行。
评价:CMS 是一款优秀的收集器,主要优点是:并发收集、低停顿。
不足:
- 对 CPU 找资源非常敏感。CMS 默认启动的回收线程数是(CPU 数量 + 3) / 4,也就是当 CPU 在 4 个以上时,并发回收时垃圾收集线程不少于 25% 的 CPU 资源,并随着 CPU 数量的增加而下降。但是当 CPU 不足 4 个时(譬如 2 个),就需要分出一半的运算能力执行收集器线程,就会导致用户程序的执行速度忽然降低了 50% 。
- CMS 收集器无法处理浮动垃圾,可能出现 “Concurrent Mode Failure” 失败而导致另一次 Full GC 的产生。当 CMS 收集器在进行清除时,由于用户线程也还在运行,可能会产生新的垃圾,要留到下一次清除,因此需要预留充足的空间给用户线程,不能像其他收集器一样等到老年代几乎填满才收集。一旦预留的空间无法满足并行的用户线程的使用,就会出现 “Concurrent Mode Failure” 失败,此时就会启动后备预案:临时启动 Serial Old 收集器进行老年代的垃圾收集。 -XX:CMSInitiatingOccupancyFraction 参数可控制老年代内存使用达到一定百分比后,启动 CMS 收集器,JDK1.6 的默认数值为 92%,
- CMS 是基于 “标记-清除” 算法实现的,会产生大量的空间碎片。CMS 提供一个 -XX:+UserCMSCompactAtFullCollection 开关参数(默认开启),用于在 CMS 收集器顶不住要进行 Full GC 时开启内存碎片的合并整理过程,内存整理的过程无法并发,停顿时间不得不变长。 -XX:CMSFullGCsBeforeCompaction ,这个参数用于设置执行多少次不压缩的 Full GC 后,跟着来一次带压缩的(默认为 0,即每次 Full GC 都进行碎片整理)。
7. G1 收集器
G1 将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,他们都是一部分 Region(不需要连续)的集合。
G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需要的时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。这种回收方式保证了 G1 收集器在有限时间内可以获取尽可能高的收集效率。
G1 收集器的运作大致可划分为以下几个步骤:
- 初始标记(Initial Marking):标记一下 GC Roots 能直接关联到的对象,并修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的 Region 中创建新对象,这阶段需要停顿线程,但耗时很短。
- 并发标记(Concurrent Marking):从 GC Root 开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可以与用户程序并发执行。
- 最终标记(Final Marking):修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分标记记录,这阶段需要停顿线程,但是可以并发执行。
- 筛选回收(Live Data Counting and Evacuation):首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。
评价:
- 并行与并发:G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU (CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿的时间。
- 分代收集:与其他收集器一 样,分代概念在 G1 中依然得以保留。虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象以获取更好的收集效果。
- 空间整合:与 CMS 的 “标记-清理” 算法不同,G1 从整体来看是基于 “标记-整理” 算法实现的收集器,从局部(两个 Region 之间)上来看是基于 “复制” 算法实现的,但无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片。
- 可预测的停顿:这是 G1 相对于 CMS 的另一大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒,这几乎已经是实时 Java(RTSJ)的垃圾收集器的特征了。
四、 内存分配和回收策略
分配的规则不是百分百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置,以下是几条最普遍的内存分配规则。
1. 对象优先在 Eden 分配
多数情况下,对象在新生代 Eden 区中分配,当 Eden 区没有足够的空间进行分配,虚拟机将发起一次 Minor GC。
虚拟机提供了 -XX:+PrintGCDetails 这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并在进程退出的时候输出当前的内存各区域分配情况。
2. 大对象直接进入老年代
大对象就是指需要大量连续内存空间的 Java 对象,最典型的就是很长的字符串以及数组。
虚拟机提供了 -XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配。(只针对 Serial 和 ParNew 两个收集器有效)
3. 长期存活的对象将进入老年代
虚拟机给每个对象定义了一个对象年龄计数器,如果对象在 Eden 出生并经过第一次 Minor GC 后仍存活,且能被 Survivor 容纳,就移动到 Survivor 空间之中,并设置对象年龄为 1,对象在 Survivor 中每 “熬过” 一次 Minor GC,年龄就增加 1 岁,当年龄增加到一定程度(默认为 15),该对象就晋升到老年代。
虚拟机提供了 -XX:MaxTenuringThreshold 参数,用来设置对象晋升老年代的年龄阈值。
4. 动态对象年龄判定
如果在 Survivor 空间中相同年龄所有对象大小总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到年龄达到阈值。
5. 空间分配担保
在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间。如果条件成立,那么 Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 HandlePromotionFailure 设置不允许冒险,则这时也要改为进行一次 Full GC。
注:JDK 6 Update 24 后,规则变更为:只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC,否则进行 Full GC。