原文链接:https://www.javaworld.com/article/2078645/java-se/jvm-performance-optimization-part-3-garbage-collection.html
概述
java中的垃圾回收器极大的提高了开发者的效率,但是垃圾回收器如果比较差可能会过多的消耗应用程序的资源。在 JVM performance optimization系列的第三篇文章中,Eva Andreasson为Java初学者提供了Java平台内存模型和GC机制的概述。 随后,她解释了影响java程序性能的主要原因是“碎片”而不是GC,以及为什么分代垃圾收集和压缩算法是目前Java应用程序中管理堆碎片的主要方式(尽管不是最新的)。
GC进程的目的是去释放被占用的内存,这些内存再也不会被可达到对象所引用,GC进程是JVM动态内存管理系统的重要组成部分。在一个典型的垃圾收集周期中,所有可到达的对象(仍然被引用的对象)都被保留。被释放的内存将会被分配给新的对象。
为了理解垃圾收集以及各种GC方法和算法,读者必须首先了解Java的内存模型。
垃圾收集器和java内存模型
当你在命令行启动应用程序的时候,如果去指定了启动参数-Xmx (例如:ava -Xmx:2g MyApp),此时内存就被分配给java进程,这个内存被叫做java的堆内存或者直接叫做堆内存。这是一块特殊的内存,所有被创建的对象都会被分配到这里。当Java程序继续运行并且不停地分配新对象时,Java堆(即地址空间)将会被填满。
最终,Java堆将被填满,这意味着一个正在分配的线程无法分配给对象一个足够大的连续的空闲内存段。这个时候JVM会通知GC去进行垃圾收集。java的 System.gc()
语句会主动触发GC。但是使用System.gc()
并不保证垃圾收集是否会进行,在执行GC之前首先要去确保是否能安全的启动它,当应用程序的所有活动线程都处于允许的安全点时,启动垃圾收集是安全的。例如当正在进行对象分配或者在执行一系列优化CPU指令的过程中,GC是不能执行的,因为在这些情况下可能会破坏数据,从而影响最终的结果的正确性。
一个垃圾收集器不能去回收一个正在被引用的对象,这种行为是违反 JVM规范的。垃圾回收器也不需要去立刻回收已经死亡的对象,死亡对象最终会在接下来的垃圾收集周期中回收。尽管垃圾回收器有很多,但是都满足以上两点。垃圾收集的真正挑战是识别出所有正在运行(仍然引用)的内存,并且回收任何未引用的内存,同时还要保证这样做不会对运行中的应用程序造成不必要的影响。因此,垃圾收集器有两个任务
- 快速的释放未被使用的内存,以此来满足应用程序的分配速率,以至于不会导致内存溢出。
- 回收内存的同时最小化地影响正在运行的应用程序的性能(例如,延迟和吞吐量)。
两种类型的垃圾收集器
在本系列的第一篇文章中,我讨论过垃圾收集的两种主要方法,即引用计数和跟踪收集器。在这篇文章中,我将深入研究每种方法,然后介绍一些用于在生产环境中实现跟踪收集器的算法
引用计数收集器
引用计数收集器跟踪每个Java对象的引用个数,一旦数字变为0,这个对象所占据的内存会被立即释放。引用计数收集器的最大的优点就是会立刻回收内存。虽然保持一个未引用的内存开销非常小,但是时刻保持引用计数是非常消耗性能的。
引用计数收集器最主要的困难是保证引用计数的准确性,以及处理圆形结构的复杂性。如果两个对象互相引用但并没有一个存活对象去指向它们,它们的引用计数永远不可能为0,因此内存永远不会被释放。
回收与圆形结构有关的内存需要大量分析计算,这给算法带来了昂贵的开销,并因此降低了引用的性能。
跟踪收集器
跟踪收集器基于一种假设,通过迭代地跟踪根对象集合(已知为存活对象的集合)寻找到根对象直接引用的对象,以及这些引用对象的后续引用对象,从而找到所有的存活对象。
在触发垃圾收集时,通过分析寄存器、全局字段和堆栈帧找到根对象集合。当根对象集合初始化之后,跟踪收集器会给这些对象 排队然后去标记为存活对象,并且跟踪这些对象的后续引用。如果要标记所有找到的引用对象为存活对象,意味着已知的存活对象集合要随着时间的推移而增加直至所有被引用的对象都被发现并且标记。一旦跟踪收集器找到所有存活对象,它将回收剩余内存。
跟踪收集器和引用计数收集器的区别在于它能够处理圆形结构问题。对于大多数跟踪收集器来说,最关键是标记阶段,在能够回收非引用内存之前等待一段时间。跟踪收集器普遍用于动态语言的内存管理。目前为止它是java语言中最常用的并且已经在生产环境中进行了多年的商业验证的垃圾收集器。接下来我将重点介绍跟踪收集器有关的内容,首先介绍实现这种垃圾收集方法的一些算法。
跟踪收集器算法
复制算法和标记清除算法不是新的算法,但它们仍然是目前实现跟踪垃圾收集的两种最常见算法。
复制算法
传统的复制算法使用from-space 和 to-space, 这是在堆上单独定义的两块地址空间,from-space上的存活对象会复制到to-space上,当from-space上的所有存活对象都被复制过去后,整个from-space将会被回收。然后从to-space的第一个空闲的位置开始进行对象分配
在以前,from-to算法的实现是当to-space满了以后,GC再次启动把to-space变成from-space 。现在的复制算法的实现允许将堆中的任意地址空间分配为from-space 和 to-space。因此,它们不必相互交换位置。相反,它们都可以成为堆中的另一个地址空间。
复制收集器的一个优点是对象被紧密地分配到空间中,完全的消除碎片,碎片是其他垃圾收集算法难以解决的一个常见问题。我将在本文后面讨论一些内容
复制收集器的缺点
复制收集器经常会导致“全局停顿”,意味着,当GC执行的时候,所有的应用程序都不能执行。在处于全局停顿的时候,需要复制的区域越大,对应用程序性能的影响就越大,尤其是那种对时间特别敏感的应用程序。使用复制收集器必须要考虑最坏的情况,即所有的存活对象都在from-space里,你必须要保证有足够的剩余空间去移动from-space,这就意味着to-space必须要足够大,能让to-space去容纳from-space的所有东西。由于这个限制,复制算法的内存效率有点低。
标记清除收集器
部署在企业生产环境中的商业jvm大多数都运行标记-清除(或标记)收集器,使用这个收集器比复制收集器的对程序的性能影响更小。一些著名的标记清除收集器有CMS, G1, GenPar, 和 DeterministicGC
标记-清除收集器跟踪引用,并把每个跟踪到的对象标记为“存活”位。通常,一个集合位对应于一个地址,或者在某些情况下对应于堆上的一组地址。活动位可以存储为对象头中的位、位向量或位映射中的位。
当标记完成后将进入清除阶段,如果收集器有一个清除阶段,它基本上包括一些机制,用于再次遍历堆(不仅是活动集,而且是整个堆长度),以定位所有未标记的连续内存地址空间块。未被标记的内存将会被释放并且回收。然后收集器将这些没有标记的块链接到有组织的空闲列表中。垃圾收集器中可以有各种空闲列表——通常按块大小组织。一些jvm(如JRockit Real Time)使用启发式实现收集器,启发式根据应用程序分析数据和对象大小统计信息动态地确定大小范围列表。如果应用程序内存消耗严重可以使用gc调优选项,去适应各种应用程序场景和需求。在许多情况下,调优至少可以帮助延缓这些阶段对应用程序或服务水平协议(slas)的风险(SLA指定应用程序将满足特定的应用程序响应时间,即,延迟)。但是,调整每次负载的改变和应用程序的修改都是一项重复性任务,因为调优仅对特定工作负载和分配率有效。
标记-清除的实现
对于实现标记-清除收集,至少有两种商业上可用且经过验证的方法。一种是并行方法,另一种是并发(或大部分是并发)方法
并发收集器
并行收集意味着分配给进程的资源被并行地用于垃圾收集。大多数商业上实现的并行收集器都会有“stop-the-world”——所有应用程序线程都停止,直到整个垃圾收集周期结束。在标记与扫描阶段停止所有的线程以便于并行高效的使用资源。这将导致非常高的效率,通常会在诸如SPECjbb这样的吞吐量基准测试中获得高分。如果吞吐量对应用程序很重要,那么并行方法是一个很好的选择。