Java高级编程:Finalize 引发的内存溢出

2022-11-07 15:34:52 浏览数 (1)

Finalize 引发的内存溢出

在 rt (jdk8) 或 java.Base (jdk9 ) 包下的 java.lang.Object 类里面有一个 finalize() 的方法。这个方法的实现是空的,不过一旦实现了这个方法,就会触发 JVM 的内部行为,威力和危险并存。

代码语言:javascript复制
// java.lang.Object
@Deprecated(since="9")  
protected void finalize() throws Throwable { }

在 OpenJDK9 以后 Oracle 官方对 finalize() 方法的解释和定义为:

Called by the garbage collector on an object when garbage collection determines that there are no more references to the object. A subclass overrides the finalize method to dispose of system resources or to perform other cleanup. 当垃圾收集确定不再有对该对象的引用时,由对象上的垃圾收集器调用。子类覆盖 finalize 方法以释放系统资源或执行其他清理。 The general contract of finalize is that it is invoked if and when the Java virtual machine has determined that there is no longer any means by which this object can be accessed by any thread that has not yet died, except as a result of an action taken by the finalization of some other object or class which is ready to be finalized. The finalize method may take any action, including making this object available again to other threads; the usual purpose of finalize, however, is to perform cleanup actions before the object is irrevocably discarded. For example, the finalize method for an object that represents an input/output connection might perform explicit I/O transactions to break the connection before the object is permanently discarded. finalize 的一般约定是:当 Java 虚拟机确定不再有任何方法可以让任何尚未终止的线程访问该对象时调用它,除非是由于所采取的操作通过完成一些其他准备完成的对象或类。 finalize 方法可以采取任何行动,包括使该对象再次可供其他线程使用;然而,finalize 的通常目的是在对象被不可撤销地丢弃之前执行清理操作。例如,代表输入/输出连接的对象的 finalize 方法可能会执行显式的 I/O 事务以在对象被永久丢弃之前中断连接。 The finalize method of class Object performs no special action; it simply returns normally. Subclasses of Object may override this definition. Object 类的 finalize 方法不执行任何特殊操作;它只是正常返回。 Object 的子类可以覆盖这个定义。 The Java programming language does not guarantee which thread will invoke the finalize method for any given object. It is guaranteed, however, that the thread that invokes finalize will not be holding any user-visible synchronization locks when finalize is invoked. If an uncaught exception is thrown by the finalize method, the exception is ignored and finalization of that object terminates. Java 不保证哪个线程会为任何给定的对象调用它的 finalize 方法。但是,可以保证调用 finalize 的线程在调用 finalize 时不会持有任何用户可见的同步锁。如果 finalize 方法抛出未捕获的异常,则忽略该异常并终止该对象的终结。 After the finalize method has been invoked for an object, no further action is taken until the Java virtual machine has again determined that there is no longer any means by which this object can be accessed by any thread that has not yet died, including possible actions by other objects or classes which are ready to be finalized, at which point the object may be discarded. 在为一个对象调用 finalize 方法后,不会采取进一步的行动,直到 Java 虚拟机再次确定没有任何方法可以让任何尚未终止的线程访问该对象,包括可能的行动由其他准备完成的对象或类,此时该对象可能被丢弃。 The finalize method is never invoked more than once by a Java virtual machine for any given object. 对于任何给定的对象,Java 虚拟机永远不会多次调用 finalize 方法。 Any exception thrown by the finalize method causes the finalization of this object to be halted, but is otherwise ignored. finalize 方法抛出的任何异常都会导致该对象的终结被暂停,否则会被忽略。

过时的 finalize()

阅读 OpenJDK9 源码发现 finalize() 是被 @Deprecated(since="9") 注解修饰的,即 finalize() 自 JDK9 后被标记为过时方法,并且 finalize() 是一种已经被业界证明了的非常不好的方法,Oracle 官方对此的解释为:

The finalization mechanism is inherently problematic. Finalization can lead to performance issues, deadlocks, and hangs. Errors in finalizers can lead to resource leaks; there is no way to cancel finalization if it is no longer necessary; and no ordering is specified among calls to finalize methods of different objects. Furthermore, there are no guarantees regarding the timing of finalization. The finalize method might be called on a finalizable object only after an indefinite delay, if at all. Classes whose instances hold non-heap resources should provide a method to enable explicit release of those resources, and they should also implement AutoCloseable if appropriate. The java.lang.ref.Cleaner and java.lang.ref.PhantomReference provide more flexible and efficient ways to release resources when an object becomes unreachable.

Finalize 的机制在本质上是有问题的。 Finalize 会导致性能问题、死锁和挂起。 终结器中的错误可能导致内存泄漏问题; 如果不再需要 Finalize 时,则会出现无法取消 Finalize 的问题;并且没有在调用之间指定排序以完成不同对象的方法。 此外,无法保证最终确定的时间。 finalize() 方法可能仅在无限延迟之后才在可终结对象上调用,如果有的话。 实例持有非堆资源的类应该提供一种方法来启用这些资源的显式释放,并且如果适当,它们还应该实现 AutoCloseablejava.lang.ref.Cleanerjava.lang.ref.PhantomReference 提供了更灵活、更有效的方法来在对象变得无法访问时释放资源。

finalize() 的使用与规避

不同于 C 的析构函数(对象在被释放之前析构函数会被调用),在Java中,由于 GC 的自动回收机制,因而并不能保证 finalize() 方法会被及时地执行(垃圾对象的回收时机具有不确定性),也不能保证它们会被执行(程序由始至终都未触发垃圾回收)。

代码语言:javascript复制
public class Finalizer {
	@Override
	protected void finalize() throws Throwable {
		System.out.println("Finalizer-->finalize()");
	}

	public static void main(String[] args) {
		Finalizer f = new Finalizer();
		f = null;
	}
}
// 无输出
代码语言:javascript复制
public class Finalizer {
	@Override
	protected void finalize() throws Throwable {
		System.out.println("Finalizer-->finalize()");
	}

	public static void main(String[] args) {
		Finalizer f = new Finalizer();
		f = null;
		
		System.gc(); // 手动请求gc
	}
}
// 输出 Finalizer-->finalize()

finalize() 方法中一般用于释放非 Java 资源(如打开的文件资源、数据库连接等),或是释放调用非 Java 方法(native 本地方法)时分配的内存(如C语言的 malloc() 系列函数)。

由于 finalize() 方法的调用时机具有不确定性,从一个对象变得不可到达开始,到 finalize() 方法被执行,所花费的时间这段时间是任意长的。因而并不能依赖 finalize() 方法能及时的回收占用的资源,可能出现的情况是在我们耗尽资源之前,GC 却仍未触发,因而通常的做法是提供显示的 close() 方法供客户端手动调用。

另外,重写 finalize() 方法意味着延长了回收对象时需要进行更多的操作,从而延长了对象回收的时间。

但利用 finalize() 方法最多只会被调用一次的特性,我们可以实现延长对象的生命周期

代码语言:javascript复制
class User{
	public static User user = null;
	
	@Override
	protected void finalize() throws Throwable {
		System.out.println("User-->finalize()");
		user = this;
	}
	
}

public class FinalizerTest {
	public static void main(String[] args) throws InterruptedException {
		User user = new User();
		user = null;
		System.gc();
		Thread.sleep(1000);
		
		user = User.user;
		System.out.println(user != null); // true
		
		user = null;
		System.gc();
		Thread.sleep(1000);
		System.out.println(user != null); // false
	}
}

引发的内存溢出问题

构造 Finalizable 类,并使用原子计数 aliveCount 在保障线程安全的同时能够记录被创建了多少个 Finalizable 类。

代码语言:javascript复制
import java.util.concurrent.atomic.AtomicInteger;

class Finalizable {
	static AtomicInteger aliveCount = new AtomicInteger(0);
	
	// 在被创建时,原子计数增加 1
	Finalizable() {
	    aliveCount.incrementAndGet();
    }
    
    @Override
    protected void finalize() throws Throwable {
	    // 重写 finalize() 方法,被终结器终结时原子计数减少 1
		Finalizable.aliveCount.decrementAndGet();
	}
	
    public static void main(String[] args) {
		for (int i = 0; ; i  ) {
			// 循环构造对象
			Finalizable f = new Finalizable();
			if ((i % 100_000) == 0) {
				System.out.format("After creating %d objects, %d are still alive.%n", i, Main.aliveCount.get());
			}
		}
    }
}

这个程序使用了一个无限循环来创建对象。它同时还用了一个静态变量 aliveCount 来跟踪一共创建了多少个实例。每创建了一个新对象,计数器会加 1,一旦 GC 完成后调用了 finalize() 方法,计数器会跟着减 1。

符合常人的想法是:由于新创建的对象很快就没人引用了,它们马上就可以被 GC 回收掉。因此很可能会认为这段程序是可以不停的运行下去,并且不断输出以下结果:

代码语言:javascript复制
After creating 100000 objects, 0 are still alive.
After creating 200000 objects, 0 are still alive.
After creating 300000 objects, 0 are still alive.

但由于 finalize() 方法的调用时机具有不确定性,其结果肯定也是具有随机性的,为了确保能出现 OOM 的报错需要加上 VM Option 参数 -Xms16m -Xmx16m

代码语言:javascript复制
After creating 0 objects, 1 are still alive.
After creating 100000 objects, 100001 are still alive.
After creating 200000 objects, 194303 are still alive.
After creating 300000 objects, 289595 are still alive.
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
	at java.lang.ref.Finalizer.register(Finalizer.java:91)
	at java.lang.Object.<init>(Object.java:37)
	at Finalizable.<init>(Finalizable.java:7)
	at Finalizable.main(Finalizable.java:20)

显而易见的是,这段代码抛出了 java.lang.OutOfMemoryError: GC overhead limitt exceeded 的异常,通过加入 JVM 运行参数 -XX: PrintGCDetails 从 JVM 的角度去看一下底层 GC 器究竟发生了什么:

代码语言:javascript复制
After creating 0 objects, 1 are still alive.

[GC (Allocation Failure) [PSYoungGen: 4096K->480K(4608K)] 4096K->2460K(15872K), 0.0108611 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] 

After creating 100000 objects, 99043 are still alive.

...

[Full GC (Ergonomics) [PSYoungGen: 4096K->4078K(4608K)] [ParOldGen: 11004K->10999K(11264K)] 15100K->15077K(15872K), [Metaspace: 3919K->3919K(1056768K)], 0.0178676 secs] [Times: user=0.00 sys=0.00, real=0.02 secs] 

Exception in thread "main" [Full GC (Ergonomics) [PSYoungGen: 4091K->4076K(4608K)] [ParOldGen: 10999K->10996K(11264K)] 15091K->15072K(15872K), [Metaspace: 3922K->3922K(1056768K)], 0.0283656 secs] [Times: user=0.02 sys=0.00, real=0.03 secs] 

[Full GC (Ergonomics) [PSYoungGen: 4095K->4092K(4608K)] [ParOldGen: 10996K->10990K(11264K)] 15091K->15083K(15872K), [Metaspace: 3922K->3922K(1056768K)], 0.0319503 secs] [Times: user=0.01 sys=0.00, real=0.03 secs] 

[Full GC (Ergonomics) java.lang.OutOfMemoryError: GC overhead limit exceeded at Finalizable.main(Finalizable.java:20)

[PSYoungGen: 4096K->4049K(4608K)] [ParOldGen: 11039K->10966K(11264K)] 15135K->15015K(15872K), [Metaspace: 3998K->3998K(1056768K)], 0.0304574 secs] [Times: user=0.02 sys=0.02, real=0.03 secs] 

[Full GC (Ergonomics) [PSYoungGen: 4096K->4037K(4608K)] [ParOldGen: 10966K->10942K(11264K)] 15062K->14979K(15872K), [Metaspace: 4016K->4016K(1056768K)], 0.0449156 secs] [Times: user=0.01 sys=0.00, real=0.04 secs] 

Heap
 PSYoungGen      total 4608K, used 4060K [0x00000000ffb00000, 0x0000000100000000, 0x0000000100000000)
  eden space 4096K, 99% used [0x00000000ffb00000,0x00000000ffef7060,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
  to   space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
 ParOldGen       total 11264K, used 10942K [0x00000000ff000000, 0x00000000ffb00000, 0x00000000ffb00000)
  object space 11264K, 97% used [0x00000000ff000000,0x00000000ffaaf9f8,0x00000000ffb00000)
 Metaspace       used 4017K, capacity 4646K, committed 4864K, reserved 1056768K
  class space    used 443K, capacity 462K, committed 512K, reserved 1048576K

少数几次的 Eden 区的新生代被 GC 过后,JVM 开始采用更昂贵的 Full GC 来清理老生代和持久代的空间。为什么会这样?既然已经没有人引用这些对象了,为什么它们没有在新生代中被回收掉?代码这么写有什么问题吗?

要弄清楚 GC 这个行为的原因,先对代码做一个小的改动:将 finalize() 方法的实现先去掉。现在 JVM 发现这个类没有实现 finalize() 方法了,于是它切换回了“正常”的模式(同样经过 Eden 的新生代),输出了一开始和我们设想一样的结果。再看一眼GC的日志,也只能看到一些廉价的新生代 GC 在不停的运行。这样问题就显而易见是发生在了 finalize() 方法上了。修改后的这段程序中,的确没有方法和类引用到了新生代的这些刚创建的对象。因此 Eden 区很快就被清空掉了,整个程序可以一直的执行下去。

另一方面,在早先的那个例子中情况则有些不同。这些对象并非没人引用 ,JVM 会为每一个 Finalizable 对象创建一个看门狗(watchdog)。这是 Finalizer 类的一个实例。而所有的这些看门狗又会为 Finalizer 类所引用。由于存在这么一个引用链,因此整个的这些对象都是存活的

现在 Eden 区已经满了,而所有对象又都存在引用,GC 没辙了只能把它们全拷贝到 Suvivor 区。更糟糕的是,一旦连 Survivor 区也满了,只能存到老生代里面了。而 Eden 区使用的是一种“抛弃一切”的清理策略,而老生代的 GC 则完全不同,它采用的是一种开销更大(Full GC)的方式。

Finalizer 守护线程

在 GC 完成后,JVM 才会意识到除了 Finalizer 对象已经没有人引用到我们创建的这些实例了,它才会把指向这些对象的 Finalizer 对象标记成可处理的,再去执行它们的 finalize() 方法来终结它们。GC 内部会把这些 Finalizer 对象放到 java.lang.ref.Finalizer.ReferenceQueue 这个特殊的引用队列里面。

在 finalizer 执行过程中通过 jstack 查询 Threads dump 可以看到与 Finalizer 守护线程相关的线程信息:

代码语言:javascript复制
"Finalizer" #3 daemon prio=8 os_prio=1 tid=0x00000249b7bbe000 nid=0x3088 runnable [0x000000c783cff000]  
   java.lang.Thread.State: RUNNABLE  
at java.lang.ref.Finalizer$FinalizerThread.run(Finalizer.java:216)

Finalizer 线程是个单一职责的线程。这个线程会不停的循环等待 java.lang.ref.Finalizer.ReferenceQueue 中的新增对象。一旦 Finalizer 线程发现队列中出现了新的对象,它会弹出该对象,调用它的 finalize() 方法,将该引用从 Finalizer 类中移除,因此下次 GC 再执行的时候,这个 Finalizer 实例以及它引用的那个对象就可以进行垃圾回收了。

现在的两个线程都在不停地循环

  1. 主线程在忙着创建新对象。这些对象都有各自的看门狗也就是 Finalizer,而这个 Finalizer 对象会被添加到一个 java.lang.ref.Finalizer.ReferenceQueue 中。
  2. Finalizer 线程会负责处理这个队列,它将所有的对象弹出,然后调用它们的 finalize() 方法。

很多时候我们可能察觉不到内存溢出这种情况。finalize() 方法的调用会比你创建新对象要早得多。因此大多数时候,Finalizer 线程能够赶在下次 GC 带来更多的 Finalizer 对象前清空这个队列,这就导致了有的对象被提前清空了,而有的对象被线程阻塞到后面清空了,导致了大量的 Finalizer 对象被滞留在了 ReferenceQueue 中。大致的情况如下:

文末总结

所以为什么会出现溢出问题?因为 Finalizer 线程和主线程相比它的线程优先级要低。这意味着分配给它的CPU时间更少,因此它的处理速度没法赶上新对象创建的速度

这就是问题的根源:对象创建的速度要比 Finalizer 线程调用 finalize() 结束它们的速度要快。最后导致堆中所有可用的空间都被耗尽了。结果就是:java.lang.OutOfMemoryError 会以不同的身份出现在你面前。

总结,Finalizable 对象的生命周期和普通对象的行为是完全不同的,基本的执行顺序如下:

  • JVM 创建 Finalizable 对象
  • JVM 创建 java.lang.ref.Finalizer 实例,指向刚创建的对象
  • java.lang.ref.Finalizer持有(锁)新创建的 java.lang.ref.Finalizer 的实例。这使得下一次新生代 GC 无法回收这些对象
  • 新生代 GC 无法清空 Eden 区,因此会将这些对象拷贝到 Survivor 区或者老生代
  • 垃圾回收器发现这些对象实现了 finalize() 方法。因为会把它们添加java.lang.ref.Finalizer.ReferenceQueue 队列中。
  • Finalizer 线程会处理这个队列,将里面的对象逐个弹出,并调用它们的finalize()方法
  • finalize() 方法调用完后,Finalizer 线程会将引用从 Finalizer 类中去掉,因此在下一轮 GC 中,这些对象就可以被回收了(完成标记)
  • Finalizer 线程会和我们的主线程进行竞争,不过由于它的优先级较低,获取到的CPU时间较少,因此它永远也赶不上主线程的步伐(阻塞滞留)
  • 程序进入死循环并消耗了所有的可用资源,最后抛出 OutOfMemoryError 的异常

参考文献

[1] 《深入理解Java虚拟机 第三版》.周志明.2021-12. [2] 《Java的Finalizer引发的内存溢出》.南极山.2016-08-27. [3] 《finalize 方法重写对 GC 的影响分析》.磊叔的技术博客.2021-11-2

0 人点赞