成为一名合格的 Java 程序员,JVM 是避不开的知识点。
这篇学习笔记的内容来自《深入理解Java虚拟机:JVM高级特性与最佳实践》。
回顾
上一篇文章讲了 JVM 的运行时数据区:
- 程序计数器
- 虚拟机栈
- 本地方法栈
- 堆
- 方法区
栈中存储的是栈帧,栈帧又包含一次方法调用的全部信息,在类的结构确定之时,栈帧的大小基本上就已经确定下来,而栈又是线程私有的,在线程存活的条件下,基本上不可能回收这一部分的内存,因此垃圾回收器通常不处理栈的内存空间。
同样的道理也可以应用于程序计数器和方法区,虽然方法区不是线程私有的,但是它加载的类信息都是有用的,除非极端情况下需要卸载类,否则没必要对这部分内存进行垃圾回收。
一、垃圾收集模块
堆里面存放的都是对象的实例,当实例的生命周期结束,垃圾收集器便需要回收这些“垃圾”,保持堆剩余空间的可用。
1.1 生存还是毁灭
对于一个实例,如何知道它是否被使用,这是垃圾收集器首先要解决的问题。
-
引用计数法:当被引用时,计数器 +1,引用失效时,计数器 -1,直到计数器为 0,表示实例可以被回收
-
根搜索算法(可达性分析):通过建立一系列
GC Roots,然后向下搜索路径,此路径称为引用链,当某个对象与GC Roots之间没有任何引用链(不可达),即表示此对象不可用
可以作为 GC Roots 的对象包括:
- 虚拟机栈(栈帧中的本地变量表)引用的对象
- 方法区的静态变量引用的对象
- 方法区的常量引用的对象
- 本地方法栈中 JNI 引用的对象
提示:引用计数法没有被 Java 采用,是因为 Java 允许 A 对象引用 B 对象的同时,B 对象也可以引用 A 对象,这种情况就是循环依赖,它会导致引用计数器永不为 0,造成 A 和 B 对象所在的内存空间泄漏,无法被回收。
1.2 引用分类
JDK 1.2 之前,引用就是 reference 类型所存储的数值对应堆里面的一个内存地址。之后,引用被扩充为四种类型:
- 强引用:类似创建一个对象赋值给声明类型的变量,只要变量还在,垃圾收集器就不会回收被引用的对象
- 软引用:
SoftReference类实现的引用,无法申请足够内存而即将抛出内存溢出异常之前,会将这些对象列进回收范围,进行第二次回收,如果回收之后内存还不够,才会将内存溢出异常抛出 - 弱引用:
WeakReference类实现的引用,无论内存是否足够,垃圾收集器工作时,都会回收掉这部分引用 - 虚引用:
PhantomReference类实现的引用,唯一的作用就是在回收之时,收到一个系统通知,除此之外,不会产生任何影响,也不能通过它获得对象的实例
1.3 自我救赎
当一个对象被垃圾收集器发现“不可达”时,会被标记一次,随后进行一次“背景审核”。审核的条件是,finalize() 方法是否被覆盖,或者已经被调用过。当条件成立,则虚拟机不调用当前对象的这个方法。
需要执行 finalize() 方法的对象,会被放入到名为 F-Queue 的队列中,再交由虚拟机自动建立的低优先级的 Finalizer 线程去处理。处理时,会触发 finalize() 方法,但不承诺会等待执行完毕。如果这一次标记成功的话,那么垃圾收集器才会真正回收这部分对象,否则还有机会“自我救赎”。逃脱被回收的方式就是重新建立与引用链上任何对象的关系,比如给某个对象的成员变量赋值 this。但“自我救赎”也只能够成功一次,因为 finalize() 方法只会被虚拟机调用一次。
1.4 永久代收集
方法区即永久代,这部分内存空间的垃圾收集效率较低,没有新生代那么有效。
- 废弃常量:系统没有任何引用常量的对象
- 无用的类:条件比较苛刻,需要满足三个条件
- 所有实例被回收
- 加载该类的类加载器也被回收
- 该类对应的
Class对象没有被引用,无法在任何地方通过反射访问该类的方法
1.5 算法
垃圾回收算法的实现有很多种,与做家务十分类似,每个人做家务的习惯不同,因此步骤与手法不尽相同,虚拟机的实现不同,垃圾回收算法也不一样。
- 标记-清除算法:最基础的算法。首先标记所有需要回收的对象,然后统一回收被标记的对象
- 效率问题:标记和清除的效率不算高
- 空间问题:清除之后,产生大量不连续的内存碎片,在创建大对象时,找不到合适的空间而再触发一次垃圾收集
- 复制算法:分配两块内存空间,每次只使用其中一块内存,当空间不足时,将存活的对象复制到另外一块内存上,再将这块内存清理干净
- 实现简单、运行高效
- 代价昂贵,可用最大内存较少
- HotSpot 虚拟机划分一块较大的 Eden 空间和两块较小的 Survivor 空间,默认按 8:1:1 的比例分配,这是因为大部分情况下,新生代回收时,约剩余 10% 的存活对象
-
标记-整理算法:这种算法的标记过程与标记-清除算法一样,只不过标记完成后,会将可回收对象向一端移动,然后清理边界以外的内存
- 分代收集算法:这是商业虚拟机的主流算法,根据对象的存活周期分为几块不同的内存区域,一般是分为新生代和老年代,然后根据各个年代的特点,采用不同的收集算法,在新生代中,每次垃圾收集时都发现有大量的对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集,而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记相关的算法来进行回收
1.6 实现
不同厂商有不同的 JVM 实现,不同的 JVM 所提供的垃圾收集算法也不一致,以下展示 Sun HotSpot 1.6 Update 22 垃圾收集器。

- Serial 收集器:最基本、历史最悠久的收集器,在 JDK 1.3 之前是新生代收集的唯一选择,单线程运行,需要先暂停其他所有线程,直到收集结束
- 对于新生代,采用复制算法
- 对于老年代,采取标记-整理算法
- 运行在 Client 模式下的默认新生代收集器
- 优势:从单个线程对比来说,简单而高效
- ParNew 收集器:实际上就是 Serial 收集器的多线程版本,共用了很多代码
- 运行在 Server 模式下的首选新生代收集器
- 除 Serial 收集器外,只有它能与 CMS 收集器配合工作
- Parallel Scavenge 收集器:新生代收集器,使用复制算法的并行多线程收集器
- 目标是达到一个可控制的吞吐量,即尽可能保证垃圾收集的停顿时间不超过设定值,或保证吞吐量不超过设定值
- 可以设定自适应调节策略,一旦打开这个策略,很多细节就不需要手工设定,全部交给虚拟机自动完成
- Serial Old 收集器:Serial 收集器的老年代版本,同样是单线程收集器,使用标记-整理算法
- 主要在 Client 模式下使用
- 在 Server 模式下,一个是在 JDK 1.5 及之前版本中与 Parallel Scavenge 收集器搭配使用
- 另外一个 Server 模式下的用途是作为 CMS 收集器的后备预案
- Parallel Old 收集器:Parallel Scavenge 收集器的老年代版本,使用多线程和标记-整理算法
- JDK 1.6 开始提供此收集器
- JDK 1.6 之前,Parallel Scavenge 收集器只能搭配 Serial Old 收集器使用
- 鉴于 Serial Old 的单线程性能,在老年代收集器中,未必有 ParNew + CMS 的组合给力
- 注重吞吐量及 CPU 资源敏感的场合,可以优先考虑 Parallel Scavenge + Parallel Old 组合
- CMS 收集器:以获取最短回收停顿时间为目标的收集器,大部分 Java 应用都是互联网站或 B/S 系统的服务端,重视服务的响应速度,自然希望停顿时间最短,用户体验较好,CMS 收集器就符合这些应用的需求
- 基于标记-清除算法,运行过程较前面几个收集器更复杂一些
- 标记步骤:1.初始标记、2.并发标记、3.重新标记、4.并发清除
- 第一步只是标记一下 GC Roots 能直接关联到的对象,速度很快,但需要暂停用户线程
- 第二步就是进行 GC Roots Tracing 的过程
- 第三步要暂停用户线程,是为了修正并发标记期间,有一部分对象的标记记录产生变动,它的停顿时间比第一步稍长,比第二步短很多
- 缺点:对 CPU 资源非常敏感,无法处理浮动垃圾,收集结束时会产生大量空间碎片
- G1 收集器:基于标记-整理算法实现,可以非常精准地控制停顿,它将整个堆分为几个固定大小的独立区域,优先回收垃圾最多的区域
1.7 参数
虚拟机内置许多垃圾收集器,对于桌面应用、服务端应用或者其他应用的不同使用场景,可以选择不同的垃圾收集器组合,提供更好的用户体验(主要是回收垃圾时的停顿时间)。
| -XX:+{参数} | 描述 |
|---|---|
| UseSerialGC | 运行在 Client 模式下的默认值,开启则使用 Serial + Serial Old 组合 |
| UseParNewGC | 使用 ParNew + Serial Old 组合 |
| UseConcMarkSweepGC | 使用 ParNew + CMS + Serial Old 组合,Serial Old 是后备收集器 |
| UseParallelGC | 运行在 Server 模式下的默认值,开启则使用 Parallel Scavenge + Serial Old (PS MarkSweep)组合 |
| UseParallelOldGC | 使用 Parallel Scavenge + Parallel Old 组合 |
| SurvivorRatio | 新生代中 Survivor 与 Eden 的容量比值,默认为 8,即 Survivor:Eden = 1:8 |
| PretenureSizeThreshold | 对象大小超过阈值将直接在老年代分配 |
| MaxTenuringThreshold | 对象年龄超过阈值将晋升为老年代,年龄是指 Minor GC 之后,自动加一 |
| UseAdaptiveSizePolicy | 动态调整 Java 堆中各个区域的大小以及进入老年代的年龄 |
| HandlePromotionFailure | 是否允许分配担保失败,即极端情况下,新生代中所有对象都存活,使得老年代剩余空间无法容纳 |
| ParallelGCThreads | 设置并行回收线程的数量 |
| GCTimeRatio | GC 时间占比,算法为 1:(1+N),默认值为 99,等于 1:100 的比率,也就是 1%,仅 Parallel Scavenge 生效 |
| MaxGCPauseMills | GC 最大停顿时间,仅 Parallel Scavenge 生效 |
| CMSInitiatingOccupancyFraction | 启动占用率,默认为 68%,即老年代空间使用超过 68% 时,触发垃圾收集,仅 CMS 生效 |
| UseCMSCompactAtFullCollection | 回收完成后是否进行内存碎片整理,仅 CMS 生效 |
| CMSFullGCsBeforeCompaction | 回收一定次数后是否进行内存碎片整理,仅 CMS 生效 |
二、内存分配
应对垃圾回收策略,了解对象在内存上的不同位置和存活时间,学会合理分配内存空间,优化代码提升性能。
2.1 优先分配
大部分情况下,对象在新生代的 Eden 区分配,当 Eden 区内存不足时,将发起一次 Minor GC。
- Minor GC:发生在新生代的垃圾回收,由于新生代内存大多数是朝生夕灭,所以会非常频繁且速度较快
- Major GC / Full GC:发生在老年代的垃圾回收,速度一般比 Minor GC 慢十倍以上
2.2 大对象分配
需要大量连续内存空间的对象,则称之为大对象,通常是很长的字符和数组。
大对象在分配时,容易导致在内存还有足够使用的空间时,依然触发垃圾回收以整理出足够安置大对象的内存空间。所以一般情况下会设置一个阈值,当对象的大小超过阈值时,则直接在老年代中分配这个对象。需要注意的是,这个阈值只对 Serial 和 ParNew 两款垃圾收集器有效。
2.3 长期存活对象
当对象在 Minor GC 发生时标记为存活,那就会被转移到 Survivor to 区域,如果 Survivor from 中也有对象存在,那么会根据年龄阈值来判断去留,超过阈值则进入老年代,否则转移到 to 区域,随后 to 区域与 from 区域转换身份,再清理掉 Eden 区域和 Survivor to 区域。Survivor from 区域中的对象,每经历一次 Minor GC,它们的年龄都会增加 1 岁,默认的年龄阈值是 15 岁。
2.4 动态年龄判断
在 Survivor 空间中,相同年龄的对象,它们的大小总和超过 Survivor 空间的一半时,那么大于等于这个年龄值的对象就可以直接进入老年代,不需要达到年龄阈值。
2.5 空间担保
在 Minor GC 时,如果之前晋升到老年代的内存平均值大于老年代的剩余空间,那么就直接进行 Full GC,否则就去查看 HandlePromotionFailure 设置是否允许担保失败,如果允许,那就只进行 Minor GC,要是不允许,还是要改为 Ful GC。
空间担保是为了减少 Full GC 的频率,在允许担保失败的情况下,如果 Minor GC 失败(新生代中 Eden 空间存活对象过多,导致 Survivor 空间无法容纳),那么就需要老年代来担保这次失败,把存活对象直接放入老年代。如果老年代不足以容纳这些存活对象,那么最终再进行一次 Full GC。
总结
避免创建朝生夕灭的大对象,减少 Full GC 的频率,有利于提升用户体验,让程序更平稳更顺畅。
下一篇文章,我们来了解一下虚拟机性能监控与故障处理。