Nested and inner classes

Static nested, inner, local and anonymous — what each captures, what it costs, and why a non-static inner class can hold a whole object graph alive.

6 min read🧱 Object-Oriented Java

Java has four kinds of class you can declare inside another, and they differ in one thing that matters: whether an instance of the inner one holds a reference to an instance of the outer one. That hidden reference is convenient, invisible, and the reason a cache of small objects can keep a hundred megabytes alive. This lesson is the four kinds, what each captures, and how to choose.

The four kinds

java
class Outer {
    static class StaticNested { }          // 1. no enclosing instance
    class Inner { }                        // 2. holds one, implicitly
 
    void method() {
        class Local { }                    // 3. inner, plus locals
        Runnable r = new Runnable() {      // 4. anonymous — inner, unnamed
            public void run() { }
        };
    }
}
Enclosing instanceCreated with
static nestednonew Outer.StaticNested()
inneryesouter.new Inner()
localyesinside the method
anonymousyes (unless in a static context)at the expression

The syntax outer.new Inner() is the language telling you the truth: an inner instance cannot exist without an outer one.

The reference you did not write

A non-static inner class gets a synthetic field — this$0 — assigned at construction and never mentioned in your source. It is what lets the inner class read the outer's fields directly:

java
class Order {
    private final String id;
    Order(String id) { this.id = id; }
 
    class Line {                       // inner
        void log() {
            System.out.println(id);    // reads Order.this.id through this$0
        }
    }
}

Convenient, and the convenience is the trap. Line now keeps its Order alive for exactly as long as the Line lives.

new Ordernew Linecache.put(line)method endsthe leak
Orderreachablereferenced bylocal variablecollectablewhen the method ends

new Order. A 40 MB Order is created and does its work. Only the local variable refers to it.

1 / 5

Making Line static removes this$0 and the whole path. That is the default to reach for: inner unless you need the enclosing instance, not the other way round.

Local and anonymous classes capture

Both can read local variables, and both require those variables to be effectively final — assigned once and never reassigned:

java
void schedule(String name) {
    int attempts = 3;
    Runnable r = () -> System.out.println(name + attempts);   // fine
    // attempts = 4;   ← adding this stops the lambda compiling
}

The reason is that the value is copied into the object at construction, not shared. If the local could change afterwards the copy would silently disagree with it, so the language forbids the change rather than the capture.

Fields are different: an inner class reads the enclosing instance's fields through this$0, so it sees changes. A local it captured, it does not. That asymmetry surprises people once.

Anonymous class or lambda

java
Runnable a = new Runnable() { public void run() { work(); } };   // anonymous
Runnable b = () -> work();                                       // lambda

They are not the same thing, and the differences are worth knowing:

  • A lambda has no this of its own. Inside a lambda this is the enclosing instance; inside an anonymous class it is the anonymous instance. This is the one that produces a genuinely confusing bug.
  • An anonymous class is a real class file; a lambda is an invokedynamic call site linked at run time. Twenty anonymous classes are twenty class files to load.
  • A lambda can only implement a functional interface — exactly one abstract method. An anonymous class can extend a class, or implement an interface with several methods.

Use a lambda where it fits. Use an anonymous class when you need state, several methods, or a class rather than an interface.

Under the hood: what the compiler emits

Outer$Inner, Outer$1 for the first anonymous, Outer$1Local for a local class. They are ordinary class files, which is why:

  • A stack trace names them, and Outer$1 is the compiler telling you it is the first anonymous class in that file. Adding another above it renumbers the rest, which is why anonymous classes make stack traces unstable across edits.
  • Reflection sees them. getDeclaredClasses() lists nested classes; isAnonymousClass() and getEnclosingClass() exist because the information is in the file.
  • Private access across the boundary used to cost a bridge method. An inner class reading Outer's private field needed a synthetic accessor, because the JVM had no notion of nesting. Java 11's nestmates fixed that: NestHost and NestMembers attributes let the JVM allow the access directly, and the synthetic methods are gone.

Walkthrough: the listener that held the screen

An Android-style pattern, and the same shape appears in any long-lived registry. A view registered a listener:

java
class ReportView {
    private final byte[] rendered = new byte[8_000_000];
 
    void attach(EventBus bus) {
        bus.subscribe(new Listener() {          // anonymous → inner → this$0
            public void onEvent(Event e) { refresh(); }
        });
    }
}

The bus outlived the view. Every ReportView ever attached was still reachable through its listener's this$0, each holding eight megabytes. After a few hundred report screens the heap was gone.

The dump named byte[] as the largest consumer, which is true and useless; the retaining path was EventBus → Listener → ReportView → byte[], and the listener was the only part small enough to overlook.

The fix is to unsubscribe — and, so the bug cannot recur, to make the listener a static nested class taking what it needs as a constructor parameter, so there is no this$0 to hold anything.

Try it yourself

Which this?

java
class Widget {
    String name = "widget";
    Runnable lambda = () -> System.out.println(this.name);
    Runnable anon = new Runnable() {
        String name = "anon";
        public void run() { System.out.println(this.name); }
    };
}
Answer

widget and anon. A lambda has no this of its own, so this is the Widget. An anonymous class is a real class with a real instance, so this is that instance and finds its own name. This is the single most common reason a lambda and an anonymous class are not interchangeable.

Why will this not compile?

java
class Outer {
    class Inner { }
    static Inner make() { return new Inner(); }
}
Answer

Inner needs an enclosing instance and a static method has none. Either make Inner static, or give the method an Outer to work from: outer.new Inner(). The error message — "non-static variable this cannot be referenced from a static context" — is describing exactly the missing this$0.

Where does the memory go?

A Map<String, Node> cache holds 10,000 entries. Node is a non-static inner class of a Document that holds a 2 MB parsed tree. Each Node is 32 bytes.

Answer

Up to 20 GB, if the nodes came from 10,000 different documents — 320 KB of Node objects holding 10,000 documents alive through this$0. If they all came from one document it is 2 MB and nothing is wrong. The cache's own size tells you nothing; the number of distinct enclosing instances is the question, and it is not visible at the call site.

Misconceptions

  • "Static nested and inner are two names for the same thing." The static one has no enclosing instance and cannot reach the outer's instance fields. That difference is the whole lesson.
  • "A lambda is an anonymous class with nicer syntax." Different this, no class file, and a functional interface only.
  • "Inner classes are a code-style preference." They have a memory consequence that outlives the file they are written in.
  • "Effectively final means the compiler is being fussy." The value is copied, so a later change could not be reflected; forbidding the change is the honest option.

Going deeper

  • The Java Language Specification section 8.1.3, Inner Classes and Enclosing Instances — where this$0 is specified rather than folklore.
  • JEP 181, Nest-Based Access Control — why the synthetic bridge methods went away in Java 11.
  • Any heap-dump tool's "path to GC root" view, which is the feature this lesson exists to make you reach for.
Progress is saved on this device and to your account when signed in.