ARTICLE DETAIL

资讯详情

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

理解Java对象与对象变量:从构造器到空指针的完整指南

理解Java对象与对象变量:从构造器到空指针的完整指南 先问个面试必考题User user new User()这行代码里user到底是对象还是只是指向对象的“遥控器”十个新手里面至少有八个会脱口而出“user就是对象”。等我把内存图画出来再让他打印一下user输出的是com.example.User1b6d3586这种地址串他就愣住了。没错这就是我今天想聊透的一个主题——Java 对象与对象变量再加上构造器和空指针这两个绕不开的老朋友。你会发现把这几个概念串成一条线之后很多你背过的面试八股文、写起来别扭的代码、莫名奇妙的空指针异常全都能用同一套逻辑解释清楚。这篇文章适合刚学 Java 基础、准备校招面试、或者工作两三年但总觉得基础不牢的朋友。我会把这套东西讲成一条线先搞懂对象变量和对象的本质区别再弄明白构造器在整个对象创建流程里到底干了什么接着动手写一个完整案例最后把空指针的几种典型来源和系统防坑打法全部拆开。全程用大白话加可复现的代码看完你再去刷那些“Java 对象组成”“对象的创建”“判断对象为空”的热搜词会轻松很多。1. 先搞清楚对象和对象变量到底是不是一回事1.1 新手最容易栽跟头的概念混淆我见过太多人在初学阶段把对象和对象变量混为一谈代码倒是能跑通但一被追问就露馅。先明确一个结论对象是实实在在存在堆内存里的一块数据而对象变量只是一个保存了对象地址的引用。你可以把对象想象成一间真正存在的房子对象变量则是写有房子地址的纸条。纸条不是房子拿到纸条也不等于拥有房子你只是知道怎么找到它而已。User user new User()做的事情就两件new User()在堆内存里盖了一间房user这张纸条上写了这间房的地址。明白了这个之后很多现象就很好解释了。比如System.out.println(user)打印出来的是com.example.User1b6d3586这个后面的十六进制串就是对象在 JVM 里的 hashCode 的一种默认表示本质上就是给你看的“地址凭证”相当于纸条上的门牌号。再比如两个对象变量用比较比的不是房子里面的内容而是两张纸条上的门牌号是不是同一个。这也就是为什么两个对象的属性一模一样出来的结果却是false——因为在堆里就是两间不同的房子哪怕装修一模一样。1.2 赋值时到底复制了什么再看这个高频场景User a new User(张三, 25); User b a;这里b a复制的是地址不是对象。复制完之后a和b指向同一个堆对象改b的属性a看着也会变。这就是所谓的“引用传递”陷阱也是对象数组去重、对象集合拷贝里最容易出 bug 的根因。如果你真想拷贝一个独立的对象得走“深拷贝”路线比如手动创建新对象再逐字段赋值或者用 Serializable 转字节流再反序列化又或者借助 JSON 序列化工具拷一份出来。但从原理上说只要你明白了“赋值复制的是地址而非房子”你就不会再犯那种“改了 b 结果 a 也变了”的低级错误。方法传参也是一样的逻辑public static void changeName(User user) { user.setName(李四); }传进去的是地址副本方法里通过这个地址改的是同一个对象所以方法执行完原来的user名字也会变。但如果你在方法里写user new User(...)那只是把方法内部的纸条换了张新的外面的变量纹丝不动。这点在面试里也是老熟脸了。2. 构造器对象从无到有的那扇门2.1 不写构造器Java 会给你默认一个很多人从第一天写 Java 起就在用构造器但构造器到底承担了什么职责却没有认真想过。构造器的职责是在对象刚出生时完成必要的初始化动作让对象处于一个可用的初始状态。它的名字必须和类名一模一样没有返回值类型连void都不能写。如果你一个构造器都没有定义Java 编译器会贴心地给你插入一个无参构造器相当于一个空壳子类里面所有字段保持默认值数值类型是 0布尔类型是false引用类型是null。但注意一个经典坑如果你写了任何带参构造器编译器就不会再给你默认无参构造器了。这时候如果你还尝试new User()直接编译报错。很多框架比如 Spring、MyBatis底层在反射创建对象时经常需要通过无参构造器这也是为什么业界有个不成文的习惯——写了带参构造器最好同时显式补一个无参构造器。2.2 构造器重载与 this() 调用构造器也可以重载参数列表不同代表多种创建姿势。比如public class User { private String name; private int age; public User() { // 无参构造器 } public User(String name) { this.name name; } public User(String name, int age) { this(name); // 必须先调用本类其他构造器 this.age age; } }这里面的this(name)是本类构造器之间的互相调用规则很严格必须放在构造器第一行。原因是Java 要求对象必须先完成基础的构造流程才能继续执行其他初始化逻辑顺序反了类加载器不认。有了这种链式构造你会发现公共的初始化逻辑只需要写一份其他构造器一圈一圈往里套。比如最完整的三参构造器负责真正干活二参、一参构造器给它补默认值。代码维护瞬间清爽。2.3 初始化顺序和代码块执行时机这是很多面试八股文里专门抠的细节一个对象被new出来内部的初始化顺序到底怎么走我直接给你一套可验证的结论先执行父类静态代码块如果类还没加载。再执行本类静态代码块。分配堆内存给字段设置默认值。调用构造器但先从上到下执行实例代码块再按字段声明顺序执行字段初始化。最后才进入构造器体执行里面的代码。也就是说实例代码块比构造器体先执行字段初始化也是在这两者之间。很多老手利用这个特性把一些统一的初始化动作丢进实例代码块避免多个构造器里重复写。我建议你用一段小代码实测一下比背十遍都管用public class Test { public Test() { System.out.println(构造器); } { System.out.println(实例代码块); } static { System.out.println(静态代码块); } public static void main(String[] args) { new Test(); new Test(); } }输出顺序是静态代码块只执行一次、实例代码块、构造器、实例代码块、构造器。跑一遍你就有肌肉记忆了。3. 从 0 到 1 手写一个完整案例让对象真正“活”起来3.1 设计一个带校验的用户对象理论说得再多不如手敲一个完整的例子。我们来设计一个更贴近真实业务场景的User类把构造器、校验、判空、对象数组去重全都整合进去。import java.util.Objects; public class User { private final String id; // 唯一标识一旦创建不可修改 private String name; private int age; // 主构造器负责真正干活带参数校验 public User(String id, String name, int age) { if (id null || id.isBlank()) { throw new IllegalArgumentException(id 不能为空); } if (name null || name.isBlank()) { throw new IllegalArgumentException(name 不能为空); } if (age 0 || age 150) { throw new IllegalArgumentException(age 必须在 0~150 之间); } this.id id; this.name name; this.age age; System.out.println(User 创建成功: this); } // 便捷构造器只给 id 和 name默认年龄传 0 public User(String id, String name) { this(id, name, 0); } // 无参构造器保留出来以便反射和后续测试 public User() { this(UNKNOWN, 未命名, -1); } public String getId() { return id; } public String getName() { return name; } public void setName(String name) { this.name name; } public int getAge() { return age; } public void setAge(int age) { this.age age; } // 重写 equals 和 hashCode让“内容相同”的对象在集合里能正确去重 Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; User user (User) o; return age user.age Objects.equals(id, user.id) Objects.equals(name, user.name); } Override public int hashCode() { return Objects.hash(id, name, age); } Override public String toString() { return User{id id , name name , age age }; } }这个类里有几个值得说的点。id我加了个final因为业务上用户 ID 创建之后不应该再变。构造器里我做了一堆防御性校验目的只有一个让非法对象在出生那一刻就被拦下来而不是带着一个null名字或者负数年龄在系统里活着等用到的时候再炸。这比到处写if (user.getName() ! null)要优雅得多。3.2 构造器参数校验的实操细节你可能觉得构造器里做校验有点小题大做但我告诉你这正是老手和新手的分水岭。新手喜欢让对象裸奔等数据传到 Service 层再检查老手在对象入口就把好关从源头保证后续逻辑不用再做重复检查。校验方式可以分三种层次主动 if 判断 抛异常最简单直观适合校验逻辑简单、项目里没有现成校验框架的场景。上面的代码就是这种。使用 Java Bean ValidationJSR 380在字段上标注NotNull、NotBlank、Min、Max然后由框架统一校验。Spring Boot 项目里非常常见配合Validated注解请求进来就能拦掉不合法数据。断言assert只适合开发和测试阶段生产环境默认关闭不建议作为唯一防线。我的建议是在系统边界Controller 入参、消息队列消费、外部接口调用用框架校验在业务对象创建时用最朴素的 if 抛异常双保险。不要把希望全部寄托在某一种方案上。3.3 对象数组与集合操作去重和判空回到热搜词里的“对象数组去重”。很多人第一反应是两层 for 循环遍历遇到相同的就删。这种写法不是不行但时间复杂度 O(n²)代码还丑。既然我们已经正确重写了equals和hashCode去重就变得非常干净了。import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashSet; import java.util.List; import java.util.stream.Collectors; public class UserDemo { public static void main(String[] args) { ListUser userList new ArrayList(); userList.add(new User(1, 张三, 25)); userList.add(new User(1, 张三, 25)); userList.add(new User(2, 李四, 30)); userList.add(new User(3, 王五, 22)); // 方式一利用 LinkedHashSet保持顺序去重 ListUser distinctUsers new ArrayList(new LinkedHashSet(userList)); // 方式二Stream 去重同样依赖于 equals/hashCode ListUser streamDistinct userList.stream() .distinct() .collect(Collectors.toList()); System.out.println(去重前数量: userList.size()); System.out.println(LinkedHashSet 去重后数量: distinctUsers.size()); System.out.println(Stream 去重后数量: streamDistinct.size()); } }注意去重能否生效完全取决于equals和hashCode是不是重写对了。如果这两个方法没重写HashSet和distinct()内部拿到的是“门牌号不同所以对象不同”的判断你去重一万次也去不掉“内容相同但地址不同”的对象。再来说判空。代码里经常要判断一个集合是不是空列表错误姿势是直接用list null然后继续遍历或者只判断了null没判断 size。规范写法我给个模板public static boolean isEmpty(List? list) { return list null || list.isEmpty(); }同理字符串判空建议直接用org.apache.commons.lang3.StringUtils.isBlank()或者 JDK11 的String.isBlank()把null、空串、纯空格一次搞定。核心思想就一句话判断为空永远把null判断和“空内容”判断绑在一起。4. 空指针的根源与系统性避坑4.1 空指针到底从哪里冒出来空指针异常NPE是 Java 世界里出现频率最高的异常没有之一。很多初学者处理 NPE 就是看日志哪一行报错就加一个if (xx ! null)治标不治本。想要根治得先弄清楚它到底是从哪冒出来的。我总结的 NPE 四个主要来源自动拆箱包装类型Integer是null时赋值给基本类型int会触发自动拆箱直接炸。比如Integer count null; int c count;。方法调用链上某个中间对象为 nulluser.getAddress().getCity()getAddress()返回了null再调getCity()就炸。集合里的元素为 null从数据库或者外部接口查回来的数据某个对象本身是null你硬调它的方法。静态方法或工具类入参为 null比如把null传进一个不检查参数的第三方库方法对方内部访问了成员变量直接抛 NPE。理解这些来源之后会发现NPE 的本质不是“忘了判断”而是对象在没被正确初始化的状态下就被拿来使用了。所以解决办法也分两层第一层是尽量让对象不可能为 null第二层是不得不为 null 的时候用安全的方式去访问。4.2 判断对象为空的最佳实践对比先看两个最常见的判空姿势对比// 反面教材直接用 null 然后手动写 equals if (str ! null str.equals(abc)) { // ... } // 正面教材把常量放前面或者用 Objects.equals if (abc.equals(str)) { // ... } if (Objects.equals(str, abc)) { // ... }str.equals(abc)是绝大多数人写过的代码问题是当str为null的时候这句代码直接 NPE。把常量放前面写成abc.equals(str)就是一个非常经典的非空安全写法因为常量池里的字符串永远不会是null。从 JDK7 开始更推荐直接使用Objects.equals它内部帮你做了null判断不会抛异常。再来对比和equals。String 这个类比较特殊比较的是地址equals比较的是内容。但如果一个String变量是通过new String(abc)创建的哪怕内容一样也是false。这些年 Java 面试特别爱考这个本质还是回到了第一节的知识比门牌号equals比房子内部装修。我在实践中养成了一个习惯项目里所有判断“值是否相等”的地方统一使用Objects.equals判断“是不是同一个对象”才用而判断“对象是否为空”则单独封装一个工具类。规则统一之后团队代码 review 的效率会高很多因为每个人都不需要停下来琢磨这一行为什么用。4.3 Optional 的正确打开方式JDK8 引入的Optional本意是让你用来优雅地处理可能为空的对象结果很多人在实际项目里用歪了写出了比if (xx ! null)更啰嗦的代码。先看一个正确的业务场景public User findUser(String id) { User user userDao.queryById(id); return Optional.ofNullable(user).orElseThrow(() - new RuntimeException(用户不存在)); }再看一个典型的错误用法OptionalUser optionalUser Optional.ofNullable(user); if (optionalUser.isPresent()) { System.out.println(optionalUser.get().getName()); }这跟直接写if (user ! null)有什么区别完全没区别只是把代码绕了个弯还多了一层Optional包装性能和可读性双双下降。真正用好Optional核心是用它的三个方法orElse(defaultValue)为空时返回默认值。orElseGet(Supplier)为空时惰性计算默认值。orElseThrow(Supplier)为空时抛出指定异常。另外一个容易踩的坑是orElse和orElseGet的区别。orElse的参数是一个已经计算好的值哪怕Optional不为空也会参与计算orElseGet的参数是一个Supplier函数式接口只有真为空时才会调用。比如默认值对象创建开销很大就千万不能用orElse否则每次调用都会白白构建一个默认对象白白浪费性能。// orElse即使 user 不为空defaultUser 也已经创建了 User result Optional.ofNullable(user).orElse(new User(0, 默认用户, 0)); // orElseGet只有 user 为空才会去创建默认用户 User result2 Optional.ofNullable(user).orElseGet(() - new User(0, 默认用户, 0));什么场景适合用Optional我总结为结果可能为空、调用方需要给出默认处理策略的时候。比如从 Map 里取值、从缓存查数据、解析外部接口返回体。什么场景不适合字段定义、集合元素、方法参数这几个地方老老实实用可空性标注或判空不要用Optional包一层否则代码会变成洋葱越剥越难受。4.4 防御式编程把空指针扼杀在源头最后这条是我想重点强调的实战心得。空指针的真正解法不在于到处都是if (xx ! null)而在于通过设计让空值根本没有出现的空间。具体打法有四条构造器兜底校验像我们上面User类那样关键字段在构造器里就校验非法值直接进不来。集合初始化为空集合字段直接声明private ListString tags new ArrayList();而不是默认null这样调用方永远不需要担心tags本身是null。返回值约定方法返回值如果可能为空要么return Collections.emptyList()要么明确返回Optional并在文档注释里写清楚调用方期望如何处理。方法入参立即检查进入方法第一步做Objects.requireNonNull(param, param 不能为空)让问题在入口暴露而不是到第 50 行才炸出一个让人摸不着头脑的 NPE。我有一次接手一个老项目里面大量字段都是天然可空的代码里到处是三层判空 catch 吞异常看得脑壳疼。后来我花了一周时间把核心链路对象都改成“构造时必填”空值从源头消失空指针异常明显减少。这就是防御式编程的价值——不是擦屁股而是从入口杜绝脏数据。5. 常见问题速查与独家心得5.1 现场排查 NPE 的实操思路真到生产环境上线日志里甩过来一个 NullPointerException你要怎么快速定位我的排查顺序是固定的效率比看到哪行改哪行高得多。第一步看异常堆栈的最顶层找到at com.example.XxxClass.methodName(XxxClass.java:88)那一行那才是真正抛错的位置。别盯着下面那一串 Spring 框架的堆栈看那只是让你顺着调用链找入口用的。第二步打开那个类第 88 行的代码看这一行里出现了几个对象变量。逐个判断哪个可能是null。判断依据很简单从数据库查出来的、从外部接口拿到的、作为方法参数传进来的、集合里取出来的这些统统默认怀疑为 null。第三步如果找不到就把这行代码涉及的对象产生的路径理一遍。比如是getUser()返回的null那就要去getUser()的实现里看它的返回值来自哪里一层一层往上找。我强烈建议在 IDE 里调试时条件断点直接写user null这样程序执行到那里断下来的时候IDE 会直接显示这个对象为 null避免你两眼一抹黑。5.2 面试和工作中最容易被问到的几个变种整理几个高频变种基本都是同一个 NPE 主题换着花样考场景一自动拆箱导致 NPEpublic class Test { private static Integer num; public static void main(String[] args) { int n num; // NPE } }num没有赋值默认是null赋值给基本类型int时自动拆箱直接炸。这里我没加任何打印就是因为死在了拆箱上。场景二三元运算符的类型隐式转换MapString, Boolean map new HashMap(); map.put(flag, null); Boolean b map.containsKey(flag) ? map.get(flag) : false;这段代码在某些 JDK 版本下也会报 NPE原因在于三元运算符的两个分支在计算时做了类型统一null被自动拆箱成基本类型boolean就炸了。这类问题在线上出现过多次排查起来很隐蔽。场景三Optional 的连环组合Optional.ofNullable(user) .map(User::getAddress) // 如果 user 为 null这行不会炸 .map(Address::getCity) // 如果 address 为 null这行也不会炸 .orElse(未知城市); // 任何一步为空直接给默认值这个写法在链式调用中非常安全map阶段遇到空值会直接跳过最后统一走orElse。相比手工一层一层if代码简洁得多也是面试官比较喜欢的答案。场景四字符串 equals 反着写String type getType(); if (type.equals(admin)) { // 如果 type 为 nullNPE // ... }正确写法用admin.equals(type)或Objects.equals(type, admin)。我把这些常见问题整理成一张表方便你随手翻阅场景报错原因推荐解法Integer自动拆箱赋值给int包装类型为 null拆箱抛 NPE先判空再拆箱或用Optional.map链式方法调用中间对象为 nulla.getB().getC()中 B 对象为 null每一级判空或重构为 Optional 链式字符串变量调用equals变量为 null常量放前面或Objects.equals三元运算符类型隐式转换null 参与基本类型转换避免在此场景使用包装类集合元素直接调方法元素本身为 null入集合前排除 null或取出后判空构造器未对字段初始化字段默认为 null构造器或字段声明处赋初始值5.3 我给新手的两条实用建议第一对 IDE 的提示和代码体检工具保持敏感。IntelliJ IDEA 会在你写str.equals(abc)时给出警告鼠标移上去会提示Method invocation equals will produce NullPointerException。看到这种黄色下划线不要忽略顺手改掉等于免费带了个老师傅在指导你。第二从今天开始给每个新写的类都认真设计好构造器。哪怕只是练手项目也当成生产代码来写。这个动作花不了几分钟但它会强制你思考“一个合法的对象应该长什么样”这就是从“会写 Java”走向“写好 Java”的标志。关于对象与对象变量、构造器、空指针这整条知识线我自己从学 Java 到工作多年反复在这个基础区域栽过跟头也总结出很多实用的坑和填坑技巧。希望这篇分享能帮你少走一些弯路让你写代码的时候对每一个对象变量背后是什么心里有一本明账。
返回列表