三、垃圾收集器与内存分配策略

3.1概述

垃圾收集需要完成三件事:

  1. 那些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

每一个栈帧中分配多少内存基 本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化,但在基于概念模 型的讨论里,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性, 在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着 回收了。 而Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能 会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才 能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器 所关注的正是这部分内存该如何管理,本文后续讨论中的“内存”分配与回收也仅仅特指这一部分内存。

3.2对象已死?

在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就
是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(“死去”即不可能再被任何途径使用的对 象)了。

1)引用计数算法:

给对象添加一个引用计数器,每当有一个地方引用他时,计数器就加1,当引用失效时,计数器就减1,任何时对象引用计数器为0则回收。Java没有使用引用计数器算法来实现管理内存。他很难解决对象之间的循环调用问题。

2)可达性分析算法:

在主流的商用语言中都采用可达性分析算法来判断对象是否存活,基本思想是通过称为GC Roots的对象作为起点,从该节点开始向下搜索,搜索走过的路劲称为引用链,当一个对象到GC Roots没有任何引用链相连接的时候(即从GC Roots到这个对象不可达),即对象是不可用的。才被判定为可回收对象。

3)在Java语言中,可做为GC Roots的对象包括:

● 虚拟机栈(栈帧中的本地变量表)中的引用的对象, 譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。
● 方法区中静态属性引用的对象。 譬如Java类的引用类型静态变量
● 方法区中常量引用的对象。 譬如字符串常量池(String Table)里的引用。
● 本地方法栈中JNI(native方法)引用的方法。

4)在谈引用:

在JDK1.2前,引用的定义为:如果reference类型的数据中存储的数值代表了另一块内存的其实地址,就称这块内存代表一个引用。
在JDK1.2后,分为强引用,软引用,弱引用,虚引用四种。

5)生存还是死亡:

如果对象在进行可达性分析之后发现没有GC Roots相连接的引用链,那将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,都视为没有必要执行。
如果被判定为有必要执行finalize(),那么对象将会放置一个叫F-Query的队列中,并在稍后由虚拟机自动建立Finalize线程去执行。
finalize()方法是对 象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对 象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己 (this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集 合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

6)回收方法区

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用 常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接 口)、方法、字段的符号引用也与此类似。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就 比较苛刻了。需要同时满足下面三个条件: ·
1、该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。 ·
2、加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP的重加载等,否则通常是很难达成的。
·3、该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方 法。

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是 和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClass-Loading、-XX: +TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class和-XX:+TraceClassLoading可以在 Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版[1]的虚拟机支持。
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载 器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压 力。

3.3垃圾收集算法

0)分代收集理论

收集器应该将Java堆划分 出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区 域之中存储。
显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那 么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对 象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块, 虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有 效利用。

Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域 ——因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;也才能够针对不同的区域安 排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算 法”“标记-整理算法”等针对性的垃圾收集算法。这里笔者提前提及了一些新的名词,它们都是本章的 重要角色,稍后都会逐一登场,现在读者只需要知道,这一切的出现都始于分代收集理论。
其实我们只要仔细思考一下,也 很容易发现分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不 是孤立的,对象之间会存在跨代引用。
假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可 能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整 个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样[3]。遍历整个老年代所有对象 的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分 代收集理论添加第三条经验法则:
3)跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极 少数。 这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾 向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以 消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时 跨代引用也随即被消除了。
依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录 每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称 为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会 存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数 据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

1)标记-清除算法:

最基础的收集算法,算法分为两个阶段,首先标记出所需要回收的对象,在标记完成后统一回收所标记的对象,

缺点:效率不高,空间问题,标记清除会产生大量不连续的内存碎片,可能当需要分配较大对象的内存时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
它的主要缺点有两个: 第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

2)复制算法:
为了解决效率问题,将可用内存按容量分为大小相等的两块,每次只使用其中的一块,当这一块内存用完了,就将还存活的对象复制到另外一块上面,然后在把已使用过的内存空间一次清理掉。即不用考虑内存碎片问题,缺点为实际使用内存变为原来的一半。一般用来回收新生代。使用额外空间对它进行分配担保。

如果内存中多数对象都是存 活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一 点。
复制算法在对象存活率较高时就要进行较多的复制操作,效率会变低,一般老年代不使用这种算法。
在1989年,Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Appel式回收”。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局[1]。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空 间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。当然,98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。 内存的分配担保好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有什么风险了。内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。关于对新生代进行分配担保的内容,在稍后的3.8.5节介 绍垃圾收集器执行规则时还会再进行讲解。

3)标记-整理算法:
根据老年代的特点,在标记清除算法的基础上不是直接对可回收对象进行清理。而是让所有存活的对象都向一端移动然后直接清理掉边界以外的内存。

4)分代收集算法:

根据对象的存活周期的不同将内存分为几块,即把Java堆内存分为新生代和老年代,然后根据各个年代的特点选择合适的收集算法,在新生代中,每次都有大量的对象死去,少量存活,采用复制算法,在老年代中,对象的存活率高,没有额外空间进行分配担保必须使用,就必须使用“标记清除法”和“标记整理法”进行回收。

3.4HotSpot的算法细节实现

3.5~3.7垃圾收集器

3.8内存分配与回收策略

Java技术体系的自动内存管理,最根本的目标是自动化地解决两个问题:自动给对象分配内存以 及自动回收分配给对象的内存。
对象的内存分配,从概念上讲,应该都是在堆上分配(而实际上也有可能经过即时编译后被拆散 为标量类型并间接地在栈上分配。

1)对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起 一次Minor GC。

2)大对象直接进入老年代
大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者 元素数量很庞大的数组  
在Java虚拟机中要避免大对象的原因是,在分配空间时,它容易 导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复 制对象时,大对象就意味着高额的内存复制开销。HotSpot虚拟机提供了-XX:PretenureSizeThreshold 参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区 之间来回复制,产生大量的内存复制操作。  
3)长期存活的对象进入老年代
HotSpot虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存 活对象应当放在新生代,哪些存活对象放在老年代中。  为做到这点,虚拟机给每个对象定义了一个对 象年龄(Age)计数器,存储在对象头中(详见第2章)。对象通常在Eden区里诞生,如果经过第一次 Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象 年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程 度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX: MaxTenuringThreshold设置。   
4)动态对象年龄判定
为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于 Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX: MaxTenuringThreshold中要求的年龄。  
5)空间分配担保

在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总 空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允 许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大 于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX: HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值