二、垃圾收集器与内存分配策略
虽然Java在实际开发中,不用像C++那样在代码中指明内存的回收,但是我们必须知晓其垃圾回收的机制以及内存分配的原理,因为当我们需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,就需要对这些技术实施必要的监控和调节。
垃圾收集器(Garbage Collection,GC),诞生于1960年的MIT的Lisp语言(一门真正使用内存动态分配和垃圾收集技术的语言),目前已经非常成熟。上一篇介绍了Java内存运行时区域的各个部分,其中程序计数器、Java栈、本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每个线帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题。但Java堆和方法区则不一样,我们只有在程序处于运行期间时才知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的内存就是指这两个区域。
1. 对象存活判断
判断对象存活一般有两种算法
1.1 引用计数算法
引用计数算法,是给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
一个简单的循环引用问题描述如下:有对象 A 和对象 B,对象 A 中含有对象 B 的引用,对象 B 中含有对象 A 的引用。此时,对象 A 和对象 B 的引用计数器都不为 0。但是在系统中却不存在任何第 3 个对象引用了 A 或 B。也就是说,A 和 B 是应该被回收的垃圾对象,但由于垃圾对象间相互引用,从而使垃圾回收器无法识别,引起内存泄漏。
public class ReferenceCounterTest {
Object instance = null;
private static final int MEMORY = 1024 * 1024; // 1MB
private byte[] size = new byte[2 * MEMORY];
public static void runGc() {
ReferenceCounterTest objA = new ReferenceCounterTest();
ReferenceCounterTest objB = new ReferenceCounterTest();
// 将两个对象互相引用进行关联
objA.instance = objB;
objB.instance = objA;
// 将两个对象设置为空
objA = null;
objB = null;
// 手动做GC处理,程序中不要这么做
System.gc();
}
public static void main(String[] args) {
runGc();
}
}
注意:执行这段代码需要加上 -XX:+PrintGCDetails参数才会打印GC的处理日志
1.2 可达性分析算法
可达性分析算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径成为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。如下图所示,对象obj5/6/7不能到达GC Roots点,所以该3个对象将会被回收。
在Java语言中,可作为GC Roots的对象有如下几种:
1. 虚拟机栈中引用的对象
2. 方法区中静态属性引用的对象
3. 方法区中常量引用的对象
4. 本地方法栈中JNI引用的对象
2. 垃圾收集算法
2.1 标记-清除算法
标记-清除算法,分为“标记”和“清除”两个阶段:首先标记处所有需要回收的对象,在标记完成后再统一回收掉所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。
缺点
- 效率:标记和清除两个过程的效率都不高
- 空间:会产生大量不连续的内存碎片,当空间需要分配较大对象时,无法找到足够的连续内存,从而不得不提前触发另一次收集动作。
2.2 复制算法
为了解决效率问题,“复制”收集算法应运而生,它将可用内存按容量划分为大小相等的两块,每次只使用其中一块,当所使用的一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
缺点
- 将内存缩小为原来的一半,代价太高
- 对象存活率较高时做复制操作(即内存较大时),效率相对较低
2.3 标记-整理算法
标记-整理算法,标记过程与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界意外的内存。
2.4 分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集”算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配,就必须采用“标记-整理”算法来进行回收。
3. 垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
3.1 Serial收集器
Serial收集器是最基本、发展历史最悠久的收集器,它是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束,即“Stop The World”。
3.2 ParNew收集器
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio/-XX:PretenureSizeThreshold/-XX:HandlePromotionFailure等)、收集算法、Stop The World对象分配规则、回收策略等,都与Serial收集器完全一样。
3.3 Parallel Scavenger收集器
Parallel Scavenger收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器,其特点是它的关注点与其他收集器不同,它关注系统的吞吐量,所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行代码时间/(运行代码时间+垃圾收集时间)。
Parallel Scavenger收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的:-XX:GCTimeRatio参数。
3.4 Serial Old收集器
Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下,那么它主要的两大用途:一是在JDK1.5以及之前的版本中与Parallel Scavenger收集器搭配使用;二是作为CMS收集器的后备预案。
3.5 Parallel Old收集器
Parallel Old是Parallel Scavenger收集器的老年代版本,使用多线程和“标记-整理”算法,这个收集器是在JDK1.6中才开始提供的,在此之前,新生代的Parallel Scavenger收集器一直处于比较尴尬的状态,原因是如果新生代选择了Parallel Scavenger收集器,老年代除了Serial Old收集器外别无选择。由于老年代Serial Old收集器在服务端应用性能上的缺项,使用了Parallel Scavenger收集器也未必能在整体应用上获得吞吐量最大化的效果,由于单线程的老年代收集中无法充分利用服务器多CPU的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有ParNew加CMS的组合有效。
3.6 CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用比较重视服务的响应速度,希望系统停顿时间最短,给用户带来良好的用户体验。
CMS收集器运作过程分为4个步骤:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
其中初始标记、重新标记两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是进行GC Roots Tracing的过程;重新标记阶段是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段较长,但比并发标记阶段要短。由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以和用户线程一起工作,所以总体来说,CMS收集器的内存回收是与用户贤臣个一起冰法的执行。
优点:并发收集、低停顿
缺点:产生大量空间碎片、并发阶段会降低吞吐量
3.7 G1收集器
G1收集器是目前技术发展的最前沿成果之一,Hotspot开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器。与CMS收集器相比G1收集器有以下特点:
- 空间整合,G1收集器采用标记整理算法,不会产生内存空间碎片;
- 可预测停顿,降低停顿时间的同时还能简历可预测停顿时间模型,能让使用者明确指定一个长度为N毫秒的时间段内,消耗在垃圾收集器上的时间不能超过N毫秒;
使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域,虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分区域的集合。
G1 收集步骤:
1. 标记阶段:首先初始标记,这个阶段是停顿的,并且会触发一次普通Minor GC;
2. Root Region Scanning,程序运行过程中会回收Survivor区,这一过程必须在young GC之前完成;
3. Concurrent Marking,在整个堆中进行并发标记,此过程可能被Young GC中断。在并发标记阶段,弱发现区域对象中的所有对象都是可回收的,那这个区域会被立即回收,同时在并发标记过程中,会计算每个区域的对象存货性;
4. Remark,再标记,会有短暂暂停。再标记阶段是用来收集并发标记阶段产生新的垃圾;G1中采用了比CMS更快的初始快照算法;
5. Copy/Clean up,多线程清除失活对象,会有短暂停顿。G1将回收区域的存活对象拷贝到新区域,清除可回收区域,并发清空回收区域并把它返回到空闲区域链表中;
6. 复制/清除过程后,回收区域的活性对象已经被击中回收到深蓝色和深绿色区域;
附录:常用收集器组合
新生代GC策略 | 年老代GC策略 | 说明 |
---|---|---|
Serial | Serial Old | Serial和Serial Old都是单线程进行GC,特点就是GC时暂停所有应用线程 |
Serial | CMS+Serial Old | CMS(Concurrent Mark Sweep)是并发GC,实现GC线程和应用线程并发工作,不需要暂停所有应用线程。另外,当CMS进行GC失败时,会自动使用Serial Old策略进行GC。 |
ParNew | CMS | 使用-XX:+UseParNewGC选项来开启。ParNew是Serial的并行版本,可以指定GC线程数,默认GC线程数为CPU的数量。可以使用-XX:ParallelGCThreads选项指定GC的线程数。如果指定了选项-XX:+UseConcMarkSweepGC选项,则新生代默认使用ParNew GC策略。 |
ParNew | Serial Old | 使用-XX:+UseParNewGC选项来开启。新生代使用ParNew GC策略,年老代默认使用Serial Old GC策略。 |
Parallel Scavenge | Serial Old | Parallel Scavenge策略主要是关注一个可控的吞吐量:应用程序运行时间 / (应用程序运行时间 + GC时间),可见这会使得CPU的利用率尽可能的高,适用于后台持久运行的应用程序,而不适用于交互较多的应用程序。 |
Parallel Scavenge | Parallel Old | Parallel Old是Serial Old的并行版本 |
G1GC | G1GC | -XX:+UnlockExperimentalVMOptions -XX:+UseG1GC #开启 *-XX:MaxGCPauseMillis =50 #暂停时间目标 -XX:GCPauseIntervalMillis =200 #暂停间隔目标 -XX:+G1YoungGenSize=512m #年轻代大小 -XX:SurvivorRatio=6 #幸存区比例 |