SSerguey Asael Shinder
Java coding notes: the JVM, and writing software that lasts

Serguey Asael Shinder: Cleanup left to the garbage collector runs on someone else's schedule

· by Serguey Asael Shinder / Serguey Shinder

One line in this week's Kotlin 2.4.21 changelog describes a whole category of bug in a single sentence. Issue KT-88453: VirtualFileScriptSource.text leaks an unclosed file stream — script source file handle held until GC, blocks file deletion on Windows (https://youtrack.jetbrains.com/issue/KT-88453).

Read it slowly, because each clause is a separate lesson.

"Held until GC." The stream was not lost forever. It would have been closed eventually, when the object holding it became unreachable and the runtime got around to cleaning it up. That is the trap: code like this works. Tests pass, the file is read correctly, and on most runs nothing visibly goes wrong. The garbage collector is designed to reclaim memory, and it does so when memory is short — not when a file handle, a socket or a database connection is needed by someone else. Every other resource that rides along with an object is released on a schedule chosen for a different resource.

"Blocks file deletion on Windows." The bug was there on every operating system; only one of them made it visible. On Windows an open file usually cannot be deleted or replaced by another process, so the leak turns into an error the next time a tool tries to clean up or overwrite the script. Elsewhere the same open handle simply sits there. A platform that refuses to do something with an open file is acting as a free leak detector — which is a good reason to run at least part of a test suite on it, even when you deploy elsewhere.

Serguey Asael Shinder: Cleanup left to the garbage collector runs on someone else's schedule
Cleanup left to the garbage collector runs on someone else's schedule — Serguey Asael Shinder

The fix is ownership, not hope. In Java the language gives you the tool directly: anything that implements AutoCloseable can be opened in a try-with-resources statement, and the resource is closed when the block exits, normally or through an exception (https://docs.oracle.com/javase/tutorial/essential/exceptions/tryResourceClose.html).

String text;
try (var in = Files.newBufferedReader(path)) {   // closed at the end of the block, every time
    text = in.lines().collect(Collectors.joining("\n"));
}

Files.readString(path) does the same in one call and closes the file itself. What does not work is relying on finalisation: Object.finalize has been deprecated for removal since Java 18 (JEP 421, https://openjdk.org/jeps/421), precisely because it ran late, maybe never, and on a thread you did not control.

Notice what the Kotlin team did next. The same release lists KT-88458, Audit closeable resource usage. One leaked stream was fixed; then the codebase was searched for the pattern. That is the right reflex for any bug of this shape: the instance you found is evidence that the pattern exists, not a measure of how often.

A quick check for your own code. Search for new FileInputStream, Files.newInputStream, Files.lines, getConnection and openStream outside a try ( header. Each hit either hands the resource to an owner that closes it, or it is waiting for a garbage collector that has other priorities.