1. 为什么我们总是要跟 null 死磕写 Java 的人几乎没有不被NullPointerException折磨过的。你在代码里写了一个方法调用自信满满地认为传进来的参数肯定有值结果上线第一天就在日志里看到熟悉的红色堆栈。这种问题不解决大数据量系统的稳定性不解决高并发下的性能瓶颈但它就是能在你最意想不到的地方冒出来打乱整个发布的节奏。判空校验也就是 null 安全校验这个需求核心就一句话在对象可能为 null 的地方提前做好防护避免程序运行时出现空指针异常。它的应用场景太广了前端传参校验、数据库查询结果处理、远程接口返回值判断、集合操作前的安全检查几乎每一个跟外部打交道的地方都需要。这篇文章我打算从最原始的if (xxx ! null)写法讲起一直聊到 Java 8 的Optional、工具类封装、以及 Spring 和 Bean Validation 里的注解式判空。适合那些写过一段时间 Java、但还没系统梳理过判空方案的开发者。哪怕你已经用了很久Optional我觉得里面有些细节和踩坑经历对你也会有参考价值。先说个我自己的感受判空这件事难度从来不在“怎么写”而在“在什么地方写、写多厚、写到什么程度”。写少了空指针防不住写多了代码全是防御判断业务逻辑反而看不清了。所以我觉得这篇内容最有价值的部分不是给你堆一堆判空 API而是帮你建立一套“判空设计”的思维。2. 传统判空方式的演进与痛点2.1 最原始的 if 判空写法先看看大多数人入行时写的判空代码长什么样public String getUserName(User user) { if (user ! null) { Address address user.getAddress(); if (address ! null) { return address.getCity(); } } return 未知; }这种逐层if嵌套的写法功能上没错但写多了之后问题很明显。一是代码缩进越来越深逻辑被“套娃”式地包裹读起来费劲二是你很容易漏掉某一层的判空因为完全靠人的记忆去追踪哪个对象可能是 null三是一旦业务逻辑变动比如中间又多了一层关联对象你得小心翼翼地在外层再加一个if稍不注意就破坏了原来的层级关系。我最早写业务代码的时候经常一个方法里连续写五六个嵌套判空。后来代码审查时同事说了一句话让我印象很深“你这个方法不是在写业务是在叠罗汉。”那时候才开始意识到判空写法和业务可读性之间需要找一个平衡点。2.2 卫语句让判空逻辑扁平化既然嵌套影响阅读那第一个改进方向就是把它拍平引入卫语句Guard Clause的模式public String getUserName(User user) { if (user null) { return 未知; } Address address user.getAddress(); if (address null) { return 未知; } return address.getCity(); }这种写法的好处是每一层判断完成之后方法就提前返回了不会再往下走。代码从纵向嵌套变成了横向顺序阅读起来更接近“从上往下讲一个故事”的感觉。我自己后来写判空逻辑基本都优先采用卫语句除非特殊情况否则不写深层嵌套。不过卫语句只是改善了代码结构真正的问题是不断重复的“判空模板”。如果一个项目里到处都是if (xxx null) return ...;其实仍然很枯燥而且很容易出现漏判。于是大家开始想有没有办法把判空这个动作本身给封装掉。2.3 工具类与三元运算符的补充在 Java 8 之前很多人会用 Apache Commons Lang 或 Guava 的工具类来简化判空。比如判断字符串是否为空if (StringUtils.isBlank(str)) { // 处理空字符串逻辑 }对比原生的写法if (str null || str.length() 0) { // 处理空字符串逻辑 }工具类确实省了不少事尤其像StringUtils.isBlank()这种还帮你把空格的情况考虑进去了。但是如果你项目里没有引入这些依赖或者只是偶尔需要对单个对象判空那可能就会选择三元运算符String city (user ! null user.getAddress() ! null) ? user.getAddress().getCity() : 未知;这种写法在对象层级浅的时候很简洁但一旦对象层级深了这个三元表达式会变得很长可读性和维护性同样受影响。而且它在语义上还是“判空然后取值”并没有从根本上减少出错的可能。3. Java 8 的 Optional判空逻辑的一次思路转变3.1 Optional 到底解决了什么问题Java 8 引入Optional的时候社区讨论非常热烈。它的设计初衷是用类型系统来表达“可能为空”的语义让方法的调用者从签名上就能看出返回值不一定存在。比如原来的写法public String findCityCode(String userId) { User user userRepository.findById(userId); if (user ! null user.getAddress() ! null) { return user.getAddress().getCityCode(); } return null; }改成 Optional 之后public OptionalString findCityCode(String userId) { return userRepository.findById(userId) .map(User::getAddress) .map(Address::getCityCode); }调用方拿到OptionalString就必须显式处理“没有值”的情况不再像以前那样直接拿去用然后等空指针。这也是 Optional 最大的价值它把“可能没有值”这件事从注释和约定变成了类型层面的表达。但这里我也要泼一盆冷水Optional 并不是用来取代所有判空的银弹。它更适合用在方法返回值、流式处理的场景。如果拿来包装实体类的字段或者方法参数反而会增加代码复杂度。比如下面这种用法我强烈不建议public class User { private OptionalString name; // 不推荐 }实体类的字段本来就应该允许为 null用 Optional 去包装只会让序列化、ORM 映射都变得麻烦起来。而且get()方法如果取不到值照样会抛NoSuchElementException本质上也还是空指针那类问题。3.2 map、flatMap 与 orElse 系列的差异Optional 的核心方法不多但每个方法用错场景的情况还挺常见。我跟大家梳理几个关键区别。首先是map和flatMap。map的映射函数返回值会被自动包一层 Optional而flatMap要求你返回的本身就是一个 Optional。举个例子OptionalUser userOpt Optional.ofNullable(user); // map 的返回值会被包装 OptionalString nameOpt userOpt.map(u - u.getName()); // flatMap 要求你自己返回 Optional OptionalString nameOpt2 userOpt.flatMap(u - Optional.ofNullable(u.getName()));如果你在map里返回的字段也可能是 null那结果会是一个OptionalOptionalString取值就麻烦了。这时候应该用flatMap搭配Optional.ofNullable()把它展平。然后是orElse、orElseGet和orElseThrow的区别。orElse(T)的参数是直接传一个默认值不管 Optional 里有没有值这个默认值对象其实已经被创建好了orElseGet(Supplier)是延迟计算的只有 Optional 为空的时候才会执行 Supplier 获取默认值orElseThrow(Supplier)则是为空时抛出你指定的异常。有一个经典的坑如果默认值的构造代价很高比如查数据库、发远程调用用orElse就会造成不必要的性能浪费。正确做法是用orElseGet// 不推荐不管是否为空都会执行 getDefaultConfig() Config config configOpt.orElse(loadDefaultConfigFromRemote()); // 推荐只有为空时才远程获取 Config config configOpt.orElseGet(() - loadDefaultConfigFromRemote());我之前排查过一个线上接口超时的问题最后定位到就是某处判空用了orElse传了一个远程调用作为默认值结果每次请求都白白触发一次远程访问。这种细节不注意性能就是这么一点一点被吃掉的。3.3 用 Optional 重构嵌套判空的实战示例拿前面的用户地址示例来做个对比用 Optional 改造之后public String getUserCity(User user) { return Optional.ofNullable(user) .map(User::getAddress) .map(Address::getCity) .orElse(未知); }四行代码搞定中间任何一层为 null 都会直接落到orElse的兜底逻辑上。比起原来两层 if 嵌套这种写法几乎不会漏判可读性也更好。如果你还需要在取不到值时抛出一个业务异常可以这样public String getUserCity(User user) { return Optional.ofNullable(user) .map(User::getAddress) .map(Address::getCity) .orElseThrow(() - new BusinessException(用户地址信息不完整)); }这里需要注意Optional.ofNullable(user)的参数可以为 null所以适合用来包装不确定的外部传入对象。如果确定对象非空只是要包一层方便链式调用可以用Optional.of()但一旦传入 null它会立刻抛空指针。4. 从判空到防御式编程Objects、注解与自定义封装4.1 Objects.requireNonNull 的正确打开方式JDK 7 开始提供了Objects.requireNonNull它的用途有点特殊不是用来做“优雅兜底”的而是用来快速失败的。什么意思呢就是当参数不该为 null 却传入了 null 时主动抛异常并给出明确提示而不是让问题在后续某个角落才暴露。public void saveUser(User user) { Objects.requireNonNull(user, User 不能为空); // 后续业务逻辑 }如果这里传入 null会抛出NullPointerException同时异常信息里带上“User 不能为空”定位问题非常直接。它的价值在于把参数的隐性约束变成显式校验让方法的调用方第一时间知道自己的调用有问题。我在团队的代码规范里比较推荐这种做法对于内部方法、私有方法、或者前后端约定好的必传参数用Objects.requireNonNull直接拦截对于外部输入如接口入参则用 Bean Validation 的注解去做校验。两种方式各司其职。4.2 NotNull 注解与 Bean Validation 的配合如果你的项目使用 Spring Boot或者引入了jakarta.validation相关依赖那注解式判空是更好的选择。最常见的做法是在 DTO 或请求对象上标注校验注解public class UserCreateRequest { NotNull(message 用户名不能为空) private String username; NotNull(message 部门ID不能为空) private Long departmentId; Email private String email; }然后在 Controller 入口加上Valid或ValidatedPostMapping(/user) public ResultVoid createUser(Valid RequestBody UserCreateRequest request) { // 走到这里说明基础校验已经通过 userService.createUser(request); return Result.success(); }配合全局异常处理器可以把校验失败的信息统一返回给前端。这样做的好处是判空逻辑从业务代码中剥离出去变成了声明式的约束。你不需要在每个 Service 方法里再手写一遍“参数是否为空”的判断因为框架层已经帮你处理了。不过这里也有个容易忽略的点NotNull只适用于对象类型如果参数是int、long这类基本类型即使你没传值它也会被默认初始化为 0NotNull根本不会报错。所以对于这种场景要么把参数类型改成包装类要么在业务层额外校验数值的合法性。4.3 自己封装一个可复用的判空工具类项目里如果相同类型的判空逻辑反复出现我建议自己封装一个轻量工具类不要满项目贴胶带。比如我们项目里有一个通用校验类最常被调用的两个方法长这样public final class CheckUtils { private CheckUtils() { } public static T T requireNonNull(T obj, String message) { if (obj null) { throw new IllegalArgumentException(message); } return obj; } public static boolean isAnyEmpty(CharSequence... values) { if (values null || values.length 0) { return true; } for (CharSequence value : values) { if (value null || value.length() 0) { return true; } } return false; } }用起来就是CheckUtils.requireNonNull(file, 上传文件不能为空); if (CheckUtils.isAnyEmpty(username, password, captcha)) { throw new BusinessException(必填参数不能为空); }可能有人觉得这跟直接用Objects.requireNonNull差不多为什么要多包一层我的理由是Objects.requireNonNull抛的是NullPointerException语义上指向“程序 bug”而业务参数校验更适合抛IllegalArgumentException或自定义异常语义上指向“调用方传参不当”。封装一层主要是为了统一团队的异常语义同时可以把一些常用的“多参数同时校验”的逻辑收敛到一起。4.4 Spring 的 Assert 系列与注解的取舍如果你在 Spring 项目里org.springframework.util.Assert也是一个可用方案。它提供了notNull、hasText、isTrue等系列静态方法写起来很简洁Assert.notNull(userId, userId 不能为空); Assert.hasText(userName, userName 不能为空);不过Assert的异常类型是IllegalArgumentException而且它是框架层的工具。如果你不想业务层跟 Spring 耦合太深或者项目并不是 Spring 技术栈那就更推荐你用自研工具类或者 Bean Validation 方案。我个人对这几个方案的取舍是这样的场景推荐方案原因内部方法参数必传校验Objects.requireNonNull 或自研工具类快速失败语义清晰外部接口入参校验Bean Validation 注解声明式约束不侵入业务代码字符串或集合空值判断工具类如 StringUtils简化重复模板代码链式处理可能为 null 的对象Optional 流式 API代码简洁天然防漏判5. 实战中的常见问题与避坑经验5.1 Optional 用错位置导致的隐藏风险我见过不少同事把 Optional 用在不该用的地方最典型的是把Optional作为方法参数类型public void processOrder(OptionalOrder orderOpt) { Order order orderOpt.orElseThrow(...); // ... }这种写法有一个问题调用方仍然可以传null进来你在方法内如果不做判空orderOpt.orElseThrow照样抛空指针。所以说到底Optional 并不能完全替代判空它只是让“可能为空”这件事变得显眼了一些。你真正要做的是在系统边界处做好校验内部方法尽量保证传入对象非空而不是到处包 Optional。另外在序列化场景下如果实体字段用了 Optional 包装很多 JSON 框架处理起来并不友好。有的会序列化成{name: {present: true, value: xxx}}这样的结构完全不是前端预期。所以我的建议非常明确Optional 只用于方法返回值不用于字段声明不用于方法参数。5.2 orElse 和 orElseGet 的性能差异前面提过 orElse 的参数会无条件执行这里再深入聊聊实际排查中遇到的一个案例。有个报表导出的功能导出前需要读取一批配置项代码写的是ReportConfig config configCache.get(cacheKey).orElse(loadConfigFromDb(configId));看起来没什么问题但线上流量一上来数据库压力突然变大。后来查监控发现loadConfigFromDb的调用量远超预期。原因是cacheKey对应缓存里没有值的时候orElse确实会执行兜底逻辑但问题是即使缓存里有值loadConfigFromDb这个方法也已经执行过了因为参数传递时方法就运行了。改成orElseGet之后只有configCache.get(cacheKey)返回空 Optional 时才会去数据库查配置数据库压力直接降下来了。这个坑在源码评审阶段很难看出来因为 Java 方法参数的求值时机对很多开发者来说属于“日常忽略项”。我把这条写进来的主要目的就是提醒大家碰到orElse的默认值来自昂贵操作时一定优先考虑orElseGet。5.3 层级校验中容易漏掉的集合与 Map 判空很多人判空只关注对象本身却忽略集合内部的元素也有为 null 的可能。比如ListString tags request.getTags(); if (tags ! null !tags.isEmpty()) { for (String tag : tags) { if (tag.equals(admin)) { // 这里的 tag 可能为 null // ... } } }当tags不为空但是包含null元素时tag.equals(admin)这一行就会抛空指针。很多人会栽在这个地方因为调试的时候往往构造的是正常数据不会刻意往集合里塞 null。稳妥的做法有两种。一种是在遍历前过滤掉 nulltags.stream() .filter(Objects::nonNull) .forEach(tag - { // 业务逻辑 });另一种是判断时把常量放前面admin.equals(tag)这样即使tag为 null也不会抛异常只是条件不成立而已。两种方式按场景选第一种语义更清晰第二种适合简单单值判断。Map 的情况类似map.get(key)返回 null 和map.containsKey(key)返回 false 是两种不同的语义。做判空时要先想清楚你要的到底是“这个 key 不存在”还是“存在但值为 null”。如果值本身就是允许为 null 的那getOrDefault(key, defaultVal)并不能帮你区分情况这一点在需求设计阶段就要想清楚。5.4 一个完整的判空设计思路案例最后用一个稍微综合一点的例子收尾吧。假设要做一个用户信息聚合查询接口输入 userId输出包括用户基本信息、绑定地址、最近订单信息。其中任何一块数据缺失都不能导致接口失败而是返回“信息不完整”的标记。如果全部用传统 if 判空这个方法会非常长而且每加一类新数据都要改动主流程。我的做法是先把每一块数据的获取逻辑封装成独立方法返回值统一用 Optional然后在主流程里聚合处理public UserAggregateVO getUserAggregate(Long userId) { if (userId null) { throw new BusinessException(userId 不能为空); } OptionalUser userOpt getUserBasicInfo(userId); OptionalAddress addressOpt getBoundAddress(userId); OptionalOrder latestOrderOpt getLatestOrder(userId); boolean complete userOpt.isPresent() addressOpt.isPresent() latestOrderOpt.isPresent(); UserAggregateVO vo new UserAggregateVO(); vo.setUser(userOpt.orElse(null)); vo.setAddress(addressOpt.orElse(null)); vo.setLatestOrder(latestOrderOpt.orElse(null)); vo.setIncomplete(!complete); return vo; }这个设计里有三个点值得说明一下。第一边界入口处也就是方法开头先对userId做必传校验保证后续逻辑的输入是可靠的。第二每个子查询方法内部自行处理自己的“查不到”逻辑返回 Optional谁拿到谁决定怎么做职责清晰。第三聚合层用orElse(null)把缺失字段置空再用一个complete标志表达整体完整性。前端拿到incompletetrue时就知道数据不完整而不是一次空指针把整个接口打挂。这个方案不是唯一的但它体现了判空设计的一个核心思路把判空下沉到数据获取层在聚合层做统一兜底避免在主流程里到处打补丁。我个人实际写代码的时候还有一个习惯对于那种“理论上永远不会为 null”的值我反而会格外小心。因为越是这么想的地方越容易在将来某次改动后变成 null。防御式编程的核心原则可以概括成一句话不要信任你看不到来源的数据尤其是来自外部系统、配置文件、数据库的数据。判空校验不是代码的负担而是你系统稳定性的基石。写的时候花一分钟上线后能少熬好几个小时的夜。