1. 先搞清楚:什么是类,什么是对象
做Java这么多年,我带过的实习生、转行过来的同事,几乎无一例外都在这两个词上栽过跟头。你要是去面试,十家公司有八家会问“类和对象的区别”,你要是答“类就是对象,对象就是类的实例”,那基本等于给面试官递了个把柄。今天不绕弯子,直接把这块地基讲透。
1.1 类就是设计图纸,对象就是按图纸造出来的实物
我习惯用一个比喻:类是一张设计图纸,对象是照着图纸造出来的房子。
图纸上画了客厅的位置、卧室的面积、水电怎么走,但它本身住不了人。只有施工队按图纸一砖一瓦盖出来,你拿钥匙开门进去,才有了实实在在的房子。这个“按图纸施工”的过程,在Java里就是 new 关键字;图纸本身,就是 class 定义。
所以:
- 类描述的是“有什么”和“能干什么”——也就是属性和行为;
- 对象是这份描述在内存里真正占据的一块空间,它有独立的状态。
举个例子,你定义一个Student类,里面有name、score两个字段,还有一个printInfo()方法。这个类本身不占堆内存,它只是模板。当你new Student()三次,JVM堆里就有了三个独立的学生对象,张三的分数改了不影响李四的分数,因为它们的内存空间是分开的。
新手最容易犯的错,是把类当成全局变量在使:所有对象共享同一份数据。那不是对象,那是静态方法加静态字段,你一写就会发现并发场景下乱成一锅粥。记住这句话:对象是用来隔离状态的。多个对象之间的数据互不干扰,这才是设计者把“类”和“对象”分开的根本原因。
1.2 从JVM视角看对象是怎么“活”起来的
光知道定义不够,你得知道 new 一个对象的时候,底层到底发生了什么。这不是为了炫技,而是排查内存问题、面试时压场子都很有用。
整个过程分三步:
- 类加载。JVM在第一次用到
Student这个类时,会通过类加载器把.class字节码加载进方法区(HotSpot里叫元空间),生成Class对象。这一步只做一次,就算你 new 一万个对象,类也只加载一次。 - 分配内存。
new指令触发对象创建,JVM在堆上为对象分配一块连续内存,大小由对象头、实例字段、对齐填充共同决定。 - 初始化。内存分配完,JVM执行构造方法,给字段赋初值(字段默认值是0、null、false),然后执行你写在构造器里的代码,最后把对象引用压入操作数栈,赋值给变量。
理解了这三步,你就能回答这些高频面试题:为什么构造器里不能用还没初始化完的字段?为什么static字段不属于任何对象?为什么说new是“强引用”的典型代表,GC永远回收不了它?
每次 new 都是一次堆内存分配。我在做接口性能优化的时候,经常看到有人在高频循环里反复 new 临时对象,结果GC压力飙升、接口RT上百毫秒。这时候就该反思:能不能复用同一个对象?
2. 对象创建的几种方式与背后的门道
大多数应用场景下,你只需要写Student s = new Student()。但到了框架源码、序列化、深拷贝这些场景,创建对象的方式就没那么单纯了。
2.1 new关键字:最常用但别忽略构造器的隐式动作
new是我们最熟的创建方式:Student s = new Student("张三", 90)。
这里有两个要点值得多琢磨一下。
第一,构造器不写,系统也会给你默认无参构造器。但是一旦你写了一个带参构造器,默认无参构造器就消失了。所以老项目里经常看到报错“Student() has private access”或者“找不到构造器”,多半是这个问题。
第二,构造器里别写太重的逻辑。我见过有人在构造器里读数据库、调远程接口的,搞到后来单元测试一 new 对象就卡半天。构造器只做状态初始化,复杂逻辑放到工厂方法里。这也是《Effective Java》里推荐静态工厂方法替代构造器的原因之一:它能起名字、可以控制实例个数、可以返回子类型。
2.2 反射、clone、反序列化:冷门创建方式在面试里特别爱考
除了new,Java里至少还有三种创建对象的方式:
- 反射:
Class.forName("com.example.Student").getDeclaredConstructor().newInstance()。这种方式在Spring等框架里用得铺天盖地,但注意私有构造器需要setAccessible(true)。 - clone():需要实现
Cloneable接口。注意这地方根本不调用构造器,直接在内存里复制一份。 - 反序列化:从文件或网络读字节流重建对象,同样不调构造器。
面试问“哪种方式不调用构造器”时,答案是克隆和反序列化。很多人背答案,但从没想过为什么。不调构造器的原因是这两个操作走的是“内存复制”和“字节流解析”路线,JVM不需要也不应该去执行你的业务初始化代码。
我建议你谨慎使用克隆,尤其是clone()方法在Object里是protected,语义也容易被绕晕。真正的业务场景更推荐用“手动复制字段”或者“序列化工具帮我们深拷贝”。这块看下一节。
2.3 浅拷贝和深拷贝:面试热点也是实战痛点
网上搜“java对象深度拷贝”能搜出一堆文章,为什么会成为热点?因为大多数人都有被浅拷贝坑过的经历。
浅拷贝:a和b是不同对象,但a里的List字段和b里的List字段指向同一个引用。你改了a.stuList.add(...),发现b也变了。这就像两家人住同一套房,只是门牌号不同。
深拷贝:把引用指向的对象也一起复制一份,新对象完完全全是独立的。
实现深拷贝的靠谱手段有这么几种:
- 手写复制:get/set每个字段,麻烦但最可控;
- 构造器复制:专门写一个入参为同类对象的构造器;
- 序列化方式:对象实现
Serializable,用流复制,性能略差; - 工具类:Apache Commons Lang的
SerializationUtils.clone、JSON序列化(Gson、Jackson)。用JSON方式做深拷贝很常见,但注意:被拷贝的对象得有可靠的JSON序列化能力,某些循环引用会出问题。
我实际项目里更喜欢手写或者JSON方案。手写对性能敏感场景最友好;JSON方案对复杂对象最省事。是否需要深拷贝,取决于你后续要不要修改对象内部的引用类型字段——如果只是只读,浅拷贝就够了,来回深拷贝反而浪费服务器CPU。
3. 类设计的关键决策:把哪些东西放进类里
写一个能跑的项目不难,写一个能维护三年的项目很难。类设计第一步,就是确定哪些东西属于类、以什么身份放进类里。
3.1 成员变量、方法、构造器的权衡
设计类的过程,其实是在回答三个问题:
- 这个对象需要记住什么?——记不住的就别放成员变量,该用局部变量就用局部变量;
- 这个对象能做什么?——行为要和自己的状态相关,别写一堆跟自身字段没关系的“工具方法”;
- 怎么创建它?——字段多时考虑构造器重载,或者Builder模式。
我在带人的时候特别强调:一个类别随便超过300行。超过这个行数,大概率是职责不单一,提醒你该拆了。比如一个订单类,又要算价格、又要校验库存、又要发短信,你拆成Order、OrderCalculator、OrderValidator、OrderNotifier,后面维护起来舒服得多。
字段方面有三个关键字值得注意:final、transient、volatile。final代表不可变引用,适合定义常量或者不让别人改的字段;transient告诉序列化机制这个字段别存;volatile保证多线程下的可见性。很多人一把volatile放在所有字段上,结果没解决问题,还搞得代码很难读。
3.2 static到底该不该用:类变量与实例变量的区别
这个点在面试里出现频率极高,网上搜“java基础”基本绕不开它。我给你一个判断标准:
- 这个数据是否不随对象变化而变化?如果是,用
static; - 这个数据是否每个对象都有一份私有的?如果是,用实例变量。
比如一个Student类,schoolName如果全校统一,就可以做成static final String,省内存、语义清晰。而name、score这种明显因人而异的东西,就必须是实例字段。
static方法则意味着“不依赖对象状态”,所以你在静态方法里直接访问实例字段,编译都过不了——编译器不知道你指的到底是哪一个对象的字段。
那些坑:
- 很多人把
static当全局变量用,写了个static List到处往里塞数据,多线程一跑就出问题,因为共享变量没有同步保护。 - 也有人搞出“静态字段存用户会话”这种骚操作,结果A用户登录后,B用户看到的是A的信息。这种bug排查起来非常隐蔽。
- 关于“类加载”,static字段是在类加载的
准备阶段分配内存并给默认值,在初始化阶段才赋真正的值。所以如果你在static块里依赖另一个类的static字段,要小心类的初始化顺序。记住:多写代码不如多想想“这个状态到底属于谁”。
3.3 抽象类与接口的选择:面试高频题背后是设计思想
网上搜“抽象类和普通类的区别”,往往是准备面试的人在临时抱佛脚。其实区分起来很简单:普通类可以直接new,抽象类不能直接new,它天生就是用来当“半成品父类”的。abstract方法只有签名没有实现,子类必须实现。
但真正难的不是语法,而是“选型”。我自己总结的经验:
- 如果多个类之间有公共字段、公共方法体,并且需要代码复用的,选抽象类;
- 如果只关心“能做什么”,不关心怎么做,且有可能跨继承体系做约束的,选接口;
- Java 8之后接口可以给
default方法,很多以前必须用抽象类的场景现在接口也能扛住了。
举一个具体例子:现在要设计一个消息发送器,有邮件、短信、钉钉三种实现。
如果你写:
abstract class MessageSender { protected String config; abstract void send(String target, String content); }那config字段可以被所有子类继承,省去重复定义。但如果以后要加一个“能同时给多个用户发送”的BatchSender,而且它还想继承别的业务父类,Java单继承就卡住你了。这时候用接口反而更灵活:
interface MessageSender { void send(String target, String content); default void batchSend(List<String> targets, String content) { targets.forEach(t -> send(t, content)); } }核心答案模板(面试版):抽象类是由“is-a”关系驱动的,侧重代码复用;接口是由“can-do”关系驱动的,侧重行为约束。你的业务里更依赖哪种关系,就用哪个。
4. 方法调用背后的那些细节
类设计决定对象长什么样,方法调用则决定对象怎么工作。这块有几个高频考点,也藏着很多线上bug的根源。
4.1 值传递还是引用传递?别再搞混了
Java里只有值传递,没有引用传递。这是Java语言规范定的,无数面试人都栽在这里。
什么叫值传递?就是方法得到的永远是实参的“副本”。
public static void main(String[] args) { int a = 1; modify(a); System.out.println(a); // 还是1 Student s = new Student("张三", 90); modify(s); System.out.println(s.name); // 李四,改了 } static void modify(int x) { x = 2; } static void modify(Student stu) { stu.name = "李四"; }int a传进去的是值副本,方法里改的是副本,原值不变。Student s传进去的也是值副本,不过这个“值”是一个内存地址。方法里通过这个地址找到了原对象,改了它的name,所以外面的s.name变成了李四。
但如果你在方法里执行stu = new Student(),重新给形参赋值,外面的s不会变,因为形参只是地址副本。
实际工程启示:不要轻易在方法里修改入参对象的内部状态。这会带来“隐式耦合”,调用方不知道数据什么时候被改了。正确的做法是:方法体内部拷贝一份数据再处理,必要时把结果作为返回值传出去。
4.2 内部类与Lambda:局部变量为什么必须是final或effectively final
这个考点在“java lambda调用内部类示例”相关搜索里非常高频。
先看一段典型报错代码:
public void test() { int times = 3; Runnable r = () -> { // 报错:Local variable times defined in an enclosing scope must be final times++; System.out.println(times); }; }原因要从JVM实现讲起。Lambda表达式(以及匿名内部类)在编译后,会生成一个独立的方法或者类。它要访问外部方法里的局部变量,就必须把变量的值“捕获”进来作为一个副本。如果允许该变量被修改,那就会出现两个副本,语义就乱了。
所以Java规定:被捕获的局部变量必须是 final 或者 effectively final(即从未被重新赋值)。这种设计既保证了线程安全,也让编译器能放心捕获值。
我建议你平时别想着钻空子。需要修改的时候,定义一个“容器”:
- 用一个数组:
int[] count = {0}; - 用
AtomicInteger; - 或者把外层变量改为实例字段。
在实战项目中,我常用于循环里收集结果:Lambda里不能改普通局部变量,所以很多人用List或者StringBuilder当收集器——因为它们本身是对象,引用不变,改的是内部状态。这个操作是合乎规范的,并没有改变“引用本身”。
5. 对象判空与空指针防护
空指针是Java程序员永恒的话题。哪怕是天天用Optional的人,也难免在某些老旧接口前栽跟头。所以“判断对象为空”这个话题能上热词榜,一点不意外。
5.1 判断对象为空的各种姿势
最原始的写法是if (obj == null),这个没啥好说的。但实际开发里,踩坑最多的不是判空本身,而是该判空的地方没判,不该判空的地方瞎判。如果对象是从Map里出来的,用map.containsKey(key)和map.get(key)组合判断更稳妥。如果对象是工具类返回的,先看工具类的文档,有些返回值明确“不会为null”,那就不必防御性判空,否则代码全是空判断,看着很累。
有一个高频问题:字符串判断空。很多人写:
if (str == null || str.equals("")) {}更稳的做法是用Objects.equals(str, "")或者str == null || str.isBlank()(JDK 11+)。为什么?因为"".equals(str)虽然可以避免NPE,但读起来总有点别扭。JDK新版本带了很多低配方法,别守着老写法不放。
5.2 Optional对象操作的正确打开方式
JDK 8引入Optional是好事,但代码里全是Optional.ofNullable(x).orElse(...),也未必是好事。很多人把Optional当成“防止空指针的唯一工具”,其实它是用来表达“可能为空”的返回值类型的,不是用来兜底所有判空逻辑的。
Optional常见的几种用法写给你:
// 1. 安全获取值并给默认值 Student s = Optional.ofNullable(findById(1)).orElseGet(() -> DEFAULT_STUDENT); // 2. 如果为空,抛异常 Student s = Optional.ofNullable(findById(1)).orElseThrow(() -> new RuntimeException("学生不存在")); // 3. 非空才做操作 Optional.ofNullable(student).ifPresent(stu -> stu.printInfo());避坑点:
Optional本身不能序列化,很多框架(比如某些RPC框架)对Optional支持并不好,别拿它当实体类字段类型。orElse和orElseGet有细微差别:orElse(默认值)不管Optional是不是空的,默认值对象都会预先创建;orElseGet(Supplier)只在为空时才执行。如果你的默认值创建开销不小,用orElseGet。- 不要为了让工具方法更酷,把所有返回值都包一层
Optional,尤其是合理情况下不可能为空的场景,包了之后反而多一层拆箱,阅读成本提高。
我给团队定了个规矩:方法返回值如果是“有可能为空且对调用方有语义”,才用Optional;否则直接返回null,由调用方自行判断。规则简单,大家执行起来也不纠结。
6. 常见问题与面试避坑实录
最后一部分我整理了实际带人、参加技术评审时反复遇到的问题。这些内容网上搜“java面试”也能搜到,但很多答案太模板化,我按自己的实战经验重新梳理一遍,希望能帮你在关键时刻少踩几个坑。
6.1 面试高频题速查
| 问题 | 答案要点 |
|---|---|
| 类和对象的区别 | 类是模板/蓝图,对象是实例/具体存在;类加载一次,对象可建多个且独立 |
| new对象时JVM做了什么 | 类加载、分配堆内存、初始化字段、执行构造器、返回引用 |
| 抽象类和接口的区别 | 抽象类偏代码复用,接口偏行为约束;类单继承,接口可多实现 |
| 浅拷贝和深拷贝的区别 | 浅拷贝共享内部引用,深拷贝递归复制内部对象 |
| Java是值传递还是引用传递 | 值传递;引用本身是值,方法内重新赋值不影响调用方 |
| 为什么Lambda捕获的局部变量必须final | 捕获是值拷贝,避免两个副本导致语义混乱 |
| 对象怎么判断是否相等 | 业务场景用 equals,引用相等才用 ==;重写 equals 必须重写 hashCode |
| 静态方法能不能访问实例字段 | 不能,编译器无法确定访问哪个对象的字段 |
有一个特别容易翻车的点,是equals和hashCode。很多人只知道面试要考,做项目时却完全不重写。结果Set去重失效、HashMap的key取不到值。我见过一个线上bug:对象当作Map的key,没有重写 hashCode,可能每次取对象都是“同一个业务key”但hash值不同,Map里存了一堆重复key。
你的原则应该是:业务对象做相等比较用的字段,就是 equals 里要比较的字段,也是 hashCode 里要参与计算的字段。用IDE自动生成即可,注意在 equals 里先判断obj == this,再判类型兼容,不要用instanceof去判两个不同继承层级的东西。
6.2 实战中容易踩的坑
第一坑:用静态字段缓存实例数据。常用来存配置或缓存数据,看似很方便,一旦多环境部署,每个JVM都有自己的静态数据,数据不一致不说,还容易漏清理出内存泄漏。
第二坑:构造器里调用可重写方法。Java里构造器可以调用方法,但子类还没初始化完,你调用的可能是子类重写后的方法,而子类字段此时还没赋值,结果得到一堆null。这是《Effective Java》里明确反对的做法,我建议你直接在构造器里只做字段赋值和参数校验,不调用业务方法。
第三坑:滥用内部类导致内存泄漏。非静态内部类持有外部类的引用。如果你在一个长期的集合里存放内部类实例,哪怕外部类已经不使用了,也无法被回收。这个坑在Android开发里尤其出名。解决办法是:能做成静态内部类的就加 static,或者用Lambda表达式替代。
第四坑:对象创建得太随意。一个接口请求里创建大量生命周期极短的小对象,高并发时直接拖垮GC。你先看热点方法,能复用对象就复用,能用基本类型就别包装类,能延迟创建就延迟创建。
写到这里,我已经把类、对象的定义、创建方式、设计决策、方法调用、空值防护、面试高频坑都翻了一遍。最后分享我个人的经验:写Java越久越会明白,一切框架、一切源码,本质上都是在搞两件事——管理对象的创建、管理对象之间的关系。你把这篇文章里的知识消化透,看Spring的Bean生命周期会清楚很多,看MyBatis的Mapper原理也会顺畅很多。类和对象是Java的骨,别嫌基础,骨头结实了,往上垒什么都不会塌。