垃圾收集 java
重要要点
- 代际假设是有效进行现代垃圾收集的关键
- HotSpot计算对象幸存下来以实现世代GC的集合数
- Parallel收集器仍然是使用最广泛的Java GC
- GC的算法复杂度难以简明地推断
- 压缩收集器(如ParallelOld)的行为与就地收集器完全不同
在Java 8中,旧版本的HotSpot VM的默认垃圾收集器称为ParallelOld。 在Java 11中,该默认值已改为G1收集器。
注意:从技术上讲,Java 9发生了收集器切换,但是在Java 10和11中对G1进行了进一步的重大增强,实际上,除了LTS发行版的Java之外,几乎没有公司在运行其他功能。
在本文中,我们将讨论垃圾收集理论的一些基础知识,以及它们如何在HotSpot的收集器中实现。 这将为讨论为何更改默认值以及Java的垃圾回收方法中最近发生的其他更改铺平道路。
基本概念
垃圾回收是与应用程序的主要处理不同的系统“内部整理”活动,旨在确定不再使用的内存并自动将其恢复以进行重复使用。
Dijkstra的定义清楚地表明,尽管引用计数是自动内存管理的一种形式,但它不是垃圾收集的一种形式。
引用计数通过在程序运行时更新每个对象的元数据来工作(例如,将引用类型的字段设置为新值时)。 更新元数据所需的工作发生在应用程序线程上,因此不能明确地划分为单独的活动。
实用的GC算法从GC根 (一组已知是活动的对象)开始,然后通过跟随指针确定所有活动的对象。
这些跟踪收集器实现了图论算法,将堆内存划分为活动和可回收的集合。
在现代GC文献中, 并发和并行这两个词都用于描述收集算法。 它们听起来好像应该是同义词,但实际上它们的含义完全不同:
- 并发-GC线程可以在应用程序线程运行时运行
- 并行-使用多个线程执行垃圾回收算法
这些术语不等效。 相反,可以将它们视为另外两个术语的对偶- 并发是世界停止的对立面,而并行是单线程的对立面。
实际的生产收集器将在GC 占空比内具有多个阶段 ,并且每个阶段都可能显示各种特性。
例如,完全有可能具有一个单线程并发的阶段,或者并行并停止的阶段。
注意:并发收集器比停止运行的收集器复杂得多。 不仅如此,它们在计算上也很昂贵,并且在行为方面还有其他警告。
您应该知道的更多GC术语:
- 精确-精确的收集器具有足够的类型信息,它可以始终告诉int和必须遍历的指针之间的差异。
- 疏散-将所有活动对象移动(疏散)到内存的另一个区域。 在收集周期结束时,源存储区完全为空,可以重复使用。
- 压缩-在收集周期结束时,将剩余的对象作为单个连续的块排列在存储区域的开始处,其余区域则可供重用。
精确GC方案的对偶是保守方案。 它们缺乏精确方案的信息,因此通常会浪费内存。
一些来源还提到移动收集器 -包括压缩和撤离算法。 但是,这两种亚型之间的差异是如此之大,以至于将它们分组在一起通常并不有用。
不移动的收集器称为就地 。 这些算法需要可用内存块的空闲列表 ,以处理内存碎片并合并空闲块。
热点中的实际考虑
让我们开始考虑定义中的一些基本事实:
- 由移动收集器分配的对象在其生存期内没有稳定的地址。
- 压缩收集器将避免内存碎片。
- 可以编写疏散收集器以避免碎片,并实现对尚存对象的部分压缩。
- 如果堆仅包含一个内存池,则无法通过疏散算法进行收集。
世代假说是观察到的面向对象系统的运行时行为,并且将对象粗略地分为两类-短期临时对象和参与程序工作集的长期对象。
注意:不能保证世代收集器总是比非世代收集器效率更高,但是实际上,几乎所有应用程序都受益于世代GC。
用于GC算法的标记扫描紧凑型命名法(例如,根据Blackburn和McKinley)是:
- 标记:通过跟踪对象图来识别活动。
- 扫除:在将活动物体留在原处的同时,确定可用空间。
- 疏散:通过将幸存者转移到另一个水池来释放空间。
- 压缩:通过在同一池中移动幸存者来释放空间。
在世代GC中,年轻的和老的收藏家通常是完全不同的算法。
其结果之一是,很难在不同阶段采用不同算法的地方准确标记收集器。 例如,在CMS中,年轻一代通过疏散收集,而老一代则通过就地标记扫描收集,如果并发收集失败(例如由于碎片),则退回到mark-compact。
HotSpot中的年轻收藏
在HotSpot中,传统的收集器将内存分为4个内存池,分别称为Eden,Survivor 0,Survivor 1和Tenured。 其中的前三个共同归为“年轻”空间,“终身制”被视为“旧”空间。
然后在Young收集期间收集这些年轻空间。 通过将幸存的物体移入幸存者空间之一,使用平行的世界末日疏散来收集年轻一代。
收集算法在当前活动的内存池中标记活动数据,然后将其疏散到不活动的池中。 在收集结束时,两个幸存者空间的角色被颠倒了-活动池变为非活动状态(即为空),非活动池变为活动状态。 这有时被称为偏文集 。
半球方法可能会浪费内存。 单遍算法无法预先知道要收集的内存区域中仍有多少是活动的。 这意味着到空间(将对象撤离到的区域)必须与要清理的区域一样大-因此该算法需要实际幸存者大小的两倍。
这也意味着在任何给定的时刻,有一半的空间是空的。 这些属性将使其不适合在工作负载可能很大的现代工作负载下的老一代:实际上,没有生产HotSpot收集器将半球收集用于老一代。
相反,半球收集用于年轻一代。 它非常适合应用世代假设的工作负载-即主要由垃圾组成的区域。 收藏家得益于这样一个事实,可以将尚存的物体从年轻一代提升为老一代。
疏散收集器的另一个主要优点是它们如何处理自由空间。 最简单的方法是使用区域顶部的指针作为空闲列表。 撤离实时数据后,它将“自然”压缩-本质上是免费的。
疏散方法是OpenJDK年轻一代收集人员的典型做法,它使用跟踪遍历。 但是,收集仅通过一次即可完成-没有单独的标记,扫描或压缩阶段。
世代假设的后果
对象生存期通常是未知的,并且在实际应用中可以动态更改。 因此,既无法实现又无法有效地跟踪对象在挂钟时间内的实际年龄。
而是,HotSpot记录对象幸存的集合数。 这仅需要对象标头中的少量元数据,并且当对象在足够的集合中幸存之后,会将其物理移动( 提升 )到旧的一代并由其他收集器进行管理。
该机制与应用程序的分配速率之间存在有趣的相互作用。 如果分配率提高,则年轻一代将更快地填充-但“短命对象”的预期寿命(以毫秒为单位)将保持不变。
这可能导致更多对象在GC周期中幸存,从而导致年轻一代的幸存者空间中充满了尚不适合升级到老一代的对象。 在这种情况下,JVM除了提早升级某些对象外别无选择,这导致了所谓的过早升级 。
这些对象中的许多实际上是短命的,并且在到达老一代后将很快消失。 不幸的是,JVM没有其他机制可以收集它们,只能等到下一个下一代收集到为止。
垃圾收集的算法复杂性
对于开发人员来说,将复杂性分析(有时称为“ big-O表示法”)应用于垃圾回收算法是很常见的。 但是,实际上,这种方法并不十分令人满意。
天真的,可以认为标记和紧凑阶段的时间复杂度在活动集的大小上是线性的,而清除阶段在总体堆大小的大小上是线性的。
但是,即使撇开了在实际实现中相分离可能不干净的问题(对于上面讨论的HotSpot年轻一代收藏家),仍然存在着更深层次的问题。
这是一个简单的事实,即垃圾收集器从其本质上讲就是所使用的最通用的算法之一。 这意味着big-O分析中的固有假设-重要的是随着数据集大小的增加而出现的限制行为-完全无效。
要求生产系统的GC算法在所有可能的输入和工作负载范围内具有可接受的行为。 它们的渐近行为根本不能代表其整体性能。
换句话说,活动集和堆大小可以本质上独立地变化(例如,由于对象图拓扑不同)。 这意味着乘法缩放因子可以对不同的工作负载产生非常不同的影响。
例如,压缩需要复制字节,因此,尽管压缩阶段在活动集的大小上是线性的,但其他因素之一是需要移动的对象的大小。 在具有许多元素的大量数组的情况下,这可能会很快淹没所有其他考虑暂停时间的因素。
对于各种不同形式的GC算法,也存在众所周知的二阶效应。 例如,当对具有很少存活对象(“稀疏堆”)的内存区域执行压缩时,实时数据将合并为一个密度更大的区域。 如果对象确实是长寿命的,那么此区域对于随后的GC周期将不那么稀疏。
将其与就地收集器(如CMS)进行比较,我们可以看到寿命长的对象在程序的整个生命周期中都将保持稀疏分布。 实际上,随着时间的流逝,可用空间将变得越来越分散,并且对空闲内存块列表 ( free list )的管理将变得越来越昂贵。
总体而言,采用不同GC方法的时间和空间成本模型非常不同,单纯的算法复杂性就没有那么有用。 在HotSpot的情况下,如果找不到足够的连续空间,就地收集器的最终故障模式将退回到压紧收集器。 这是一种重大的行为影响,而没有一种简单化的方法可以解决。
摘要
我们已经讨论了在Java虚拟机中实现的垃圾回收的一些基本方面。 垃圾收集是计算机科学的成熟领域,HotSpot中存在的收集器功能强大且经过了良好的测试,并且在很大的工作负载类别中应该表现良好。 大多数Java应用程序只是不必担心它们的GC行为。
对于一小部分对GC行为敏感的情况,对垃圾回收的原理(以及如何在JVM中实现)进行更深入的了解对于开发人员来说是非常有用的工具。
在Java的最新版本中,垃圾收集子系统再次成为活跃的改进领域。 但是,要完全了解这些变化,必须对基础知识有很好的了解。 即将发表的文章将详细讨论这些更新,并解释例如为什么更改了默认收集器以及这对于升级到Java 11的团队意味着什么。
垃圾收集 java