ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java类与对象核心解析:从new一个对象到equals与hashCode

Java类与对象核心解析:从new一个对象到equals与hashCode 1. 先别急着背概念看类和对象的本质1.1 类就是图纸对象才是建好的房子实在要说类和对象的关系在我带过的所有新人里最好懂的例子还是图纸和房子。你在设计图上画一套三居室标注好客厅多大、主卧朝南、厨房带阳台这本身不能住人但它把“一套房子应该有什么”定义清楚了。类干的就是这件事把一类事物的共同属性和行为写下来。比如学生有姓名、班级、成绩这些属性有上课、考试这些行为把这些整理成一个 Student 类就相当于画了一张“学生”的图纸。而对象就是照着这张图纸真正建起来的那套房。Student s1 new Student()这行代码本质上是让 JVM 按照 Student 这张图纸在内存里给你盖了一间属于“小明”的房间。你可以用同一张图纸盖无数间格局相同但内部装修不同的房子对应到代码里就是能 new 出无数个彼此独立的对象。s1 的 name 改成“小明”跟 s2 的 name 改成“小红”互不干扰因为它们是两块独立的内存区域。这里要提醒刚接触 Java 的朋友一个非常容易混的点类名后面带括号 new 出来的是实实在在的对象它占据堆内存有生命周期而平时写的Person p只是一个引用变量它本身不存任何成员数据只保存了指向某个对象的“门牌号”。用门牌号叫外卖外卖能送到但你手里拿的从来不是房子本身。搞不清引用和对象的分工后面学传参、学内存分析一定会卡壳。1.2 Java 为什么非要把类和对象拆开有人会问Python、JavaScript 也写类好像大家差不多为什么 Java 里类和对象的地位被抬得这么高原因在于 Java 是强类型、静态编译语言它希望在编译阶段就把类型体系钉死。有了类编译器才知道某个变量能调用哪些方法、不能调用哪些方法有了对象程序在运行期才有真正可操作的数据载体。两者缺一个整套类型系统就转不动了。另外一个容易忽略的层面是类这个定义本身在 JVM 里也是以对象形式存在的也就是java.lang.Class类的实例。你写Student.class拿到的那个 Class 对象里面装着这个类的元信息方法表、字段结构、父类是谁、实现了哪些接口。理解了这一层后面学反射、学类加载机制类加载器把.class文件读进内存并生成 Class 对象就会顺畅很多。可以说Java 里万物皆对象连类自己的“肖像权”也是由一个对象来承载的。2. 一个类的完整定义成员变量、方法与构造方法怎么搭2.1 成员变量、方法、构造方法各干什么概念说完了直接上代码。一个最基础的类长这样public class Person { // 成员变量描述对象的状态 private String name; private int age; // 构造方法负责初始化对象 public Person(String name, int age) { this.name name; this.age age; } // 成员方法描述对象的行为 public void introduce() { System.out.println(我叫 name 今年 age 岁); } }这三样东西各司其职成员变量是对象的“状态”它决定对象现在是个什么样子成员方法是对象的“行为”它决定对象能干什么构造方法则是对象的“出生仪式”保证对象一诞生就处于一个合理的初始状态。你不能想象一个没有名字、没有年龄的人被 new 出来那会是一个状态残缺的对象后面所有业务逻辑都容易踩空。关于成员变量有个非常实用的细节如果你没有显式赋值Java 会给基本类型默认值int、double、boolean 分别是 0、0.0、falseString、数组这类引用类型默认是 null。这个特性经常被忽略但在实际排查 bug 时特别有用。比如你 new 了一个对象发现某个 Integer 字段是 null 而不是 0多半就是它压根没有被初始化而不是被算成了 0。构造方法有几个硬规矩我优化代码时几乎每周都会给人纠一遍名字必须和类名完全一致、不能写返回值类型void 也不行、可以用重载实现多种初始化方式。如果你不写任何构造方法编译器会补一个默认的无参构造但只要你写了一个带参构造这个默认无参构造就会消失。遇到new Person()报编译错误往往不是因为你写错了什么而是因为写了带参构造后无参构造“被挤掉”了。2.2 static 成员到底该怎么理解static 是类与对象话题里绕不开的点。凡是加了 static 的成员都不再属于某个具体对象而是属于整个类。换句话说不管 new 多少个对象静态变量只有一份所有对象共享它。最典型的例子是计数器每次 new 一个对象静态变量 count 加一它能记录这个类总共创建过多少实例如果改用成员变量每个对象各自持有一份 count永远统计不出总数。静态方法同理它不依附于对象。这就是为什么静态方法里不能直接访问非静态成员变量、不能直接调用非静态方法——静态方法被调用时压根不存在“当前对象”自然也没有 this。你可以把 this 理解成“我自己”一个不属于任何人的入口怎么可能找到“我自己”反过来普通成员方法里访问静态成员就没问题因为静态成员大家都有份。实际开发里Math类就是典型的纯静态类Math.sqrt、Math.max这些工具方法不用先 new 一个 Math 对象再调用。这也是为什么很多项目里会单独做一个静态工具类把一组与具体实例无关的算法收拢到一个类里用类名直接调调用方代码干净也不需要维护多余的对象状态。2.3 抽象类和普通类的区别看过不少人在抽象类和普通类这里翻车。其实一句话就能概括核心区别普通类可以直接 new 出对象抽象类不能。抽象类用 abstract 修饰它的存在意义是当“父类模板”把子类公共的东西沉淀下来同时用抽象方法强行规定子类必须实现某些行为。public abstract class Animal { public abstract void makeSound(); public void eat() { System.out.println(动物在吃东西); } } public class Dog extends Animal { Override public void makeSound() { System.out.println(汪汪汪); } }Animal 里 makeSound 没有方法体因为它无法决定叫声是什么猫叫猫的、狗叫狗的这个行为只能让子类各自实现。但 eat 是大部分动物共有的就写在抽象类里子类直接继承。注意Dog 必须实现 makeSound否则 Dog 自身也得声明成 abstract仍然不能 new。抽象类就是半成品模板普通类是可直接使用的成品这个定位想清楚很多设计层面的选择就顺了。3. new 一个对象的完整链路类加载、内存分配与回收3.1 new 一个对象JVM 干了四件事很多人写new Person()写得飞快却不知道这一行的底层发生了什么。完整流程大致分四步类加载、分配内存、默认初始化、执行构造方法。类加载指的是类加载器把 Person 类的字节码读进内存经过验证、准备、解析、初始化几个阶段最后在方法区新版主要靠元空间承载生成 Class 对象。只有这个 Class 对象存在了JVM 才知道 Person 有哪些字段、哪些方法、父类是谁。然后 JVM 在堆内存里给即将诞生的对象划出一块区域先把这块区域全部清零——刚才提到的 int 默认 0、引用默认 null就是这么来的。最后一步才轮到执行构造方法里的代码把我们传进来的 name、age 赋值给对应字段。这里有一个先后顺序的讲究如果类里有字段直接赋值语句和初始化块它们在构造方法执行之前就先跑完构造方法做的是最后的精细化定制。理解这个顺序能帮你解释很多“字段为什么不是我想要的值”的诡异问题——八成是初始化块和构造方法哪个先执行没搞明白。3.2 对象在内存里到底长什么样对象创建好了它在堆里并不是一团乱麻。以常用的 HotSpot 虚拟机为例对象在内存中的布局大致分成三块对象头、实例数据、对齐填充。对象头里存着 Mark Word用来记录哈希码、GC 分代年龄、锁状态标志这些运行时信息还有类型指针指向它的类元数据告诉 JVM 这个对象属于哪个类。实例数据就是对象的成员变量值按声明顺序排列。对齐填充则是为了读取效率做的内存对齐让对象总大小凑成 8 的整数倍。而Person p new Person()里的这个 p存放在当前方法栈帧的局部变量表里。它不是对象只是一个指向堆中对象的引用。引用本质就是对象的地址相当于地址簿里的门牌号。这一点在传参时极其重要方法之间传 Person 对象传的是引用不是对象拷贝所以方法里改了对象字段外面能看到因为它们操作的是同一块内存。很多人 debug 时发现“我明明调用了方法改了值外面怎么变了”其实这才是正常现象。3.3 对象什么时候被回收对象不会永远活着Java 的自动垃圾回收会盯住堆内存。判断一个对象是否该被回收核心标准是它是否还被“活着的引用”指着。把引用置空比如p null对象就失去了外部引用的锚点成为回收候选。但这不是立刻执行GC 有自己的运行时机你观察到的内存占用变化往往会有滞后。这里要提醒一句finalize()方法已经被官方明确不推荐使用它在对象回收前被调用的时机完全不可控还可能引发性能问题。如果需要释放数据库连接、IO 流这类非 Java 资源用 try-with-resources 显式关闭才是正确姿势。从我实际经验看99% 的业务场景不需要我们关注对象被回收的精确时刻要关注的反而是别让大对象长期被无关引用持有那才是真正的内存浪费源头。4. 对象操作四大高频坑判空、equals、去重与内部类4.1 判断对象为空 null、Objects.isNull 到底用哪个“判断对象为空”是几乎每个 Java 新手都搜过的问题。先给结论常规代码里直接写obj null就对了它最直观、易读也没有额外开销。Java 7 起提供的Objects.isNull(obj)本质上也是obj null的封装它真正的价值是配合方法引用使用比如stream.filter(Objects::isNull)而不是在日常判空时非用它不可。真正要小心的不是用哪个判空 API而是哪里最容易出现空指针。我总结了几个重灾区一是对自动拆箱不设防Integer 对象干脆直接跟 int 比较或者赋值给 int 变量对象为 null 时一拆箱就炸二是链式调用getXxx().getYyy()这种写法中间任何一环返回 null 都直接空指针三是数据库查询或 JSON 反序列化出来的对象字段为 null 的概率远比你想象的高。建议养成习惯外部传入的对象、跨系统拿到的数据使用前先做一次判空。4.2 equals 和 hashCode 为什么必须一起重写面试里十有八九会问两个对象用和用equals比较有什么区别。比较的是引用指向的内存地址equals没重写时用的也是 Object 默认的地址比较。要比较业务意义上是否相等就必须要重写 equals。但只重写 equals 不重写 hashCode会埋下一个更隐蔽的雷。原因在哈希表这种数据结构里。HashSet、HashMap 判断对象是否重复流程是先调用 hashCode 定位到某个“桶”再调用 equals 确认是否真的相等。如果两个对象业务上相等equals 返回 true但 hashCode 返回不同它们会被存进两个不同桶结果就是 HashSet 里同时出现两个“一模一样”的人。反过来hashCode 相同但 equals 不等只是同一个桶里多存一个节点而已影响稍小。所以规范是equals 相等的两个对象hashCode 必须相等。来一个实际例子。Person 类按 id 判断是不是同一个人Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof Person)) return false; Person person (Person) o; return id.equals(person.id); } Override public int hashCode() { return Objects.hash(id); }这样设计之后把 Person 放进 HashSet 可以真正按 id 去重用 Map 做缓存搜人也能正确命中。这个组合是对象最核心的规范之一值得反复默写因为面试高频、代码里也高频。4.3 对象数组去重有几种靠谱写法对象数组去重是开发里绕不开的需求。最朴素的做法是双重循环来一层层比较再用 contains 判断但这是 O(n²) 的复杂度数据量一大就明显变慢而且代码很啰嗦。推荐的做法是用 LinkedHashSet 包一层既去重又保持插入顺序ListPerson list new ArrayList(); // ... 添加数据 ListPerson distinctList new ArrayList(new LinkedHashSet(list));这里有一个前提Person 必须已经正确重写了 equals 和 hashCode不然 HashSet 的去重机制完全不生效等于白做。如果你更喜欢 Java 8 风格也可以写成list.stream().distinct().collect(Collectors.toList())底层依赖的依然是 equals。实际项目里如果对象来自数据库我更建议直接在 SQL 里用 DISTINCT 或 GROUP BY 去重性能远好于把全部数据拉到内存再处理但内存去重的价值在于它跟数据源无关任何集合进来都能处理通用性强得多。4.4 内部类的分类一次性理清楚内部类分成成员内部类、静态内部类、局部内部类、匿名内部类四种其中匿名内部类在开发里出现频率最高。成员内部类是定义在类内部的普通类可以访问外部类的一切成员静态内部类相当于外部类的静态成员不能直接访问外部类的非静态成员局部内部类定义在方法内生命周期只在方法执行期间匿名内部类则是没有类名、只用一次时图省事的快捷写法Runnable task new Runnable() { Override public void run() { System.out.println(匿名内部类执行); } };这里藏着面试官最爱问的一个细节为什么局部内部类和匿名内部类只能访问方法里的 final 或有效 final 的局部变量因为这些内部类对象本质上是“捕获”了外部方法里局部变量的值副本如果允许变量在捕获后再变化内部类里看到的值就会和外部实际值对不上产生认知错乱。Java 8 之后不强制写 final只要变量声明后没再被重新赋值就算有效 final也可以访问。这个设计的根源在于内部类对象可能活得比方法更久保存的其实是值拷贝理解到这一层这个问题才算真正答透。5. 综合案例用类与对象写一个学生成绩管理5.1 类的设计与对象构建说了这么多不如全部串起来跑一遍。来个最常见的场景学生成绩管理。先定义 Student 类包含学号、姓名、分数提供构造方法和展示方法public class Student { private String id; private String name; private double score; public Student(String id, String name, double score) { this.id id; this.name name; this.score score; } Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof Student)) return false; Student s (Student) o; return id.equals(s.id); } Override public int hashCode() { return Objects.hash(id); } Override public String toString() { return id - name : score; } }构造方法保证每个对象创建时学号、姓名、分数必须齐全避免字段缺失的脏状态。equals 和 hashCode 按学号定义把“同一个学生”的判定标准钉死。这些设计不是花架子它们决定了下面所有逻辑跑不跑得对。如果你偷懒不写 equals 和 hashCode那去重、排序、放进 Map 的时候全是坑。5.2 排序、去重、输出一气呵成基础类有了Main 里就可以 new 多个 Student 对象放进 List。要对分数排序可以写冒泡排序也可以用内置的排序方法配合 Comparator。冒泡排序在面试和算法学习里经久不衰手写版长这样for (int i 0; i list.size() - 1; i) { for (int j 0; j list.size() - 1 - i; j) { if (list.get(j).getScore() list.get(j 1).getScore()) { Collections.swap(list, j, j 1); } } }冒泡排序的思路很直白双重循环挨个比较相邻元素每一轮把当前最大的“浮”到端点。它的优点是代码简单、容易记但时间复杂度是 O(n²)数据过万就要谨慎了。工程环境里我一般直接用Collections.sort(list, Comparator.comparingDouble(Student::getScore).reversed())底层排序算法高效得多还能用方法引用写出很简洁的代码。排序之后如果想按学号去重再把 List 用 LinkedHashSet 包一层最后循环输出集合这样一个“对象创建—业务操作—统一输出”的完整链路就跑通了。几十行代码几乎把类和对象最核心的语法点全部过了一遍。6. 我带项目时见过的高频翻车现场说句实在话类与对象这个主题语法本身不难难的是把面向对象的思考方式嵌进肌肉记忆。我带过不少新人转正后交的第一版代码类里字段全是 public一个类堆了七八个职责对象之间互相 new、互相改字段联调的时候改一处崩三处是常有的事。这里分享两条我自己的土办法。第一类并不是越多越好也不是越少越好判断标准是这个类能不能用一句人话讲清楚它是什么。讲不清说明职责混杂趁早拆开。第二字段能用 private 就 private外部一律通过方法访问。虽然前期看着啰嗦但等你需要加校验、加日志、改内部实现时封装的价值就显现出来了。另一个高频翻车点是不注意不可变性。一个对象创建之后核心业务字段如果总被外部随意修改很快就会没人说得清“当前状态到底是什么”。工程里可以故意不写 setter只保留构造方法让对象创建后就不可变这在并发场景下尤其友好——不可变对象天然线程安全。这种设计习惯比单纯背十遍“封装、继承、多态”的概念都管用。回到最初那张图纸和房子的比喻你在设计图上纠结的每一根线都会决定建出来的房子好不好住。你的类定义得清不清楚成员设计合不合理equals 规不规范也直接决定了后面每一个对象的行为是否可靠。我自己每次写新业务也会先花十来分钟想清楚这个类代表什么、有哪些状态、谁有权利改状态、对象之间怎么协作。把这道工序前置后面写代码就顺很多bug 也会少很多。这算是类和对象带给我的一个很重要的习惯了。
返回列表