《深入理解Java虚拟机:JVM高级特性与最佳实践(第2版)》笔记 - 自动内存管理机制(垃圾收集器与内存分配策略)
垃圾收集器与内存分配策略
1、概述
当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。 其中程序计数器、虚拟机栈、本地方法栈 3 个区域随线程而生,随线程而灭; 而 Java 堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象。
2、对象已死吗
2.1、引用技术算法
主流的 Java 虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。
2.2、可达性分析算法
这个算法的基本思路就是通过一系列的称为" GC Roots" 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链( Reference Chain), 当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说,就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。
在 Java 语言中,可作为 GC Roots 的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中 JNI( 即一般说的 Native 方法)引用的对象。
2.3、再读引用
我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。
强引用( Strong Reference) 强引用就是指在程序代码之中普遍存在的,类似" Object obj= new Object()" 这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
软引用( Soft Reference) 软引用是用来描述一些还有用但并非必需的对象。 在 JDK 1. 2 之后,提供了 SoftReference 类来实现软引用。
弱引用( Weak Reference) 弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。
WeakReference 虚引用( Phantom Reference) 为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在 JDK 1. 2 之后,提供了 PhantomReference 类来实现虚引用。
2.4、生存还是死亡
要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。 如果对象要在 finalize() 中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可。 任何一个对象的 finalize() 方法都只会被系统自动调用一次。 笔者并不鼓励大家使用这种方法来拯救对象。相反,笔者建议大家尽量避免使用它,因为它不是 C/ C++ 中的析构函数,而是 Java 刚诞生时为了使 C/ C++ 程序员更容易接受它所做出的一个妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。 关闭外部资源,使用 try- finally 或者其他方式都可以做得更好、更及时,所以笔者建议大家完全可以忘掉 Java 语言中有这个方法的存在。
2.5、回收方法区
Java 虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区中进行垃圾收集的“性价比”一般比较低。
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。
类需要同时满足下面 3 个条件才能算是“无用的类”:
- 该类所有的实例都已经被回收。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 java. lang. Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
是否对类进行回收, HotSpot 虚拟机提供了-Xnoclassgc 参数进行控制,还可以使用 -verbose: class 以及-XX:+ TraceClassLoading、- XX:+ TraceClassUnLoading 查看类加载和卸载信息。
在大量使用反射、动态代理、 CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
3、垃圾收集算法
3.1、标记-清楚算法
它的主要不足有两个:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片。
3.2、复制算法
它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。 这种算法的代价是将内存缩小为了原来的一半,未免太高了一点。
现在的商业虚拟机都采用这种收集算法来回收新生代。
将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor[ 1]。 当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。
HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8: 1。 当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保( Handle Promotion)。 如果另外一块 Survivor 空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。
3.3、标记-整理算法
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。老年代中一般不能直接选用这种算法。
标记-整理算法让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
3.4、分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集”( Generational Collection) 算法。
一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。
4、HotSpot的算法实现
4.1、枚举根节点
当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。在 HotSpot 的实现中,是使用一组称为 OopMap 的数据结构来达到这个目的的。
4.2、安全点
在 OopMap 的协助下, HotSpot 可以快速且准确地完成 GC Roots 枚举,但一个很现实的问题随之而来:可能导致引用关系变化。
在“特定的位置”记录了这些信息,这些位置称为安全点。
对于 Safepoint, 另一个需要考虑的问题是如何在 GC 发生时让所有线程(这里不包括执行 JNI 调用的线程)都“跑”到最近的安全点上再停顿下来: 抢先式中断( Preemptive Suspension) 和主动式中断( Voluntary Suspension)
4.3、安全区域
在线程要离开 Safe Region 时,它要检查系统是否已经完成了根节点枚举(或者是整个 GC 过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开 Safe Region 的信号为止。
5、垃圾收集器
5.1、Serial收集器
" Stop The World" 这个名字也许听起来很酷,但这项工作实际上是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说都是难以接受的。
读者不妨试想一下,要是你的计算机每运行一个小时就会暂停响应 5 分钟,你会有什么样的心情? 从 JDK 1. 3 开始,一直到现在最新的 JDK 1. 7, HotSpot 虚拟机开发团队为消除或者减少工作线程因内存回收而导致停顿的努力一直在进行着,从 Serial 收集器到 Parallel 收集器,再到 Concurrent Mark Sweep( CMS) 乃至 GC 收集器的最前沿成果 Garbage First( G1) 收集器,我们看到了一个个越来越优秀(也越来越复杂)的收集器的出现,用户线程的停顿时间在不断缩短,但是仍然没有办法完全消除(这里暂不包括 RTSJ 中的收集器)。
在用户桌面应用场景中,分配的内存一般不会很大,收集几十兆的新生代,停顿在几十毫秒是可以接受的,它依然是虚拟机运行在 Client 模式下的默认新生代收集器。 所以, Serial 收集器对于运行在 Client 模式下的虚拟机来说是一个很好的选择。
5.2、Parallel Scavenge收集器
Parallel Scavenge 收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器。
CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量( Throughput)。
虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。 停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
控制最大垃圾收集停顿时间 -XX: MaxGCPauseMillis
直接设置吞吐量大小 -XX: GCTimeRatio GC
停顿时间缩短是以牺牲吞吐量和新生代空间来换取的
GC自适应调节策略: -XX:+ UseAdaptiveSizePolicy
自适应调节策略也是 Parallel Scavenge 收集器与 ParNew 收集器的一个重要区别。
5.3、ParNew收集器
ParNew 收集器其实就是 Serial 收集器的多线程版本。
它却是许多运行在 Server 模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。
CMS收集器第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
CMS 作为老年代的收集器,却无法与 JDK 1. 4. 0 中已经存在的新生代收集器 Parallel Scavenge 配合工作,只能选择ParNew或者Serial收集器中的一个。
由于存在线程交互的开销,该收集器在通过超线程技术实现的两个 CPU 的环境中都不能百分之百地保证可以超越 Serial 收集器。
它默认开启的收集线程数与 CPU 的数量相同
-XX:+UseConcMarkSweepGC
-XX:+UseParNewGC
可以使用- XX: ParallelGCThreads 参数来限制垃圾收集的线程数。
5.4、Serial Old收集器
Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。
5.5、Parallel Old收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法。 这个收集器是在 JDK 1. 6 中才开始提供的。在此之前,如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old( PS MarkSweep) 收集器外别无选择(还记得上面说过 Parallel Scavenge 收集器无法与 CMS 收集器配合工作吗?这种组合的吞吐量甚至还不一定有 ParNew 加 CMS 的组合“给力”。)
5.6、CMS收集器
CMS收集器获取最短停顿时间为目标,多数应用于互联网站或者B/S系统的服务器端上。
“标记—清除”算法实现的。
优点:并发收集、低停顿, Sun 公司的一些官方文档中也称之为并发低停顿收集器( Concurrent Low Pause Collector)。
缺点: CMS 收集器对 CPU 资源非常敏感。 CMS 收集器无法处理浮动垃圾( Floating Garbage)。如果在应用中老年代增长不是太快,可以适当调高参数- XX: CMSInitiatingOccupancyFraction 的值来提高触发百分比,以便降低内存回收次数从而获取更好的性能。
CMS运行期间预留的内存无法满足程序需要时,虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
所以说参数- XX: CM SInitiatingOccupancyFraction 设置得太高很容易导致大量" Concurrent Mode Failure" 失败,性能反而降低。 收集结束时会有大量空间碎片产生。
CMS 收集器提供了一个- XX:+ UseCMSCompactAtFullCollection 开关参数(默认就是开启的),用于在 CMS 收集器顶不住要进行 FullGC 时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。
5.7、G1收集器
未来可以替换掉 JDK 1. 5 中发布的 CMS 收集器。
特点:
- 并行与并发
- 分代收集
- 空间整合
- 可预测的停顿
将整个 Java 堆划分为多个大小相等的独立区域( Region), 虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region( 不需要连续)的集合。 虚拟机都是使用 Remembered Set 来避免全堆扫描的。当进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏。
5.8、理解GC日志
33. 125:[ GC[ DefNew: 3324K- > 152K( 3712K), 0. 0025925 secs] 3324K- > 152K( 11904K), 0. 0031680 secs]
100. 667:[ Full GC[ Tenured: 0 K- > 210K( 10240K), 0. 0149142secs] 4603K- > 210K( 19456K),[ Perm: 2999K- > 2999K( 21248K)], 0. 0150007 secs][ Times: user= 0. 01 sys= 0. 00, real= 0. 02 secs]
5.9、垃圾收集器参数总结
6、内存分配与回收策略
对象主要分配在新生代的 Eden 区上 少数情况下也可能会直接分配在老年代中
6.1、对象优先在Eden分配
大多数情况下,对象在新生代 Eden 区中分配。
当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。
新生代 GC( Minor GC): 指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
老年代 GC( Major GC/ Full GC): 指发生在老年代的 GC, 出现了 Major GC, 经常会伴随至少一次的 Minor GC( 但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程)。 Major GC 的速度一般会比 Minor GC 慢 10 倍以上。
堆空间分配例子:
-verbose: gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails -XX:SurvivorRatio=8
在运行时通过- Xms20M、- Xmx20M、- Xmn10M 这 3 个参数限制了 Java 堆大小为 20MB, 不可扩展,其中 10MB 分配给新生代,剩下的 10MB 分配给老年代。- XX: SurvivorRatio= 8 决定了新生代中 Eden 区与一个 Survivor 区的空间比例是 8: 1
新生代Minor GC例子:
6.2、大对象直接进入老年代
比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,写程序的时候应当避免)
-XX: PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配。 PretenureSizeThreshold 参数只对 Serial 和 ParNew 两款收集器有效, Parallel Scavenge 收集器不认识这个参数。
大对象直接进入老年代例子:
6.3、长期存活的对象将进入老年代
如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1。 对象在 Survivor 区中每“熬过”一次 Minor GC, 年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数- XX: MaxTenuringThreshold 设置。
6.4、动态对象年龄判断
如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。
6.5、空间分配担保
在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。当大量对象在Minor GC后仍绕存活,就需要老年代进行空间分配担保,把Survivor无法容纳的对象直接进入老年代。如果老年代的判断到剩余空间不足(根据以往每一次回收晋升到老年代对象容量的平均值作为经验值),则进行一次Full GC。
7、本章小结