This difference is fairly easy to demonstrate with the experiment. Take this class as example:

public class LocalFinalize { ... private static volatile boolean flag; public static void pass() { MyHook h1 = new MyHook(); MyHook h2 = new MyHook(); while (flag) { // spin } h1.log(); } public static class MyHook { public MyHook() { System.out.println("Created " + this); } public void log() { System.out.println("Alive " + this); } @Override protected void finalize() throws Throwable { System.out.println("Finalized " + this); } } }

Naively, one could presume that the lifetime of h2 extends to the end of the pass method. And since there is a waiting loop in the middle that might not terminate with flag set to true , the object would never be considered for finalization.

The caveat is that we want the method to be compiled to see the interesting behavior. To force this, we can do two passes: first pass will enter the method, spin for a while, and then exit. This will compile the method fine, because the loop body would be executed many times, and that will trigger compilation. Then we can enter the second time, but never leave the loop again.

Something like this will do:

public static void arm() { new Thread(() -> { try { Thread.sleep(5000); flag = false; } catch (Throwable t) {} }).start(); } public static void main(String... args) throws InterruptedException { System.out.println("Pass 1"); arm(); flag = true; pass(); System.out.println("Wait for pass 1 finalization"); Thread.sleep(10000); System.out.println("Pass 2"); flag = true; pass(); }

We would also like a background thread forcing GC repeatedly, to cause finalization. Okay, the setup is done (full source here), let’s run:

$ java -version java version "1.8.0_101" Java(TM) SE Runtime Environment (build 1.8.0_101-b13) Java HotSpot(TM) 64-Bit Server VM (build 25.101-b13, mixed mode) $ java LocalFinalize Pass 1 Created LocalFinalize$MyHook@816f27d # h1 created Created LocalFinalize$MyHook@87aac27 # h2 created Alive LocalFinalize$MyHook@816f27d # h1.log called Wait for pass 1 finalization Finalized LocalFinalize$MyHook@87aac27 # h1 finalized Finalized LocalFinalize$MyHook@816f27d # h2 finalized Pass 2 Created LocalFinalize$MyHook@3e3abc88 # h1 created Created LocalFinalize$MyHook@6ce253f1 # h2 created Finalized LocalFinalize$MyHook@6ce253f1 # h2 finalized (!)

Oops. That happened because the optimizing compiler knew the last use of h2 was right after the allocation. Therefore, when communicating what live variables are present — later during the loop execution — to the garbage collector, it does not consider h2 live anymore. Therefore, garbage collector treats that MyHook instance as dead and runs its finalization. Since the h1 use is later after the loop, it is considered reachable, and finalization is silent.

This is actually a great feature, because it lets GC can reclaim huge buffers allocated locally without requiring to exit the method, e.g.: