面试官您好,关于并发问题的识别和预防,我想结合我在项目中的经验,和您分享一下我的看法和实践方法。
一、从一个真实的“并发事故”说起
在我之前参与的一个外卖项目(苍穹外卖)中,就遇到过一个典型的并发问题。当时我们的库存扣减逻辑是这样的:
if (product.getStock() > 0) {
product.setStock(product.getStock() - 1);
// 更新数据库
}
看起来没什么问题,对吧?但在一次大促活动中,我们发现库存经常被扣成负数。经过排查,发现是多个用户同时下单时,都读取到了相同的库存值,然后都通过了if判断,导致超卖。
这个经历让我深刻认识到,并发问题如果不提前预防,一旦在生产环境爆发,后果是非常严重的。
二、我是怎么检查代码有没有并发风险的?
现在,每当接手一个新模块或者review代码时,我都会按这三个步骤来“扫描”一遍,就像医生给病人做体检一样。
第一步:找“大家都能碰的东西”
并发问题的前提是有多个人(线程)能接触到同一个东西(数据),而且这个东西还能被修改。
我会特别留意这几种情况:
1.静态变量:比如一个类里的public static User currentUser;,所有地方都能访问和修改,风险很高。
2.单例模式的对象:全局只有一个实例,它的成员变量也是大家共用的。
- 传来传去的对象:比如把一个订单对象放到任务队列里,多个线程都可能去修改它。
第二步:看“操作安不安全”
找到了共享的数据,就要看对它的操作会不会出问题:
-
危险操作(需要特别小心):
- 自增自减:比如i++,看起来是一步,其实是三步(读取、加1、写回),中间可能被打断。
- 先检查再操作:比如先看购物车里有没有某件商品,有就删掉,但可能在你检查完和删除之间,别人已经删了。
-
安全操作(可以放心用):
- 只读取不修改:比如只是查看某个值。
- 使用专门设计的“安全工具”:比如Java里的ConcurrentHashMap、AtomicInteger等。
第三步:检查“锁”有没有用对
如果代码里用了锁(比如synchronized),还要检查:
- 锁的对象对不对:不能每次new一个新对象来当锁,这样每个线程锁的都是不同的东西,等于没锁。
- 锁的范围合不合适:锁得太宽影响性能,锁得太窄又保护不了数据。
- 复合操作的原子性:比如AtomicInteger能保证单个操作安全,但像“先检查再减1”这样的组合操作,还是需要额外保护。
三、我的几个“防并发”小技巧
在日常开发中,我也总结了一些实用的技巧,可以有效避免并发问题:
1. 能不共享,就不共享
尽量让每个线程都有自己的“工作区”,不要去碰别人的东西。比如:
// 不好:多个线程可能同时修改temp
public class BadExample {
private String temp;
public void process() {
temp = "xxx"; // 可能被其他线程覆盖
// ...
}
}
// 好:每个线程都有自己的temp
public class GoodExample {
public void process() {
String temp = "xxx"; // 只在当前方法里使用
// ...
}
}
2. 用专门的“安全工具”
Java为我们提供了很多线程安全的工具类,用它们来替代不安全的旧工具:
不安全的工具 | 推荐的安全替代品 |
---|---|
HashMap | ConcurrentHashMap |
ArrayList | CopyOnWriteArrayList |
SimpleDateFormat | DateTimeFormatter |
i++ | AtomicInteger.incrementAndGet() |
3. 合理使用锁
使用锁时,尽量只锁真正需要保护的那几行代码,不要锁住整个方法。比如:
public void updateOrder(Order order) {
synchronized(order) { // 只锁住订单对象
// 修改订单状态的核心代码
}
// 其他不涉及共享数据的操作
}
4. 写点测试代码试试
我会写个简单的多线程程序来验证我的代码是不是安全的:
// 创建10个小工
ExecutorService executor = Executors.newFixedThreadPool
(10);
// 用一个安全的计数器
AtomicInteger counter = new AtomicInteger(0);
// 让100个小工都来给计数器加1
for (int i = 0; i < 100; i++) {
executor.submit(() -> {
counter.incrementAndGet();
});
}
// 等所有小工都干完活
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
// 看看结果是不是100
System.out.println(counter.get());
四、总结一下
面试官,我觉得并发问题虽然看起来复杂,但核心就一条:多个线程同时修改同一份数据。
我的经验是,通过“三步检查法”:
- 找到共享的、可变的数据
- 检查对这些数据的操作是否安全
- 验证同步措施(如锁)是否有效
再配合这几个技巧:
- 减少共享数据
- 使用安全的工具类
- 合理加锁
- 多写测试代码