
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、反序列化冷门创建方式在面试里特别爱考除了newJava里至少还有三种创建对象的方式反射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(ListString 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的骨别嫌基础骨头结实了往上垒什么都不会塌。