> 应用程序因OutOfMemoryError而宕机的原因有多种。大多数版本的OutOfMemoryError都有一些有价值的错误提示信息,例如 “Java heap space…” 或“请求分配的数组大小超出 VM 限制”。然而,“超出 GC 开销限制(GC overhead limit exceeded)”消息往住会仍然会让经验丰富的开发人员感到困惑。
如果应用程序用于GC的时间开销超过总的98%,JVM 会抛出 OOM 异常信息。通常,根本原因是垃圾收集器销毁对象的速度低于创建对象的速度,但 JVM 仍然设法顺利完成了任务。
在本文中,我们将深入探讨为什么会出现这种情况以及如何解决这个问题。
重现资源超出开销限制的错误
超出开销限制的主要原因之一可能是创建率和消耗率之间的差异。这种类型的OutOfMemoryError在生产环境中通常是一个微妙且难以重现的问题。
出于是教学展示原因,我们可能在重现此问题时遇到问题。 主要问题是我们需要在分配器和收集器之间取得平衡。 如果创建率明显较高,我们会立即达到堆边界并得到 OutOfMemoryError 。同时,如果我们的垃圾收集器处理垃圾,我们的吞吐量会降低,但应用程序会设法艰难地度过难关。
另一件需要考虑的事情是我们使用的垃圾收集器的类型。对于我们的情况, ParallelGC会更稳定,抛出“ OutOfMemoryError: GC overhead limit exceeded*”。* 因为ParallelGC是一个STW(停止应用)的收集器,所以降低吞吐量并使应用程序停止运行更容易。此外,超出堆边界会更困难。
请看以下示例
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
int i = 0;
while (true) {
list.add(i);
++i;
}
}
**此代码会产生“ OutOfMemoryError: Java heap space”。 **原因是,尽管我们每次都添加一个元素,但在底层,ArrayList会分配越来越长的数组。每次达到限制时,长度都会翻倍(八股文):
private Object[] grow(int minCapacity) {
int oldCapacity = elementData.length;
if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
int newCapacity = ArraysSupport.newLength(oldCapacity,
minCapacity - oldCapacity, /* minimum growth */
oldCapacity >> 1 /* preferred growth */);
return elementData = Arrays.copyOf(elementData, newCapacity);
} else {
return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
}
}
这样,JVM 也可能会突然中止,因为它无法分配足够的空间。但是,使用ArrayList仍然可能出现 “OutOfMemoryError:超出 GC 开销限制”, 但需要更多设置和测试。如果我们使用 LinkedList,会更容易:
public static void main(String[] args) {
List<Integer> list = new LinkedList<>();
int i = 0;
while (true) {
list.add(i);
++i;
}
}
ArrayList会创建一个内部数组来保存数据。但是,当数组空间不足时,默认行为是将其大小加倍,占用更多内存。这样,我们就会不断增加堆大小,导致 OutOfMemoryError ,因为我们无法分配足够的空间。
同时,在使用 LinkedList 时 , 我们在每次迭代中分配一个 节点 ,并稳步接近堆的限制,垃圾收集器就会崩溃,导致*“OutOfMemoryError:超出 GC 开销限制”。*
为了更快地获取 OutOfMemoryError,我们还可以减少堆的大小。我们的 VM 选项可能如下所示:
-Xmx100m -XX:+UseParallelGC
根本原因
在实际场景中,系统(例如零售网站)可能在平日表现良好,但在周末客户数量增加时会出现问题。所以,请求高峰可能会导致出现此问题并使垃圾收集器不堪重负。
同时,我们可以通过实现终结器(finalizers)来实现这一点。 finalizers中的额外附加逻辑可以延长对象的生命周期,并且在撤销时需要两次垃圾收集。 但是,如前所述,这两个示例都需要非常严格的平衡。
除了错误日志中的消息之外,我们还可以通过特定的垃圾收集器行为来识别此问题。我们分析垃圾收集器日志,我们可能会看到以下图片:
图 1:GC 开销超出限制的收集模式
我们注意到垃圾收集周期重复的特定模式。 虽然它经常运行,但垃圾收集器无法回收空间并继续尝试。 发现这种模式是识别问题的第一步。
1. 普通的内存泄漏
我们的示例使用了一个简单的内存泄漏来复制此行为。 但是,如前所述,这需要精心设置。 我们应该调整对象创建率,垃圾收集器应该以某种方式运行,并且我们应该稳步接近堆的极限。
我们可以分析堆转储以获取有关故障期间堆状态的更多信息。VM 选项 -XX:+HeapDumpOnOutOfMemoryError将在OutOfMemoryErorr 上创建转储 ,为我们提供有价值的信息。配置自动堆转储始终是一个好习惯,因为它可能有助于我们进行故障排除并且不会花费太多。
如果发生内存泄漏,我们可以使用HeapHero分析堆转储。我们可能会在堆中看到一堆对象:
图 2:存在内存泄漏的堆结构
仔细分析这些对象通常有助于发现问题。 确定罪魁祸首对象后,我们可以跟踪创建-回收过程并找到系统中的错误。 幸运的是,内存泄漏通常更容易重现。
2. 重写Finalizers
超过吞吐量限制的另一个可能原因是 finalizer 的问题。在这种情况下,问题不是来自占用堆中的空间,而是因为生产-收集速率的差异。
HeapHer o也帮助我们解决了这个问题。我们只需要检查不同的部分:
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
图 3:无法访问的对象
如果队列中有许多对象在等待,应用程序中的某些东西通常会阻止垃圾收集器快速完成其工作。 重写的终结器可能是罪魁祸首。
与内存泄漏不同,我们会看到许多无法访问的对象等待回收。 因此,我们本身并没有内存泄漏,但较慢的回收率会将它们保留在内存中。 我们可以将这种情况想象成交通堵塞:汽车在行驶,但速度非常慢。
通常,只需查看对象的代码和实现就可以帮助我们识别问题。此外,linters 和静态代码分析工具也可以帮助我们解决问题。IDE 也可以引起我们的注意,因为从 Java 9 开始 ,finalize() 方法已被弃用。
3. 慢线程
造成此类问题的另一个原因可能是垃圾收集线程速度慢。有时,JVM 会随机选择线程,因此终结器在优先级较低的线程中运行,因此我们在垃圾收集上花费的 CPU 周期较少。
为了识别此类问题,我们需要使用其他工具,例如fastThread。这样,我们就可以识别应用程序中正在运行的状态和线程数:
图 4:Finalizer 线程
但是,我们没有内置方法来在OutOfMemoryError上创建线程转储。幸运的是,我们可以使用 yCrash 360° 工具来监控应用程序在其生命周期内的运行状况:
图 5:终结器线程的状态
从技术上讲,我们可以将 yCrash 360° 工具与*-XX:OnOutOfMemoryError*结合起来,但在应用程序即将死亡时,有时很难获取有关它的有意义的信息。
结论
OutOfMemoryError有多种类型。每种类型都表明我们的应用程序中存在不同的问题。因此,我们需要特殊的工具来识别根本原因。每种OutOfMemoryError类型都需要不同的方法和解决方案。
yCrash 提供各种工具来帮助我们识别和解决我们可能遇到的应用程序性能和内存管理问题。良好的做法、基准测试和监控有助于我们避免难以调试的问题、缺少 SLA 以及在开发中正常运行的情况。
以上翻译自原文链接