面试必问:王昱丹教你3招搞定Java空指针与并发陷阱
昨晚加班到两点,盯着IDE里那一长串红色的StackTrace,眼睛都快花了。NullPointerException 或者 ConcurrentModificationException,看着像天书一样,根本不知道是哪行代码炸了。这种报错一堆看不懂的情况,在Java开发里太常见了,也是面试官最爱用来试探你基础是否扎实的“面试必问”题。
很多刚入行的兄弟,一看到报错就慌,要么直接try-catch吞掉异常,要么盲目加锁。其实,大部分线上事故和面试翻车,都源于对底层机制理解不透。今天结合我在掘金技术社区看到的一些真实案例,以及自己踩过的坑,专门针对“王昱丹”这个在技术圈常被提及的严谨派风格,拆解两个最典型的Java陷阱:空指针引用(NPE)和并发修改异常。咱们不整虚的,直接上干货,看看怎么从根源上消灭这些Bug。
1. 现象:为什么你的代码在测试环境好好的,一上线就崩?
先说个典型的场景。你写了一个查询用户订单的接口,逻辑很简单:先查用户,再查订单。在本地测试,数据都在,跑通了。结果到了生产环境,只要有一个新注册用户还没下过单,或者数据同步延迟了,接口直接500,日志里满屏都是NPE。
还有一种更隐蔽的坑:ConcurrentModificationException。你在遍历一个ArrayList,同时在一个线程里往这个列表里add数据。单线程测试没事,因为执行顺序是确定的。但一旦上线,高并发请求打过来,A线程在for循环里遍历,B线程在另一处逻辑里修改列表,瞬间爆炸。
核心痛点在于: 你看到的异常堆栈,往往只是“结果”,而不是“原因”。NPE告诉你“这里有个null”,但没告诉你“为什么它是null”;并发异常告诉你“结构被改了”,但没告诉你“是谁改的”。如果不去深挖,你就只是在擦屁股,而不是在堵漏洞。
2. 根本原因:Java内存模型与引用机制的误解
要解决这些问题,得先搞清楚Java里到底发生了什么。
2.1 空指针引用(NPE)的真相
很多初学者以为NPE就是“变量没初始化”。其实不然。Java是强类型语言,局部变量不初始化根本编译不过。NPE通常发生在引用链的中断。
比如:user.getOrders().get(0).getId()。
这里有三个潜在的空指针点:
user本身是 null。user.getOrders()返回的是 null(比如用户没下过单,数据库查出来是null,而不是空集合)。getOrders()返回的集合是空的,get(0)越界(这其实是IndexOutOfBoundsException,但常被误判)。
根本原因: 你对“外部数据源”的信任过度了。数据库里的字段允许为空,RPC调用可能返回null,JSON反序列化时字段缺失也是null。你的代码逻辑假设了“只要查到了用户,就一定有订单”,这个假设在现实业务中往往是脆弱的。
2.2 并发修改异常的底层逻辑
Java的 ArrayList、HashMap 等集合类,内部都有一个 modCount 字段,记录结构修改的次数。
当你使用 for-each 或迭代器遍历集合时,迭代器会记录创建时的 modCount。每次 next() 调用时,它都会检查当前的 modCount 是否和记录的一致。如果不一致,说明集合在遍历过程中被结构性地修改了(如add、remove),迭代器就会抛出 ConcurrentModificationException。
注意: 这不是线程安全问题,而是迭代器的一致性保护机制。即使是单线程,如果你在遍历中调用 list.add(),也会报这个错。但在线程安全场景下,它更是并发冲突的信号。
3. 正确写法对比:从“防御性编程”到“不可变设计”
光知道原因没用,得看代码怎么写。下面对比一下“坑爹写法”和“靠谱写法”。
3.1 空指针防御:Optional vs 判空
错误写法(典型的链式调用陷阱):
// Java - 危险写法
public String getFirstOrderUser(Long userId) {User user = userService.findById(userId);// 假设 user 不为 null,但 getOrders() 可能为 nullList<Order> orders = user.getOrders(); Order firstOrder = orders.get(0); return firstOrder.getUser().getName();
}
问题点: 每一层都可能为null,任何一层断裂,整个方法崩溃。而且这种代码可读性极差,面试时容易被问:“如果orders是空集合呢?”
正确写法(使用 Optional 链式处理):
// Java - 推荐写法
public String getFirstOrderUser(Long userId) {return Optional.ofNullable(userService.findById(userId)).flatMap(User::getOrdersOpt) // 假设 getOrdersOpt 返回 Optional<List<Order>>.flatMap(orders -> orders.stream().findFirst()).map(Order::getUser).map(User::getName).orElse("Unknown");
}
解析:
Optional.ofNullable处理第一个可能的null。- 后续每一步都用
map或flatMap传递,如果中间任何一步是Optional.empty(),整个链直接短路,返回默认值。 - 这种写法不仅安全,还表达了业务意图:“我要找一个可能有值的东西”。
进阶技巧: 在DTO设计阶段,尽量避免返回 null。比如,订单列表没数据,返回 Collections.emptyList() 而不是 null。这样在调用方,list.isEmpty() 的判断就比 list == null 更直观且安全。
3.2 并发安全:CopyOnWrite vs 加锁
错误写法(裸奔的ArrayList):
// Java - 并发不安全
private List<String> cacheList = new ArrayList<>();public void addCache(String item) {cacheList.add(item); // 线程A执行
}public void processCache() {for (String item : cacheList) { // 线程B执行遍历// 如果此时线程A执行了add,这里直接抛 ConcurrentModificationExceptionSystem.out.println(item);}
}
正确写法(根据场景选择 CopyOnWriteArrayList 或 锁):
方案一:读多写少场景,用 CopyOnWriteArrayList
// Java - 高并发读场景
private List<String> cacheList = new CopyOnWriteArrayList<>();public void addCache(String item) {cacheList.add(item); // 内部加锁,写操作开销大,但读操作无锁
}public void processCache() {for (String item : cacheList) { // 遍历的是副本,绝对安全,不会抛异常System.out.println(item);}
}
解析: CopyOnWriteArrayList 在写操作时,会复制整个底层数组,然后在新数组上修改。读操作始终在旧数组上进行。优点是读性能极高,无锁;缺点是写性能差,内存占用大(两个数组)。适用于缓存、配置监听等读多写少的场景。
方案二:读写均衡,用 synchronized 或 ReentrantLock
// Java - 通用并发安全
private final List<String> cacheList = new ArrayList<>();
private final Object lock = new Object();public void addCache(String item) {synchronized (lock) {cacheList.add(item);}
}public void processCache() {synchronized (lock) {// 必须在同一把锁下遍历,确保期间没有修改for (String item : cacheList) {System.out.println(item);}}
}
解析: 最简单粗暴,但有效。关键点在于:遍历和修改必须在同一临界区内。如果业务逻辑复杂,建议改用 ReentrantLock 配合 try-finally 确保锁释放,或者使用 ConcurrentHashMap 等并发容器。
4. 复现与修复:一个真实的线上Bug排查过程
这里分享一个我在掘金技术社区看到的一个真实案例,稍微简化一下。
背景: 某电商平台,商品库存扣减接口。
现象: 高峰期偶尔出现库存超卖,同时日志里有大量的 ConcurrentModificationException 和 IllegalStateException。
排查过程:
- 看日志: 堆栈指向
StockService.deductStock()方法里的inventoryMap.entrySet().iterator()。 - 看代码:
Map<Long, Integer> inventoryMap = new HashMap<>(); // 问题源头public boolean deductStock(Long skuId, int count) {// 1. 遍历所有库存,检查是否有足够的库存(这是为了做某种全局校验,虽然逻辑有点怪,但先不管)for (Map.Entry<Long, Integer> entry : inventoryMap.entrySet()) {if (entry.getKey().equals(skuId) && entry.getValue() >= count) {// 2. 在遍历中直接修改entry.setValue(entry.getValue() - count); return true;}}return false; } - 分析:
HashMap不是线程安全的。- 在
for-each遍历entrySet时,直接修改了value。虽然setValue看起来不是结构修改(不增加/减少key),但在某些JDK版本或复杂逻辑下,如果涉及到扩容或内部哈希调整,依然可能触发modCount变化,或者在并发下导致数据不一致。 - 更严重的是,高并发下,两个线程同时进入循环,可能都判断库存充足,然后都执行扣减,导致超卖。
修复方案:
- 换容器: 将
HashMap替换为ConcurrentHashMap。 - 改逻辑: 不要遍历整个Map来查找特定Key。直接
get然后compute。
// Java - 修复后
private final Map<Long, Integer> inventoryMap = new ConcurrentHashMap<>();public boolean deductStock(Long skuId, int count) {// 使用 compute 方法,原子性地执行检查和扣减boolean[] success = {false};inventoryMap.compute(skuId, (key, currentStock) -> {if (currentStock != null && currentStock >= count) {success[0] = true;return currentStock - count;}return currentStock; // 库存不足,保持不变});return success[0];
}
解析: ConcurrentHashMap 的 compute 方法保证了对指定Key的操作是原子的。要么成功扣减并返回新值,要么失败并返回原值,中间不会被打断。这彻底解决了并发修改和竞态条件问题。
5. 规避建议:建立你的“代码洁癖”
怎么避免以后再踩这些坑?给你几条实战建议,也是我在团队Code Review时必查的点。
Null Check 不是万能的,设计才是。
- 在接口定义和DTO设计中,尽量使用
Optional或明确的非空约束(如JSR-305注解)。 - 数据库查询结果,特别是
selectOne或selectById,永远假设可能为null。 - 集合类型,尽量返回空集合
Collections.emptyList()或Collections.emptyMap(),而不是null。这能让调用方少写一半的判空代码。
- 在接口定义和DTO设计中,尽量使用
并发容器不是银弹,理解其代价。
ConcurrentHashMap不是万能的,它保证的是单条操作的原子性,但不保证复合操作(如check-then-act)的原子性。对于复合操作,依然需要compute、merge或显式加锁。CopyOnWriteArrayList内存开销大,只适用于读多写少。如果写频繁,直接用synchronized或ReentrantLock更合适。
单元测试必须包含边界和并发场景。
- 不要只测Happy Path(正常流程)。必须测
null输入、空集合、并发调用。 - 可以使用 JUnit 5 的
@ParameterizedTest来测试各种边界值。 - 对于并发代码,可以用 JMeter 或 Gatling 做简单的压力测试,观察是否有异常抛出。
- 不要只测Happy Path(正常流程)。必须测
善用IDE和静态分析工具。
- IntelliJ IDEA 的
Inpections功能非常强大,能提前发现潜在的NPE和并发问题。 - 在CI/CD流水线中集成 SonarQube 或 SpotBugs,它们能扫描出未处理的空指针检查和并发修改风险。别等上线了再发现问题,那时候代价就大了。
- IntelliJ IDEA 的
阅读源码,理解底层。
- 别只背API。去看看
ArrayList的iterator()是怎么实现的,看看ConcurrentHashMap的lock是怎么分段的。理解modCount的作用,理解CAS的原理。当你真正理解了底层,你就不会再被那些晦涩的异常堆栈吓到了。
- 别只背API。去看看
结语
技术这条路,坑是踩不完的,但每个坑都是经验。王昱丹老师在分享中常强调:“代码要写得让人放心,而不是让人惊讶。” 空指针和并发异常,看似低级,实则反映了我们对语言机制和业务边界的理解深度。
面试时,如果面试官问你“怎么避免NPE”,不要只说“加判空”。要讲你的设计思路,讲 Optional 的使用,讲你对数据源的不信任原则。如果问并发,不要只说“加锁”,要分析读写比,选择 CopyOnWrite 或 Concurrent 容器,并解释其原理。
这种对细节的把控和对底层的敬畏,才是区分初级和中级开发的分水岭。
你更常用哪种写法?是在业务层大量使用 Optional 链式调用,还是倾向于在DAO层保证非空,然后在上层直接使用?或者你在并发场景下,更偏爱 synchronized 还是 ReentrantLock?评论区交流一下,看看大家的实战习惯。