Inner classes

Distinguish nested and inner classes, understand enclosing instances, local capture, and anonymous classes, and avoid lifecycle and semantic bugs.

level intermediate time 13 min at Standard depth
version Java 25 LTS
what

A nested class is declared inside another class or interface; only a nested class that isn’t explicitly or implicitly static is an inner class .

trap

A non-static member inner class belongs to an outer object; making an otherwise static helper an inner class changes the object graph and its lifetime.

fix

Use a member inner class only when it genuinely needs implicit access to an outer instance; otherwise prefer a static member class, and check local callbacks for capture and this semantics.

What it is and why it exists

Java lets you put a class declaration inside another class, an interface, or a block. You can also declare a nameless class directly in an object creation expression. Nesting keeps an implementation that serves one host close to where it is used while retaining fields, methods, inheritance, and interface implementation.

In the specification, “nested class” and “inner class” aren’t synonyms. Nested classes include member, local, and anonymous classes; inner classes are the ones among them that aren’t explicitly or implicitly static. The common phrase “static inner class” is therefore inaccurate. The correct term is “static nested class.”

A member inner class suits behavior that can’t exist apart from one outer object, such as an iterator that directly reads its collection’s current state. A static nested class suits a type that belongs to its host only for naming and visibility, such as a builder, result type, or private implementation. Local and anonymous classes keep a small implementation near one method or expression.

Nesting isn’t inheritance, and it doesn’t create an automatic encapsulation boundary. An outer class and its nested classes can access each other’s private members under Java’s access rules, but a public nested type can still become external API. Choose a form after deciding the type’s owner, visibility, and instance lifetime.

When an implementation needs only one functional-interface method, a lambda is often shorter than an anonymous class. A lambda isn’t an anonymous class, though: their this meanings, permitted members, and target types differ. You can’t mechanically exchange them when those differences affect behavior.

How it works

The compiler determines the category from where a declaration appears and whether it is static, then resolves names from lexical scope. The important questions aren’t how many braces surround the code, but whether an object needs an enclosing instance and whether it captures local names.

The classification below uses Java 25 specification terms. A member class is a member of its outer type. Local and anonymous classes aren’t members of any package, class, or interface, even though their declarations appear in a method’s or class’s lexical surroundings.

Four common forms

FormNamedInner classCreation or use
Non-static member classYesYesouter.new Member()
Static member classYesNonew Outer.Nested()
Local normal classYes, within its blockYesUse its simple name within the declaration scope
Anonymous classNo simple nameYesnew Supertype(...) { ... }

Member enum classes, member record classes, and member classes of interfaces are implicitly static, so they are nested classes but not inner classes. Local enum and local record classes are implicitly static too. Local normal and anonymous classes remain inner classes even when declared in a static context.

An anonymous class is declared implicitly by a class instance creation expression or an enum constant with a class body. It selects one direct superclass or direct superinterface through that expression and can’t explicitly declare a constructor. It can use field initializers or an instance initializer to initialize its own fields.

Enclosing instances and name resolution

An object of a non-static member inner class has an enclosing instance . Outside the outer class, outer.new Member() names that object explicitly. A static nested class has no such implicit object relationship and can be created through the outer type name.

Inside an inner-class method, this denotes the inner object. Use Outer.this to name the lexically enclosing object; the qualifier also resolves ambiguity when fields share a name. A static context has no enclosing this, so a local or anonymous class declared there can still be an inner class by the specification’s classification while having no immediately enclosing instance.

“A static nested class can access only static members of the outer class” is misleading shorthand. More precisely, it has no implicit outer object, so it can’t use outer instance members unqualified. If it has an explicit outer object reference, it can access that object’s members, including private members permitted by the language’s access rules.

A member inner class may declare static fields, methods, member types, and static initializers. Java 16 relaxed the old restriction. Static members have neither an inner-class object’s this nor an enclosing instance to use.

Capturing local variables

A local or anonymous class may refer to local variables, parameters, and exception parameters in its lexical scope, but those variables must be final or effectively final . An effectively final variable omits the modifier but satisfies the assignment rules such that adding final wouldn’t introduce a compilation error.

The rule restricts reassignment of the variable, not mutation of an object. A captured List variable can’t later point to another list, but the inner class can still call add() on the original list. Using a one-element array to evade the reassignment rule may compile, but it often makes state ownership harder to see.

Capture happens on demand. A local class doesn’t retain every local name merely because it is declared in the same method. Conversely, one captured name can refer to a large object graph, so the number of names says little about how much data stays reachable.

The compiler must preserve the values that the inner object is required to observe after the original method returns. The fields, constructor parameters, and class files it generates to do so are implementation details. Application code must not depend on their synthetic names.

Access and type ownership

A member class may be public, protected, package-private, or private. A local class can’t use those access modifiers, and its name is visible only in the corresponding block’s scope. An anonymous class has no simple name for another declaration to reference at all.

Nested classes can access private host members, and the host can access private nested members. That convenience shouldn’t become indiscriminate state sharing. If a helper type needs only two values at construction, explicit parameters are usually clearer than implicit access to the entire outer object.

A non-static member inner class of a generic outer class is in scope of the outer type parameters. A static nested class can’t directly use the outer class’s type parameters; it declares its own when needed. This difference often reveals whether a type truly depends on an outer instance.

Examples

These three examples cover member forms, local capture, and the difference between this in an anonymous class and a lambda. Every output shown was produced locally by compiling with OpenJDK 21.0.12 and javac --release 21 -Xlint:all; the semantics remain valid in Java 25.

Member inner and static nested classes

The picker label inherently belongs to one warehouse object, so Picker reads NestedKinds.this.warehouse. Snapshot receives its own data and doesn’t depend on a NestedKinds instance.

NestedKinds.java
public class NestedKinds {
    private final String warehouse;

    NestedKinds(String warehouse) {
        this.warehouse = warehouse;
    }

    final class Picker {
        String label(int orderId) {
            return NestedKinds.this.warehouse + "/order-" + orderId;
        }
    }

    static final class Snapshot {
        private final String warehouse;

        Snapshot(String warehouse) {
            this.warehouse = warehouse;
        }

        String label() {
            return warehouse + "/snapshot";
        }
    }

    public static void main(String[] args) {
        var north = new NestedKinds("north");
        var picker = north.new Picker();
        var snapshot = new NestedKinds.Snapshot("south");

        System.out.println(picker.label(17));
        System.out.println(snapshot.label());
    }
}
north/order-17
south/snapshot

Creating picker must supply north, so their lifetimes have an object-level connection. The snapshot constructor contains only explicit parameters. Putting it in NestedKinds expresses naming and visibility, not instance ownership.

If Snapshot received a NestedKinds parameter, it could still read that object’s private field. static removes the implicit enclosing instance, not private access within the same nest.

Share captured state between local and anonymous classes

Recorder captures tenant and events, and the returned anonymous Runnable captures recorder. Two audit() calls create two state groups, while repeated calls to one result keep modifying its original list.

LocalAndAnonymous.java
import java.util.ArrayList;
import java.util.List;

public class LocalAndAnonymous {
    static Runnable audit(String tenant) {
        List<String> events = new ArrayList<>();

        class Recorder {
            void add(String action) {
                events.add(tenant + ":" + action);
            }

            String summary() {
                return String.join(" | ", events);
            }
        }

        Recorder recorder = new Recorder();
        recorder.add("created");

        return new Runnable() {
            @Override
            public void run() {
                recorder.add("sent");
                System.out.println(recorder.summary());
            }
        };
    }

    public static void main(String[] args) {
        Runnable north = audit("north");
        Runnable south = audit("south");
        north.run();
        south.run();
        north.run();
    }
}
north:created | north:sent
south:created | south:sent
north:created | north:sent | north:sent

Neither events nor recorder is reassigned, so both satisfy the effectively final rule; events.add() changes the object instead. Uncommenting an events = new ArrayList<>() assignment would make the name not effectively final even if the assignment followed the inner-class creation, and the compiler would reject the capture.

The example has no LocalAndAnonymous instance because audit() is static. Capturing a local variable and retaining an outer-class object are two separate relationships. Trace them separately during review.

Compare this in an anonymous class and a lambda

An anonymous class creates a new object scope, so its this.owner is its own field. A lambda doesn’t introduce a new this; its this.owner still denotes the ThisBinding object.

ThisBinding.java
public class ThisBinding {
    private final String owner = "enclosing";

    void compare() {
        Runnable anonymous = new Runnable() {
            private final String owner = "anonymous";

            @Override
            public void run() {
                System.out.println("anonymous this: " + this.owner);
                System.out.println("outer this: " + ThisBinding.this.owner);
            }
        };

        Runnable lambda =
                () -> System.out.println("lambda this: " + this.owner);

        anonymous.run();
        lambda.run();
    }

    public static void main(String[] args) {
        new ThisBinding().compare();
    }
}
anonymous this: anonymous
outer this: enclosing
lambda this: enclosing

An anonymous class can also declare fields, extra methods, and instance initializers, and it can extend an ordinary class. A lambda can target only a functional interface. If an anonymous class relies on its own this or extra state, redesign those semantics before replacing it with a lambda.

Declare static members in an inner class

Formatter is a non-static member inner class, yet it legally declares a static field and method. Its two inner objects share created, while the instance method label() still reads prefix through an enclosing instance.

InnerStatics.java
public class InnerStatics {
    private final String prefix;

    InnerStatics(String prefix) {
        this.prefix = prefix;
    }

    class Formatter {
        private static int created;

        Formatter() {
            created++;
        }

        static int created() {
            return created;
        }

        String label(int id) {
            return prefix + id;
        }
    }

    public static void main(String[] args) {
        var owner = new InnerStatics("A-");
        var first = owner.new Formatter();
        owner.new Formatter();

        System.out.println(InnerStatics.Formatter.created());
        System.out.println(first.label(7));
    }
}
2
A-7

The static method is called through InnerStatics.Formatter.created() and needs neither a Formatter nor an InnerStatics object. It can’t read prefix directly. The instance method can because it has both an inner object and its enclosing instance.

This example compiles locally with --release 21 and doesn’t rely on syntax added in Java 25. The same declaration fails under the older rules if a project still targets Java 15 or earlier source.

Pitfalls

Calling every nested form an inner class

Fix: Use “nested class” for the whole category, then say whether the class is inner. Name the member, local, or anonymous form and any explicit or implicit static; don’t infer the source category from a $ in a file name.

Retaining an outer instance for an unrelated helper

Fix: Make a helper a static nested class when it doesn’t need implicit instance members, and pass only the data it genuinely needs to its constructor. A callback that does need outer state should have an explicit unregister or cancellation path, with a lifecycle test for the release boundary.

Evading effectively final with a mutable holder

Fix: Decide which object and concurrency boundary own the state first. Put state with several operations or invariants in a named class. Use an atomic class only when the contract truly needs a cross-thread atomic counter, and test the complete compound operation.

Mechanically replacing an anonymous class with a lambda

Fix: Check the target type, overload selection, this and Outer.this, object identity, and whether extra members affect behavior. Convert only after confirming that one functional-interface operation is enough, then run tests that cover callback registration and invocation timing.

Depending on compiler-generated binary names

Fix: Give a type a proper name and public contract when it needs a stable identity. Use javap to inspect a particular build during diagnosis; production code should find behavior through interfaces, explicit registration, and controlled serialization formats.

Applying pre-Java 16 static-member rules

Fix: Check the language rules for the project’s --release target. Static members are now legal, but their code is a static context and can’t use an inner object or enclosing instance through this.

Deep Class files and nestmates

Class files and nestmates

Source nesting doesn’t put every implementation into one class file. Compiling the example with local javac produces NestedKinds.class, NestedKinds$Picker.class, and NestedKinds$Snapshot.class; local and anonymous classes also receive class files. The $ form is a binary naming result from this compiler, not source API.

Running javap -p on NestedKinds$Picker under local OpenJDK 21.0.12 shows a synthetic field named this$0 with type NestedKinds, and its constructor accepts a NestedKinds. NestedKinds$Snapshot has no such field. This observation explains the object relationship, but the field name and translation strategy aren’t Java language guarantees.

Since Java 11 introduced nestmate access control, class files from the same nest can establish their runtime relationship through NestHost and NestMembers attributes and perform permitted private access directly. Older compilation targets may use synthetic access methods. It is no longer correct to claim that reading a private outer field in modern Java 25 necessarily generates access$000.

InnerClasses, EnclosingMethod, NestHost, and NestMembers describe different relationships. When debugging reflection, class loading, or tooling, inspect the actual class files and target version instead of reconstructing the entire lexical structure from one $ name.

Edges that change the category

Inner class doesn’t mean “always stores an outer object.” A non-static member inner class requires an enclosing instance. A normal local or anonymous class in a static method has no enclosing this, even though the specification still classifies it as an inner class. Capturing local variables is another independent decision.

Every nested interface is implicitly static; there are no inner interfaces. Member and local enum and record classes are implicitly static as well. Changing a local normal class into a local record doesn’t merely add concise data-carrier syntax; it also changes its relationship to enclosing instances and outer type parameters.

A static method, static field initializer, or static initializer inside an inner class is a static context. It can use the class’s static members but not Outer.this or the inner object’s this. Permission to declare a static member doesn’t grant an enclosing object to its code.

An anonymous class is always an inner class, but it doesn’t always have an enclosing instance. Its direct supertype comes from the creation expression, and the compiler declares its constructor implicitly. When initialization needs several construction paths or extra methods visible to callers, a named class usually expresses the contract better.

Lifetime and API boundaries

An implicit enclosing instance is a strong-reference path in the object graph. While the inner object remains reachable, the outer object may remain reachable through that path. Whether this becomes a real leak depends on how long roots such as registries, threads, and caches keep it alive, not on the word inner alone.

Captured local objects follow the same reachability rule. A callback that reads only request.userId() but captures the entire request keeps request-associated data reachable. Extracting the required immutable identifier before capture shrinks the graph, but the callback still needs cancellation and unregistration where appropriate.

Anonymous and local classes work well for narrow implementations. Once an implementation has several operations, complex state, independent testing needs, or a stable serialization format, a named member or top-level class is usually clearer. Nesting should express ownership, not merely reduce the number of files.

Test public behavior and lifetime rather than synthetic fields. Create two outer instances and interleave calls through their inner objects. For captured state, invoke callbacks repeatedly after registration and add a concurrency test. Use heap dumps, reference paths, and javap only when you need to investigate the concrete retention mechanism.

Further reading

checkpoint

4 questions · 1 predict-the-output · 1 spot-the-bug

next up Lambdas soon Records Class loading soon Garbage collection soon
Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?