垃圾收集器算法

垃圾收集算法评价标准:
* 暂停时间:收集器是否停止所有工作来进行垃圾收集?要停止多长时间?暂停是否有时间限制?
* 暂停的可预测性:垃圾收集暂停是否规划为在用户程序方便而不是垃圾收集器方便的时间发生?
* CPU 占用:总的可用 CPU 时间用在垃圾收集上的百分比是多少?
* 内存大小:许多垃圾收集算法需要将堆分割成独立的内存空间,其中一些空间在某些时刻对用户程序是不可访问的。这意味着堆的实际大小可能比用户程序的最大堆驻留空间要大几倍。
* 虚拟内存交互:在具有有限物理内存的系统上,一个完整的垃圾收集在垃圾收集过程中可能会错误地将非常驻页面放到内存中来进行检查。因为页面错误的成本很高,所以垃圾收集器正确管理引用的区域性 (locality) 是很必要的。
* 缓存交互:即使在整个堆可以放到主内存中的系统上 ―― 实际上几乎所有 Java 应用程序都可以做到这一点,垃圾收集也常常会有将用户程序使用的数据冲出缓存的效果,从而影响用户程序的性能。
* 对程序区域性的影响:虽然一些人认为垃圾收集器的工作只是收回不可到达的内存,但是其他人认为垃圾收集器还应该尽量改进用户程序的引用区域性。整理收集器和复制收集器在收集过程中重新安排对象,这有可能改进区域性。
* 编译器和运行时影响:一些垃圾收集算法要求编译器或者运行时环境的重要配合,如当进行指针分配时更新引用计数。这增加了编译器的工作,因为它必须生成这些簿记指令,同时增加了运行时环境的开销,因为它必须执行这些额外的指令。这些要求对性能有什么影响呢?它是否会干扰编译时优化呢?

基本算法

所有垃圾收集算法所面临的问题是相同的 ―― 找出由分配器分配的,但是用户程序不可到达的内存块。不可到达是什么意思?可以以两种方式之一访问内存块 ―― 或者用户程序在 根 (root)中有对这一内存块的引用,或者在另一个可到达的块中有对这个块的引用。在 Java 程序中,根是对静态变量中或者活跃的堆栈框架上的本地变量中所包含的对象的引用。可到达的对象集是指向关系下根集的传递闭包。

引用计数

最直观的垃圾收集策略是引用计数。引用计数很简单,但是需要编译器的重要配合,并且增加了赋值 函数 (mutator)的开销(这个术语是针对用户程序的,是从垃圾收集器的角度来看的)。每一个对象都有一个关联的引用计数 ―― 对该对象的活跃引用的数量。如果对象的引用计数是零,那么它就是垃圾(用户程序不可到达它),并可以回收。每次修改指针引用时(比如通过赋值语句),或者当引用超出范围时,编译器必须生成代码以更新引用的对象的引用计数。如果对象的引用计数变为零,那么运行时就可以立即收回这个块(并且减少被回收的块所引用的所有块的引用计数),或者将它放到迟延收集队列中。

许多 ANSI C++ 库类,比如 string ,使用了引用计数来提供垃圾收集的特性。通过重载赋值操作符并利用 C++ 作用域提供的确定性结束,C++ 程序可以将 string 类当成是被收集的垃圾那样使用。引用计数很简单,很适用于增量收集,收集过程一般会得到好的引用区域性,但是出于几个理由,它很少在生产垃圾收集器中使用,如它不能回收不可到达的循环结构(彼此直接或者间接引用的几个对象,如循环链接的列表或者包含指向父节点的反向指针的树)。

跟踪收集器

JDK 中的标准垃圾收集器都没有使用引用计数,相反,它们都使用某种形式的 跟踪收集器 (tracing collector)。跟踪收集器停止所有工作(尽管不需要在收集的整个过程中都这样)并开始跟踪对象,从根集开始沿着引用跟踪,直到检查了所有可到达的对象。可以在程序注册表中、每一个线程堆栈中的(基于堆栈的)局部变量中以及静态变量中找到根。
标记-清除收集器

最早由 Lisp 的发明人 John McCarthy 于 1960 年提出的最基本的跟踪收集器形式是标记―清除收集器,它停止所有工作,收集器从根开始访问每一个活跃的节点,标记它所访问的每一个节点。走过所有引用后,收集就完成了,然后就对堆进行清除(即对堆中的每一个对象进行检查),所有没有标记的对象都作为垃圾回收并返回空闲列表。

标记-清除实现起来很简单,可以容易地回收循环的结构,并且不像引用计数那样增加编译器或者赋值函数的负担。但是它也有不足 ―― 收集暂停可能会很长,在清除阶段整个堆都是可访问的,这对于可能有页面交换的堆的虚拟内存系统有非常负面的性能影响。

标记-清除的最大问题是,每一个活跃的(即已分配的)对象,不管是不是可到达的,在清除阶段都是可以访问的。因为很多对象都可能成为垃圾,这意思着收集器花费大量精力去检查并处理垃圾。标记-清除收集器还容易使堆产生碎片,这会产生区域性问题并可以造成分配失败,即使看来有足够的自由内存可用。

复制收集器

在另一种形式的跟踪收集器 ―― 复制收集器中,堆被分成两个大小相等的半空间,其中一个包含活跃的数据,另一个未使用。当活跃的空间占满以后,程序就会停止,活跃的对象被从活跃的空间复制到不活跃的空间中。空间的角色就会转换,原来不活跃的空间成为了新的活跃空间。

复制收集的优点是只访问活跃的对象,这意味着不会检查垃圾对象,也不需要将它们页交换到内存中或者送到缓存中。复制收集器的收集周期时间是由活跃对象的数量决定的。不过,复制收集器因为要将数据从一个空间复制到另一个空间、调整所有引用以指向新备份而增加了成本。特别是,长寿的对象在每次收集时都要来回复制。

堆整理

复制收集器有另一个好处,活跃对象集会被整理到堆的底部。这不仅改进了用户程序的引用区域性并消除了堆碎片,而且极大地减少了对象分配的成本 ―― 对象分配变成了在堆顶部的指针上增加指针。不需要维护自由列表或者后备列表,或者使用性能最佳或者第一合适的算法 ―― 分配 N 字节就是在堆顶部指针上加 N 并返回前一个值这么简单
为非垃圾收集语言实现了复杂内存管理方案的开发人员可能会对复制收集器中廉价的内存分配感到吃惊 ―― 就是指针加法这么简单。以前的 JVM 实现没有使用复制收集器 ―― 这可能是对象分配是昂贵的这一想法是如此普遍的原因之一,开发人员仍然下意识地假设分配成本与其他语言(如 C)类似,而事实上在 Java 运行时中可能要廉价得多。不但是分配成本减少了,而且对于在下次收集之前成为垃圾的对象,解除分配的成本为零,因为既不会访问也不会复制垃圾对象。

标记-整理收集器

复制算法的性能很优异,但是它有一个缺点是需要两倍于标记-清除收集器所需要的内存。 标记-整理算法结合了标记-清除和复制,避免了这个问题,代价是增加了一些收集复杂性。与标记-清除类似,标记-整理是两阶段过程,在标记阶段访问并标记每个活跃对象。然后,复制标记的对象,使所有活跃对象被整理到堆的底部。如果每一次收集时进行彻底的整理,那么得到的堆就类似于复制收集器的结果 ―― 在堆的活跃部分与自由部分有明确的界线,这样分配成本与复制收集器相当。长寿的对象趋向于沉在堆的底部,这样就不会像在复制收集器中那样反复复制它们。

选择哪一种呢?

早期的 JDK 使用了单线程的标记-清除或者标记-清除-整理收集器。1.2 及以后的 JDK 使用了混合的方式,称为分代收集,其中根据对象的年龄将堆分为几个部分,不同的代是用不同的收集算法收集的。

根据GC的工作原理,对程序设计的几点建议。

1. 最基本的建议就是尽早释放无用对象的引用。大多数程序员在使用临时变量的时候,都是让引用变量在退出活动域(scope)后,自动设置为null。我们在使用这种方式时候,必须特别注意一些复杂的对象图,例如数组,队列,树,图等,这些对象之间有相互引用关系较为复杂。对于这类对象,GC回收它们一般效率较低。如果程序允许,尽早将不用的引用对象赋为null。这样可以加速GC的工作。
2. 尽量少用finalize函数。finalize函数是Java提供给程序员一个释放对象或资源的机会。但是,它会加大GC的工作量,因此尽量少采用finalize方式回收资源。
3. 如果需要使用经常使用的图片,可以使用soft应用类型。它可以尽可能将图片保存在内存中,供程序调用,而不引起OutOfMemory。
4. 注意集合数据类型,包括数组,树,图,链表等数据结构,这些数据结构对GC来说,回收更为复杂。另外,注意一些全局的变量,以及一些静态变量。这些变量往往容易引起悬挂对象(dangling reference),造成内存浪费。
5. 当程序有一定的等待时间,程序员可以手动执行System.gc(),通知GC运行,但是Java语言规范并不保证GC一定会执行。使用增量式GC可以缩短Java程序的暂停时间

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值