应用程序性能是我们的首要考虑因素,垃圾收集优化是取得小而有意义的进步的好地方
自动化垃圾收集(与JIT HotSpot编译器一起)是JVM中最先进,最有价值的组件之一,但是许多开发人员和工程师对垃圾收集(GC),其工作方式以及如何影响应用程序性能的了解都很少。 。
首先,GC还可以做什么? 垃圾收集是堆中对象的内存管理过程。 将对象分配给堆时,它们会经历几个收集阶段–通常相当快,因为堆中的大多数对象的寿命很短。
垃圾收集事件包含三个阶段-标记,删除和复制/压缩。 在第一阶段,GC遍历堆,并将所有内容标记为活动(引用)对象,未引用对象或可用内存空间。 然后删除未引用的对象,并压缩剩余的对象。 在分代的垃圾收集中,对象“老化”并通过其生活中的3个空间进行提升-伊甸园,幸存者空间和保有权(旧)空间。 这种移动也作为压实阶段的一部分发生。
但是足够了,让我们进入有趣的部分!
了解Java中的垃圾回收(GC)
自动化GC的一大优点是,开发人员实际上不需要了解其工作原理。 不幸的是,这意味着许多开发人员不了解其工作原理。 了解垃圾收集和许多可用的GC,有点像了解Linux CLI命令。 从技术上讲,您不需要使用它们,但是了解和使用它们会对您的生产率产生重大影响。
就像CLI命令一样,这里有绝对的基础知识。 ls命令查看父文件夹中的文件夹列表, mv将文件从一个位置移动到另一个位置,等等。在GC中,这些类型的命令等同于知道有多个GC可供选择,并且GC可能引起性能问题。 当然,还有很多东西要学习(关于使用Linux CLI和垃圾回收)。
学习Java的垃圾回收过程的目的不仅是免费(无聊)的对话入门者,其目的还在于学习如何针对特定环境有效地实现和维护具有最佳性能的正确GC。 知道垃圾回收会影响应用程序性能是基础,并且有许多先进的技术可以增强GC性能并减少其对应用程序可靠性的影响。
GC性能问题
1.内存泄漏–
通过了解堆结构以及如何执行垃圾回收,我们知道内存使用量逐渐增加,直到发生垃圾回收事件并且使用率回落为止。 被引用对象的堆利用率通常保持稳定,因此下降幅度应大致相同。
发生内存泄漏时,每个GC事件都会清除一小部分堆对象(尽管没有使用许多留在后面的对象),因此堆利用率将继续增加,直到堆内存已满,并且将抛出OutOfMemoryError异常。 原因是GC仅将未引用的对象标记为删除。 因此,即使不再使用引用的对象,也不会从堆中清除该对象。 有一些有用的编码技巧可以防止这种情况,稍后我们将介绍。
2.连续的“停止世界”活动–
在某些情况下,垃圾回收可以称为Stop the World事件,因为当垃圾回收发生时,JVM中的所有线程(并因此在其上运行的应用程序)都将停止以允许GC执行。 在健康的应用程序中,GC执行时间相对较短,并且不会对应用程序性能产生太大影响。
但是,在次优情况下,“停止世界”事件可能会极大影响应用程序的性能和可靠性。 如果GC事件需要“停止世界”暂停并需要2秒钟的时间执行,则该应用程序的最终用户将遇到2秒的延迟,因为运行该应用程序的线程被停止以允许GC。
当发生内存泄漏时,连续的“停止世界”事件也是有问题的。 由于每次执行GC都会清除较少的堆内存空间,因此剩余内存填满的时间会更少。 当内存已满时,JVM会触发另一个GC事件。 最终,JVM将运行重复的Stop the World事件,从而引起严重的性能问题。
3. CPU使用率–
而这全都取决于CPU使用率。 连续GC /“停止世界”事件的主要症状是CPU使用率激增。 GC是一项计算量大的操作,因此所花费的资源不应该超过它的CPU能力。 对于运行并发线程的GC,CPU使用率可能更高。 为您的应用程序选择合适的GC将对CPU使用率产生最大的影响,但是还有其他方法可以优化以提高该领域的性能。
从围绕垃圾收集的性能问题中我们可以了解到,尽管高级GC获得了(并且它们已经相当先进),但其致命弱点仍然保持不变。 冗余且不可预测的对象分配。 要提高应用程序性能,仅选择正确的GC是不够的。 我们需要了解流程的工作方式,并且需要优化代码,以使我们的GC不会占用过多的资源或在应用程序中造成过多的暂停。
世代GC
在深入研究不同的Java GC及其对性能的影响之前,了解世代垃圾收集的基础很重要。 世代GC的基本概念基于这样的思想,即对堆中某个对象的引用存在的时间越长,将其标记为删除的可能性就越小。 通过用具有象征意义的“年龄”标记对象,可以将它们分隔到不同的存储空间,以使GC进行标记的频率降低。
将对象分配给堆时,会将其放置在所谓的Eden空间中。 那是对象开始的地方,在大多数情况下,它们是标记为删除的地方。 在该阶段幸存的对象将“庆祝生日”,并复制到Survivor空间。 此过程如下所示:
伊甸园和幸存者空间组成了年轻一代。 这是大部分操作发生的地方。 当(如果)年轻一代中的某个对象达到一定年龄时,该对象将被提升到终身(也称为旧)空间。 根据年龄划分对象内存的好处是GC可以在不同级别上运行。
次要GC是仅关注年轻一代的集合,实际上完全忽略了Tenured空间。 通常,年轻代中的大多数对象都标记为删除,并且不需要主要或完整GC(包括旧代)来释放堆上的内存。 当然,必要时会触发“主要”或“完全” GC。
在此基础上优化GC操作的一个快速技巧是调整堆区域的大小,以最适合您的应用程序的需求。
收集器类型
有许多可用的GC可供选择,尽管G1成为Java 9中的默认GC ,但它最初旨在替代低暂停的CMS收集器,因此使用吞吐量收集器运行的应用程序可能更适合保留其当前收集器。 对于Java垃圾收集器,了解操作差异以及性能影响差异仍然很重要。
吞吐量收集器
更适合需要针对高吞吐量进行优化的应用程序,并且可以交易更高的延迟来实现。
序列号–
串行收集器是最简单的一种,也是您最不可能使用的一种,因为它主要用于单线程环境(例如32位或Windows)和小型堆。 该收集器可以垂直扩展JVM中的内存使用量,但需要几个主要/完全GC才能释放未使用的堆资源。 这会导致频繁的Stop the World暂停,从而使它在所有意图和目的上都无法在面向用户的环境中使用。
平行 -
顾名思义,此GC使用并行运行的多个线程来扫描并压缩堆。 尽管Parallel GC使用多个线程进行垃圾回收,但它在运行时仍会暂停所有应用程序线程。 Parallel收集器最适合需要针对最佳吞吐量进行优化并且可以忍受更高延迟的应用程序。
低暂停时间收集器
大多数面向用户的应用程序都需要低暂停GC,因此长时间或频繁的暂停不会影响用户体验。 这些GC都是为了优化响应能力(时间/事件)和强大的短期性能。
并发标记扫描(CMS)–
与并行收集器相似,并发标记扫描(CMS)收集器利用多个线程来标记和清除(删除)未引用的对象。 但是,此GC仅在以下两个特定实例中启动“停止世界”事件:
(1)在初始化根的初始标记(旧代对象中可以从线程入口点或静态变量访问的对象)或main()方法的任何引用时,等等
(2)当应用程序在算法同时运行时更改了堆的状态时,迫使它返回并进行最后的修改以确保它标记了正确的对象
G1 –
垃圾第一收集器(通常称为G1)利用多个后台线程扫描通过堆将其划分为多个区域。 它的工作方式是先扫描那些包含最多垃圾对象的区域,然后为其命名(垃圾优先)。
这种策略减少了在后台线程完成对未使用对象的扫描之前耗尽堆的机会,在这种情况下,收集器将不得不停止应用程序。 G1收集器的另一个优点是它可以在移动过程中压缩堆,这是CMS收集器仅在完全Stop the World收集期间执行的操作。
改善GC性能
垃圾收集的频率和持续时间直接影响应用程序的性能,这意味着可以通过减少这些指标来优化GC流程。 有两种主要方法可以做到这一点。 首先,通过调整年轻人和老年人的堆大小 ,其次,以减少对象分配和提升的速度 。
在调整堆大小方面,它并不像人们期望的那么简单。 合理的结论是,增加堆大小将减少GC频率,同时增加持续时间,而减少堆大小将减少GC持续时间,同时增加频率。
不过,事实是,次要GC的持续时间不取决于堆的大小,而是取决于可以幸存的对象数量。 这意味着对于大多数创建寿命短的对象的应用程序,增加年轻代的大小实际上可以减少GC的持续时间和频率。 但是,如果增加年轻代的大小会导致需要在幸存者空间中复制的对象显着增加,则GC暂停将花费更长的时间,从而导致延迟增加。
编写GC高效代码的3个技巧
提示1:预测收集容量–
所有标准Java集合以及大多数自定义和扩展的实现(例如Trove和Google的Guava)都使用基础数组(基于原始或对象的数组)。 由于数组一旦分配就不会改变大小,因此在许多情况下向集合中添加项目可能会导致丢弃旧的基础数组,而使用较大的新分配的数组。
即使未提供预期的集合大小,大多数集合实现都尝试优化此重新分配过程并将其保持在摊销后的最小值。 但是,通过在构造时为集合提供预期的大小可以达到最佳效果。
提示2:直接处理流–
例如,在处理数据流(例如从文件读取的数据或通过网络下载的数据)时,通常会看到以下内容:
byte[] fileData = readFileToByteArray(new File("myfile.txt"));
然后,可以将结果字节数组解析为XML文档,JSON对象或协议缓冲区消息,以列举一些常用的选项。
当处理大文件或大小无法预测的文件时,这显然不是一个好主意,因为在JVM无法实际分配整个文件大小的缓冲区的情况下,这会使我们面临OutOfMemoryErrors。
解决此问题的更好方法是使用适当的InputStream(在这种情况下为FileInputStream),将其直接输入解析器,而无需先将整个内容读取到字节数组中。 所有主要库都公开了API以直接解析流,例如:
FileInputStream fis = new FileInputStream(fileName);
MyProtoBufMessage msg = MyProtoBufMessage.parseFrom(fis);
提示3:使用不可变对象–
不变性有很多优点。 很少受到关注的一个问题是它对垃圾收集的影响。
不变对象是指在构造对象之后其字段(在我们的情况下尤其是非原始字段)无法修改的对象。
不变性意味着不变容器引用的所有对象都是在容器构造完成之前创建的。 用GC的术语来说:容器至少与所保存的最小引用一样年轻。 这意味着,在年轻一代执行垃圾回收周期时,GC可以跳过位于老一代中的不可变对象,因为它可以确定它们不能引用正在收集的一代中的任何对象。
要扫描的对象越少,意味着要扫描的内存页面越少,而要扫描的内存页面就越少,意味着GC周期越短,这意味着GC暂停时间越短,总体吞吐量就越高。
有关更多技巧和详细示例,请查看这篇文章,其中涵盖了用于编写内存效率更高的代码的深入策略。
***非常感谢OverOps研发团队的Amit Hurvitz对本文的热情和见解!
翻译自: https://www.javacodegeeks.com/2018/08/improve-application-performance-gc.html