Command Palette

Search for a command to run...

[Advanced Java] Nested, Inner, Local and Anonymous Classes in Java

Java lets you put a class inside another class in four different ways, and they are not four spellings of one idea. Two of them produce an object that quietly holds a reference to the object that created it; two of them do not. That single difference decides whether a listener you registered keeps eight megabytes of screen buffer alive after everything else has let go of it.

Beginner courses tend to present nested classes as a namespace trick — a way to keep a small helper next to the class that uses it. That is the least interesting thing about them. This article works from the class files instead: what javac writes to disk for each shape, which synthetic fields it adds, what those fields cost at runtime, and why an anonymous class and a lambda that look almost identical in source produce completely different objects.

Class Outer containing four nested shapes, only the inner one wired back to the enclosing object

Every class file listing, javap dump, program output and compiler error below was produced on OpenJDK 21.0.6 (arm64). Where a behaviour depends on the language level, the boundary was found by recompiling the same source with --release and is reported as the release where it changes.

The four shapes and what separates them

A nested class is any class declared inside the body of another class. A local class is declared inside a method body, and an anonymous class is declared inside an expression. Here is one file with all four:

class Outer {
    static class Config { }

    class Session { }

    void open() {
        class Attempt { }
        Runnable r = new Runnable() {
            public void run() { }
        };
        r.run();
        new Attempt();
    }
}

javac Outer.java writes five class files:

Outer$1.class
Outer$1Attempt.class
Outer$Config.class
Outer$Session.class
Outer.class

Nesting is a source-level idea only. The JVM has no concept of a class inside a class: every one of those five is a separate top-level class file, and the $ in the name is an ordinary identifier character, not structure. What survives compilation is a set of attributes — InnerClasses, EnclosingMethod, NestHost — plus, for two of the four shapes, an extra field.

Four declaration sites in one source file mapped to the four class files javac produced

The vocabulary matters because the JLS is strict about it. "Nested class" is the umbrella term. A static nested class is a nested class declared static. An inner class is any nested class that is not static — which includes local and anonymous classes, since neither can be static. So "inner class" is not a synonym for "nested class", and the compiler will tell you so: the error you get for a bad capture in an anonymous class says local variables referenced from an inner class must be final or effectively final, using "inner class" to cover the anonymous case.

ShapeDeclaredHas an enclosing instanceClass file
static nestedin a class body, with staticnoOuter$Config
inner (member)in a class body, without staticyesOuter$Session
localin a method bodyonly if the method is an instance methodOuter$1Attempt
anonymousin an expressiononly if the expression is in an instance contextOuter$1

The static nested class is the default choice

A static nested class is a normal top-level class that happens to live inside another class's namespace. It has no enclosing instance, so it cannot read the outer object's instance fields, and you create it without one:

public class Outer {
    static class Config {
        private final String key;
        Config(String key) { this.key = key; }
        String key() { return key; }
    }

    public static void main(String[] args) {
        Config c = new Config("timeout");
        System.out.println("static nested: " + c.key());
    }
}

From another file the type is written Outer.Config and constructed with new Outer.Config("retries"), no Outer instance involved. javap -p on the class file shows nothing you did not write:

Compiled from "Outer.java"
class Outer$Config {
  private final java.lang.String key;
  Outer$Config(java.lang.String);
  java.lang.String key();
}

One field, one constructor, one method. Nesting bought scoping and a private-access relationship with Outer, and cost nothing at runtime.

Why Map.Entry is a static nested class

Map.Entry is the standard-library example everyone has already used without noticing. It is nested inside Map because it means nothing on its own — an entry is a Map concept — but it is static because an entry is data, not a view onto a particular map object. Reflection confirms both halves:

Class<?> e = Map.Entry.class;
System.out.println("name        -> " + e.getName());
System.out.println("enclosing   -> " + e.getEnclosingClass().getName());
System.out.println("isMemberOf  -> " + e.isMemberClass());
System.out.println("static?     -> " + Modifier.isStatic(e.getModifiers()));
name        -> java.util.Map$Entry
enclosing   -> java.util.Map
isMemberOf  -> true
static?     -> true

The same file makes the contrast inside HashMap itself, which contains both kinds:

HashMap.Node static? -> true
HashMap.HashIterator static? -> false
HashIterator fields -> 
   java.util.HashMap$Node java.util.HashMap$HashIterator.next
   java.util.HashMap$Node java.util.HashMap$HashIterator.current
   int java.util.HashMap$HashIterator.expectedModCount
   int java.util.HashMap$HashIterator.index
   final java.util.HashMap java.util.HashMap$HashIterator.this$0

HashMap.Node holds a key, a value and a hash, and is static. HashMap.HashIterator has to see the live map — it reads the table and compares expectedModCount against the map's modCount to throw ConcurrentModificationException — so it is an inner class, and the last field in that list is the reference the compiler gave it. That is the rule in one example: make it static unless the nested type genuinely needs to see the enclosing object.

The inner class and its hidden reference

Drop the static and the class becomes a view onto one particular enclosing object:

public class Outer {
    private String name = "outer-object";
    private int counter = 0;

    class Session {
        private String name = "inner-object";
        String describe() {
            return name + " / " + this.name + " / " + Outer.this.name;
        }
        void bump() { counter++; }
    }

    int counter() { return counter; }
}

Session reads counter, which is an instance field of Outer. It can only do that if it knows which Outer. So an inner class instance cannot exist without one, and the syntax for creating it says so:

Outer outer = new Outer();
Outer.Session s = outer.new Session();
s.bump();
s.bump();
System.out.println("inner: " + s.describe());
System.out.println("outer.counter = " + outer.counter());
inner: inner-object / inner-object / outer-object
outer.counter = 2

Outer.this and outer.new Inner()

Two pieces of syntax appear there and both are unfamiliar the first time.

outer.new Session() is the qualified class instance creation expression. The enclosing instance goes to the left of new. Inside an instance method of Outer you write plain new Session() and the compiler supplies this for you — which is why calling it from main fails:

E4.java:4: error: non-static variable this cannot be referenced from a static context
        Inner i = new Inner();
                  ^
1 error

Outer.this is the qualified this. Inside Session.describe(), the plain name name resolves to the innermost declaration, which is Session's own field; this.name is the same thing spelled out; Outer.this.name reaches past the shadowing to the enclosing object. The output line inner-object / inner-object / outer-object is those three expressions in order. The same qualification works for methods, and it is the only way to call an outer method that an inner class has overridden.

What javac generates for an inner class

Here is the same class file the JVM sees:

Compiled from "Outer.java"
class Outer$Session {
  private java.lang.String name;
  final Outer this$0;
  Outer$Session(Outer);
  java.lang.String describe();
  void bump();
}

You wrote one field. There are two. final Outer this$0 is synthetic — added by the compiler, not nameable in source — and the constructor signature grew a parameter of type Outer that you never declared. The bytecode shows it being stored before anything else, even before the superclass constructor call:

  Outer$Session(Outer);
    Code:
       0: aload_0
       1: aload_1
       2: putfield      #1                  // Field this$0:LOuter;
       5: aload_0
       6: invokespecial #7                  // Method java/lang/Object."<init>":()V
       9: aload_0
      10: ldc           #13                 // String inner-object
      12: putfield      #15                 // Field name:Ljava/lang/String;
      15: return

And counter++, which reads as a plain field increment in source, is really a two-hop dereference through that field:

  void bump();
    Code:
       0: aload_0
       1: getfield      #1                  // Field this$0:LOuter;
       4: dup
       5: getfield      #26                 // Field Outer.counter:I
       8: iconst_1
       9: iadd
      10: putfield      #26                 // Field Outer.counter:I
      13: return

Two details worth knowing about how this has changed.

Private access no longer goes through a bridge method. Before Java 11, an inner class reading a private field of its enclosing class could not do so directly — the JVM would reject it — so javac generated a package-private accessor. Here is a class whose inner class reads one private int of its enclosing class, compiled at --release 8:

Compiled from "Nest.java"
public class Nest {
  private int secret;
  public Nest();
  static int access$000(Nest);
}

At --release 11 the bridge is gone:

Compiled from "Nest.java"
public class Nest {
  private int secret;
  public Nest();
}

Java 11 introduced nestmates: the class file now carries NestHost and NestMembers attributes, the JVM accepts direct private access between members of the same nest, and the synthetic access$000 bridge is gone.

The this$0 field is elided when it is unused. If an inner class body never touches the enclosing instance, a modern javac does not emit the field at all. The same inner class at --release 17:

Compiled from "Leak.java"
class Leak$Host$InnerListener implements java.lang.Runnable {
  final Leak$Host this$0;
  Leak$Host$InnerListener(Leak$Host);
  public void run();
}

At --release 18 and later the field is not emitted:

Compiled from "Leak.java"
class Leak$Host$InnerListener implements java.lang.Runnable {
  Leak$Host$InnerListener(Leak$Host);
  public void run();
}

The constructor keeps its Leak$Host parameter in both — the argument is simply discarded. So the leak below is real but conditional: it happens when the inner class actually uses the enclosing object, which is the whole reason people write inner classes.

What an inner class costs: the enclosing object cannot be collected

The reference is invisible in source, which is precisely what makes it dangerous. An inner class instance that outlives its creator drags the creator along with it. Two listeners that do the same job make the difference visible without measuring anything:

static class Screen {
    final byte[] pixels = new byte[8 * 1024 * 1024];
    String title = "dashboard";

    class InnerListener implements Runnable {
        @Override public void run() { System.out.println(title); }
    }

    static class StaticListener implements Runnable {
        private final String title;
        StaticListener(String title) { this.title = title; }
        @Override public void run() { System.out.println(title); }
    }
}

Both print the title. One reads it through the enclosing object; the other was handed a copy. Now drop the only strong reference to each Screen, keep only the listener, and ask a WeakReference whether the Screen survived a collection:

Screen a = new Screen();
Runnable innerListener = a.new InnerListener();
WeakReference<Screen> wa = new WeakReference<>(a);
a = null;
report("keeping the inner listener ", wa);

Screen b = new Screen();
Runnable staticListener = new Screen.StaticListener(b.title);
WeakReference<Screen> wb = new WeakReference<>(b);
b = null;
report("keeping the static listener", wb);
keeping the inner listener  -> Screen STILL REACHABLE
keeping the static listener -> Screen collected
InnerListener  fields -> [final Leak$Screen Leak$Screen$InnerListener.this$0]
StaticListener fields -> [private final java.lang.String Leak$Screen$StaticListener.title]

Two listeners over the same object graph: only the inner one keeps the enclosing Screen reachable

This is reachability, not timing. The WeakReference is cleared when the referent becomes weakly reachable, so "STILL REACHABLE" after System.gc() means a strong path to the Screen still exists — and the field listing names it. The eight-megabyte array is not the point; it is there so the cost is obvious. The point is the last field in that list.

⚠️ The classic production version of this is an event listener, a Runnable submitted to a long-lived executor, or a callback registered with a singleton. The registry outlives the screen, the listener outlives the screen, and this$0 keeps the screen — and everything the screen references — out of reach of the collector for as long as the registry holds the listener.

The double-brace initialisation idiom is the same bug wearing a disguise:

List<String> tags() {
    return new ArrayList<>() {{ add("a"); add("b"); }};
}

That is not special syntax. It is an anonymous subclass of ArrayList whose instance initialiser block calls add twice — and because it is created in an instance method, it is an inner class:

tags class  -> DoubleBrace$1
tags fields -> [final DoubleBrace DoubleBrace$1.this$0]
holder      -> STILL REACHABLE

A list you hand out, holding the object that built it. Use List.of("a", "b") instead.

The fix in every case is the same: make the nested class static and pass it exactly the data it needs, the way StaticListener takes a String instead of reaching for one.

Local classes are declared inside a method

A local class is declared in a block — a method body, a constructor, an initialiser. Its scope is that block; nothing outside can name the type, so it is only useful when the method returns it as a supertype.

static Supplier<String> makeGreeter(String name) {
    int year = 2026;
    class Greeter implements Supplier<String> {
        @Override public String get() { return "hello " + name + " (" + year + ")"; }
    }
    return new Greeter();
}
hello java (2026)
class      -> LocalDemo$1Greeter
simpleName -> 'Greeter'
isLocal    -> true
enclosing  -> static java.util.function.Supplier LocalDemo.makeGreeter(java.lang.String)

The class file is LocalDemo$1Greeter.class. The leading digit is a disambiguator, because two methods in the same class may each declare a class called Greeter and the class files cannot collide. Declaring the same local class name in two methods produces Numbering$1Local and Numbering$2Local. Unlike an anonymous class, a local class keeps its getSimpleName(), reports isLocalClass() == true, and carries an EnclosingMethod attribute naming the exact method it came from — which is why stack traces from local classes are readable.

Capture is a copy of an effectively final value

Greeter reads name and year, which are a parameter and a local of a method that has already returned by the time get() runs. The stack frame is gone. The values are not, because the compiler copied them into the object:

class LocalDemo$1Greeter implements java.util.function.Supplier<java.lang.String> {
  final java.lang.String val$name;
  final int val$year;
  LocalDemo$1Greeter();
  public java.lang.String get();
  public java.lang.Object get();
}

Two more synthetic fields, val$name and val$year. javap prints the constructor as no-arg, but the bytecode gives it away — it loads slot 1 and slot 2 into those fields before calling Object.<init>, so the real descriptor takes (String, int). Captured values are constructor arguments.

That is why capture requires an effectively final variable. The field is a snapshot; if the local could still change afterwards, the snapshot and the variable would disagree and there is no sensible answer as to which one wins. Java refuses instead:

Capture.java:8: error: local variables referenced from an inner class must be final or effectively final
            @Override public String get() { return label; }
                                                   ^
1 error

Effectively final means the variable is never assigned after its initialisation, whether or not you wrote final. The trap that catches people is the loop variable, because the two loop forms differ:

for (String s : List.of("a", "b", "c")) {
    ok.add(new Supplier<String>() {
        @Override public String get() { return s; }
    });
}

That compiles and prints abc: the enhanced for declares a fresh s on each iteration and never reassigns it. The basic for reuses one variable and increments it, so it is not effectively final and capturing it fails with the same error. To capture in a basic for, copy the value to a new local inside the body first.

Capturing a mutable object is unaffected: the reference must not be reassigned, but the object it points at can be modified freely. A local class shows both halves at once, since unlike an anonymous class it may declare a constructor and be instantiated more than once:

static Supplier<String> build(String base) {
    List<String> seen = new ArrayList<>();
    class Tagger implements Supplier<String> {
        private final String suffix;
        Tagger(String suffix) { this.suffix = suffix; }
        @Override public String get() {
            seen.add(suffix);
            return base + suffix + seen;
        }
    }
    Supplier<String> t = new Tagger("-a");
    System.out.println(t.get());
    return new Tagger("-b");
}
root-a[-a]
root-b[-a, -b]

base and seen are captured — javap shows val$base and val$seen beside the declared suffix — and both instances append to the same list. Capture freezes the reference, never the object behind it.

The anonymous class is a subclass with no name

An anonymous class combines a declaration and an instantiation in one expression. new Job("job-7") { ... } means "define a subclass of Job with this body, and give me one instance of it".

Job j = new Job("job-7") {
    private int runs;
    { runs = 1; }
    @Override void execute() {
        System.out.println("running " + id() + " (runs=" + runs + ")");
    }
};
running job-7 (runs=1)
name        -> AnonShapes$1
simpleName  -> ''
superclass  -> AnonShapes$Job
constructors-> [AnonShapes$1(java.lang.String)]

The class file is AnonShapes$1.class — numbered, not named, in the order the compiler meets the expressions inside each enclosing class. A second anonymous class in the same class becomes AnonShapes$2, while one declared inside a nested class starts its own count: in one test file the five came out as Numbering$1, Numbering$2, Numbering$1Local, Numbering$2Local and Numbering$Helper$1. getSimpleName() returns the empty string, which is why an anonymous class in a stack trace or a log line tells you almost nothing about where it came from.

What an anonymous class may and may not declare

It may declare fields, methods and an instance initialiser block, and it may pass arguments to the superclass constructor — new Job("job-7") above did, and the generated constructor is AnonShapes$1(java.lang.String).

It may not declare a constructor of its own. There is no name to give one, and javac reads the attempt as a method missing a return type:

E1.java:5: error: invalid method declaration; return type required
            E1() { }
            ^
1 error

The instance initialiser block is the substitute, and it is the only one you get.

It also has exactly one supertype: the new expression names one class to extend or one interface to implement, and the grammar has no place to put a second. A type that needs two interfaces, or a constructor, or two instances, needs a name — which is what a local class is for.

Anonymous class versus lambda

These two lines look like the same thing written two ways:

Task anon   = new Task() { @Override public void run() { ... } };
Task lambda = () -> { ... };

They are not. Below is the comparison run as one program, where both bodies print this.getClass().getName().

Anonymous class and lambda side by side: what this refers to, and what javac writes to disk

this refers to different objects

anon  this      -> AnonVsLambda$1
anon  outer     -> AnonVsLambda instance
lambda this     -> AnonVsLambda
lambda owner    -> AnonVsLambda instance

Inside the anonymous class, this is the anonymous object, because an anonymous class is a class and its body is its own scope. To reach the enclosing object you need AnonVsLambda.this.

Inside the lambda, this is the enclosing object, unchanged. A lambda body is not a new scope for this, super or names in general; it is in the same scope as the code around it, which is usually described as being transparent to this. That is not a detail: it is why a lambda cannot refer to itself, and why converting an anonymous class to a lambda silently changes the meaning of every unqualified this in the body. It is also why an anonymous Runnable that calls this.toString() for a log tag and the "equivalent" lambda print different things.

The same transparency applies to names. A lambda parameter may not shadow a local variable of the enclosing method:

Shadow.java:5: error: variable s is already defined in method m()
        Function<String, String> f = s -> s.toUpperCase();
                                     ^
1 error

The identical anonymous class compiles, because apply(String s) is a real method in a real class and its parameter opens a new scope.

One writes a class file, the other does not

Two files, three implementations each:

public class Anons {
    Supplier<String> a = new Supplier<>() { public String get() { return "a"; } };
    Supplier<String> b = new Supplier<>() { public String get() { return "b"; } };
    Supplier<String> c = new Supplier<>() { public String get() { return "c"; } };
}
public class Lambdas {
    Supplier<String> a = () -> "a";
    Supplier<String> b = () -> "b";
    Supplier<String> c = () -> "c";
}
Anons$1.class
Anons$2.class
Anons$3.class
Anons.class
Lambdas.class

Four class files against one. The bytecode shows why — the anonymous class is a real allocation of a real type, the lambda is a single invokedynamic call site:

   0: new           #15                 // class AnonVsLambda$1
   3: dup
   4: aload_0
   5: invokespecial #17                 // Method AnonVsLambda$1."<init>":(LAnonVsLambda;)V
   8: astore_1
   9: aload_0
  10: invokedynamic #20,  0             // InvokeDynamic #0:run:(LAnonVsLambda;)LAnonVsLambda$Task;
  15: astore_2

The lambda's implementation class is spun by LambdaMetafactory the first time that call site runs, so it exists at runtime but never as a file. It shows up with a generated name that includes an address and therefore differs between runs — on one run, AnonVsLambda$$Lambda/0x000000e001000400 — which is another reason not to depend on getClass() of a lambda.

The runtime consequence is not only file count. A lambda that captures nothing is instantiated once and reused:

static Supplier<String> make() { return () -> "constant"; }
same instance? -> true
fields         -> []
anon same?     -> false
anon classes   -> Lam$1 / Lam$2

Two calls to make() returned the same object. Two syntactically identical anonymous classes are two different classes, and every new is a new object. A capturing lambda does allocate — its captured values become fields, exactly as in an anonymous class:

anon   fields   -> [final AnonVsLambda AnonVsLambda$1.this$0]
lambda fields   -> [private final AnonVsLambda AnonVsLambda$$Lambda/0x000000e001000400.arg$1]

Both captured the enclosing instance, one as this$0 and one as arg$1. A lambda in an instance method that touches an instance field captures this too — so a lambda held by a long-lived registry leaks exactly like an inner class. The lambda is not immune, it is just cheaper to create.

A lambda only fits a functional interface

The last difference is the one that decides the choice for you. A lambda is a conversion to a functional interface — an interface with exactly one abstract method. Anything else is rejected:

E3.java:4: error: incompatible types: TwoJobs is not a functional interface
        TwoJobs t = () -> System.out.println("x");
                    ^
    multiple non-overriding abstract methods found in interface TwoJobs
1 error

An anonymous class has no such restriction. It can extend an abstract class, extend a non-final concrete class, or implement an interface with any number of methods. So the rule is short: use a lambda when the target is a functional interface and the body does not need this; use an anonymous class when it is not, or when you need state, several methods, or a real type name.

anonymous classlambda
thisthe new objectthe enclosing object
Supertypeany class or interfacefunctional interface only
Methodsas many as the supertype needsexactly one
Declared fieldsallowednot allowed
Class fileone per expressionnone
Non-capturing instancenew object each timereused
Runtime class nameOuter$1, stablegenerated, varies per run

Errors you will actually hit

non-static variable this cannot be referenced from a static contextnew Inner() from main or any other static method. There is no enclosing instance to supply. Either write outer.new Inner(), or ask whether the class should have been static in the first place. It almost always should.

local variables referenced from an inner class must be final or effectively final — a captured local is assigned somewhere. Copy it into a fresh local at the point of capture.

Illegal static declaration in inner class — until Java 16, an inner class could not declare static members at all except compile-time constants. On --release 15:

E2.java:4: error: Illegal static declaration in inner class E2.Inner
        static int counter = 0;
                   ^
  modifier 'static' is only allowed in constant variable declarations
E2.java:5: error: Illegal static declaration in inner class E2.Inner
        static void help() { }
                    ^
  modifier 'static' is only allowed in constant variable declarations
2 errors

The same file compiles cleanly on --release 16 and later — Java 16 lifted the restriction as part of the work that allowed records to be declared in inner classes. Note that static final int LIMIT = 10; was always legal, because a constant variable is inlined and needs no storage.

invalid method declaration; return type required in an anonymous class body — you tried to write a constructor. Use an instance initialiser block.

A class file you did not expect. Outer$1 appearing in a stack trace, a serialization error or a proxy failure is an anonymous class. Outer$1Attempt is a local class. If you are serializing, remember that both capture whatever the compiler decided they capture, which may include the entire enclosing object.

Choosing between them

Work down this list and stop at the first match.

  1. Does the type need to see the enclosing object's state? If no — and this is the common case — write a static nested class. It is a plain class with a scoped name and no hidden field.
  2. Does it genuinely model a view onto one enclosing instance, the way HashMap.HashIterator does? Then an inner class is right, but keep its lifetime shorter than the enclosing object's. Never hand one to something long-lived.
  3. Is it used in exactly one method and needs a name, several methods, or its own state? A local class.
  4. Is it a one-off implementation of a single abstract method that does not need this? A lambda.
  5. Same, but the target is not a functional interface, or the body needs this, state or several methods? An anonymous class.

FAQ

What is the difference between a nested class and an inner class in Java?

"Nested class" is the umbrella term for any class declared inside another. An inner class is a nested class that is not static, so it has an enclosing instance and the compiler gives it a synthetic this$0 field pointing at it. A static nested class has no such field and is created with new Outer.Config(...); an inner class needs outer.new Session(). Local and anonymous classes are also inner classes by the language's definition, which is why the compiler error about captured variables mentions "inner class" for both.

Why does an inner class cause a memory leak?

Because it holds a strong reference to the enclosing object that you never wrote and cannot see in the source. If an inner class instance is registered with something long-lived — an executor, an event bus, a static cache — that reference keeps the enclosing object, and everything it refers to, reachable for as long as the registration lasts. In the test above, a WeakReference to an 8 MB object was still not cleared after System.gc() while an inner listener was held, and was cleared immediately when the equivalent static nested listener was held instead. Declaring the class static and passing it the data it needs removes the reference entirely.

What does this$0 mean in Java?

It is the synthetic field javac adds to an inner class to hold the enclosing instance. javap -p on the class file shows it as final Outer this$0, and the generated constructor takes an Outer parameter to fill it. You cannot name it in source — Outer.this is the language-level way to read it. Since Java 18 the compiler omits it when the inner class body never uses the enclosing instance, though the constructor parameter remains.

Can an anonymous class have a constructor?

No. There is no class name to write one with, and the attempt is reported as invalid method declaration; return type required. Use an instance initialiser block — a bare { ... } in the class body — for setup, and pass any constructor arguments to the superclass constructor in the new expression: new Job("job-7") { ... } compiles to a constructor taking a String.

Is a lambda just shorthand for an anonymous class?

No, and the differences are observable. this inside a lambda is the enclosing object, while inside an anonymous class it is the new object. A lambda produces no class file — three lambdas in a class give one class file, three anonymous classes give four — because it compiles to an invokedynamic call site whose implementation class is generated at runtime. A non-capturing lambda is instantiated once and reused, while every anonymous class expression allocates. And a lambda can only target a functional interface, whereas an anonymous class can extend any non-final class or implement any interface.

When should a nested class be static?

Whenever it does not use the enclosing instance, which is most of the time. The static form has no hidden field, no lifetime coupling to the outer object, can be constructed without one, and can be moved to its own file later without a rewrite. Map.Entry and HashMap.Node are static; HashMap.HashIterator, which must observe the live map, is not. If a code review question is "why is this inner class not static", the answer usually is that it should be.

Why must a captured local variable be effectively final?

Because capture copies the value into a synthetic field of the generated class — val$name in the javap output above — at construction time. The object outlives the method's stack frame, so there is no shared storage to keep the two in sync. If the local could be reassigned afterwards, the field and the variable would silently diverge. Making the requirement a compile error avoids the ambiguity. Note the restriction is on reassigning the variable, not on mutating the object it points to.

What is the difference between a local class and an anonymous class?

A local class has a name, can be instantiated more than once, can declare constructors, and can implement several interfaces; its class file is Outer$1Name and it reports a non-empty getSimpleName(). An anonymous class is created and instantiated in one expression, has exactly one supertype, cannot declare a constructor, and compiles to a numbered Outer$1. Use a local class when you need to construct it twice, need a constructor, or want a readable name in stack traces.

Conclusion

The choice between the four shapes is not stylistic. A static nested class is a top-level class with a scoped name and no hidden state. An inner class is a different object entirely: it carries a compiler-generated reference to its creator, that reference is what makes Outer.this and outer.new Inner() meaningful, and it is also what keeps an eight-megabyte screen buffer reachable after everything else has released it. A local class is an inner class scoped to one block, with captured values copied into synthetic val$ fields, which is where the effectively-final rule comes from. An anonymous class is a local class you did not name, so you get one supertype, no constructor, and a class file called Outer$1.

And a lambda is not a shorter anonymous class. It is a conversion to a functional interface with different scoping — this stays the enclosing object — different compilation, invokedynamic instead of a class file, and different allocation behaviour when it captures nothing. Reach for it when the target is a functional interface and the body wants no identity of its own; reach for an anonymous class the moment either of those stops being true.

Next in this series: advanced enums — constant-specific bodies, enums that implement interfaces, EnumMap and EnumSet, and why an enum is the correct way to write a singleton.

Related Posts

[Advanced Java] Queue, Deque, Stack and PriorityQueue in Java

Queue, Deque, Stack and PriorityQueue on OpenJDK 21: the two families of Queue methods and exactly what each one does on an empty and a full queue, the full Deque method table and the stack view, why Stack extends Vector is a design mistake with both surprises demonstrated, and proof that a PriorityQueue is a binary heap whose toString and iterator are not in priority order.

[Java Basics] Nested Loops, break and continue in Java

Nested loops in Java and the two keywords that cut them short: how many times the inner body runs, break leaving only the innermost loop, continue skipping the update in a while loop, labelled break and continue, and the switch-inside-a-loop trap.

[Advanced Java] Iterator, ListIterator, and Fail-Fast versus Fail-Safe Iteration

How iteration really works in Java on OpenJDK 21: the Iterator cursor and lastRet fields, the enhanced for loop disassembled with javap, ListIterator set and add, the modCount and expectedModCount mechanism behind ConcurrentModificationException, a real case where fail-fast silently does not fire, CopyOnWriteArrayList snapshots, weakly consistent ConcurrentHashMap iterators, and writing your own Iterable.

[Java Basics] Fields, Methods and Constructors in Java

Fields, instance methods and constructors in Java: default field values, field initialisers, constructor overloading and this(...) chaining, the exact initialisation order proved with print statements, and every real javac error from writing void on a constructor to putting this(...) second.