深入理解Java虚拟机--第3章垃圾收集器与内存分配策略
概述
GC需要完成的三件事情:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
程序计数器、虚拟机栈、本地方法栈3个区域随线程生,随线程灭:栈中的栈帧随着方法进入执行着出栈和入栈,每一个栈帧中分配多少内存基本在类结构确定下来时候就是已知的。因此这几个区域的内存分配和回收都具备确定型,在这几个区域内就不需要考虑回收的问题,因为方法结束或者线程结束,内存就自然跟着回收了。
而java堆和方法区不一样,因为一个接口中多个实现类需要的内存可能不一样,一个方法中多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才知道会创建哪些对象,这部分内存分配和回收都是动态的,GC关注的就是这一部分内存。
对象已死吗?
引用计数算法
给对象中添加一个引用计数器,每当有一个地方引用时候计数器加1,引用时效时候计数器减1,任何时刻计数器作为0的对象就不可能再被引用。
实现简单,判定效率也搞,但是很难解决对象之间互相循环引用的问题。
可达性分析算法
通过一些列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链(Reference Chain),当GC roots 到这个对象不可达的时候,则证明这个对象时不可用的。
在java语言中,可以作为GC Roots的对象包括以下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(一般说的Native方法)引用的对象
再谈引用
无论是通过引用计数算法判断对象的引用数量还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。
我们希望能够描述这样一类对象,当内存空间还足够的时候,则能保存在内存中,如果内存空间在进行垃圾回收后还是十分紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的场景。
JDK1.2后java对引用的概念进行了扩充,将引用分为强引用,软引用,弱引用,虚引用4中,这四种强度一次减弱。
- 强引用:就是指在程序代码中普遍存在的类似“Object obj = new Object()”这类的引用,只要强引用还在,垃圾收集器永远不会回收掉被引用的对象。
- 软引用:软引用是用来秒死一些还有用但是并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够内存才会抛出内存溢出异常,SoftReference实现软引用。
- 弱引用:也是非必须对象。被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象,WeakReference。
- 虚引用:是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的唯一目的是能在这个对象被收集器回收时收到一个系统通知,PhantomReference。
生存还是死亡
即使在可达性分析算法中不可达的对象也未必是“非死不可”,暂时缓刑。至少要经历两次标记过程:
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那么他将会被第一次标记并进行一次筛选,筛选条件是次对象是否有必要执行finalize()方法。当对象没有覆盖finalize()或者finalize()已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为有必要执行finalize方法,那么这个对象会被放置到一个F-Queue队列之中,并在稍后由一个虚拟机自动建立的,低优先级的Finalize线程去执行它。“执行”是指虚拟机会触发这个方法,但不承诺会等待它运行结束。
这样做是因为如果一个对象在finalize方()法中执行缓慢,或者发生死循环,将会导致F-queue永久处于等待中,甚至导致整个内存回收系统崩溃。finalize是对象逃脱死亡命运的最后一次,随后GC会对F-queue中对象进行第二次标记,如果对象成功拯救自己(重新与引用链上任何一个对象建立关联,例如把自己赋值给某个类变量或者对象成员变量),那么在第二次标记时它将被移除“即将回收”集合;如果对象这时候还没有逃脱就基本上真的被回收了。
需要注意的是,笔者不建议用finalize拯救对象。因为它不是析构函数,java诞生时候是为了使C++程序员更容易接受做的一个妥协。它的运行代价高昂,不确定大,无法保证各个对象的调用顺序。有些教程说建议“关闭外部资源”类工作,但完全是自我安慰。所有finalize()能做的工作,使用try finally或者其他方式都能做的更好更及时,所以笔者建议大家可以忘掉java中这个方法存在。
回收方法区
方法区(永久代)垃圾收集性价比比较低。
永久代中垃圾回收分类两部分:废弃常量和无用的类。
回收废弃常量与回收java堆中对象十分相似。
判定一个类是无用的类需要满足3个条件:
- 该类的所有实例都已经被回收,也就是java堆中不存在类的任何实例
- 加载该类的ClassLoader已经被回收
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类。
满足3个条件的无用的类“可以”被回收。
是否对类进行回收,HotSpot提供-Xnoclassgc进行控制。
在大量使用反射、动态代理、CGLIb等ByteCode框架、动态生成JSP以及OSGI这类频繁自动以ClassLoader场景都需要虚拟机具备类卸载的功能,保证永久代不溢出。
垃圾收集算法
标记清除算法
分标记清除两个阶段:首先标记处所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
不足:1. 效率:标记清除两个效率都不高;2. 空间:标记清除后悔产生大量不连续的内存碎片,空间碎片太多会导致以后需要分配较大对象时需要进行垃圾回收。
复制算法
把内存分为大小相等两块,每次只用一块,这一块用完以后将还活着的对象复制到另一块,再把已经使用过的内存空间一次清除,每次只对一般进行内存回收不需要考虑内存碎片,只要移动堆顶指针按顺序分配,但是代价太高。
可以分为一块较大Eden区和两块较小的Survivor空间(8:1:1),每次使用Eden和Survivor一块,回收时复制到S2,每次只有10%空间被浪费,上
不够时候放到老年代进行分配担保。
标记整理算法
复制算法在对象存活率较高时候要进行较多的复制操作,效率会变低,如果不想浪费50%空间,需要有恩爱的空间进行分配担保,应对100%对象存活的极端,所以在年老代一般不采用这种方法。
与标记过程一致,但是后继不直接对可回收对象进行清理而是让所有存活对象都向一端移动,然后直接清理掉边界以外的内存。
分代收集算法
当前商业虚拟就都采用这种算法。根据对象存活周期不同将内存分为几块。一般是吧java堆分为新生代和老年代,这样可以根据年代特点选择收集算法。在新生代中每次垃圾收集都有大量对象死去,只有少量存活,采用复制短发,只需要复制少量成本即可;老年代因为对象存活率号、没有额外空间进行分配担保,必须使用“标记清理”或者“标记整理”
HotSpot算法实现
对执行效率要有严格的考量。
枚举根节点
HotSpot使用一组名为OopMap的数据结构,当类加载完成的时候,HosSpot会把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中也会在特定位置记录下栈和寄存器中哪些位置是引用。这样GC扫面时候就可以直接得知这些信息。
安全点
程序并非在所有地方都能停顿下来开始GC,只有到达安全点时候才可以。安全点的选择基本上是以“是否具有然程序长时间执行的特征”为标准选定的。最明显就是指令序列的复用如方法调用、循环跳转、异常跳转等。
安全区域
在一段代码之中,引用关系不会发生变化。在这个区域开始GC都是安全的。Safe Region可以看做被扩展的SaftPoint
垃圾收集器
收集算法是内存回收的方法论,垃圾收集器是内存回收的具体实现。Java虚拟机规范中对垃圾回收器具体怎么实现没有任何规定。不同厂商版本之间都会有区别。这里讨论JDK 1.7之后的:
7中不同分代的收集器,如果两个收集器之间存在连线,说明可以搭配使用。没有万能收集器,只有对应用适合的。
Serial收集器
单线程收集器。不仅是只会使用一条收集线程完成GC,更重要的是在进行GC适合必须暂停其他所有工作线程,直到收集结束。但是回收内存不会很大,停顿时间可以完全控制咋几十毫秒。
是虚拟机在运行Clien模式下的默认新生代收集器,简单高效。
ParNew收集器
是Serial收集器的多线程版本,除了使用多条线程进行垃圾回收,其他和Serial完全一致。包括Serial收集器所有控制参数(-XX:SurvivorRatio、-XX:PretenureSizeThreshold,)
、收集算法、Stop the World、对象分配规则、回收策略。
Server模式下虚拟机首选的新生代收集器。更重要是唯一一个与CMS配合的。
Paralle Scavenge收集器
新生代收集器,也使用复制算法,也是并行多线程收集器。
Paralle Scavenge特点是关注点和其他收集器不同,CMS等收集器关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Paralle Scavenge收集器目标是达到一个可控制的吞吐量。吞吐量=运行时用户代码时间/(运行时用户代码时间+垃圾收集时间)。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提高用户体验;而高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算不需要太多交互的任务。
Paralle Scavenge提供两个参数用于精确控制吞吐量:控制最大垃圾收集停顿时间 -XX:MaxGCPauseMillis以及直接设置吞吐量大小的: -XX:GCTimeRatio参数。
Serial Old收集器
是Serial收集器老年代版本,同样是一个单线程收集器,使用“标记整理”算法。主要意义在于给Client模式下虚拟机使用,在Server模式下:跟 Paralle Scavenge搭配或者做CMS后被原,并发收集器发生Concurrent Mode Failure是使用。
Parallel Old收集器
Paralle Scavenge收集器的老年代版本,使用多线程和“标记整理算法”。
在注重吞吐量以及资源敏感时候用Paralle Scavenge和Paralle old。
CMS收集器
Concurrent mark sweep。以获取最短回收停顿时间为目标。重视服务器响应速度,希望系统停损时间最短。
采用“标注清除”。整体分为四个步骤:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
初始标记和重新标记仍需stop the world。初始标记仅仅表一一下GCroot直接关联到的对象,速度很快;并发标记是GC root tracing,重新标记时为了修正并发标记期间用户程序继续运作而导致的标记产生变动拿一部分对象的标记记录。比初始标记稍长,与并发标记短得多。
CMS收集器内存回收与用户线程一起并发执行。
优点:并发收集。低停顿
缺点:- 对CPU资源非常敏感(并发的都会)占用一部分线程导致程序变慢,吞吐量降低。为了应对有“增量式并发收集器” ,反而导致GC时间变长,效果一般
- 无法处理浮动垃圾,可能出现“Concurrent Mode Failure”导致另一次Full GC。
- 标记清除容易导致大量空间碎片。
G1收集器
面向服务器端的垃圾收集器。
- 并行与并发
- 分代收集
- 空间整合:整体上是“标记整理”,局部上看是“复制”不会产生碎片
- 可预测的停顿:跟踪Region里面垃圾堆价值大小维护优先列表。
运作: - 初始标记
- 并发标记
- 最终标记
- 筛选回收
内存分配与回收策略
对象的内存分配,往大方向讲,就是在堆上分配(也可能经过JIT编译后拆为标量间接分配到栈),对象主要分配在新生代的Enen去,如果启动了本地线程分配缓冲,按线程优先在TLAB上分配。少数直接分配在老年代。分配规则细节取决于GC和虚拟机内存设置。
对象优先在Eden分配
当Eden没有足够空间,将Minor GC。
MinorGC是发生在新生代的GC,MajorGC或者FullGC是发生在老年代的GC,伴随至少一次MinorGC。
大对象直接进入老年代
很长的字符串以及数组。需要避免”短命大对象”。
长期存活的对象将进入老年代
JVM给每个对象定义一个年龄计数器。如果对象在Enen出生并且经过第一次MinorGC后仍然存活,并且能被Survivor容纳,就移动到Survivor并且年龄设为1。每熬过一次MinorGC,年龄加1,到15岁晋升到老年代。
动态对象年龄判断
如果在surv空间中相同年龄所有对象大小综合大于Surv空间一般,年龄大于或等于该年龄对象可以直接进入老年代。
空间分配担保
发生Mino GC之前,虚拟机会先检查老年代最大可用连续空间是否大于新生代所有对象的总空间,如果成了MinorGC可以确保安全,不成立查看HandlePromotionFailure是否允许担保失败。