摘抄自: 《深入理解 Java 虚拟机》 - 周志明 著
概述(为何没有谈及永久代)
为何要了解GC和内存分配
当需要排查各种内存溢出,内存泄漏问题时,当垃圾收称为系统达到更高并发的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。
对象已死吗
引用计数器法
描述:给对象中添加一个引用计数器每当有一个地方引用它时,计数器就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能在被使用的。
缺陷:很难解决对象间相互循环引用的问题。
可达性分析算法
在主流的商用语言的主流实现中,都是称通过可达性分析(Reachablity Analysis)来判断对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说,就是从 GC Roots 到这个对象不可达)时,则证明对象是不可用的。
在 Java 语言中,可作为 GC Roots 的对象包括下面几种
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中 JNI (即一般说的 Native 方法)引用的对象。
再谈引用
在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。
- 强引用 :在程序代码中普遍存在,垃圾收集器永远不会回收被引用的对象。
- 软引用 :是用来描述一些还有用但并非必须的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
- 弱引用:也是用来描述非必须对象的,强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。
- 虚引用:也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。唯一的目的就是能在这个对象被收集器回收时收到一个系统通知。
生存还是死亡
finalize 相关
回收方法区
Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区中进行垃圾收集的“性价比”一般比较低:在堆中,尤其在新生代中,常规的应用一次垃圾收集一般可以回收70% ~ 95%的空间,而永久代的垃圾收集效率远低于此。
永久代的垃圾收集主要回收两部分:废弃常量 和 无用的类。
- 回收废弃常量与回收 Java 堆中的对象非常类似。
- 要判定一个类是否是“无用的类”的条件相对苛刻许多。类需要同时满足下面3个条件才能算“无用的类”
- 该类的所有实例都已经被回收。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
在大量使用反射、动态代理、GGLib 等 ByteCode 框架、动态生成 Jsp 以及 OSGI 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
垃圾收集算法
标记-清除算法
最基础的收集算法是“标记-清除”(Mark-Sweep)算法,算法分为两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有标记的对象。
不足:
效率问题:标记和清除的效率都不高
空间问题:标记清除之后会产生大量不连续的内存碎片,导致以后需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另外一次垃圾收集。
复制算法(Copying)
现在的商业虚拟机都采用这种收集算法来回收新生代。
HotSopt 将内存分为一块比较大的 Eden 空间和两块比较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor 。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另外一块 Survivor 空间上,最后清理掉 Eden 和 刚才用过的 Survivor 空间。
HotSopt 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有10%的内存会被“浪费”。
当 Survivor 空间不够用时,需要依赖其他内存(老年代)进行分类担保(Handle Promotion)。如果另外一块 Survivor 空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象向直接通过分配担保机制进入老年代。
标记-整理算法(Mark-Compact)
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率会变低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,所以老年代一般不能直接选用这种算法。
“标记-整理”算法的标记过程仍然与“标记-清除”算法一样,而后续是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
分带收集算法(Generation Collection)
当前商业虚拟机的垃圾收集都采用“分代收集”算法。根据对象存活周期的不同将内存划分为几块,一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
在新生代中,每次垃圾收集时都发现大批对象死去,只有少量存活,那就选用复制算法。
老年代中因为对象存活率高、没有额外的空间对他进行分配担保,就必须使用“标记-清除”或者“标记-整理”算法。
HotSpot的算法实现
枚举根节点
可作为 GC Roots 的节点主要是在全局的引用(例如常量或者类静态属性)与执行上下文(例如栈帧中的本地变量表)中。
存在的问题
- 现在很多应用仅仅方法区就有数百兆,如果逐个检查这里面的引用,会消耗很多时间。
- 可达性分析对执行时间的敏感还体现在 GC 停顿上,因为分析必须在一个能确保一致性的快照中进行。这点事调至 GC 进行时必须停顿所有 Java 执行线程(“Stop the World”)的其中一个重要的原因。
解决方案
由于目前主流 Java 虚拟机使用的都是准确式 GC,所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。
在 HotSpot 的实现中,是使用一组称为 OopMap 的数据结构来达到这个目的的,在类加载完成的时候,HotSpot 就把对象内什么偏移量上是什么类型的数据计算出来,在 JIT 编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样GC 在扫描时就可以直接得知这些消息了。
安全点
在 OopMap 的协助下,HotSpot 可以快速且准确地完成 GC Roots 枚举。
存在的问题:
使OopMap 变化的指令非常多,如果每一条指令都生成对应的 OopMap,GC 的空间成本将会变得很高。
解决方案:
只在“特定的位置”记录了这些信息,这些位置称为安全点(Safepoint),程序只在到达安全点时才停顿 GC。
安全点的选定
安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间都非常短暂,所以复合这个条件最明显的特征就是指令复用,例如方法调用、循环跳转、异常跳转等,所以具有这些特征的指令才会产生 Safepoint。
抢占方案
GC发生时如何让所有的线程都“跑”到最近的安全点上在停顿下来。两种方案:抢占式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。
几乎没有虚拟机实现采用抢占式中断来暂停线程从而响应 GC 事件。
安全区域
安全区域是指在一段代码片段中,引用关系不会发生变化。在这个区域中的任意地方开始 GC 都是安全的。Safe Region 可以看做是被扩展了的 Safepoint。
垃圾收集器
这类讨论的收集器基于 JDK1.7 Update14 之后的 HotSpot 虚拟机(这个版本中正式提供了商用的 G1 收集器)
Serial 收集器
Serial 收集器是最基本,发展历史最悠久的收集器,曾经(在 JDk1.3.1之前)是虚拟机新生代收集的唯一选择。它是一个单线程收集器。在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。
实际上到现在为止,它依然是虚拟机运行在 Client 模式下的默认新生代收集器。
优点:简单而高效(与其他收集器的单线程相比)。
ParNew收集器
ParNew 收集器其实就是 Serial 收集器的多线程版本。
它是许多运行在 Server 模式下的虚拟机中首选的新生代收集器,其中一个与性能无关的很重要的原因是,除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。
在JDK1.5 时期,HotSpot 推出了 CMS 收集器(Concurrent Mark Sweep),它是 HotSpot 虚拟机中第一款真正意义上的并发收集器。不幸的是,CMS 作为老年代的收集器,却无法与 JDK1.4.0 中已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK1.5中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。
ParNew 默认开启收集线程数与CPU的数量相同。
并发与并行收集器
- 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
- 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行在另一个 CPU 上。
Parallel Scavenge 收集器以及后面提到的 G1 收集器都没有使用传统的 GC 收集器代码框架,而另外独立实现,其余集中收集器则共用了部分的框架代码。
Parallel Scavenge 收集器(吞吐量优先收集器)
Parallel Scavenge 收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器。
与 ParNew 的不同之处:
关注点与其他收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可可控的吞吐量(Throughput)。
吞吐量就是 CPU 运行用户代码的时间与 CPU 总消耗时间的比值。
吞吐量 = 运行用户代码时间 / (运行用户嗲吗时间 + 垃圾收集时间)
停顿时间越短就越适合需要与用户交互的程序,良好的相应速度能提升用户体验,
而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务
Parallel Scavenge 收集器气提供了两个参数用于精确控制吞吐量
- 最大垃圾收集停顿时间: -XX:MaxGCPauseMills
- 吞吐量大小:-XX:GCTimeRatio
MaxGCPauseMills 参数允许的值是一个大于0的毫秒数,收集器将尽可能地保证内存回收所花费的时间不超过设定值。但 GC 的停顿时间缩短是以牺牲吞吐量和新生代空间来换取的。停顿时间下降,但吞吐量也降下来了。
GCTimeRatio 参数的值是一个大于0且小于100的整数,也就是垃圾收集时间占总时间的比例,相当于吞吐量的倒数。区间 1/(1+99) ~ 1/(1+1),即 1% ~ 50%。
由于与吞吐量关系密切,Parallel Scavenge 收集器也经常称为“吞吐量优先“ 收集器。
-XX:+UserAdaptiveSizePolicy: GC 自适应调节策略(GC Ergonomics),打开参数后,就不需要手工指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象的年龄(-XX:PretenureSizeThreshold)等细节参数了。
Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用”标记-整理“算法。
这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。如果在 Server 模式下,那么它主要还有两大用途:
- 在JDK1.5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用。
- 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
Parallel Old 收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和”标记-整理“算法。这个收集器在 JDK 1.6 中才考试提供。
在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Old 和 Parallel Scavenge 收集器。
CMS 收集器(标记-清除)
CMS (Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。基于”标记-清除“算法那实现的,他的运作过程更复杂一些,整个过程分为4个步骤,
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS Concurrent sweep)
其中,初始标记、重新标记着两个步骤任然需要”Stop The World“。初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,并发标记阶段就是进行 GC Roots Tracing 的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记时间短。
CMS 是一款优秀的收集器,主要优点:并发收集、低停顿,Sun公司也称之为并发低停顿收集器(Concurrent Low Pause Collection)。
3个明显的缺点:
CMS 收集器读 CPU 资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS 默认启动的回收线程数是 (CPU数量+3)/4,也就是当 CPU 在4个以上时,并发回收时垃圾收集器线程不少于25%的 CPU 资源,并且伴随着CPU数量的增加而下降。
CMS 收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另外一次 Full GC 产生。由于 CMS 并发清理阶段用户线程还在运行着,伴随着程序运行自然就还会有新的垃圾不断产生,着一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。
这也就还需要预留足够的内存空间给用户线程使用,因此 CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运行使用。
- 在 JDK1.5 的默认设置下,CMS收集器当老年代使用了 68% 的空间后就会被激活,这是一个偏保守的设置。
- 在 JDK1.6 中, CMS 收集器启动阈值已经提升至92%。
要是 CMS 运行期间预留的内存不够,就会出现一次 “Concurrent Mode Failure” 失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
参数 -XX:CMSInitiatingOccupancyFraction 设置得太高很容易导致大量 “Concurrent Mode Failure”,性能反而下降。
CMS是一款基于 “标记-清除”算法实现的收集器,着意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前出发一次 Full GC。
- CMS 提供了一个开关参数 -XX:+UseCMSCompactAtFullCollection(默认开启),用于在 CMS 收集器顶不住要进行 Full GC 时开启内存碎片的合并整理过程,内存整理的过程是无法并发的。
- 参数 -XX:CMSFullGCsBeforeCompaction 用于设置执行多少次不压缩的 Full GC后,跟着来以此带压缩的,(默认值为0)
G1 收集器
补充阅读: