内部类

区分嵌套类与内部类,掌握外围实例、局部变量捕获和匿名类,并避免生成代码中的生命周期与语义错误。

难度 进阶 时长 标准深度约 14分钟
版本 Java 25 LTS
what

嵌套类(nested class) 是在另一个类或接口中声明的类;其中没有显式或隐式使用 static 的才是 内部类(inner class)

trap

非静态成员内部类依附于一个外部对象;把本可静态的辅助类写成内部类,会让对象关系和生命周期发生变化。

fix

只有确实需要隐式访问外部实例时才使用成员内部类;其余成员类优先声明为 static,局部回调还要检查捕获和 this 的含义。

是什么,为什么存在

Java 允许把类声明放进另一个类、接口或代码块,也允许在对象创建表达式中直接声明一个没有名称的类。嵌套把只服务于一个宿主的实现放在使用位置附近,同时仍保留类的字段、方法、继承与接口实现能力。

规范中的「嵌套类」与「内部类」不是同义词。嵌套类包含成员类、局部类和匿名类;内部类只包含其中没有显式或隐式 static 的类。因此,「静态内部类」这个常见说法并不准确,应该叫「静态嵌套类」。

成员内部类适合表示离不开某个外部对象的行为,例如迭代器需要直接读取所属集合的当前位置。静态嵌套类适合只在命名与可见性上属于宿主的类型,例如构建器、结果类型或私有实现。局部类和匿名类则把很小的实现限制在一个方法或表达式附近。

嵌套不等于继承,也不会自动产生封装边界。外部类与其嵌套类可以按 Java 访问规则互相访问 private 成员,但公开嵌套类型仍可能成为外部 API。选择形式时要先确定类型的所有者、可见性和实例生命周期。

当实现只需要一个函数式接口的方法时,lambda 往往比匿名类短。不过 lambda 不是匿名类:两者的 this、可声明成员和适用目标不同。若这些差异会影响行为,就不能把两种写法机械互换。

工作原理

编译器根据声明出现的位置和 static 状态决定类别,再根据词法作用域解析名称。关键不是大括号嵌套了几层,而是对象是否需要外围实例,以及局部名称是否被捕获。

下面的分类采用 Java 25 规范术语。成员类是外部类型的成员;局部类和匿名类不是任何包、类或接口的成员,即使它们写在方法或类体的词法范围内。

四种常见形式

形式有名称是否为内部类创建或使用方式
非静态成员类outer.new Member()
静态成员类new Outer.Nested()
局部普通类有,仅在代码块内在声明作用域内使用简单名称
匿名类无简单名称new Supertype(...) { ... }

成员枚举类、成员记录类以及接口的成员类都隐式为 static,所以它们是嵌套类而不是内部类。局部枚举类与局部记录类也隐式为 static。普通局部类和匿名类仍属于内部类,即使声明出现在静态上下文中。

匿名类通过类实例创建表达式或带类体的枚举常量隐式声明。它只能在该表达式中选择一个直接父类或直接父接口,不能显式声明构造器;需要初始化自身字段时可以使用字段初始化器或实例初始化块。

外围实例与名称解析

非静态成员内部类的对象带有一个 外围实例(enclosing instance) 。在外部类之外创建它时,语法中的 outer.new Member() 明确指定这个对象。静态嵌套类没有这条隐式对象关系,可以直接通过外部类型名创建。

内部类方法中的 this 指向内部类对象。需要指明词法外围对象时使用 Outer.this;字段同名时,这个限定也能消除歧义。静态上下文没有外围 this,所以在那里声明的局部类或匿名类虽然仍按规范归入内部类,却可能没有直接外围实例。

「静态嵌套类只能访问外部类的静态成员」是一种容易误导的简写。准确说法是:它没有隐式外部对象,因此不能直接使用外部实例成员;如果手中有外部对象引用,仍可通过该引用访问其成员,包括按语言规则可访问的 private 成员。

成员内部类也可以声明 static 字段、方法、成员类型和静态初始化块。这项限制已在 Java 16 放宽;static 成员没有内部类对象的 this,也没有可供使用的外围实例。

局部变量捕获

局部类和匿名类可以引用词法作用域中的局部变量、形参和异常参数,但这些变量必须是 final 实际 final(effectively final) 。后者没有写出 final,却满足加上 final 也不会引入编译错误的赋值约束。

这个规则限制的是变量重新赋值,不是对象状态。捕获的 List 变量不能再指向另一份列表,但内部类仍可调用 add() 修改原列表。用单元素数组绕过重新赋值限制虽然能编译,却常把状态所有权藏得更深。

捕获按需要发生。局部类没有引用某个局部名称时,不会因为处于同一方法就自动保留它。反过来,一个被捕获的名称可能指向很大的对象图,所以名称数量不能说明实际保留的数据大小。

编译器必须让内部类对象在原方法返回后仍能观察到规范要求的值。具体如何生成字段、构造器参数和类文件属于实现细节;应用代码不应依赖这些合成名称。

访问与类型所有权

成员类可以声明为 publicprotected、包级可见或 private。局部类不能使用这些访问修饰符,它的名称只在对应代码块的作用域内可见;匿名类甚至没有可供其他声明引用的简单名称。

嵌套类能够访问宿主的私有成员,宿主也能访问嵌套类的私有成员。这种访问便利不应演变成随意共享状态。若一个辅助类型只需要构造时传入的两个值,显式参数通常比隐式读取整个外部对象更清楚。

泛型外部类的非静态成员内部类处于外部类型参数的作用域内。静态嵌套类不能直接使用外部类的类型参数;需要泛型时,它应声明自己的类型参数。这条差异常能暴露某个类型是否真的依赖外部实例。

示例

下面三个示例依次展示成员形式、局部捕获,以及匿名类与 lambda 的 this 差异。所有输出均由本地 OpenJDK 21.0.12 使用 javac --release 21 -Xlint:all 编译后实际运行得到;所用语义在 Java 25 中保持有效。

成员内部类与静态嵌套类

Picker 的标签天然属于某个仓库对象,所以它读取 NestedKinds.this.warehouseSnapshot 自己接收所需数据,不依赖 NestedKinds 实例。

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

picker 的创建表达式必须提供 north,因此两者的生命周期存在对象级联系。snapshot 的构造只包含显式参数;把它放在 NestedKinds 中表达的是命名和可见性,而不是实例所有权。

Snapshot 收到一个 NestedKinds 参数,它仍能读取该对象的私有字段。static 取消的是隐式外围实例,不是同一嵌套范围中的私有访问权限。

让局部类与匿名类共享捕获状态

Recorder 捕获 tenantevents,返回的匿名 Runnable 又捕获 recorder。两次 audit() 调用产生两组状态,而重复调用同一个结果会继续修改原列表。

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

eventsrecorder 都没有被重新赋值,所以符合实际 final 规则;events.add() 修改的是对象内容。若取消注释一条 events = new ArrayList<>(),即使赋值写在创建内部类之后,名称也不再是实际 final,编译器会拒绝捕获。

示例没有任何 LocalAndAnonymous 实例,因为 audit() 是静态方法。这也说明捕获局部变量与持有外部类对象是两条不同关系。代码审查时应分别追踪它们。

比较匿名类与 lambda 中的 this

匿名类建立新的对象作用域,所以它的 this.owner 是自身字段;lambda 不引入新的 this,其中的 this.owner 仍指向 ThisBinding 对象。

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

匿名类(anonymous class) 还可以声明字段、额外方法和实例初始化块,并且可以继承普通类。lambda 只能以函数式接口为目标;若匿名类依赖自身 this 或额外状态,改成 lambda 前必须重新设计这些语义。

在内部类中声明静态成员

Formatter 是非静态成员内部类,却合法声明了静态字段和静态方法。两个内部对象共享 created,而实例方法 label() 仍通过外围实例读取 prefix

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

静态方法通过 InnerStatics.Formatter.created() 调用,不需要 Formatter 对象或 InnerStatics 对象。它也不能直接读取 prefix;实例方法拥有内部对象及其外围实例,才可以进行未限定访问。

这个示例在本地使用 --release 21 编译通过,没有依赖 Java 25 新增语法。若项目仍以 Java 15 或更早版本为源码目标,同一声明会因旧规则而失败。

陷阱

把所有嵌套形式都叫内部类

修复方法: 先用「嵌套类」概括全部形式,再区分是否为内部类。明确写出成员、局部或匿名,以及显式或隐式 static,不要只凭文件名中的 $ 推断源码类别。

为不相关的辅助类保留外围实例

修复方法: 辅助类不需要隐式实例成员时,把它声明为 static 嵌套类,并通过构造器传入真正需要的数据。确实需要外围状态的回调要有明确的注销或取消路径,并用生命周期测试确认释放边界。

用可变容器绕过实际 final

修复方法: 先确定状态属于哪个对象和哪个并发边界。需要多个操作或不变量时,把状态放进命名良好的类;只有契约确实要求跨线程原子计数时才使用原子类,并测试完整的复合操作。

把匿名类机械改成 lambda

修复方法: 检查目标类型、重载选择、thisOuter.this、对象身份,以及额外成员是否参与行为。确认只需要一个函数式接口操作后再转换,并运行覆盖回调注册与调用时机的测试。

依赖编译器生成的二进制名称

修复方法: 需要稳定标识的类型应拥有正式名称和公开契约。诊断时可以用 javap 查看当前构建,生产逻辑则通过接口、显式注册和受控序列化格式定位行为。

沿用 Java 16 之前的静态成员规则

修复方法: 按项目的 --release 目标核对语言规则。静态成员现在合法,但其代码处于静态上下文,不能借此访问内部类对象或外围实例的 this

深入 类文件与 nestmate

类文件与 nestmate

源码中的嵌套不会把所有实现塞进一个类文件。用本地 javac 编译示例后,会得到 NestedKinds.classNestedKinds$Picker.classNestedKinds$Snapshot.class;局部类与匿名类也各有对应类文件。$ 形式是当前编译器的二进制命名结果,不应当作源码 API。

NestedKinds$Picker 执行 javap -p,本地 OpenJDK 21.0.12 显示一个类型为 NestedKinds 的合成字段 this$0,构造器也接收 NestedKindsNestedKinds$Snapshot 没有这个字段。这个观察解释了对象关系,但字段名称和具体翻译策略不属于 Java 语言保证。

Java 11 引入 nestmate 访问控制后,同一嵌套中的类文件可以通过 NestHostNestMembers 属性建立运行时 nest,并直接进行彼此允许的私有访问。旧编译目标可能使用合成访问方法;对现代 Java 25 代码声称「访问外部私有字段一定生成 access$000」已经不正确。

InnerClassesEnclosingMethodNestHostNestMembers 描述不同关系。需要排查反射、类加载或工具链问题时,应检查实际 class 文件与目标版本,而不是从一个 $ 名称反推完整的词法结构。

改变类别的边界

内部类不等于「一定保存外部对象」。非静态成员内部类需要外围实例;但静态方法中的普通局部类和匿名类没有可用的外围 this,即使规范仍把它们归为内部类。是否捕获局部变量又是另一项独立判断。

所有嵌套接口都隐式为 static,不存在「内部接口」。成员与局部的枚举类、记录类同样隐式为 static。把一个局部普通类改成局部记录类时,不只是获得简洁的数据载体语法,它与外围实例和外部类型参数的关系也会改变。

内部类中的静态方法、静态字段初始化器和静态初始化块属于静态上下文。它们可以使用自身静态成员,却不能使用 Outer.this 或内部类实例的 this。合法声明静态成员并不意味着静态代码获得了外围对象。

匿名类始终是内部类,但不一定有外围实例。它的直接父类型由创建表达式决定,构造器由编译器隐式声明。若初始化逻辑复杂到需要多个构造路径或外部可见的额外方法,命名类通常更能表达契约。

生命周期与 API 边界

隐式外围实例本质上是对象图中的一条强引用路径。只要内部类实例仍可达,外围对象就可能经由这条路径保持可达;是否形成实际泄漏取决于注册表、线程、缓存等根对象能让它存活多久,而不是取决于 inner 这个词本身。

捕获的局部对象也遵循同样的可达性原则。一个回调只读取 request.userId(),却捕获整个 request,会保留请求关联的数据;先提取所需的不可变标识再捕获,可以缩小对象图,但仍应提供取消和注销机制。

匿名类和局部类适合范围很窄的实现。实现开始拥有多个操作、复杂状态、单独测试需求或稳定序列化格式时,命名成员类或顶层类通常更清楚。嵌套位置应表达所有权,不应只是为了减少文件数量。

测试应围绕公开行为和生命周期,而不是合成字段。分别创建两个外部实例,交错调用它们的内部对象;对捕获状态则在注册完成后重复调用并加入并发测试。需要调查保留关系时,再使用堆转储、引用链和 javap 验证具体实现。

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

下一篇 Lambdas 即将上线 记录类 Class loading 即将上线 Garbage collection 即将上线
复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?