本文为译文,原文地址:Problems With Finalizer
1. 前言
在Java中,finalize方法自语言早期以来就是其组成部分,提供了一种机制,在对象被垃圾回收之前执行清理活动。然而,由于几个与性能相关的问题,使用finalizers已经受到了质疑。从Java 9开始,finalize方法已经被弃用,强烈不推荐使用它。
2. 延迟垃圾回收
Finalizers 可以显著减慢垃圾回收过程。当一个对象准备好被收集,但它有 finalize 方法时,垃圾回收器必须调用这个方法,然后在下一个垃圾回收周期中重新检查该对象。这个两步过程延迟了内存回收,导致内存使用量增加和潜在的内存泄漏。
这个问题会引起两种形式的 CPU 使用率上升。第一个也是最明显的问题是,实现了 finalize 方法的对象必须经历两个垃圾回收周期。另一个不太明显的问题是由于对象在内存中停留的时间更长,会触发更多由于内存不足而产生的垃圾回收周期。
如果垃圾回收器无法长时间回收一个对象,应用程序可能会因 OutOfMemoryError 而崩溃,因为对象的创建速率明显高于回收速率。
然而,上述情况很容易发现,因为应用程序会崩溃报错。让我们考虑一个更隐蔽的情况,即应用程序可以运行,但在垃圾回收上占用更多的资源和时间。
public class BigObject {
private int[] data = new int[1000000];
}
public class FinalizeableBigObject extends BigObject {
@Deprecated
protected void finalize() throws Throwable {
super.finalize();
}
}
让我们引入另一个继承自 BigObject 的类,但是它不会实现 finalize 方法:
public class NonFinalizeableBigObject extends BigObject {
}
我们将评估这两个类的创建性能。代码只是在无限循环中创建这些对象:
此处使用JMH微基准性能测试工具
@Benchmark
@BenchmarkMode(Mode.Throughput)
@Fork(value = 1, jvmArgs = {"-Xlog:gc:file=gc-with-finalizable-object-%t.txt -Xmx6gb -Xms6gb"})
public void finalizeableBigObjectCreationBenchmark(Blackhole blackhole) {
final FinalizeableBigObject finalizeableBigObject = new FinalizeableBigObject();
blackhole.consume(finalizeableBigObject);
}
@Benchmark
@BenchmarkMode(Mode.Throughput)
@Fork(value = 1, jvmArgs = {"-Xlog:gc:file=gc-with-non-finalizable-object-%t.txt -Xmx6gb -Xms6gb"})
public void nonFinalizeableBigObjectCreationBenchmark(Blackhole blackhole) {
NonFinalizeableBigObject nonFinalizeableBigObject = new NonFinalizeableBigObject();
blackhole.consume(nonFinalizeableBigObject);
}
即使是一个空的 finalize 方法也可能导致性能显著下降。任何额外的逻辑都会使情况变得更糟。我们可以从性能测试中看到这一点。现在,我们不会使用垃圾收集日志来分析这段代码。
Benchmark | Mode | Cnt | Score | Error | Units |
---|---|---|---|---|---|
OverheadBenchmark.finalizeableBigObjectCreationBenchmark | thrpt | 6 | 23221.308 | ± 226.856 | ops/s |
OverheadBenchmark.nonFinalizeableBigObjectCreationBenchmark | thrpt | 6 | 23807.144 | ± 117.467 | ops/s |
这个测试在两个独立的分支中分别进行了三次迭代,每次迭代持续二十分钟,其中包含一次十秒钟的迭代用于预热。这意味着总体上每次测量需要两个小时,这应该足够估计每个测试的相对性能。本文中使用的其余测试都具有相同的配置。
3. Finalizers 作为安全网
将 finalizers 作为安全网是一个合理的想法。然而,在这样做之前,我们应该知道所有的优缺点。通常这种安全网场景涉及实现 AutoCloseable
接口的资源。在这种情况下,finalizers 会调用 close 方法,我们可以确信资源将在某个时刻关闭。
从 finalize 方法中关闭资源应该是罕见的。管理资源的主要方式应该涉及 try-with-resources
。在这种情况下,即使我们一直遵循良好的实践,也会受到惩罚,如前一个示例所示。拥有一个实现的 finalize 方法将需要两步内存回收。
如果我们有清理逻辑,它包含昂贵的操作,或者在我们尝试两次关闭资源时抛出异常,我们可能会遇到严重的性能问题。这甚至可能导致 OutOfMemoryError
。让我们检查一下,如果我们暂停线程一毫秒,之前的例子会发生什么:
public class DelayedFinalizableBigObject extends BigObject {
@Override
protected void finalize() throws Throwable {
Thread.sleep(1);
}
}
@Benchmark
@BenchmarkMode(Mode.Throughput)
@Fork(value = 1, jvmArgs = {"-Xlog:gc:file=gc-with-delayed-finalizable-object-%t.txt -Xmx6gb -Xms6gb"})
public void delayedFinalizeableBigObjectCreationBenchmark(Blackhole blackhole) {
DelayedFinalizableBigObject delayedFinalizeableBigObject = new DelayedFinalizableBigObject();
blackhole.consume(delayedFinalizeableBigObject);
}
同时,让我们检查抛出异常的 finalize 方法的相同指标:
public class ThrowingFinalizableBigObject extends BigObject {
@Override
protected void finalize() throws Throwable {
throw new Exception();
}
}
@Benchmark
@BenchmarkMode(Mode.Throughput)
@Fork(value = 1, jvmArgs = {"-Xlog:gc:file=gc-with-throwing-finalizable-object-%t.txt -Xmx6gb -Xms6gb"})
public void throwingFinalizeableBigObjectCreationBenchmark(Blackhole blackhole) {
ThrowingFinalizableBigObject throwingFinalizeableBigObject = new ThrowingFinalizableBigObject();
blackhole.consume(throwingFinalizeableBigObject);
}
从性能测试的角度来看,我们可以看到,finalize 方法可能会显著降低性能:
Benchmark | Mode | Cnt | Score | Error | Units |
---|---|---|---|---|---|
OverheadBenchmark.delayedFinalizeableBigObjectCreationBenchmark | thrpt | 6 | 142.630 | ± 1.282 | ops/s |
OverheadBenchmark.throwingFinalizeableBigObjectCreationBenchmark | thrpt | 6 | 23100.262 | ± 632.131 | ops/s |
4. 识别问题
如前所述,这类问题的最佳情况是应用程序失败并出现 OutOfMemoryError
。这明确显示了内存使用问题。然而,让我们关注更微妙的问题,这些问题会降低性能,但不会明确表现出来。
第一步是分析垃圾回收日志,并检查是否有一些不寻常的回收周期数量。市场上有一些很好的工具可以分析垃圾回收日志。我们使用 GCeasy 分析捕获的垃圾回收日志。这里我们比较了前面示例中从垃圾回收日志中获取的指标。请注意,这些比较是从一个二十分钟的迭代中获取的:
NonFinalizeableBigObject | FinalizeableBigObject | ThrowingFinalizeableBigObject | DelayedFinalizeableBigObject | |
---|---|---|---|---|
Throughput | ≈98% | ≈95% | ≈95% | ≈5% |
Avg Pause GC Time | ≈0.4 ms | ≈0.5 ms | ≈0.5 ms | ≈2 ms |
Max Pause GC Time | ≈14 ms | ≈47 ms | ≈22 ms | ≈50 ms |
吞吐量显示了应用程序在有用工作上花费的时间。在 DelayedFinalizeableBigObject 的情况下,我们只花费了 5% 的时间来完成工作。其余时间都用于垃圾回收器。这意味着在十分钟的运行时间中,我们只花了三十秒在实际工作上。
GCeasy 报告中有一部分包含了上述信息:
5. 结论
我们永远不应低估性能问题。浪费的几毫秒可能在一年内累积成相当可观的时间。性能问题不仅可能导致花费更多本可以节省的钱,而且可能影响服务水平协议(SLA),并导致更严重的后果。