《深入理解Java虚拟机》第三章——垃圾收集器与内存分配策略
“Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。”
文章目录
一、概述
垃圾收集(Garbage Collection,GC)
GC 的历史远比 Java 久远,1960 年的 Lisp 语言是使用内存动态分配和垃圾收集技术的语言
当 Lisp 还在胚胎时期,其作者就思考过 GC 要做的三件事情:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
二、对象已死?
在堆中存放这 Java 几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事就算要判断哪些内存需要回收,即判断对象的“存活”或“死亡”。(死亡 即不可能再被任何用途使用的对象)
1.引用计数算法
- 在对象中添加一个引用计数器,每当一个地方引用它时,计数器加一;当引用失效时,计数器减一。
- 任何时刻计数器为零的对象就是不可能再被使用的。
虽然占用了一些额外的内存空间,但是原理简单、判定效率高。一些比较著名的应用案例:
- 微软COM(Component Object Model)
- 使用ActionScript 3 的 FlashPlayer
- Python语言
但是,在 Java 领域,主流的 Java 虚拟机并没有使用引用计数算法来管理内存。
原因:这个看似简单的算法,必须要配合大量额外处理才能保证正确地工作,比如对象之间互相循环引用的问题:
2.可达性分析算法
Java、C#、甚至古老的Lisp 的内存管理子系统都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。
基本思路:
- 通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索搜索过程走过的路径称为“引用链”(Reference Chain)
- 如果某个对象到GC Roots 之间没有任何引用链相连,则证明此对象是不可能再被使用的
在Java技术体系下,固定可作为GC Roots 的对象有:
- 虚拟机栈(栈桢中的本地变量表)中的引用的对象
- 方法区中的类静态属性引用的对象
- 方法区中的常量引用的对象
- 本地方法栈中JNI(Native方法)的引用的对象
- Java虚拟机内部的引用
- 同步锁synchrionized持有的对象
即使在可达性分析算法中判定为不可达的算法,这时候还处于”缓刑“的阶段。
3.JDK 1.2 之后对引用概念的补充
对于一些“食之无味,弃之可惜”的对象,我们希望在内存充足的时候保存它们,在内存紧张时抛弃这些对象。
JDK 1.2之后,对引用概念进行了补充:
- 强引用:传统引用的定义,“Object obj = new Object()”,只要强引用关系还存在,就永远不会回收被引用的对象
- 软引用:有用但非必须的对象。在内存溢出之前,会把这些对象列入回收范围内进行第二次回收。
- 弱引用:非必须对象,比软引用弱。只能活到下一次垃圾收集方式为止。WeakReference类实现弱引用
- 虚引用(幽灵、幻影引用):完全不会对对象生存时间构成影响,唯一目的就是:为了能在这个对象被收集器回收时收到一个系统通知。PhantomReference类实现虚引用。
4.回收方法区
《Java虚拟机规范》中不要求虚拟机在方法区实现垃圾收集。
方法区垃圾收集的性价比是比较低的。在Java堆中,尤其是 新生代中,对常规应用的一次垃圾收集通常可以回收70%~99%的内存空间,相比之下,方法区由于苛刻的判定条件,其回收成果远远低于此。
方法区垃圾收集主要回收两部分:
- 废弃的常量
- 不再使用的类型
判定一个类型是否属于“不再被使用的类”需要同时满足:
- 该类的所有实例已经被回收
- 加载该类的类加载器已经被回收
- 无法在任何地方通过反射访问该类的方法
在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
三、垃圾收集算法
从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC),即“直接垃圾收集”和“间接垃圾收集”。由于引用计数式垃圾收集在Java主流的虚拟机中均为涉及,所以本文介绍的所有算法均属于追踪式垃圾收集的范畴。
1.分代收集理论
Generational Collection ,分代收集名为理论,实际是一套符合大多数程序运行实际情况的经验法则。它建立在两个分代假说上:
- 弱分代假说(Weak Generation Hypothesis):绝大多数对象都是朝生夕灭的
- 强分代假说(Strong Generation Hypothesis):熬过、越过多次垃圾收集过程的对象就越难消亡
这两个假说奠定了多个垃圾收集器常用的一致性的设计原则:
- 收集器将Java堆划分为不同的区域,根据年龄的大小(即对象熬过垃圾收集的次数)来分配到不同的区域存储。
- 对于大多是朝生夕灭(难以熬过垃圾收集过程)的对象的区域,只关注如何保留少量的存活对象。
- 对于大多是难以消亡的对象,就把它们集中在一起。
这样就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
由此诞生了这样的回收类型的划分:
- 部分收集(Partial GC):不是完整收集整个Java堆的垃圾收集
- 新生代收集(Minor GC/Young GC)
- 老年代收集(Major GC/Old GC)
- 混合收集(Mixed GC):目标是收集整个新生代以及部分老年代的垃圾收集。此书出版时只要 G1 收集器有这种行为。
- 整堆收集(Full GC):收集整个Java堆和方法区
2.标记-清除算法
Mark-Sweep最早的也是最基础的垃圾收集算法。
- 标记:首先标记要 回收/存活 的对象
- 清除:标记完成后,统一回收 标记/未标记 的对象
缺点:
- 执行效率不稳定:如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这是需要进行大量标记和清除操作
- 内存空间的碎片化:标记、清除之后会产生大量的不连续的内存碎片,当需要分配较大对象时,不得不提前进行一次GC
3.标记-复制算法
解决了标记-清除算法面对大量可回收对象时的执行效率低的问题。处理新生代。
半区复制:
- 将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当其中一块内存使用完了,将存活的对象复制到另一块上去,然后再把已使用的内存空间一次清理掉。
- 缺点:这种算法将会产生大量的内存间复制的开销,可用内存缩小为了原来的一半空间浪费。
Appel 式回收:
- 把新生代划分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和一块 Survivor
- 发生垃圾收集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间,然后直接清理掉 Eden 和 已用过的那块 Survivor。
HotSpot虚拟机中默认Eden 和 Survivor 的大小比例时8:1。当 Survivor 空间不足以容纳一次 Minor GC 后存活的对象时,就需要依赖其他内存区域(大多是老年代)进行分配担保。
4.标记-整理算法
在对象存活率较高的老年代就不适合前两种算法。
标记-整理(Mark-Compact)算法:
- 标记过程与“标记-清除算法”一致
- 将所有存活的对象都想内存空间一端移动,然后直接清理掉边界以外的内存
缺点:
- Stop The World:移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作
还有一种“和稀泥式”解决方法:平时采用标记-清除算法,当碎片化程度已经大到影响对象时,再采用标记-整理算法。
四、实战:内存分配与回收策略
Java技术体系的自动内存管理,最根本的目标是自动化解决两个问题:自动给对象分配内存和自动回收分配给对象的内存。
接下来来看看 给对象分配内存。
1.对象优先在Eden分配
- 对象优先在Eden分配,当Eden没有足够空间时,将会Minor GC
- GC 之后如果空间不足,将对象转移到Survivor 区,如果Survivor区内存不足以转移,则通过分配担保机制提前转移到老年代
在还没有为实验对象分配内存时Eden区已经被占用了2M(以下简称:原对象),所以修改实验数据
测试:
在为allocation3分配内存时,Eden区内存不足,进行GC,原对象变成了不到1M被转移到了Survivor区,而allocation1、2没有足够空间分配而被转移到了老年代。
需要注意的是,在这里内存的分配没有正确的适应性改变的话,很容易产生大对象直接进入老年代的现象,从而影响本小节的理解,建议实现时出现差错的看下一小节,设置老年代的最大值之后再来实现本小节。
2.大对象直接进入老年代
大对象是指需要大量连续内存空间的Java对象,最典型的是那种很长的字符串、元素数量很庞大的数组。
在Java虚拟机中要尽量避免大对象,它容易导致提前触发垃圾收集。HotSpot虚拟机提供了-XX:PertenureSizeThreshold参数,指定大对象的值大于设定值就直接在老年代分配,其目的就是避免,在Eden和两个Survivor区之间的来回复制。
经由笔者测试,这里默认的大对象进入老年代的值为新生代的40%。
3.长期存活的对象将进入老年代
《深入理解Java虚拟机》1 —— 第二章 Java内存区域与内存溢出异常_HotRabbit.的博客-CSDN博客
在前面的博客中提到虚拟机给每个对象定义了一个对象年龄计数器,存储在对象头中。
对象在经历第一次Minor GC 之后仍然存活,并且能被Survivor区接纳,年龄设为1岁。对象在Survivor区每熬过一次Minor GC 年龄都会+1,直到年龄增加到了一个特定值(默认15),会被晋升到老年代中。
-XX:MaxTenuringThreshold=?可以设定年龄的默认值。
这里不再演示。
4.动态对象年龄判定
在HotSpot虚拟机中,在Survivor空间中,相同年龄的所有对象大小的综合大于Survivor空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代。
这里不再演示。
5.空间分配担保
- 在Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间。
- 如果这个条件成立,那么这一次Minor GC就是安全的。
- 否则,虚拟机会先查看-XX:HandlePromotionFailure(JDK 6 Update 24 之后已被弃用,默认打开) 参数的设置值是否允许担保失败。
- 如果允许冒险,会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
- 如果大于,将尝试进行一次Minor GC,尽管这次Minor GC 是危险的
- 如果小于,改进行一次Full GC
- 如果不允许冒险,改进行一次Full GC
- 如果允许冒险,会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
这里的冒险:这里在Survivor区无法满足容纳Eden的对象情况下讨论,在正常情况下,新生代的对象会在Minor GC之后转移到老年代,但是如果在Minor GC 之前的新生代的对象就已经大于老年代的剩余连续空间,那么就会产生冒险,因为无法预知在Minor GC之后新生代的对象还剩下多少,在极端情况下,新生代的对象可能全部存活,这样只能进行Full GC 了。
虚拟机只能取之前每一次回收晋升老年代对象容量的平均大小作为经验值,与老年代的剩余空间作比较,决定是否进行Full GC 来让老年代腾出更多空间。
这样做虽然是一种概率时间,但是可以避免Full GC 的频繁发生(如果新生代对象容量大于老年代容量就Full GC)。
需要注意的是:在JDK 6 Update 24 之前,可以通过-XX:HandlePromotionFailure 来设置是否打开,一般情况下都是打开的。但在JDK 6 Update 24 之后,尽管 OpenJDK的源码中还定义了这个参数,但在实际虚拟机中,已经不再起作用,默认打开。