
写这篇笔记的起因很简单有次在代码评审会上一个小伙子把Student student;当成对象用了半天空指针报得一头雾水。Java 基础里的类和对象人人都见过可真正能把两者关系、内存布局、初始化顺序讲清楚的十个里面未必有一个。所以我打算把面向对象编程最核心的这一课——类与对象——彻底掰开揉碎用我做项目这些年踩过的坑、调过的 JVM、翻过的源码写成一份可以直接当手册翻的笔记。适合正在学 Java 的初学者也适合写了两年代码但想回头夯实基础的开发者。1. 类和对象的本质一张图纸和一栋房子的距离1.1 从代码层面看类和对象到底是什么关系Java 是面向对象编程语言这句话几乎每个教程都会说。但“面向对象”不是一个口号它落到语言层面就是两个关键词类class和对象object。先给一个最直白的类比类是施工图纸对象是按图纸盖出来的房子。图纸可以被复印、修改、复用但它本身不占土地、不住人房子是实实在在的东西有自己的地址、自己的住户、自己的状态。对应到 Java 代码里public class Student { String name; int age; void study() { System.out.println(name is studying.); } }这段代码定义了“学生”这一类抽象概念包括学生拥有的属性name、age和能执行的行为study。它只是一个描述不占内存。只有当用new关键字实例化时才算真正创建了一个具体的、有血有肉的对象Student s1 new Student(); s1.name 张三; s1.age 20; s1.study(); // 张三 is studying. Student s2 new Student(); s2.name 李四;s1和s2都是按Student这张图纸盖出来的独立房子。你改s1.name不会影响s2。这就是“对象是类的实例”这句话的本义——每个对象独占一份实例变量。这里有个高频误区声明引用变量Student s;并不会创建对象。它只是在栈上划出一个可以存放引用的位置此时它是 null直接调用s.study()就会抛出NullPointerException。必须执行new之后引用才真正指向堆内存中的那个对象。1.2 从内存层面看类与对象的存放位置完全不一样理解了代码层面的关系再看内存就顺理成章了。JVM 的内存区域纷繁复杂但和类与对象直接相关的只有三个地方区域存放内容生命周期堆Heap真正实例化出来的对象数据、对象的实例变量从 new 到不再被引用由 GC 回收栈Stack局部变量、方法调用的参数、引用变量的值即堆地址方法执行结束即销毁方法区/元空间类的结构信息、静态变量、方法字节码、常量池类被卸载才算结束我用一个实际场景串起来。假设写这样一段代码public class Main { public static void main(String[] args) { Student s new Student(); s.name 王五; } }执行到main的第一行时JVM 会先在堆中分配一块内存把Student的实例变量 name 和 age 放进去name 默认是 nullage 默认是 0。然后栈上的main方法中会生成一个局部变量s它的值是堆中那块内存的地址。之后s.name 王五本质就是顺着s里保存的地址去堆内存中把 name 字段改掉。很多人把“对象”和“引用”混为一谈实际上引用才是栈上的那个“遥控器”对象是堆里的“电视机”。遥控器可以被拷贝、传递、置空但电视机本身只有一个。理解这一点后面讲浅拷贝、深拷贝、方法传参时的按值传递都会清晰很多。1.3 为什么说“对象是类的实例”这句话是理解一切的钥匙“对象是类的实例”这句话的价值在于它点出了 Java 的可扩展性来源。类是模板意味着同一套结构可以批量生产无数个状态各异的对象对象是运行时实体意味着程序运行中所有动态变化的数据都存放在对象里。举个业务场景。电商系统里定义一个Order类包含订单号、金额、用户ID、状态。每次用户下单new Order()就生成一个独立订单对象这些对象共享同一个类的代码定义但数据互不干扰。如果不用类你就得为每个订单写一套变量代码量爆炸且无法维护如果只有类没有对象那所有订单都共享同一份数据一单变了全平台都变显然违背常识。理解这层关系后再看一个经典面试题“两个相同类型的对象用比较和用equals比较有什么区别”Student a new Student(); Student b a; Student c new Student(); System.out.println(a b); // true因为 a 和 b 指向同一个堆内存地址 System.out.println(a c); // false因为是两个独立对象地址不同 System.out.println(a.equals(c)); // false因为默认 equals 也是比较地址默认情况下equals和完全等价都是比较引用地址。要让两个不同对象在业务上相等比如学号相同就视为同一个学生就必须重写equals方法。这也顺带引出一个结论对象比较这件事从来不是语言帮你决定的而是业务规则帮你决定的。讲“类与对象”如果不讲到这里底层逻辑始终是缺一角的。2. 一个完整的类骨架从字段到内部类2.1 成员变量和方法是类的两块基石一个类最核心的部分就是成员变量描述状态和方法描述行为。但这两块基石都藏着不少细节。成员变量分为实例变量和静态变量。实例变量依附于对象每个对象各存一份默认有初始值引用类型是 null基本类型是 0 / 0.0 / false /\u0000。静态变量用static修饰属于类本身全类共享一份存放在方法区。静态变量最常见的用途是常量、全局配置、工具类的共享状态但共享也意味着并发问题多线程环境下改静态变量绝大多数情况都不是好主意。局部变量则完全不同必须显式初始化才能使用否则编译报错。这背后的设计逻辑是——实例变量默认值对业务对象来说往往可以被接受但局部变量是方法内部临时的计算中间结果默认让它为 null 或 0 很容易掩盖逻辑错误所以编译器干脆强制你初始化。方法层面值得一提的坑是重载Overload和方法重写Override的混淆。重载是同一个类中方法名相同、参数列表不同重写是子类对父类方法重新实现要求方法签名完全一致、访问权限不能缩小、返回值类型可以协变。很多新手在实现接口时把方法签名抄错参数多写了一个结果不是重写而是新增了一个方法编译不报错但功能完全没生效这种 bug 隐蔽性极强。2.2 构造器对象唯一正规的初始化入口构造器是一个类的特殊方法方法名与类名相同、没有返回值。对象创建后JVM 会调用对应的构造器来完成初始化。很多人忽略的是如果你没有写任何构造器编译器会自动生成一个无参构造器但只要你写了一个带参构造器默认无参构造器就消失了。这时候在别处调用new Student()就会编译失败。构造器之间可以互相调用用this(...)但只能放在第一行。子类构造器必须通过super(...)调用父类构造器同样必须是第一行。JVM 的规则是子类构造器如果没有显式调用super(...)编译器会自动加一个super()即无参父类构造器。所以一旦父类没有无参构造器子类就必须显式写super(...)否则编译不过。这个规则还直接导致一个连锁问题父类构造器里调用了可被重写的方法子类重写后创建子类对象时父类构造器会抢先触发子类方法而此时子类字段还没初始化大概率拿到 null 或 0——这是非常经典的陷阱。构造器还有个进阶玩法私有化。把构造器设为private外部就不能随意 new常用于单例模式和工具类。单例模式下这一招尤其重要目的就是彻底封死外部创建实例的入口。2.3 初始化顺序静态代码块、实例代码块、构造器谁先谁后类加载和对象创建的过程中初始化顺序是个高频考点也是排查线上偶发问题时要考虑的变量。完整规则分两类类级别的初始化只执行一次且首次加载类时触发父类静态代码块、静态变量初始化按书写顺序子类静态代码块、静态变量初始化按书写顺序对象级别的初始化每次 new 都触发父类实例代码块和实例变量初始化按书写顺序父类构造器子类实例代码块和实例变量初始化按书写顺序子类构造器举个例子class Parent { static { System.out.println(父类静态代码块); } { System.out.println(父类实例代码块); } Parent() { System.out.println(父类构造器); } } class Child extends Parent { static { System.out.println(子类静态代码块); } { System.out.println(子类实例代码块); } Child() { System.out.println(子类构造器); } }执行new Child()输出父类静态代码块 子类静态代码块 父类实例代码块 父类构造器 子类实例代码块 子类构造器静态代码块只执行一次意味着全局性的预加载工作可以放在里面比如加载配置文件、初始化线程池。但不要在静态代码块里做耗时操作因为它会阻塞类的首次加载而这个类首次被用到的地方往往就是性能瓶颈的起点。2.4 内部类藏在类里面的类以及它与 Lambda 的关系内部类是类中类共分四种成员内部类、静态内部类、局部内部类、匿名内部类。成员内部类隐含地持有外部类的引用因此可以直接访问外部类的私有成员。但这句“好处”恰恰是内存泄漏的隐患如果外部类对象本来要回收了可内部类对象还被长期引用比如被某个集合或监听器持有外部类就永远无法释放。静态内部类则不同它不持有外部类引用相当于独立类只是用外部类做了个命名空间。需要访问外部类实例成员时必须自己显式传递引用。Android/Java 开发中常见的ViewHolder静态内部类模式就是为了避免每个列表项都隐式持有 Activity 引用导致 Activity 无法及时回收。匿名内部类则是一种没有类名的内部类无法定义构造器多用于接口的快速实现。这也是 Lambda 表达式的前身。Java 8 之后引入的 Lambda 表达式本质上是编译器把函数式接口的匿名内部类实现做了一个语法简化。写Runnable r () - System.out.println(hello);编译后实际上等价于生成一个类实现Runnable接口并把run方法指向那行打印。很多人会忽略 Lambda 同样遵循“局部变量捕获”规则lambda 表达式引用的外部局部变量必须是 final 或 effectively final声明后不再被重新赋值因为编译器要保证运行期间捕获到的值是不会被后续逻辑篡改的快照。如果你在一个循环里用 lambda 引用循环变量并想让它保存每次迭代的值经典的坑就来了——变量不是 effectively final无法编译。解法是新建一个局部变量暂存当前值再在 lambda 里引用。3. new 出来的对象去了哪里创建流程与生命周期3.1 new 关键字背后JVM 做了四件事如果只看 Java 代码new Student()只是一行调用但 JVM 在背后做了四步类加载如果Student类还没被加载到方法区JVM 先执行类加载过程包括加载、校验、准备、解析、初始化。初始化阶段会执行前面讲到的静态代码块和静态变量赋值。分配内存在堆中为对象分配一块连续内存。分配方式取决于堆内存是否规整可能是“指针碰撞”或“空闲列表”配合 GC 一起工作。初始化零值和设置对象头实例变量先赋默认值 0/null/false然后设置对象头包含对象所属类的元数据指针、哈希码、GC 分代年龄等。执行构造器按初始化顺序执行实例代码块、实例变量初始化和构造器方法。第 3 步经常被忽略。对象刚分配完内存时所有实例变量都是零值状态随后才在构造器阶段被赋业务初值。所以构造器里如果根据某个字段是否为 null 做逻辑判断要小心此时字段可能已经被显式赋值过了。类加载时机也值得记住不是类文件在被 import 时就加载而是“首次主动使用”时才加载包括 new 对象、访问静态字段、调用静态方法、反射获取 Class 对象、初始化子类触发父类初始化等。这也是为什么有些类里静态代码块的打印在你写 main 方法却没 new 它的时候完全不出现。3.2 栈、堆、方法区对象的出生地和临终地把三个区域再展开一层因为它们直接决定了两类经典错误StackOverflowError和OutOfMemoryError。栈是每个线程私有的存放方法调用帧。方法每调用一次就压入一个栈帧方法返回栈帧弹出。如果递归没有出口栈空间耗尽抛出StackOverflowError。栈的大小可以用-Xss参数配置但默认栈深度通常是几千层足够正常业务用。真正出现栈溢出的代码几乎全是递归设计缺陷或无限循环调用。堆是线程共享的所有对象都在这里出生。堆又分成年轻代和老年代年轻代内部还有 Eden 区和两个 Survivor 区比例默认 8:1:1。对象通常在 Eden 区出生经历一次 Minor GC 后如果还活着且年龄达标就晋升到老年代。当老年代也放不下时会触发 Full GC如果 Full GC 之后仍然空间不足就抛OutOfMemoryError: Java heap space。方法区在 JDK 8 之后改名为元空间Metaspace最大的变化是不再使用 JVM 堆内存而是使用本地内存。被我们的类结构信息、静态变量、常量池占据。如果项目里不断用字节码框架动态生成新类或者大量使用 CGLib 代理元空间内存溢出OutOfMemoryError: Metaspace也是可能发生的。3.3 对象回收GC 如何判断一个对象该不该死判断对象是否可回收现在主流通用算法是可达性分析。从一组叫作 GC Roots 的根对象出发沿着引用链往下搜索凡是无法到达的对象判定为死亡。GC Roots 包括栈帧中的局部变量引用、方法区中的静态变量引用、JNI 引用、活跃线程等。注意这里说的是“无法到达”不是说“没有引用”。一个对象可以被变量引用着如果它不在任何一条以 GC Roots 为起点的引用链上依然会被回收。这也侧面说明不要再相信“引用计数法”这种方案它解决不了循环引用的问题——A 引用 B、B 引用 A两个都已无法被外部触达但引用计数不为零就永远无法回收。Java 的引用类型分为强引用、软引用、弱引用、虚引用四种。强引用是普通赋值只要存在对象就不会被回收。软引用SoftReference适合做缓存内存不足时会被回收。弱引用WeakReference一遇到 GC 就会被回收典型应用就是ThreadLocalMap里的 key。虚引用PhantomReference主要用于对象回收后的系统级通知日常不常用。还有一个知识点System.gc()只是建议 JVM 执行回收不代表立即执行、也不保证回收结果。线上代码写System.gc()是大忌因为它可能引发耗时极长的 Full GC把系统卡顿点全部引爆。真需要触发 GC 做调优测试应该用 JVM 参数而不是靠这行代码。3.4 逃逸分析对象不一定都在堆上教科书里说“对象在堆中分配”但现代 JVM 在开启逃逸分析后会优化一部分对象的分配位置。这块属于进阶内容但理解了它对解读 JVM 日志和性能调优非常有用。逃逸分析的核心是分析对象的作用域如果对象只在方法内部使用、不会被外部引用JVM 判定它“不逃逸”就可能把它拆散到栈上存储栈上分配方法结束自动销毁连 GC 都不用参与。另一种优化叫同步消除如果对象不逃逸对其加锁就没有意义JVM 会直接去掉锁还有标量替换把对象的字段拆成一个个局部变量来存储。我见过一个实际调优案例一个高并发接口里频繁创建小对象压测 JVM 日志显示 Minor GC 次数极多。后来调整了 JVM 参数、确认对象没有逃逸再压测发现 GC 次数大幅下降。但逃逸分析不是银弹影响分配位置的因素很多开启-XX:DoEscapeAnalysis只是一个开关最终效果以压测为准。日常业务代码更多需要关注的还是不要频繁在循环体里 new 大对象、不要用无意义的大数组中间值、及时断开引用以配合 GC。4. 封装、继承、多态让类与对象真正面向对象4.1 封装类把数据和操作数据的门锁在一起类之所以叫“封装”核心思想是成员变量用private隐藏、对外提供公共方法访问。这样做的价值有三层第一层保护完整性。比如订单金额字段是private double amount外部不能直接赋值成负数必须通过setAmount在方法里做校验。如果放纵外部直接改字段业务约束名存实亡。第二层隐藏实现细节。外部调用order.calculateTax()时不在意它内部是查数据库还是调远程服务类内部怎么改都不影响外部调用方。这给系统重构留出了喘息空间。第三层良性依赖反转。配合接口使用时外部只依赖抽象不依赖具体类模块间耦合度降低。封装最怕做过头。我见过很多项目把每一个字段都配上 getter/setter但方法体里什么校验都没有本质跟公开字段没有区别还多了一堆样板代码。封装的度应该由业务规则决定真正有约束的字段才需要通过方法控制没有额外约束的简单字段尽可以用更轻量级的方案比如 Java 17 的 record 或者 Lombok 的 Data。4.2 继承复用父类的结构但别滥用继承是面向对象里最容易被误用的能力。它的作用是子类复用父类的属性与方法同时可以扩展和重写形成 is-a 关系。重写Override有严格约束方法名、参数列表、返回值协变除外一致访问权限不能比父类更严格不能抛出比父类更宽泛的受检异常。还有一点父类的private方法不能被重写static方法也不能被重写只是隐藏final方法完全禁止重写。继承带来的最大问题是脆弱的基类问题。子类继承父类时对父类内部结构是无感知的一旦父类的实现细节调整子类行为可能被意外改变。所以有一个广为人知的建议组合优先于继承。意思是如果两个类之间不是严格的 is-a 关系就优先用内部持有引用的方式实现复用而不是继承。判断什么时候该用继承可以问三个问题子类是否真的是父类的一种猫是动物所以猫能继承动物子类是否需要父类的绝大多数行为父类是否足够稳定。三个问题都是肯定回答才考虑继承否则就用组合。所以 Java 也给了类一个控制继承的末级手段final修饰类表示不可继承修饰方法表示不可重写。JDK 自带的String、Integer等都是 final 类目的就是防止子类破坏它们的不变性和哈希约定。4.3 多态与接口面向抽象而不是面向实现多态在 Java 中有两种表现形式。一是方法重载编译期多态同一个方法名根据参数列表不同选择不同版本二是方法重写运行期多态父类引用指向子类对象时调用的方法以实际对象类型为准。真正改变系统架构弹性的是运行期多态也是接口存在的意义。接口interface将“做什么”与“怎么做”分离。调用依赖接口实现类随时替换模块之间就可以像插拔一样合作。接口还可以多实现而类只能单继承所以接口在灵活性上天然胜过抽象类。那抽象类和接口怎么选维度抽象类接口本质is-a 关系模板定义能力契约can-do 关系继承限制单继承可多实现成员变量可以有实例变量常量public static final方法类型抽象方法、普通方法、构造器抽象方法、默认方法、静态方法适用场景多个类有公共骨架且要复用字段为不相关类提供统一能力约束接口的默认方法default method是 Java 8 的重要补充它允许接口在不破坏既有实现类的情况下增强能力。比如List接口新增sort默认方法所有实现类自动受益。但默认方法也带来菱形继承问题如果一个类实现的两个接口拥有相同签名的默认方法类必须重写该方法否则无法编译。在实际开发中我常用的判断标准是如果几组类之间需要共享代码和数据且有清晰的父子层级用抽象类如果只是为了定义能力契约、让不同实现之间可以替换用接口。多数业务场景下接口是首选。5. 实战高频场景从空对象到深拷贝5.1 判断对象是否为空别只用 null 判断“判断对象为空”是出现频率极高的需求但很多人的写法有隐患。最原始的写法if (obj ! null) { ... }这种写法本身没错问题在于业务里的“空”含义复杂对象是 null、字符串是空串、集合是空列表、Optional 是 empty四种“空”处理方式不一样。推荐的做法是区分上下文// 对象级 if (Objects.isNull(obj)) { ... } // 与 obj null 等价 if (Objects.nonNull(obj)) { ... } // 字符串级 if (str null || str.isEmpty()) { ... } // 或使用 commons-lang3 的 StringUtils.isBlank // 集合级 if (list null || list.isEmpty()) { ... } if (CollectionUtils.isEmpty(list)) { ... } // Optional 级 Optional.ofNullable(obj).ifPresent(...);最容易被忽视的是判断完对象非空之后对象的内部字段仍然可能为空。比如从数据库查出一个用户对象它本身非 null但它的address字段是 null继续user.getAddress().getCity()还是空指针。所以现代 Java 代码里链式层层判空已经写吐了Java 8 的 Optional 正是为此而生String city Optional.ofNullable(user) .map(User::getAddress) .map(Address::getCity) .orElse(未知);每一层如果为 null表达式就短路最终得到默认值。但 Optional 也是一把双刃剑作为方法返回值使用时调用方仍然可以拿到 null把它放在属性字段里则违背设计初衷。我见过有人把 Optional 大量用于字段类型结果序列化、比较逻辑全都变得怪异。Optional 最适合的场景是避免链式判空和作为方法返回值表达“可能缺失”不要滥用。5.2 对象比较equals 与 hashCode 的约定两个对象要判断“业务上是否相等”必须重写 equals 和 hashCode并且遵守约定equals 相等的两个对象hashCode 必须相等反之不要求。这个约定直接决定了对象在 HashMap、HashSet 等哈希容器中的行为。比如重写 equals 时只比较 userIdOverride public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; User user (User) o; return userId.equals(user.userId); } Override public int hashCode() { return Objects.hash(userId); }如果不重写 hashCode两个 userId 相同的 User 对象在 HashMap 里会被分到不同桶导致明明业务上相等的对象被当成两个 key引发数据错乱。经验之谈使用 Lombok 的 EqualsAndHashCode 可以省事但遇到需要排除某些字段比如排除冗余的创建时间字段时要写exclude更重要的是EqualsAndHashCode 会默认包含类中所有非静态字段如果类中有集合字段且集合被业务逻辑修改hashCode 会变对象一旦放入 HashSet 后再修改集合内容就会出现在集合中“存在却查不到”的诡异问题。这类 bug 非常隐蔽排查时我只能送上一句把加入容器后不会再变化的关键字段单独作为 hashCode 依据。字符串比较也是重灾区。str1 str2判断的是引用是否相同str1.equals(str2)判断的是内容是否相同。字符串常量池的存在让abc abc有时候返回 true但new String(abc) abc却返回 false。真实项目中判断字符串内容相等符号用错是上线后才发现的高频 bug没有之一。5.3 浅拷贝还是深拷贝别让两个对象互相踩数据对象的拷贝肉眼可见的分歧点是拷贝出来的新对象和原对象是共享内部对象还是完全隔离。Java 自带的Object.clone()默认是浅拷贝。浅拷贝下新对象的基本类型字段完全复制但引用字段只是复制了地址新对象的内部引用和原对象指向同一个实例。浅拷贝的经典事故现场public class User implements Cloneable { ListString tags; Override protected Object clone() throws CloneNotSupportedException { return super.clone(); } } User a new User(); a.tags new ArrayList(); a.tags.add(vip); User b (User) a.clone(); b.tags.add(coupon); System.out.println(a.tags.size()); // 2a 的 tags 也被改了原因就是b.tags和a.tags指向同一个 ArrayList。深拷贝要求把内部的对象也重新创建一遍实现方式有三种第一种手动逐层拷贝。重写 clone 时对每个可变引用字段都 new 一份比如this.tags new ArrayList(original.tags)。这种方式最直观但嵌套层级一深代码维护成本成倍上升。第二种序列化拷贝。对象实现 Serializable通过序列化和反序列化生成新对象。优点是通用缺点是对象里所有字段都得可序列化、涉及 IO 性能开销大而且 transient 字段会丢失。第三种借助第三方工具。Json 工具Jackson/Gson把对象转成 JSON 再转回来或者使用 Apache Commons BeanUtils 的属性拷贝再或者用 MapStruct 这类编译期映射框架。拷贝大对象时MapStruct 性能最好。工具选型的核心依据是字段是否复杂、拷贝频率高不高、是否要求绝对性能。判断应该用浅拷贝还是深拷贝有一个偷懒又有效的标准拷贝出来的对象是否可能被并发线程或不同业务模块各持一份并各自修改内部属性。如果是必须深拷贝否则数据串包事故只是时间问题。5.4 用 Lambda 与 Optional 组合操作复杂对象这节回应开头提到的一个热搜场景Java Lambda 调用内部类。很多初学者写 lambda 时觉得语法神奇但实际在编译层面它就是内部类实现的语法糖。复杂对象往往嵌套层次多、结构深比如订单里有用户、用户里有地址、地址里有城市。传统写法是层层 if else代码冗余度惊人。Lambda 和 Optional 组合后代码变得非常干净BigDecimal total Optional.ofNullable(order) .map(Order::getItems) .filter(items - !items.isEmpty()) .map(items - items.stream() .map(Item::getPrice) .reduce(BigDecimal.ZERO, BigDecimal::add)) .orElse(BigDecimal.ZERO);这类链式写法在函数式编程里叫做“声明式操作”你不再描述“怎么做”而是描述“要什么”如果订单不为空取它的商品列表过滤掉空表把每个商品的价格加起来没有就返回 0。它比逐层 if 处理更不容易漏判空指针也更容易阅读。但使用 lambda 链式操作时有一个性能细节每次 map/filter 都可能创建新的 Optional 和中间对象在极高并发、百万级循环场景下对象创建对 GC 是有压力的。日常业务完全不用担心但如果你是基础组件开发还是要权衡可读性和性能必要时回归迭代循环写法。6. 类与对象常见坑位和排查思路6.1 构造器传参陷阱重载太多时如何优雅构造对象当一个类字段数量多起来构造器的重载会迅速膨胀。比如一个用户类有八个字段全参构造器、缺省构造器、各种组合写起来麻烦、调用时稍不留神就把参数顺序传错而编译期根本不会发现。行业内处理多字段对象构造的主流方案有三种。第一种是 JavaBean 模式只保留无参构造器依赖 setter 逐个赋值。缺点是对象可能处于“中间状态”在并发场景下容易读到未完整初始化的对象还要额外处理不可变量保证。第二种是 Builder 模式通过静态内部 Builder 类链式设置属性最后build()返回对象。这是最推荐的方式尤其在字段较多或字段有默认值时User user User.builder() .name(张三) .age(20) .address(北京) .build();Builder 模式的本质是“分阶段构造”每一阶段的build方法通常可以对必填字段做校验在构造完成时一次性验证。注意Builder 模式里的内部类必须是静态的否则它会隐含持有外部类实例造成内存泄漏。第三种是工厂方法或静态工厂。用命名清晰的方法暴露创建入口比如User.of(String name, int age)语义比new User(...)明确得多同时还能控制缓存、约束单例。日常写业务类我的建议是字段不超过三四个时直接全参构造器或 record 很清爽字段多且配置复杂时Builder 是首选方向。真正要避免的是同时提供大量重载构造器ABC 三个参数还能看明白ABCEDFG 八个参数传错人就是事故了。6.2 内部类带来的内存泄漏监听器、回调与集合内部类持有外部类引用的特性决定了它是内存泄漏高发地带。举一个最常见的场景某个组件持有一个 Runnable匿名内部类/Lambda这个 Runnable 引用了 Activity/Service 的实例。组件生命周期比 Activity 长Activity 已经销毁了但组件还持有 RunnableRunnable 又持有 ActivityActivity 占用的整个对象树都无法回收。排查思路通常有两种。一种是使用弱引用内部类改成静态内部类并显式传入外部对象的 WeakReference。二是及时解除注册在外部对象销毁入口反注册监听器把长时间存活组件对它的引用断开。很多框架自带生命周期方法如 onDestroy就是做这件事的不要偷懒不调用。还有一类问题藏在Handler、EventListener、回调队列里。它们的风险同类加了监听器但忘了移除。诊断这种问题时最有效的工具是内存分析器MAT 或 JProfiler抓一个 heap dump按保留大小排序看看是哪些对象撑满了堆然后顺着引用链找到被外部类持有内存泄漏的位置。6.3 类与对象的高频面试题和诊断思路最后把面向对象方向的高频面试题和平时排查会遇到的问题串一遍供参考问题一句话答案与踩坑点面向对象三大特性是什么封装、继承、多态。只答出名称没分要能结合实际代码说清各自的意义类和对象的区别类是模板/抽象对象是实例/具体。类在方法区存结构信息对象在堆存状态equals 和 的区别 比较引用地址equals 默认也比较地址业务相等要重写 equals 和 hashCodeString 为什么是 final 的保证不变性、哈希稳定性、字符串常量池的安全抽象类和接口的区别抽象类是 is-a 单继承可包含字段接口是 can-do 多实现可含默认方法接口更灵活一个类初始化过程类加载→分配对象内存→设置对象头→执行构造器构造器前先执行父类构造器和实例代码块Java 有垃圾回收为什么还会内存泄漏对象虽然可被 GC但被无意识的长生命周期引用持有无法回收Lambda 和内部类的关系Lambda 是函数式接口匿名内部类的语法糖捕获变量要求 effectively final排查日常代码问题时如果对象数量异常、GC 频繁第一优先抓 heap dump如果出现的是ClassCastException优先查类型强转处对象的实际类型如果是NullPointerException用 JVM 参数-XX:ShowCodeDetailsInExceptionMessagesJDK 14开启详细空指针信息能直接看到到底是哪个引用为 null。写这段内容时我有意把每个坑位都对应到真实工作场景原因很简单面向对象这套理论背定义容易真正用得好的人少。类与对象是整个 Java 程序的骨架每行代码都在和它们打交道。遇到问题能快速定位到是引用传递、初始化顺序、还是生命周期管理出问题比记住十本理论书都管用。我自己的体会是把这些细节融进肌肉记忆之后写代码的速度和质量会上一个台阶review 别人的代码时也能一眼看出隐患在哪里。建议你把这篇笔记里的代码片段在本地跑一遍特别是初始化顺序、深浅拷贝和内部类持引用那几段踩过一次比看十遍都深刻。