ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java类与对象深度解析:从底层原理到工程实践

Java类与对象深度解析:从底层原理到工程实践

1. 从一段“报错”说起:类与对象到底在解决什么问题

我经常在项目代码评审里看到一种现象:一个类里塞了几十个方法,字段全部public,谁想改就直接改,改完又不知道哪里受影响。这不是个例,而是很多Java开发者对“类与对象”基本功不扎实的表现。聊到框架、中间件头头是道,但一追问“构造器能不能是private”“子类构造器为什么第一行要调super()”“重写equals不重写hashCode会怎样”,很多人的回答就开始含糊了。先说结论:这些问题不是冷门面试题,而是你日常开发中那些隐蔽Bug的根源。类与对象是Java这门语言的地基,Java所有的语法特性、框架原理、JVM机制,最后都能落到这两个词上。

这份总结不是在教材目录上划重点,而是把我自己在真实项目里用到过、踩过坑的知识点做了一次完整梳理。内容覆盖类的基本结构、对象在JVM中的生命周期、三大特性、抽象类与接口、内部类,以及常见的运行时异常排查。适合正在学Java、准备面试,以及写了几年代码但基础知识有盲区的人。你可以把它当作一个能直接用的知识地图,按图索引,哪里薄弱补哪里。

1.1 面向对象不是玄学,是管理复杂度的方式

为什么Java要设计成“面向对象”?很多人会背三大特性:封装、继承、多态,但并不知道它到底要解决什么问题。举个例子,一个电商订单,如果不用类,你可能要用好几个数组分别存订单号、用户ID、金额、下单时间,然后写一堆工具方法去操作这些数组。这样做的第一个问题是数据和行为分离,订单金额的计算逻辑散落在各个地方;第二个问题是扩展很痛苦,再加一个收货地址字段,所有相关方法都要跟着改。

有了类之后,订单的数据和操作订单的行为被装进同一个结构里,每个订单对象都是这个结构的一个具体实例。这样做最大的收益不是“代码看起来高级”,而是降低了业务逻辑的复杂度。当你把“订单”抽象成一个类,程序里的表达就变成“创建一个订单对象”“查询订单状态”“计算订单金额”,这种描述方式与真实世界几乎一一对应。人脑处理复杂问题靠的是分类和归纳,面向对象正好顺应了这种思维模式。

另一个容易被忽略的点是,类提供了“边界”。一个设计良好的类会明确哪些信息是对外公开的,哪些是内部实现的。外部的调用者只需要通过公开方法使用它,不需要关心内部细节。这种边界一旦建立,重构内部代码时就不会影响到外部使用者。如果整个项目没有边界,所有代码都能互相随意访问,那项目规模一上来,牵一发动全身的问题就会层出不穷。

1.2 “类”是模板,“对象”是实例

“类”和“对象”的关系,我常用一个比喻来讲:类是一张人员信息登记表的模板,规定了这张表有哪些栏目:姓名、年龄、联系电话。对象则是按照模板填好的某一条具体记录。模板本身不占一个“格子”,只有具体填写的记录才有真实内容。

在代码里,这种关系更直接。你定义一个类,就是在描述“这类东西长什么样、能干什么”:

public class User { private String name; private int age; public User(String name, int age) { this.name = name; this.age = age; } public String getName() { return name; } public int getAge() { return age; } }

这个User类本身只是一段定义,你无法拿它去存数据。只有执行了 new User("张三", 25),JVM 才会在堆内存中划出一块区域,把 name 指向“张三”,把 age 存成 25,并把这块区域的引用赋值给你。此后你可以再 new 一个 User("李四", 30),它又是一块完全独立的内存区域。两个对象都有 name 和 age,但彼此之间互不影响。

这种“模板-实例”关系的价值在于复用。类只写一遍,对象却可以根据需要创建成千上万个。Java 里几乎所有业务代码都在围绕这个关系运行:定义抽象概念,然后创建具体对象,并在对象之间传递信息。理解了这一点,后面再去理解构造器、this、重写、多态,就有了一个整体的坐标体系。

2. 类的骨架:成员变量、构造器与方法

一个类的基本构成很简单:字段、构造器、方法。字段描述状态,构造器负责初始化,方法定义行为。但越是基础的东西,里头的细节越值得抠。很多运行时问题在类设计阶段就已经埋下了,只不过要等运行到特定条件下才爆发。

2.1 字段的设计:可见性、命名与不变性

字段设计的第一原则是:默认用 private。很多初学者喜欢把字段直接写成 public,图省事。但一旦字段公开,外部代码就能绕过你所有的校验和逻辑去修改它。比如一个账户余额字段,如果直接用 public double balance,外部可以随便赋成负数,而你在类里定义的“金额不能为负”这类约束就形同虚设。把字段设为 private,再通过 getter/setter 暴露,等于给状态设置了一道闸门。

当然,不是所有字段都需要 setter。有些字段只允许在构造器里初始化,之后就再也不应该变。比如创建时间 createdAt、用户 id,这类字段可以不提供 setter,甚至直接用 final 修饰。我见过不少代码,明明字段语义上是不可变的,却硬生生提供 setter,结果被其他地方乱调,Bug 查半天。正确的做法是:先想清楚这个字段的“生命周期”。是创建后不变,还是整个对象存活期都可以改,还是只能由类内部修改。想清楚之后再决定要不要暴露写方法。

还有一个细节是命名。字段命名要见名知意,不要用 a、b、tmp 这种。Java 的字段习惯使用小驼峰,比如 userName、orderAmount。常量要全大写下划线分隔,比如 MAX_RETRY_COUNT。团队协作时,命名不统一很容易让人误读。比如 isDeleted 和 deleted 在语义上并没有本质区别,但有人会用 isDeleted 作为布尔属性,有人会用 deleted,如果项目里两种混用,代码读起来会很割裂。

2.2 构造器:默认行为、重载与this调用

每个类都可以有构造器。如果你没写,编译器会生成一个默认的无参构造器,它什么都不做,只是让这个类可以被 new 出来。这里最经典的坑是:如果你定义了一个有参构造器,默认无参构造器就消失。

public class Product { private String sku; public Product(String sku) { this.sku = sku; } }

写了这个类之后,如果你还想执行 new Product(),编译器会直接报错。很多框架依赖无参构造器创建对象,比如某些序列化框架、ORM框架,它要先无参构造出一个对象,再去填充属性。一旦你只写了有参构造器,框架启动或运行时就会抛出异常。所以我在项目里的习惯是:如果类提供了有参构造器,同时确定还需要无参创建,就显式写一个无参构造器。不要依赖编译器默认生成,因为你的有参构造器会把它顶掉。

构造器重载也很常见,但要注意别设计成“参数类型模糊”。比如有一个构造器接收 int,一个接收 long,调用时传一个数字常量,编译器会优先选 int。如果你传的是 null,那么多个构造器分别是 String 和 Integer 时,调用 new Example(null) 会直接编译报错,因为编译器无法判断该选哪个。解决方式是把核心构造器只保留一个,其他构造器通过 this(...) 调用它。

this 调用还有一个约束:必须在构造器第一行。这是为了让初始化顺序可控。你不能在某个构造器里做了一些操作之后再 this(...),编译器不允许。另外要小心嵌套调用,比如 A 构造器里 this(...) 调 B 构造器,B 又 this(...) 调 A 构造器,这是编译不通过的,因为最终会形成循环。在实际项目中,构造器逻辑越简单越好,最好只做参数校验和字段赋值,复杂的初始化逻辑放到实例方法或工厂方法里,更容易测试。

2.3 方法的参数传递:值传递与重载

“Java 是值传递,不是引用传递”这句话很多人会背,但一写代码就忘。看一段最常见的例子:

public static void changeName(User user) { user.setName("李四"); } public static void changeName(String name) { name = "李四"; }

第一个方法传入的是 user 对象引用,方法内部通过引用修改的是同一个堆对象,所以外部看到对象 name 变了。第二个方法传入的是 name 参数的副本,方法内部对参数重新赋值,只影响副本,外部字符串不变。很多人把第一个也理解成“引用传递”,其实不对。Java 里所有参数传递都是把变量里的值复制一份给形参。如果变量存的是基本类型,复制的是数值本身;如果变量存的是引用,复制的是这个引用的指向。你复制的是“指向对象的遥控器”,不是对象本体。真正区分的关键是:你在方法里有没有修改对象内部的内容,以及你有没有给形参重新赋新对象。

参数传递还牵扯到重载。重载是同一个类中,方法名相同但参数列表不同。注意几个易错点:重载与返回值类型无关,只有返回值不同不算重载;重载是编译期决定的,编译器根据调用时参数的类型、数量、顺序挑一个最匹配的方法。比如有重载方法 f(int) 和 f(String),调用 f(null) 如果两个都不匹配基本类型,编译器会在引用类型里选最具体的,如果同时存在 f(String) 和 f(Integer),null 同时兼容,编译就会报“对 f 的引用不明确”。这类坑在代码里一旦出现,往往是写了太多重载。我的建议是重载别超过两三个,如果参数组合过于复杂,改成不同方法名更清晰。

3. 对象的一生:创建、使用与销毁

类写好了,最终要变成对象才能发挥作用。对象从创建到回收的整个过程,是理解Java内存模型的关键。很多人只会在代码里一个劲 new,出了内存泄漏和OOM就抓瞎,原因就是不知道 new 的背后发生了什么。

3.1 new对象后JVM做了什么

执行一句 User user = new User("张三", 25) 时,JVM 做的事远比表面看起来多。大致流程是:先检查 User 类是否已经被加载,如果没有,执行类加载过程,把字节码加载到方法区;然后为新的 User 对象在堆内存中分配一块内存;接着把堆中这块内存的基本类型字段初始化为零值,引用类型字段初始化为 null;然后调用构造器,真正执行你在代码里写的初始化逻辑;最后把对象的引用压到栈上,赋值给 user 变量。

这里有两个理解难点。第一个是“零值初始化”和“构造器赋值”是分开的。也就是说,即使你的构造器里没有给 age 赋值,age 也已经是 0,而不是随机值。第二个是变量 user 本身存储在栈上,它指向堆中的对象。Java 并没有“对象直接存在栈上”这回事,除了某些逃逸分析后的特殊情况,绝大多数对象都在堆上。

理解这一点,对调优和排错都有帮助。比如你看到内存占用高,要意识到是堆里的对象太多或者对象太大;你看到某个局部变量在方法结束后还占用着对象,要意识到只要栈上的变量还指向它,GC 就不会回收它。很多内存泄漏的起点,就是“无意中被某个集合或静态变量一直引用着,导致对象永远可达”。

3.2 this引用与对象身份

this 是 Java 里一个看起来简单,但理解不深很容易糊涂的关键字。它的含义是“当前正在执行方法的对象引用”。在实例方法中,你可以写 this.getName(),也可以省略 this 直接写 getName()。冲突发生在字段名和局部变量名一样的时候,比如构造器参数叫 name,字段也叫 name,直接用 name 会指向参数,要赋给字段就必须写 this.name。

另一个容易被忽略的点是 this 只能在实例方法、构造器中使用,不能在静态方法中使用。这本质上是因为静态方法不依赖某个具体对象,它属于类本身,所以没有“当前对象”这个概念。如果你在静态方法里写 this,编译器会直接报错。有些代码在静态上下文里想访问实例字段,往往就是没有想清楚“这个字段到底属于谁”。

this 还有一个常见用途是返回当前对象,实现链式调用。比如很多 builder 风格的 API,每个设置方法都返回 this,然后调用方可以写 new Builder().name("张三").age(25).build()。这个设计很简洁,但要注意不要滥用。如果链很长,每个方法都返回 this,一旦其中某个方法出错,调用链会非常难调试。我自己的习惯是链式调用保留在参数少、语义清晰的场景,业务逻辑复杂的对象构造还是用分步调用或者专门的工厂方法。

3.3 垃圾回收与对象生命周期

Java 对象不需要手动释放,JVM 的垃圾收集器会帮我们回收不再使用的对象。那什么算“不再使用”?标准是“没有从 GC Roots 出发的引用链可达”。GC Roots 包括局部变量、静态变量、活动线程等。一个对象如果没有任何可达引用,就会被标记为垃圾,等待回收。

这对开发者有一个启发:对象的生命周期不是由 new 决定的,而是由引用决定的。只要有一个静态集合一直持有某个对象,哪怕业务上已经不用它了,它也不会被回收。这就是常见的“对象泄漏”场景。比如你在一个 Map 里缓存了所有历史用户对象,又不清理,内存就会越来越大。

在实际项目里,我很少依赖垃圾回收来解决问题,更多是做好“引用管理”。能用局部变量就不要用实例字段,能用成员变量就不要用静态变量,监听器、回调用完要注销。Java 提供的 SoftReference、WeakReference 也可以用于缓存场景,让对象在内存紧张时可以被回收,但这个机制用得比较复杂,不建议在没搞清楚引用链的情况下一上来就用。另外,finalize 方法在旧代码里偶尔能看到,但现在已经被标记为弃用,不要用它来做清理工作,它的执行时机不可控,还可能影响性能。

4. 封装、继承与多态:三大特性的底层逻辑

封装、继承、多态,是Java面向对象的三个标志性特性。大部分教材都会讲,但真正能把这三点串起来理解的人不多。三者不是孤立的,封装确定边界,继承实现复用,多态让代码可以面向抽象编程。

4.1 访问修饰符与包结构

Java 有四个访问级别:private、default(什么都不写)、protected、public。privatem 只有同一个类内部能访问;default 同一包内能访问;protected 同一包内以及不同包的子类能访问;public 所有人都能访问。

实际项目里的经验是:字段尽量 private,对外提供服务的方法用 public,类与类之间协作需要暴露给子类的方法才用 protected。default 访问权限比较微妙,它只在同一个包内可见,很多人不太用。这个权限适合包内部实现细节的共享,比如同一个包内的辅助类、工具类,但又不想暴露给外部调用者。不过一旦包结构调整,default 权限很容易被破坏,所以建包时要谨慎。

protected 的细节更难一些。父类里有一个 protected 成员,子类继承它以后,在子类内部可以直接访问;但是子类不能通过父类引用去访问另一个子类的 protected 成员。举个例子:父类 Base 有 protected int age,子类 ChildA 内部写 new ChildB().age 是不行的,除非 ChildB 也是 Base 的子类并且通过 ChildB 自己的引用访问,具体规则在不同场景下容易混淆。我的建议是:protected 成员不要铺得太多,否则很容易造成访问边界混乱。能用 private 就 private,只有明确需要子类覆盖或读取时,才考虑 protected。

4.2 继承与super的细节

继承给代码复用提供了一条直接路径,但也带来了耦合。写 class Dog extends Animal 之后,Dog 自动拥有了 Animal 的字段和方法。这个方向很爽,但代价是 Dog 强依赖 Animal 的内部实现。父类一旦修改方法签名或字段,子类可能就编译不过;父类某个方法的内部行为变了,子类继承来的行为也变了。

真正能把继承用好的人,一定会关注 super 的用法。子类构造器会隐式调用父类无参构造器,所以如果父类只有有参构造器,子类构造器第一行必须显式写 super(...) 调用父类构造器,否则编译器报错。这个机制是为了保证父类先完成初始化,子类才能基于父类的状态再初始化自己。很多人踩过这个坑:父类新增了一个带参构造器,删掉了无参构造器,结果所有子类全报错。

super 还可以用来调用父类的方法。子类重写了某个方法后,如果还想执行父类的逻辑,可以在方法里用 super.methodName()。这个场景在扩展父类既有功能时很常见。但是要小心:不要在构造器里调用可被重写的方法。因为子类对象还没完成初始化,如果父类构造器调用了一个被子类重写的方法,很可能取到子类还没赋值的字段,导致空指针或错误行为。这是Java里一个非常隐蔽的坑,也是面试官喜欢追问的点。

我个人的建议是:继承层次不要超过两层,超过两层以后,类之间的依赖关系就很难理清。如果一个类只是“想复用几个方法”,不要轻易去继承它,可以优先考虑组合,也就是在类内部持有另一个类的实例。

4.3 重写与重载:看起来像,其实完全不是一回事

重写是发生在继承关系中的,子类重新实现父类的方法。重写要求方法名相同、参数列表相同、返回类型要么相同要么是父类返回类型的子类型,访问权限不能比父类更小,抛出的受检异常不能比父类更宽。比如父类方法声明 public void save(),子类写 private void save(),编译器会直接报错,因为这样会破坏“父类能用的地方子类也可以用”的约定。

重写一定要加 @Override 注解。这个注解不是装饰,它能帮你检查方法签名是否正确。我见过不少代码,本来想重写父类方法,结果因为参数类型写错了,变成了重载,程序运行起来行为完全不对,但又没有报错。加上 @Override 后,编译器会立刻提示你“父类没有这个方法”,问题在编译期就被拦住了。

重载和重写最大的区别是决策时机。重载在编译期就决定调用哪个方法,编译器根据参数的静态类型来选;重写是在运行期决定调用哪个方法,JVM 根据对象的实际类型来分派。因此面试题里常常出现“编译看左边,运行看右边”这句话。比如 Animal animal = new Dog(); animal.sound(); 编译器只看 animal 的静态类型是 Animal,只要 Animal 有 sound() 方法就能编译通过;运行时 JVM 发现实际对象是 Dog,就去执行 Dog 重写过的 sound()。这就是重写和多态的底层机制。

4.4 多态的动态绑定

多态听起来抽象,本质就是“同一个行为,不同对象有不同的实现”。它能带来的最大好处是代码的扩展性:你可以面向父类写逻辑,实际传入的是任意的子类对象,逻辑不需要改。比如这样一个方法:

public void printSound(Animal animal) { animal.sound(); }

不管你传入 Cat、Dog 还是 Bird,只要它们是 Animal 的子类并重写了 sound(),这个方法都能正确输出各自的声音。以后新增一个 Animal 的子类,printSound 不需要改一行代码。这就是“开闭原则”的直观体现:对扩展开放,对修改关闭。

动态绑定之所以能工作,是因为 JVM 在加载类时会给每个类生成一张方法表,方法表里记录了每个方法实际指向的代码地址。调用 animal.sound() 时,JVM 根据 animal 的实际类型找到对应的方法表,然后调用正确的方法。这个查表动作有一定的性能开销,但在现代 JVM 的努力下,大部分场景几乎可以忽略。

要正确使用多态,前提条件是“子类重写了父类的方法”。如果子类没有重写,那么调用到的是父类的方法;如果父类方法本身没有定义目标行为,那么即便子类有,也无法通过父类引用调用。所以设计类时,如果某些子类必须实现不同的行为,就要在父类中把这些行为定义成抽象方法,迫子类实现,这就是抽象类和接口存在的意义。

5. 抽象类、接口与内部类:设计层面的选择

到了这一层,类与对象就不再是“怎么写一个类”的问题了,而是“怎么设计一组类协作”。抽象类和接口是 Java 提供的两个核心抽象工具,内部类则更适合封装依附于外部类的辅助逻辑。

5.1 抽象类 vs 接口:不要凭感觉选

抽象类用 abstract 修饰,可以包含抽象方法,也可以包含普通方法、字段和构造器。接口在 Java 8 之后也可以包含 default 方法和静态方法,但它仍然不能有实例字段,也不能有构造器。选择抽象类还是接口,关键看你要表达的是什么。

接口表达“能力”或者“契约”。比如 Runnable 接口表达“可以运行”,Comparable 接口表达“可以比较大小”。实现接口的类不一定要有共同的父类,它们可以有各自完全不同的继承结构。Java 只允许单继承,但可以实现多个接口,所以当你希望一个类具备多个行为维度时,接口是唯一选择。

抽象类表达“是什么”并抽取公共状态。比如多个子类都有 name、age 字段,共用一个构造逻辑和基础方法,这些状态和行为可以放到抽象父类里。抽象类可以持有字段,子类通过继承直接获得这些状态。如果你的抽象逻辑里需要共享状态,抽象类更合适;如果只是一组行为规范,接口更合适。

实际项目中,类继承结构很容易被过度抽象。我看到过一些代码,接口叠接口,抽象类叠抽象类,最后找一个方法定义得翻半天。我的经验是:开始写代码时不要急着抽象,先让几个具体类跑起来,等发现确实有重复和变化点,再提接口或抽象类。过早抽象通常都是负资产。

5.2 内部类的四种形态:成员内部类、静态内部类、局部内部类、匿名内部类

内部类是定义在另一个类内部的类,Java 里有四种形态。成员内部类相当于外部类的一个成员,它可以访问外部类的所有字段和方法,包括 private 的。但它隐式持有外部类对象的引用,这会导致外部对象无法被回收,如果这个成员内部类的生命周期比外部类长,就有内存泄漏风险。

静态内部类和成员内部类的区别在于,它不持有外部类引用。它相当于一个“放在类名空间里的普通类”,所以访问外部类的实例字段时必须先有外部类对象。静态内部类通常用来封装和外部类强相关的辅助数据结构,比如一个 key-value 对,或一个请求结果封装。用它的好处是不会引入意外的外部引用,内存更安全。

局部内部类定义在方法里,作用范围只在当前方法,可以在方法中创建对象和使用。用得比较少,因为方法内逻辑通常简单,不需要再定义一个完整类。匿名内部类是没有类名的局部内部类,常见于事件监听、异步回调。Java 8 之后很多匿名内部类可以用 Lambda 表达式替代,代码更简洁。比如 new Runnable() { ... } 改为 () -> {...}。

使用内部类我最重要的建议是:优先用静态内部类,不要用非静态内部类,除非你确实需要访问外部类的实例字段。非静态内部类对外部对象有隐式强引用,一旦内部类对象被传入其他线程或长期存活,外部对象就永远不会被回收,这是很多内存泄漏的根源。

5.3 用组合代替继承:一个实战取舍

面向对象设计有一个经典原则:组合优先于继承。继承是“is-a”关系,组合是“has-a”关系。继承最直观,但也最容易破坏封装。比如你写了一个 Cat 类,发现 Dog 类有很多代码能复用,于是让 Dog 继承 Cat,语义上就很别扭:Dog 是 Cat 吗?显然不是。这种为了复用硬造的继承,后续维护会非常痛苦。

组合的做法是:Dog 类里持有一个 Cat 类型的对象,或者更抽象地持有一个“动物行为器”对象。Dog 想复用 Cat 的能力,就委托给内部对象去执行。这样 Dog 和 Cat 之间没有强耦合,内部对象可以随时替换,Dog 的对外接口也完全由自己控制。

举一个更现实的例子:假设你有一个 OrderService,另一个 OrderValidationService,传统继承写法可能让 OrderService 继承 OrderValidationService 来获得校验方法。这听起来不合理,因为 OrderService 不是 OrderValidationService。组合写法是在 OrderService 内部注入一个 OrderValidationService 实例,在需要校验的地方调用它。这样职责分离,测试也能分别 mock。类与对象设计得好的项目,很多类看起来“很小”,每个类只做一件事,然后通过各种方式协作,整体反而更容易理解和扩展。

6. 常见问题与排查实录

类与对象相关的问题,集中体现在运行时。代码能编译通过,但运行结果不对,或者直接抛异常。我把项目里反复出现的几类问题整理成一份排查实录,每一个都是真实踩过的坑。

6.1 空指针的定位思路

NullPointerException 是 Java 开发最常见的异常。它出现的原因总结起来就一句话:你调用了某个引用变量,但这个引用变量的值是 null。问题在于定位它往往比想象中耗时。很多初学者看到异常栈里某一行报错,就以为是那一行的值问题,但真正的原因可能在好几层之前就已经把 null 传进来了。

我的排查习惯是先看异常调用栈,找到最开始抛出异常的那一层,再看调用链上哪个方法可能返回 null。项目里写了太多链式调用,比如 user.getAddress().getCity().substring(...) 这种,一旦 user 为 null,只有第一次调用会报空指针,但排查起来非常麻烦。所以我在代码里会主动避免深层链式调用,必要的时候拆成多行,或者用 Objects.requireNonNull 在入口处校验关键参数。

除了“别传 null”,更值得做的是明确方法的返回值约定。如果某个方法可能找不到数据,与其返回 null,不如返回 Optional,让调用方显式处理“没有结果”的情况。当然 Optional 也不是万能药,不能把每个字段都包成 Optional。重点是让“可能为空”这件事在类型层面就可见,而不是等到运行时报空指针。

6.2 equals与hashCode必须一起重写

Object 类提供了 equals 和 hashCode 两个方法。默认的 equals 比较的是“引用是否相同”,也就是两个引用是否指向同一个对象。默认的 hashCode 根据对象内存地址生成。如果你没有重写它们,new User("张三", 25) 和另一个 new User("张三", 25) 就被视为两个不相同的对象。这在业务上往往不对:两个内容相同的 User 应该相等。

很多人知道要重写 equals,却忽略 hashCode。HashMap、HashSet 这些基于哈希的集合,先通过 hashCode 找到桶位置,再用 equals 判断桶内对象是否相等。如果你只重写 equals 不重写 hashCode,两个内容对象相等但 hashCode 不同,就会落到不同的桶里,HashMap 里就可能同时存在两个“相等”的 key。这会带来非常隐蔽的业务 Bug,集合的 size 不对、contains 方法总返回 false。

重写 hashCode 的原则是:与 equals 用到的字段保持一致。equals 比较了 name 和 age,hashCode 也要用相同的字段参与计算。这样两个相等对象的 hashCode 必然相等。计算时可以用 Java 的 Objects.hash(name, age) 简化。需要特别注意的是,放进 HashMap 或 HashSet 之后,不要再修改对象中参与 hashCode 的字段。改掉之后对象的 hash 值变了,但集合里它还在原来的桶位,再去 get 它就会找不到。

6.3 集合中存放可变对象的坑

这一条和上一条紧密相关,值得单独拿出来说。集合在 Java 程序中无处不在,很多对象一旦放进 Map 或 Set,它的“身份”就由 hashCode 和 equals 决定了。如果你的 key 对象是可变的,放入集合后再修改关键字段,就会导致集合内部状态不一致。

举例来说,先创建一个 User 对象,name 为“张三”,放进 HashMap 作为 key。然后调用 user.setName("李四"),此时 user 的 hashCode 发生变化。再去 map.get(user) 时,HashMap 根据新的 hashCode 计算出另一个桶位,发现桶位上没有数据,于是返回 null。但数据明明还在集合里,只是藏在了错误的桶中。这个问题特别难查,因为集合的 size 也不对,遍历又会看到那个 key,唯独按 key 查找时找不到。

解决方案有两个方向:一是让 key 对象不可变,字段全部 final,没有 setter;二是明确约定:对象一旦加入 HashSet 或作为 HashMap 的 key,就不能修改其 hashCode 相关字段。在实际项目中,我更推荐前者,因为约定容易被人遗忘,而不可变设计从代码层面就杜绝了这个问题。

6.4 静态变量与线程安全

静态变量属于类,被所有实例共享。这个机制本身没问题,但放到多线程环境里就会引发竞争。比如一个静态 SimpleDateFormat 实例,SimpleDateFormat 不是线程安全的,多个线程同时用它格式化日期,就会出现解析结果不对或者直接抛异常。

静态变量另一个容易被忽视的问题是“隐藏的对象泄漏”。服务启动时把对象放进静态 List,线程池任务往这个 List 里持续添加元素,但没人负责清理,最后内存就一直涨。这其实是生命周期管理问题。静态变量天然带有“全局唯一”的性质,任何地方都能访问它,所以它的生命周期几乎等于进程生命周期,只要有人往里塞数据,就会被一直引用。

我的建议是:静态变量只用来保存真正“全局不变”的东西,比如常量、配置项、线程池名称、系统级单例。如果某个数据是每个请求或者每个任务各有一份,就放到实例字段或者局部变量里,不要为了省事写成静态。必须使用静态可变集合时,要明确谁往里写数据、谁负责清理,并加上必要的同步机制。每次写静态变量之前,先问自己一句:这个值真的需要被所有实例共享吗?

7. 把类与对象变成肌肉记忆:自检清单

知识总结到最后,如果不能转化为写代码时的本能判断,就还是停留在“看过”的层面。我自己复习类与对象知识时,会对着一组问题自测,答不上来的就回去看。这里分享一套自检清单,你可以拿它来检验自己的掌握程度。

第一组是关于类的结构。如果删掉一个类的无参构造器,会有什么影响?为什么构造器里不能调用一个可被重写的方法?一个类可以有 private 的构造器吗,它常见于哪些场景?第二个问题要联想到父类构造器调用子类重写方法的隐患;第三问要想到单例模式和工具类的实现方式。

第二组是关于对象和内存。new 一个对象到变量赋值,JVM 做了哪几步?局部变量存储在哪个区域,对象存储在哪个区域?为什么说 Java 是值传递而不是引用传递?这些问题的答案能直接对应到内存分析和空指针定位。

第三组是关于三大特性。子类重写父类方法时,访问权限能不能更小?重写和重载分别由谁决定?多态的前提条件有哪些?为什么说组合优先于继承?这一类问题最能暴露你做设计时是“凭感觉”还是“按原则”。

第四组是关于集合与对象的关系。为什么重写 equals 必须重写 hashCode?把可变对象放进 HashMap 后修改它,会发生什么?哪些对象适合做 key,哪些不适合?这类问题直接关系线上 Bug 的排查效率。

我在实际项目里最有体会的一点是:类设计得越小,越容易写对。一个类如果把字段、方法、初始化逻辑、校验逻辑全堆在一起,那它一定在承担太多职责。写类之前先问自己:这个类只有一个逻辑核心吗?字段是否都围绕这个核心?方法是否都能用这些字段回答“怎么做”?如果答案模糊,就拆类。

最后再分享一个小技巧。写任何类之前,先在测试里写出你希望它怎么被使用。这个动作看起来很慢,实际上能逼你把 API 设计清楚。比如 User 类,测试里先写 User u = User.create("张三", 25),再写 u.setAge(26)。如果 create 方法不存在,你就补它;如果 setAge 不该存在,测试代码会别扭。类和对象的本质就是对外暴露什么样的行为,对内隐藏什么样的细节。把这一层想通了,Java 大部分语法问题都会迎刃而解。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进