文章收录专栏:Java 核心原理全解:源码・并发・面试实战
老读者都知道,我写技术文章有个习惯:不背结论,看字节码,看规范。内部类这块,网上 90% 的文章都停留在「静态内部类要加 static」的层面,但面试官真正想听的,是术语边界清不清楚、this$0 为什么会造成内存泄漏、JDK16 到底改了什么。
这是《Java 内部类系列》的第一篇:先立术语坐标系,再把内部类家族里最常用的两类 ——静态内部类和成员内部类讲透。四类内部类在内存引用、生命周期、访问权限、实例化规则、生产风险上差异极大,这一篇你掌握前两类,后面两篇(局部 / 匿名、Lambda)就顺了。
一、先把官方术语说清楚(面试第一句的加分项)
JLS 的定义非常严格,很多老手都会栽在这:
- 嵌套类(Nested Class):所有定义在另一个类内部的类,统称。
- 内部类(Inner Class):仅指非静态嵌套类 —— 成员内部类、局部内部类、匿名内部类。
- 静态嵌套类(Static Nested Class):带 static 修饰的嵌套类,官方不认为它是内部类,是独立嵌套类型。
国内习惯把静态嵌套类叫「静态内部类」,无妨,但你要心里清楚:从语言规范上讲它是独立类型。面试第一句话把这个边界讲清楚,立刻和背答案的拉开差距。
class Outer { static class StaticInner {} // 静态嵌套类,俗称"静态内部类",无 this$0 class MemberInner {} // 成员内部类(真·内部类) void method() { class LocalInner {} // 局部内部类(系列②) Runnable r = () -> {}; // Lambda(系列③) } }二、静态内部类:最安全的内部类
被 static 修饰的嵌套类,属于外部类的静态成员,与外部类实例完全解耦。
核心特性:
- 不持有外部类实例引用,没有 this$0 字段,可独立实例化、独立存活。
- 外部类加载时不会加载、初始化静态内部类;只有静态内部类被主动使用时才触发类加载(按需加载)。
- 生命周期与外部类实例无关,外部类被 GC 回收不影响静态内部类。
- 编译生成独立字节码文件:
Outer$StaticInner.class。
访问权限:
- 可访问外部类全部静态成员(含 private 静态字段、静态方法)。
- 私有互访机制有版本差异:JDK8–10 靠编译器生成
access$xxx合成访问方法;JDK11+ 引入 nestmates 后大多数场景不再生成(底层机制系列④细讲)。 - 无法直接访问外部类实例成员,只能手动持有外部类引用间接操作。
- 支持 public/protected/ 默认 /private 修饰;私有静态内部类仅外部类可访问。
实例化方式—— 无需外部类实例,直接通过类名创建:
Outer.StaticInner inner = new Outer.StaticInner();可包含内容:语法层面非常接近顶级类 —— 可定义静态 / 非静态字段、方法、构造器、静态 / 实例初始化块。注意它依旧是嵌套类,不是真正顶层类,不能拥有包声明等顶层类能力。
优缺点与典型场景:
- 优点:无隐式内存泄漏、按需初始化、高解耦、便于测试。
- 缺点:无法直接操作外部实例状态;若含可变字段需自行保证线程安全。
- 典型场景:建造者模式、HashMap.Node、Map.Entry、专属工具类、常量封装。
Builder 模式就是教科书级应用:
public class User { private final String name; private final int age; private User(Builder b) { this.name = b.name; this.age = b.age; } public static class Builder { // 静态内部类 private String name; private int age; public Builder name(String n) { this.name = n; return this; } public Builder age(int a) { this.age = a; return this; } public User build() { return new User(this); } } } // User u = new User.Builder().name("木雨").age(18).build();三、成员内部类:this$0 与内存泄漏的根源
无 static 修饰,定义在类的成员位置,语义上强依赖外部类实例。
核心特性:
- JDK<18 编译目标下:成员内部类只要实例化,就必定合成一个 final 字段 this$0,持有创建它的外部类实例强引用(与创建时处于静态还是实例上下文无关)。
- 必须依托外部类实例创建,不能独立存在。
- 可通过
Outer.this区分与外部类同名的成员。 - 编译字节码:
Outer$MemberInner.class。 - this$0 是编译器合成字段,业务代码禁止修改;JDK16 起强封装默认启用、JDK17 全面生效后,反射访问合成字段也受到限制,改它属于依赖编译器内部实现,生产严禁。
访问权限:可访问外部类所有成员(私有 / 公有、静态 / 实例)。
实例化与反射:
Outer.Inner inner = new Outer().new Inner(); // 必须先有外部实例- 静态方法中也可以通过外部类对象引用创建成员内部类。
- 反射重点:构造器隐式携带第一个外部实例参数(源码不可见),反射实例化必须传入外部实例;私有内部类需要
setAccessible(true)。
JDK16+ 语言规范更新(注意:不是 JEP-397!)
这是最容易答错的点,很多人背成 JEP-397,其实是错的(JEP-397 是 Sealed Classes,JDK17)。
- JDK16 之前:非静态内部类不能拥有静态成员,仅允许 static final 编译期常量,禁止普通静态字段、静态方法、静态初始化块。
- JDK16 起:放开限制,允许静态字段、静态方法、静态初始化块。该变更随JEP 395(Records)配套的 JLS 更新《Local and Nested Static Declarations》落地,对应 CSRJDK-8254321;但所有静态成员不能访问外部实例、局部变量、类型参数。
- 静态常量赋值(编译期常量表达式)在所有版本均不受限。
class Outer { class Inner { static int MAX = 10; // JDK16+ 合法;JDK16 之前编译报错 static void hello() {} // JDK16+ 合法 // static void bad() { System.out.println(Outer.this); } // 编译错误 } }main 方法细节:JDK16+ 语法上可以在非静态内部类里声明public static void main,但生产不推荐把非静态内部类作为程序入口,主类优先用顶层类或静态嵌套类。静态内部类可以正常定义并运行 main。
内存泄漏风险:内部类实例被长生命周期对象(静态集合、线程池、单例)持有,会导致外部类对象无法被 GC—— 经典内存泄漏场景:
class Outer { class Task implements Runnable { @Override public void run() { /* 用不到任何外部状态 */ } } void submit() { executor.submit(new Task()); // Task 被线程池长期持有 → Outer 无法被 GC } }上面这段即使 Task 完全没用到外部状态,JDK18 之前也会强制持有 Outer。这就是为什么老代码里动不动就内存泄漏。
使用场景:迭代器实现、事件回调、与外部实例强耦合且生命周期可控的逻辑。
四、一张表对比:静态 vs 成员
| 维度 | 静态内部类 | 成员内部类 |
|---|---|---|
| 依赖外部实例 | 不依赖 | 必须依赖 |
| this$0 | 无 | 必定生成(JDK18+ 不引用外部状态时可省略,属编译器优化) |
| 访问外部成员 | 仅静态成员 | 全部成员(私有 / 公有、静态 / 实例) |
| 实例化 | new Outer.StaticInner() | new Outer().new Inner() |
| 内存泄漏风险 | 无 | 高风险 |
| 独立存活 | 可以 | 不可以 |
五、小结与下篇预告
静态 vs 成员,本质差异就一条:成员内部类持有外部实例引用(this$0),静态内部类不持有。这一条能推出访问权限、实例化方式、内存泄漏风险的所有结论。
下一篇我们讲另外两个更灵活的成员:局部内部类(作用域与 effectively-final 底层原理)和匿名内部类(合成构造器与双括号初始化陷阱),记得关注「木雨」不迷路。