类和对象(2)这个标题,放在课程体系里往往是第二讲,但真正写过业务代码的人都知道,这个话题够你啃很久。第一讲大家学会了“class 定义一个类型,new 出一个对象”,看懂了语法,但一落到实际项目里,问题就来了:对象到底存在哪?为什么这个对象传进方法里改一改,外面的数据也变了?static 到底该不该用?封装就是把字段设成 private 吗?
作为一个跟代码打交道十来年的人,我的建议很直接:类和对象第二讲,别急着学继承和多态,先把对象的创建、内存布局、数据保护和生命周期这些底层机制吃透。这些东西不搞清楚,后面写再多代码都是沙上建塔。这篇内容适合刚学完类和对象入门语法、准备做第一个“正经一点”的项目,或者在面试前想系统补一补对象底层知识的同学。接下来我用自己实际写代码时的理解方式,把这几个核心环节拆开讲。
1. 对象从无到有:构造方法里值得较真的细节
1.1 不写构造方法,系统真的会送你一个吗
很多新手会有个误解:觉得构造方法可有可无,因为不写代码也能编译通过。确实,编译器会提供一个无参的默认构造方法,但这里有个特别容易踩的坑:只要你写了任何一个带参构造方法,默认的无参构造方法就没了。也就是说,你定义了public User(String name)之后,再想写new User(),直接编译报错。
我见过太多人栽在这个地方。写了一个带参构造,然后某个工具类里还在new User(),报错之后一脸茫然。解决办法要么显式补一个无参构造,要么把调用处改成带参方式。个人的经验是:如果你的类必然需要某些字段才能工作,那就别留无参构造,强制调用方必须传值,这从源头上挡住了大量“对象状态不完整”的问题。
1.2 多个构造方法之间怎么优雅地复用初始化逻辑
业务类经常有多个构造方法:一个接收全量参数,一个只接收核心参数,另一个可能是为了兼容旧接口。如果每个构造方法都写一遍字段赋值和校验逻辑,代码会变得又臭又长。而且一旦校验规则变了,你很可能只改了一个构造,另外几个忘了改,bug 就悄悄埋下来了。
优雅的做法是让一个“主构造方法”持有完整的初始化逻辑,其他构造方法通过this(...)链式调用它。以 Java 为例:
public class Order { private final long orderId; private String status; public Order(long orderId) { this(orderId, "待支付"); } public Order(long orderId, String status) { if (orderId <= 0) { throw new IllegalArgumentException("订单号非法"); } this.orderId = orderId; this.status = status; } }这里有个硬性要求:this(...)调用必须写在构造方法的第一行。这不是编译器故意刁难,而是因为 Java 要求先完成构造链,之后才能执行当前构造方法体内的语句,保证初始化顺序是确定的。
另外要注意,final字段必须在构造方法里被赋值。用上面的写法,orderId被声明为final,而在主构造方法中完成赋值后,所有链式调用路径都不会漏掉它,正因为this(orderId, "待支付")最终会落到有参的主构造上。所以从经验来看,把必填字段设为final,并用链式构造统一赋值,是一种值得推广的模式。
1.3 构造方法里别写太重的业务逻辑
新手容易犯的另一个问题,是把构造方法当成一个“初始化即干活”的地方:在构造里查询数据库、调用远程接口、启动线程。看起来方便,实际上后患无穷。因为在测试里 new 一个对象就要连数据库,跑一遍单测可能触发一系列你根本不想触发的副作用。
构造方法的职责应该限定在“让对象状态合法”。怎么理解这个“合法”?就是校验参数、赋初值、建立不可变的状态。真正需要连数据库、发消息、启动后台任务的逻辑,应该放到专门的方法或工厂里,由调用方显式触发。这既让对象容易测试,也让对象的使用意图更清晰。我实际写代码时,经常把“创建对象”和“初始化外部资源”分开,创建是纯内存操作,资源获取则放到显式调用的方法中。
2. 封装:真正把数据保护起来
2.1 访问权限不是只有 private 和 public
很多人理解的封装就是把字段private,然后生成一堆 getter/setter。封装的核心其实是“把不变量掌握在类自己手里”。不过在细讲之前,先看全 Java(C# 也类似)的四种访问级别,很多不起眼的 bug 都是因为选错了访问级别造成的。
| 访问修饰符 | 同类 | 同包 | 子类(不同包) | 全局 |
|---|---|---|---|---|
| public | 可访问 | 可访问 | 可访问 | 可访问 |
| protected | 可访问 | 可访问 | 可访问 | 不可访问 |
| 默认(无修饰符) | 可访问 | 可访问 | 不可访问 | 不可访问 |
| private | 可访问 | 不可访问 | 不可访问 | 不可访问 |
我自己的原则很简单:字段一律private,对外暴露的方法才考虑public;如果你想让某个行为只给子类扩展,用protected;同包可见性这个默认级别,往往就是团队里两个人写不同模块时因为包结构变化导致的诡异问题来源。所以跨模块协作时,我宁可多写几行访问方法,也不用包可见性来偷懒。
2.2 getter/setter 不是机械地生成
很多 IDE 一键生成的 getter/setter 只是“字段的窗户”,但封装的价值在于:setter 里可以做校验和约束。比如一个学生成绩的字段:
public class Student { private int score; public int getScore() { return score; } public void setScore(int score) { if (score < 0 || score > 100) { throw new IllegalArgumentException("分数必须在0-100之间"); } this.score = score; } }这样写,任何调用方都不可能把成绩设成 150,这个约束由类自己维护,而不是靠每个调用方自觉。如果你哪天发现业务里到处都在if (score < 0 || score > 100),那说明这个约束放错了位置,应该收拢到类内部去。
getter 同样需要设计。如果字段是一个集合,比如List<String> tags,直接return tags会把内部可变引用暴露出去,调用方可以student.getTags().clear()直接把对象内部数据清空。安全的做法是返回不可修改视图:
public List<String> getTags() { return Collections.unmodifiableList(tags); }这样外部可以读取,但没办法修改。不能小看这个细节,线上很多数据被莫名其妙篡改的事故,源头就是返回了内部可变引用。
2.3 什么时候可以大胆不封装
凡事都有例外。有些类本身就是“数据的搬运工”,比如从接口接收参数、传给数据库或中间件的数据传输对象,它们的字段天然就是公开读写的。如果这种类也做一堆 getter/setter,除了代码膨胀没有别的好处。另外,类内部的私有辅助类或局部使用的数据结构,也不值得过度封装。
我的判断标准是:一个类如果承担了业务规则、状态流转、数据一致性,就值得认真设计封装;如果它只是“结构化的数据容器”,直接暴露字段反而让代码简洁。封装是手段不是目的,目的永远是保护状态的不变性。
3. 静态成员:属于类,而不是属于对象
3.1 static 变量到底存在哪
static 变量也叫类变量,它在内存中只占一份空间,所有的对象共享这一份。而实例变量是每个对象各自持有一份。用网上的比喻就是:同一个班级的白板是大家共用的(static),每个人的笔记本是私有的(实例变量)。
举一个常见场景:统计当前类创建过多少个实例。
public class User { private static int instanceCount = 0; public User() { instanceCount++; } public static int getInstanceCount() { return instanceCount; } }这个计数器就属于类,不能用user.instanceCount这种思路去访问,因为它是类级别而不是对象级别的。访问静态成员,规范的做法是通过类名调用:User.getInstanceCount()。虽然 Java 也允许用对象访问静态成员,但那样会产生“这个值好像属于这个对象”的误导,我建议团队规范里直接禁止这种写法,能少很多迷惑。
3.2 为什么静态方法里不能直接访问实例成员
静态方法没有this。这是一个很直白的解释。this指向的是当前对象,而静态方法是挂在类上的,调用时可能根本没有任何对象存在。所以你在静态方法里直接写name = "张三",编译器根本不知道这个name是哪个对象的。
实际开发中,静态方法适合放三类逻辑:不依赖对象状态的计算工具方法、读取类级别状态的访问器、以及创建对象的静态工厂方法。如果写静态方法时发现自己想去访问某个实例字段,大概率说明这个方法应当改成实例方法,或者需要把对象作为参数传进来。
3.3 用静态工厂方法替代 new,是进阶的正解
比new更值得掌握的是静态工厂方法。它本质还是一个构造对象的入口,但相比直接用构造方法,它有几个实实在在的好处:方法名可以表达意图,比如User.of("张三")比new User("张三")更有可读性;还可以在方法里做校验和缓存实例,甚至返回类的子类型。
举个例子:
public class User { private final String name; private User(String name) { this.name = name; } public static User of(String name) { if (name == null || name.isBlank()) { throw new IllegalArgumentException("用户名不能为空"); } return new User(name); } }注意这里构造方法被声明为private,外部不能直接new User(...),只能通过User.of(...)。这样创建对象的唯一入口就是静态工厂方法,所有校验规则都被收拢在一个地方。你可以看到一个很有意思的点:静态工厂方法内部仍然用了new,但它把“创建对象的入口”和“创建对象的细节”分离了。
4. 对象的引用传递:内存模型决定行为
4.1 等号赋值到底复制了什么
很多人以为User a = new User("张三"); User b = a;是把整个对象复制了一份。实际上,这里复制的是“引用”,也就是对象在堆内存中的地址。对象本身只有一份,a和b是两根同时指向它的“绳子”。
用大白话说,new User("张三")在堆里盖了一间房子,门上写着地址;User a是让a记住这个地址;User b = a是让b也记住同一个地址。之后你通过b把房子里的家具换了,再从a进去看,家具当然也是新的,因为那是同一间房。
4.2 方法参数:Java 永远是值传递
这是面试高频题,也是工作里最容易犯迷糊的地方。Java 的参数传递永远是值传递,只不过这个“值”是基本类型的值,还是引用类型的引用(地址值),要看参数类型。区别在于:如果方法内部修改了引用对象的属性,外部会受影响;如果方法内部把参数变量重新指向一个新对象,外部不会受影响。
public void changeName(User u) { u.setName("李四"); // 外部对象的名字会变 } public void rebind(User u) { u = new User("王五"); // 外部引用不会变,u只是本地变量被重新赋值 }我在带新人时最爱用这个例子。很多 bug 的根源是“我以为把对象换成新的了,结果外面还是旧的”。理解了引用传递,你就知道:方法内部对参数重新赋值是无效的,除非把返回值传出去重新赋值给外部变量。
4.3 来一份特别的对比:浅拷贝和深拷贝
前面说的是引用传递的“共享”,那如果确实想要一份独立的副本呢?这里就要分清浅拷贝和深拷贝。先做个小实验:给一个类实现clone(),然后复制对象、修改副本内部的集合,观察原对象是否变化。
public class Cart implements Cloneable { private List<String> items = new ArrayList<>(); @Override protected Cart clone() { try { Cart c = (Cart) super.clone(); // 只复制了引用 return c; } catch (CloneNotSupportedException e) { throw new RuntimeException(e); } } }这个clone()完成的是浅拷贝:items字段指向的还是同一个ArrayList对象。你往副本的items里加一个商品,原对象的items也能看到。要真正做到深拷贝,需要手动把内部集合也复制一份:
@Override protected Cart clone() { try { Cart c = (Cart) super.clone(); c.items = new ArrayList<>(this.items); // 深拷贝内部可变对象 return c; } catch (CloneNotSupportedException e) { throw new RuntimeException(e); } }写业务代码时我的原则是:默认不提供拷贝。等真正需要拷贝时,优先考虑深拷贝或者干脆用不可变对象。浅拷贝的“半半拉拉”状态最坑人,使用方往往误以为拿到了独立副本,结果数据在悄悄共享。
4.4 对象共享带来的线程安全隐患
对象共享另一个绕不开的问题是并发。多个线程同时读写同一个对象的状态,如果没有同步,轻则读到过期数据,重则直接进程崩溃。这并不是只有写服务器才会碰到的问题,桌面程序里多个线程同时操作同一个 UI 对象也很常见。
最简单的规避手段之一就是让对象不可变:所有字段final,不提供 setter,不把内部可变对象泄露出去。不可变对象天然线程安全,因为没人能改它。如果对象确实需要在运行时变化,那就得靠加锁或使用并发容器。我个人的经验是:在设计阶段优先问一句“这个对象真的需要可变吗?”有一半情况下答案是不需要。
5. 对象的生命周期:从创建到回收
5.1 对象从创建到回收的全过程
一个对象从new开始,到变得不可达,再到被垃圾收集器回收,这个过程比你想象的长。对象刚创建时肯定在堆里,栈上有一个引用指向它。当这个引用变量出了作用域,或者被显式置为null,对象就失去了“存活”线索,变成垃圾。
举个例子:
public void run() { List<String> list = new ArrayList<>(); // 方法结束时,list 引用出栈,对象成为垃圾 }这里要注意:垃圾收集器回收对象的具体时机是不确定的,所以你不能依赖“对象被回收时一定会执行某个方法”这种假设来做关键操作,比如释放数据库连接、关闭文件流。这类资源必须用显式的close()或 try-with-resources 来保证释放,而不是等着垃圾回收来救你。
5.2 空指针异常的三个最常见来源
对象生命周期和引用传递的话题,最终都会汇聚到一个具体症状上:空指针异常。我总结下来,空指针的来源无外乎三种:方法返回了null但调用方没判断、从集合里取元素没判断是否存在、隐式自动拆箱出现空值。
排查这类问题,先靠堆栈信息定位到行号,再看这段代码里哪个变量可能为null。多说一句,堆栈信息只能告诉你“在哪一行崩了”,它不会告诉你“为什么变成 null”。所以排查时不要纠结崩溃那行代码,应当往上去看变量的来源,看看是哪一次方法调用把null传进来的。
5.3 资源释放:对象不只是构造那么简单
构造方法对应对象的诞生,那对象销毁呢?垃圾收集器会回收内存,但回收不了文件句柄、网络连接、数据库会话这类操作系统资源。实际开发的惯例是:凡是需要显式释放资源的类,就实现一个关闭方法,并用语法糖确保调用。Java 里的 try-with-resources 就是一个典型:
try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { // 执行查询 }这段代码最大的价值是把释放逻辑交给 try 结构去管理,即使中途抛异常,资源也会被正确关闭。很多线上“连接泄漏”问题,根源就是有人手动new了一个连接对象,用完之后随意丢给垃圾回收,从来不关。所以对象生命周期的话题,本质上是要记住两件事:内存由垃圾回收负责,非内存资源必须显式释放。
6. 实战经验彩蛋:设计对象时先在脑子里过一遍内存图
最后分享一个我自己的习惯。每当我定义一个比较复杂的类,或者准备把某个对象传给别的方法之前,我都会先在脑海里画一张内存图:这个对象在堆里是哪一块,哪个变量持有了指向它的引用,方法参数传进去的是引用还是副本,有没有可能造成共享,对象的生命周期大概多久。
别看这个习惯不起眼,它能提前消灭一大批 bug。你可以这样练习:随便找一段代码,把一个对象的创建语句、方法调用、传参过程都标注出来,研究每一步在内存里发生了什么。练过那么十几次,你就不会再被“为什么这里变了那里没变”这种问题困住了。
这一讲的内容到这里,已经覆盖了构造方法、封装、静态成员、引用传递和生命周期这几个核心环节。下一讲可以放心去啃继承和多态了,因为你现在已经知道对象是怎么活的,也清楚它的边界在哪里。写代码的时候,多想想你创建的这个对象在内存里是什么样子,很多棘手问题就没那么难了。