写代码这么多年,最烦看见的不是复杂业务逻辑,而是层层嵌套的判空。一打开同事的类,七八个if (xxx != null)像俄罗斯套娃一样叠在一起,看的人脑壳疼。更离谱的是,有时候费劲巴拉判了半天,业务代码还没写几行。你一定也经历过这种时刻:心里默默吐槽一句“瞧瞧别人家的判空,那叫一个优雅”,然后默默关掉代码,假装无事发生。
其实判空这件事,真不是“加上一个 if 就完事”那么简单。它背后反映的是代码设计习惯、工具箱的熟练度,以及对边界条件的理解。同一个需求,有人能写出像柳岩一样的代码,有人写出来就像钢筋混凝土——能用,但看着难受。这篇就好好聊聊判空的进化史、各种方案之间的取舍、以及我踩过的那些坑,保证你看完能直接用在自己的项目里。
1. 判空需求还原:一个“小判断”背后的大问题
1.1 为什么救空能把代码写成一团浆糊
先别急着学优雅写法,得先搞明白丑代码丑在哪。你在项目里天天见到的那种“丑判空”,大概率长这样:一个方法里先判user != null,再判user.getAddress() != null,然后判address.getCity() != null,最后取city.getName()。整个过程下来,你写了七八行代码,实际有效逻辑就一行。
这种代码的痛点不只是看着乱。最核心的问题是,每一次判空都在消耗你的注意力,你被迫关心“这个值到底会不会是 null”这种本该由调用方保证的事。业务的复杂度被空指针焦虑取代,核心流程被淹没在防御逻辑里。真正优雅的判空,不是消灭 if,而是让判空的代码从主流程里“消失”——要么用工具类把样板代码收起来,要么用 Optional 把操作串起来,要么在入口处就直接拦截掉。
我见过很多团队,代码评审时大家就盯着功能对不对,没人管这块判空是不是合理。结果 3 个月后需求一变,面对一坨嵌套 if,谁都懒得改,最后只能复制粘贴出一个新的判断分支,代码腐化就是这么开始的。所以,判空绝对不是一个可以一笔带过的小问题。
1.2 不同场景的判空姿势完全不同
判空也分三六九等。你写一个工具类方法,入参是String name,和写一个接收前端 JSON 的 Controller 方法,判空策略完全不一样。工具类里你讲究空安全(null-safe),传入 null 要返回合理默认值或抛出明确异常;Controller 入口你讲究校验前置,参数不对劲就直接 400 或 422,压根不能让它进入到业务层。
还有数据库查询的结果集、第三方接口返回的包装对象、配置中心读取的配置项,每一类的判空侧重点都不同。新手容易犯的错误就是一把梭:所有的判空都用同一个套路,要么全部 if,要么全部 Optional。真正的资深工程师写代码,脑子里是有一张“判空地图”的——什么位置适合快速失败,什么位置适合兜底容错,什么位置适合静默跳过。这个地图不是天生的,是踩坑踩出来的。
1.3 判空方案进化:从 if 到工具类再到 Optional
Java 世界里的判空方案,其实一直在进化。上古时期就是纯if (obj != null),要多啰嗦有多啰嗦。后来 Apache Commons、Guava 这些工具库火了,大家开始用StringUtils.isNotEmpty、CollectionUtils.isEmpty,一下子清爽不少,但对象嵌套判空还是老样子。Java 8 引入Optional之后,大家以为救世主来了,结果很多人又玩脱了——用 Optional 是因为它“看起来高级”,结果写出来的代码比 if 还难懂。
我个人的观点:判空方案的选型一定要跟着场景走。单值判空,Optional 是好东西,但仅限链式调用和收敛返回;集合和字符串判空,工具类是永远的神,别自己写;对象字段校验,注解校验是正道,省得你到处埋雷;不需要继续处理的分支,早点 return 才是最好的判空。把这几条原则记在心里,基本就不会翻车。
2. 核心细节拆解:单值判空与 Optional 的优雅边界
2.1 单个对象的判空:从优雅到玄学
先拿最常见的场景开刀:从 Service 层查了一个对象出来,可能是 null,你要不要处理?很多人第一反应是 if 判空,这没错,但不够好看。初级版长这样:
User user = userMapper.selectById(id); if (user != null) { return user.getName(); } return "默认用户";这个写法没啥毛病,就是有点低下。默认值逻辑被塞在 if 里,主流程反而不直观。稍微好一点的写法是“卫语句”风格,先把 null 的情况干掉,正常逻辑平铺在后面:
User user = userMapper.selectById(id); if (user == null) { return "默认用户"; } return user.getName();可别小看这一个小小的改动,代码的可读性提升是肉眼可见的。面试的时候,很多人连这一点都做不到,但如果你在代码评审时这么提出来,大家对专业性的感知会立刻不一样。“卫语句”的精髓就是:把异常、空值、边界条件这些跟主流程无关的判断,全部提前拦截掉。
2.2 Optional 链式调用:真正的省心写法
接下来要上主角了。假设你的需求是“根据用户 id 查询他所在城市的名称,查不到就返回‘未知’”。用纯 if 写,你得一层层判 user、address、city,至少 9 行代码。用 Optional,一行搞定:
String cityName = Optional.ofNullable(userMapper.selectById(id)) .map(User::getAddress) .map(Address::getCity) .map(City::getName) .orElse("未知");这才是别人家代码的感觉。三重判空被压缩成一个方法链,每一步只要前一个值不是 null,就继续往下走,哪一环是 null,直接落到orElse。业务语义非常清晰:有值就取,没有就用默认值。
但这里有个巨大的天坑,我见过无数人踩:很多人分不清orElse和orElseGet的区别。直接说结论:如果默认值的计算有开销,一定要用orElseGet,因为它是一个Supplier,只在 Optional 真的为空时才执行;orElse的参数是一个已经算好的值,也就是不管有没有值,这段代码都会执行。
// 糟糕:defaultUser() 无论如何都会执行 User user = Optional.ofNullable(userMapper.selectById(id)) .orElse(createDefaultUser()); // 靠谱:defaultUser() 只有在真正为 null 时才执行 User user = Optional.ofNullable(userMapper.selectById(id)) .orElseGet(this::createDefaultUser);我当年在公司的用户服务里就踩过这个坑,orElse里放了一个查数据库构建默认用户的逻辑,结果每次有值的请求也白白执行了一次查询。压测的时候才发现 TPS 不对,一查全是这个。这个细节,优化一次性能就能追回好几个点的改善。
2.3 优雅判空不是玄学,是有迹可循的方法
很多人看完 Optional 的链式调用,迫不及待把项目里的所有 if 判空都改成 Optional。我劝你冷静。Java 的 Optional 本来设计为返回值使用,不是让你拿去当字段、当方法参数到处传的。而且 Optional 本身也有性能和可读性成本,硬改反而灾难。
比如下面这种“为了 Optional 而 Optional”的写法,就非常抽象:
public void process(Optional<User> user) { User u = user.orElseThrow(() -> new IllegalArgumentException("user is required")); // ... }调用方本来传一个 User 就行,被你硬生生要求包一层 Optional,这不叫优雅,叫过度设计。真正值得用 Optional 的场景,一定是你需要做连续取值、逐级判空,或者需要把“没有值”这个状态优雅地传导到下游。单个对象的简单判空,直接卫语句是最快最稳的。
3. 实操硬货:集合与字符串判空的正确工具选择
3.1 StringUtils 门派林立,选对才是关键
单值对象聊完,再来看看字符串和集合这两个日常碾压我们耐心的家伙。很多码农写的字符串判空是这样的:
if (str != null && !str.isEmpty()) { // ... }能用,但真的是“土法炼钢”。同样的逻辑用工具类一行搞定:
if (StringUtils.isNotEmpty(str)) { // ... }可你以为这就完了?天真。StringUtils里有isEmpty和isBlank两组方法,看起来差不多,实际天差地别。Apache Commons Lang3 的isEmpty只判断“字符串为 null 或长度为 0”,而isBlank更进一步,会把纯空格、制表符这些“看不见的字符”也当成空处理。你用isEmpty接了一个“ ”(三个空格),根本拦不住。
选型建议:绝大多数前后端交互场景,用isBlank更符合业务预期。因为用户提交的空格、换行、特殊不可见字符,本质上都是无效输入。Spring 自带的StringUtils又不一样,它没有isBlank,只有一个hasText,作用和isBlank差不多。所以你在项目里用hasText或者 commons-lang3 的isBlank都可以,关键是全队用同一个。
下面做个简单对比,方便你记:
| 方法 | 归属 | 判断逻辑 | 适用场景 |
|---|---|---|---|
Objects.isNull(obj) | JDK | 判断是否为 null | 单对象判空,简单直接 |
StringUtils.isEmpty(str) | common-lang3 | null 或长度为 0 | 只关心长度,不关心空格 |
StringUtils.isBlank(str) | common-lang3 | null、长度为 0 或全空白字符 | 用户输入校验、业务非空校验 |
CollectionUtils.isEmpty(list) | spring / commons | null 或 size 为 0 | 集合判空,最常用 |
StringUtils.hasText(str) | spring-core | 存在且包含非空白字符 | Spring 项目里替代 isBlank |
3.2 集合判空的隐藏知识点
集合判空也有讲究。最常见的写法是:
List<User> userList = userMapper.selectByDeptId(deptId); if (userList != null && !userList.isEmpty()) { // ... }用CollectionUtils.isEmpty(Spring 或 Apache Commons 都行)就可以一行搞定:
if (CollectionUtils.isEmpty(userList)) { return; }这里必须提一个很多老手都会忽略的问题:isEmpty()和size() == 0有什么区别?答案是在特定集合实现下性能差异显著。比如ConcurrentLinkedQueue这种无界并发队列,size()是一个 O(n) 操作,需要遍历整个队列去统计元素数量;而isEmpty()只需判断头节点是否为 null,是 O(1)。你在大促场景下用while (queue.size() > 0)这种写法,等于每次循环都 O(n) 一次,并发量一上来,CPU 直接告警。
我的习惯是:集合判空统一用isEmpty()或工具类的isEmpty(),永远不要依赖size() == 0。同时,很多 MyBatis 查询返回的集合在正常情况下不会是 null,但为了防御将来有人改了 SQL 或 Mapper 定义,CollectionUtils.isEmpty这种写法依然建议保留,成本几乎为零,却能挡住一次空指针事故。
3.3 字符串判空在实战中的正确姿势
还有一个非常实际的问题是:拿到一个字符串,想判断它是不是有值,你到底该用哪个?我直接给出自己的判断标准:
- 如果是前端传来的表单值、查询参数,优先
StringUtils.isBlank,因为用户的输入充满了各种不可见的空格。 - 如果是配置中心的值、环境变量,优先
StringUtils.isNotBlank,因为这些值往往来自外部配置,大概率会带换行符或缩进。 - 如果是程序中拼接出来的字符串,用
StringUtils.isNotEmpty就够了,毕竟程序内部不会给你塞空格。
有人会说,这不就是个判空吗,有必要分这么细吗?太有必要了。生产环境里,多少“诡异 bug”最后查出来都是因为一个空格没被识别为“空”,导致逻辑走进了完全错误的分支。把判空策略精细化,是防止这种无聊 bug 的最有效手段。
4. 批量化处理与框架级判空:从源头拦截才是王道
4.1 对象内部的字段校验:注解的威力
前面聊的都是“局部判空”,也就是在代码运行过程中你拿到一个参数,手动判断它是否有效。但一个完整的项目里,更多的判空需求发生在对象创建和入口接收时。与其在业务代码里判来判去,不如在一开始就把非法值拦在门外。这个东西在 Java 生态里叫 Bean Validation,规范是 JSR 303 / JSR 380,默认实现是 Hibernate Validator。
做法非常直观:在 DTO 的字段上直接打注解。
public class UserCreateRequest { @NotBlank(message = "用户名不能为空") private String username; @NotNull(message = "年龄不能为空") @Min(value = 1, message = "年龄必须大于 0") private Integer age; @NotEmpty(message = "标签列表不能为空") private List<String> tags; }然后在 Controller 入口加一个@Valid或@Validated,框架就会在进入业务方法之前帮你完成所有判空。校验失败时返回什么,你可以通过全局异常处理器统一处理,不用在每个方法里写 if 了。
这里有两个点特别容易混淆,我单独说一下:
@NotNull:只判断非 null。Integer 类型的 age 为 null,会拦截;String 类型的 username 为空字符串,不拦截。@NotBlank:只适用于 CharSequence,判断标准是非 null 且至少包含一个非空白字符。@NotEmpty:适用于集合、Map、数组和字符串,判断标准是非 null 且 size > 0。
你如果只是判空,用@NotNull会漏掉空字符串和空集合;用@NotBlank校验 Integer 又会直接报错。所以选注解时一定要先搞清楚字段类型和你能接受的“空”的定义。
4.2 Spring Assert:用异常代替 if 的优美姿势
Spring 提供了一个很实用的工具类Assert,它做的事情很简单:条件不满足就直接抛异常。比如:
public User getUserDetail(Long userId) { Assert.notNull(userId, "userId 不能为空"); User user = userMapper.selectById(userId); Assert.notNull(user, "用户不存在,id: " + userId); return user; }写完这段代码,你就不需要再手写 if 抛出 IllegalArgumentException 了。Assert的语义非常明确,读代码的人一眼就知道这里是“前置校验”,不会把它跟“业务逻辑”扯在一起。在团队里,这种表达方式比一堆if (xxx == null) throw new XxxException()要轻量得多,也更统一。
不过我要提醒一句,Assert默认抛的是IllegalArgumentException,属于非受检异常。如果你的项目自定义了统一业务异常(比如BizException),不一定能直接兼容。我的建议是:底层校验用Assert,业务状态校验用自定义异常,两者分工明确,不冲突。
4.3 延迟判断与边界收敛:判空设计的高级思路
如果说工具类和注解是降维打击,那么“边界收敛”就是更高阶的判空设计策略。它的核心思想是:不要在每一层业务代码里都判断参数是否合法,而是把判空逻辑集中在系统的边界——Controller 入口、MQ 消费入口、定时任务入口、外部接口的 Facade 层。
举个例子:一个用户下单流程,涉及 Controller、OrderService、OrderRepository、第三方库存接口。如果你在每个类里都写一遍orderParam != null、userId != null,那就是“分布式判空”,事故率反而更高,因为总有一个地方忘记判。正确的做法是在 Controller 入口用注解校验或明确参数校验,一旦校验通过,后面的 Service 层就默认参数合法;在 OrderRepository 里对数据库返回结果做一次收敛,之后调用方就不用再对结果判空了。
这里就涉及一个隐蔽但极其重要的经验:从数据库或远程接口取出的对象,默认都要判空或兜底;传给自己下游方法的对象,默认都保证不为空。这两条边界一旦立住了,中间层的代码立刻清爽许多。你写的方法签名也更有指导意义,别人看你的代码时不用提心吊胆地思考“这玩意儿会不会是 null”。
5. 实战现场:从脚本小子到优雅判空的重构实录
5.1 一个典型的“脏乱差”重构案例
光说不练假把式。我拿一个实际重构案例给你们看看,同一段功能,从“能用”到“优雅”到底差在哪里。假设我们要实现一个方法:根据订单 ID 查询订单,并且把订单里关联的用户昵称返回;如果订单不存在或用户不存在,返回“匿名用户”。
初版代码,典型的“层层递进 if 判空流”:
public String getOrderUserName(Long orderId) { if (orderId == null) { return "匿名用户"; } Order order = orderRepository.selectById(orderId); if (order == null) { return "匿名用户"; } User user = userRepository.selectById(order.getUserId()); if (user == null) { return "匿名用户"; } return user.getNickname(); }六七个“提前返回”,重复了一堆“匿名用户”。代码功能没错,但每个分支都在重复同一个逻辑,后期维护要改默认值时,你得改好几个地方。用 Optional 重构一下:
public String getOrderUserName(Long orderId) { return Optional.ofNullable(orderId) .map(orderRepository::selectById) .map(Order::getUserId) .map(userRepository::selectById) .map(User::getNickname) .orElse("匿名用户"); }对比一下,第一版的 15 行瘦身成 6 行,所有判空逻辑被链式调用替代,默认值只出现一次。而且可读性不降反升,方法语义非常清晰:这是一个“有值就取、无值兜底”的流程。面试时如果你能写出这种代码,面试官很难不眼前一亮。
5.2 重构后的隐藏风险与团队一致性
但是,重构也不是没有代价。Optional 链式调用把每一步的意图都“藏”在了map里,虽然代码短了,但调试时没法在中间打日志,不像 if 版本那样能精确知道是哪一步返回了 null。所以我的经验是:在核心链路、关键日志场景,宁可多写两行 if 也别硬上 Optional。在非核心场景、简单取值场景,Optional 优势明显。
这个经验在真实业务里太重要了。我见过同事把整个核心交易链路全部改成 Optional 链,结果线上出故障后,排查问题时根本不知道是哪一步数据不对,只能慢慢加日志回溯。优雅当然好,但优雅不能以牺牲可观测性为代价。
另外还有一个团队协作的坑:如果你重构了代码,但团队其他人还在用 if,代码评审时大概率会有人问“这个 Optional 是啥意思”。这不是说你不能写 Optional,而是要注意团队的代码风格基线。如果项目里没人用 Optional,你贸然引入,反而会造成“阅读屏障”。这时候最好的策略是:小范围试点,挑一个非核心模块改好,给团队演示对比,大家觉得好再铺开。
5.3 判空效率与工具类选型的终极建议
写了这么多,我想给出一份“给普通开发者的判空工具箱”,直接照着用就行:
- 普通方法入参非空:优先
Objects.requireNonNull或 SpringAssert.notNull - 对象取值链:优先
Optional.ofNullable().map().orElse() - 集合判空:统一
CollectionUtils.isEmpty() / isNotEmpty() - 字符串判空:统一
StringUtils.isBlank() / isNotBlank() - DTO 入参校验:优先 Bean Validation 注解
- 返回值兜底:能返回空集合就返回空集合,别返回 null
- 项目里没有引入工具库时:用 JDK 自带的
Objects.isNull、Objects.nonNull,别再手动写== null
这些工具选型的逻辑,归根到底就一句话:用语义明确的 API 替代原始的语法判断,让代码表达意图,而不是表达过程。判空只是一个小切面,但写得好不好,直接反映你对代码整洁度的态度。一个连判空都随便写的人,很难让人相信你在其他核心设计上会严谨。
6. 常见问题与排查技巧实录:判空路上的坑我替你踩
6.1 orElse 和 orElseGet 的执行陷阱
这个坑我前面提过,但值得再展开说一下。很多人在写 Optional 时,下意识地使用orElse,因为它参数直接传值,看起来更简单。问题在于:orElse传入的是一个已经计算好的值,哪怕 Optional 不为空,这个计算也早就发生了。
// 坑在哪?createDefaultUser() 一直会执行 Optional<User> opt = Optional.ofNullable(cache.get(key)); User user = opt.orElse(createDefaultUser());如果createDefaultUser()里有一次数据库查询,那么每一次请求,无论缓存有没有命中,你都会多执行一次查询。而orElseGet是懒执行的,只有 Optional 为空时才调用 Supplier,所以同样语义下性能差异天壤之别。
我建议团队里干脆约定:默认值不只是一个常量,而是需要计算的,一律使用orElseGet。默认值是简单的字符串或数字常量时,才可以用orElse。
提示:排查类似问题时,不要只盯代码逻辑,要关注默认值构造的开销。Cost of a default value,往往是性能优化的盲区。
6.2 Optional 的反模式:字段与参数不能乱用
Java 的 Optional 官方定位是“返回值包装”,它没有实现Serializable,所以你不能把 Optional 当做实体类的字段存到数据库或序列化到 Redis。更不要把它当方法参数,因为调用方并不知道要不要传 Optional。我见过一个项目把所有 Service 方法的参数都改成 Optional,结果不到一个月,整个项目里充满了.orElse(null),比原来更丑。
正确姿势:Optional 只出现在 Service 或 Repository 的返回类型里,用来表达“可能有值,也可能没有值”的语义。其他位置出现 Optional,大概率是过度设计。
6.3 空集合与 null:一个让数据库背锅的惯例
还有一个常年被忽略的问题:查询一个列表,返回 null 和返回空列表,是两种完全不同的语义。返回 null 往往表示“查询出错”或者“状态未知”,返回空列表表示“确实没有数据”。但很多项目里,MyBatis 查询一个selectList如果没查到数据,默认返回的是空 list,不是 null。这本来是件好事。
但如果你用List<User> list = mapper.selectList(xxx); if (list != null) {...},看似防御了,其实反而是安全隐患——你把“没有数据”和“查询异常”处理成了同一种逻辑。当你后续要对列表做parallelStream或分页时,可能就会遇到新的问题。真正的实践是:集合查询结果统一按空集合处理,不需要额外判 null,除非你的 DAO 层明确可能返回 null(比如某些第三方客户端)。
6.4 从评审视角看判空规范
最后说点团队管理的经验。如果你的团队经常为判空代码的写法争论,建议直接在代码规约里定下几条硬性规范,省得每次评审都扯皮:
- 所有 Public 方法入口必须进行参数校验,不允许把 null 传入 Service 层。
- 所有从数据库、缓存、远程接口获得的数据,默认都当成“可能为 null”处理。
- 集合返回类型统一返回空集合,禁止返回 null。
- 字符串统一使用
isBlank系列判断,不单独判断空字符串。 - 禁止在代码里写多层嵌套判空;超过两层必须使用卫语句或 Optional 重构。
这些规则看起来简单,但落地之后,代码的整洁度会肉眼可见地上升。我自己的团队推行这套规范三个月后,新人写的代码质量明显提高,Code Review 的时间都缩短了不少。
判空这个看似基础的操作,其实是整个代码质量的试金石。你写判空的方式,暴露了你对空值语义的理解、工具库的熟练度、以及对代码可读性的追求。从今天开始,把你代码里的if (xxx != null) { doSomething(); }认认真真过一遍,能收敛的收敛,能工具化的工具化,能前置校验的前置校验。等你写出来的判空漂亮起来,你就会发现,代码评审时别人不再绕着你的类走,而是忍不住问一句:“这代码谁写的,还挺优雅。”