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 和 agehashCode 也要用相同的字段参与计算。这样两个相等对象的 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 大部分语法问题都会迎刃而解。
阅读完成 · 觉得有帮助?