竞态条件|并发编程最易踩坑点|原理+案例+解决方法全解析

竞态条件|并发编程最易踩坑点|原理+案例+解决方法全解析 一

文章目录CloseOpen

竞态条件的底层原理:为什么多线程会“打架”?

要搞懂竞态条件,得先明白一个核心问题:为什么单线程好好的代码,多线程一跑就出问题? 这得从“共享资源”和“线程调度”的特性说起。你可以把共享资源想象成公司茶水间的咖啡机——单线程就像只有一个人用,接完咖啡就走,一切井然有序;但多线程就像早高峰时十个人同时冲进去,有人加奶、有人加糖、有人直接端走,没个规则的话,最后拿到的可能是杯“咖啡+盐+洗洁精”的黑暗料理。

竞态条件的三大“作案条件”

竞态条件要“作案”,必须同时满足三个条件,缺一不可:

  • 共享资源:多个线程都能访问的变量、数据库记录、缓存等,比如电商系统的“商品库存”字段
  • 多线程并发:至少两个线程同时对共享资源执行操作,注意这里的“同时”不是物理上的同一时刻,而是CPU时间片切换导致的“交错执行”
  • 非原子操作:操作不能一步完成,比如“查库存→扣库存→写库存”这三步,中间可能被其他线程打断
  • 去年我帮朋友的电商项目排查“库存超卖”时,就遇到了典型的“三条件齐全”案例。他们的库存扣减逻辑很简单:先查库存是否大于0,再扣减1,最后写回数据库。单线程测试时,库存从100减到0完全正常,但上线后用JMeter模拟100个并发请求,结果库存直接变成了-8。当时我们对着日志抓瞎了三天,最后在扣减方法里加了行System.out.println(Thread.currentThread().getId() + ":当前库存" + stock),才发现两个线程的执行顺序是这样的:

    线程A:查库存 → 得到10 

    线程B:查库存 → 得到10(此时线程A还没扣减)

    线程A:扣减1 → 9 → 写回数据库

    线程B:扣减1 → 9 → 写回数据库

    本该扣2件库存,结果只扣了1件,这就是非原子操作被线程调度打断的典型后果。后来我才知道,这种“读取-修改-写入”的三步操作,在多线程下几乎必然出问题——CPU的时间片调度是“雨露均沾”的,每个线程执行几十毫秒就会被切换,谁也没法保证自己的操作能“一口气做完”。

    线程调度:让结果“不可预测”的幕后推手

    你可能会问:“既然线程会被打断,那为什么不能让线程A执行完再让线程B执行?” 这就涉及到操作系统的线程调度机制了。现在的CPU都是多核心,但每个核心在同一时刻只能执行一个线程,为了让用户感觉“多任务同时运行”,操作系统会用“时间片轮转”的方式切换线程——每个线程分到10-20毫秒的执行时间,时间一到就暂停,保存当前状态,然后切换到下一个线程。

    这种切换对用户是透明的,但对开发者来说就是“坑”。比如两个线程执行i++操作(看起来是一步,实际分“读i→加1→写i”三步),在时间片切换时就可能出现这样的交错:

    | 步骤 | 线程A | 线程B | i的实际值 |

    |||||

    | 1 | 读i=0 |

  • | 0 |
  • | 2 |

  • | 读i=0 | 0 |
  • | 3 | 加1→1 |

  • | 0 |
  • | 4 |

  • | 加1→1 | 0 |
  • | 5 | 写i=1 |

  • | 1 |
  • | 6 |

  • | 写i=1 | 1 |
  • 明明两个线程都执行了i++,结果i只从0变成1,而不是2。这就是竞态条件最直观的表现:程序执行结果依赖于线程调度的顺序,而调度顺序是不可预测的,所以结果也变得“薛定谔”起来。

    从“踩坑”到“填坑”:解决竞态条件的实战方案

    知道了原理,接下来就是最关键的:怎么解决?这两年我在支付系统、电商库存、即时通讯三个项目里和竞态条件“斗智斗勇”, 出一套“分层解决方案”——从简单到复杂,从本地到分布式,总有一款适合你的场景。

    基础方案:用“锁”给共享资源“上把锁”

    最直接的办法就是给共享资源加“互斥锁”,让同一时间只有一个线程能操作它。就像给茶水间装个门,每次只允许一个人进去,出来了下一个才能进。Java里最常用的就是synchronized关键字和ReentrantLock,但两者用法和性能差异很大,选错了反而会影响系统效率。

    synchronized:简单但“笨重”的选择synchronized

    是Java内置的锁,用法简单,直接加在方法或代码块上。比如给库存扣减方法加锁:

    public synchronized void deductStock() {
    

    int stock = getStock(); // 查库存

    if (stock > 0) {

    setStock(stock

  • 1); // 扣库存
  • }

    }

    去年我接手一个老项目时,发现他们用synchronized解决了超卖问题,但接口响应时间从50ms涨到了300ms。后来排查发现,他们把synchronized加在了整个Service类上,导致所有方法都串行执行。其实synchronized应该“按需加锁”——只锁共享资源相关的代码块,而不是整个类。

    ReentrantLock:灵活但需“手动关门”

    如果需要更灵活的控制(比如尝试获取锁、超时释放、公平锁),ReentrantLock是更好的选择。它就像带密码的门,你可以尝试输密码(tryLock()),输错了不等(避免死锁),还能设置多久没打开就放弃(tryLock(timeout, unit))。我在支付系统里处理订单并发时,就用它替代了synchronized

    private final Lock lock = new ReentrantLock();
    

    public void createOrder() {

    if (lock.tryLock(3, TimeUnit.SECONDS)) { // 尝试3秒获取锁

    try {

    // 检查订单是否已存在、创建订单的逻辑

    } finally {

    lock.unlock(); // 必须手动释放锁,不然会造成死锁

    }

    } else {

    throw new RuntimeException("系统繁忙,请稍后再试"); // 获取锁失败,友好提示

    }

    }

    这里要强调:用ReentrantLock一定要在finally里释放锁,不然线程异常退出时锁没释放,其他线程就永远进不来了——我见过有人忘了写unlock(),导致服务卡死,最后只能重启解决。

    进阶方案:用“无锁编程”提升并发效率

    如果共享资源的操作很简单(比如加减、赋值),用锁就有点“杀鸡用牛刀”了——毕竟加锁解锁要消耗性能,还可能导致线程阻塞。这时候“原子操作”就是更好的选择,它能保证操作一步完成,不会被打断。Java的java.util.concurrent.atomic包提供了很多原子类,比如AtomicIntegerAtomicLong,底层用CPU的“CAS指令”(比较并交换)实现,性能比锁高得多。

    比如库存扣减如果只是简单的-1,用AtomicInteger就能搞定:

    private AtomicInteger stock = new AtomicInteger(100);
    

    public void deductStock() {

    int remaining = stock.decrementAndGet(); // 原子操作:先减1,再返回结果

    if (remaining < 0) {

    // 库存不足,回滚操作

    stock.incrementAndGet();

    throw new RuntimeException("库存不足");

    }

    }

    decrementAndGet()

    方法会直接在硬件层面保证“减1并返回”是原子的,不会被其他线程打断。去年我把电商项目的库存计数从synchronized改成AtomicInteger后,接口QPS直接从500涨到了2000,响应时间也从150ms降到了30ms。

    不过原子类只适合简单操作,如果是复杂逻辑(比如查库存→判断→扣减→记录日志),还是得用锁。这时候可以用“读写锁”(ReentrantReadWriteLock)——读操作不互斥,写操作互斥,适合“读多写少”的场景(比如商品详情页的库存展示,读请求远多于下单扣减)。

    分布式场景:跨JVM的“分布式锁”

    如果你的系统是分布式部署(多个服务实例),本地锁就失效了——因为每个实例有自己的JVM,锁只在当前实例内有效,其他实例的线程照样能操作共享资源。这时候就需要“分布式锁”,让所有实例共享同一把锁。

    最常用的分布式锁实现有两种:Redis的SET NX EX命令和ZooKeeper的临时节点。我在即时通讯项目里用过Redis分布式锁,原理很简单:用一个key代表锁,SET NX(只在key不存在时设置)保证只有一个实例能抢到锁,EX设置过期时间避免死锁(万一服务宕机,锁自动释放)。

    // Redis分布式锁伪代码
    

    public boolean tryLock(String lockKey, String requestId, int expireTime) {

    // SET NX EX:不存在则设置,过期时间expireTime秒

    String result = jedis.set(lockKey, requestId, "NX", "EX", expireTime);

    return "OK".equals(result);

    }

    public void unlock(String lockKey, String requestId) {

    // 用Lua脚本保证释放锁的原子性:判断requestId是否匹配,避免误删别人的锁

    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

    jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

    }

    这里有个坑要注意:释放锁时一定要校验requestId(每个实例生成唯一ID),不然可能释放其他实例的锁。之前有同事没加校验,结果A实例的锁超时释放了,B实例拿到锁,这时候A实例处理完又执行del,把B的锁删了,导致两个实例同时操作共享资源,竞态条件再次出现。

    如果你需要更可靠的分布式锁(比如强一致性),可以用ZooKeeper,它通过临时节点的“惊群效应”实现锁的抢占和释放,但性能比Redis稍低。具体选哪种,要看业务场景——秒杀场景追求高性能,选Redis;金融交易追求高可靠,选ZooKeeper。

    最后想对你说:竞态条件虽然“坑”,但只要掌握原理,选对工具,就能轻松应对。下次写并发代码时,先问自己三个问题:“有没有共享资源?”“是不是多线程操作?”“操作是不是原子的?” 如果三个都是“是”,那就要小心了——这时候拿出今天讲的锁、原子类、分布式锁,按需选用,保你代码稳如老狗。如果你之前踩过竞态条件的坑,或者有更好的解决方法,欢迎在评论区分享,咱们一起把并发编程的“坑”都填上!


    你可别以为原子操作是什么“万能神药”,能把所有竞态条件问题一刀切。原子类(像AtomicInteger这些)确实有两把刷子——它能保证单个操作“一口气干完”,中间不会被其他线程打断,比如你用decrementAndGet()给库存减1,就像用剪刀“咔嚓”一下剪断绳子,干脆利落,绝不会出现剪到一半被人抢了剪刀的情况。但这招只对“简单任务”管用,要是遇上需要“团队协作”的复杂操作,它就抓瞎了。

    就拿电商系统里的订单处理来说吧,你以为扣库存就完事儿了?真实场景里得先查库存够不够,够的话扣减,扣完了得写日志记录操作人、时间,还得更新订单状态从“待支付”变成“已确认”,最后可能还要给用户发个短信通知。这一长串操作,原子类顶多帮你把“扣库存”这一步做好,但日志、订单状态、短信这些步骤可管不了。去年我帮一个生鲜平台调代码时就踩过这坑——他们用AtomicInteger处理库存,结果有好几次库存扣了,订单状态却没更新,用户付了钱显示“未下单”,客服电话被打爆。后来一查才发现,扣库存后线程被切换了,另一个线程先更新了订单,导致两个线程的日志和状态完全对不上。这种时候你就得用锁把整个流程“打包”,让这一串操作要么全做完,要么全不做,原子类可扛不起这活儿。


    如何判断代码中是否存在竞态条件?

    判断竞态条件可以从三个核心条件入手:首先看是否有多个线程共享的资源(如全局变量、数据库记录、缓存键等);其次确认这些线程是否会并发执行操作(比如接口被多用户同时调用);最后检查操作是否为非原子性(比如需要“读取→修改→写入”多步完成,而非一步到位)。比如电商系统的库存扣减逻辑,如果包含“查库存→判断是否可扣→扣减并写回”三步,且多线程同时执行,就很可能存在竞态条件。

    本地锁(如synchronized)和分布式锁有什么区别?什么时候该用分布式锁?

    本地锁(如Java的synchronized、ReentrantLock)只在单个JVM进程内有效,适合单服务实例的并发控制;而分布式锁(如Redis锁、ZooKeeper锁)是跨JVM的,能让多个服务实例共享同一把锁,适合分布式部署场景(比如微服务架构中多个服务实例操作同一份数据库)。举个例子:如果你的系统是单台服务器部署,用本地锁即可;但如果是3台服务器集群,且都操作同一个“商品库存”数据库字段,就必须用分布式锁,否则本地锁只能锁住单台服务器的线程,其他服务器的线程仍会同时操作资源。

    原子操作(如AtomicInteger)能解决所有竞态条件问题吗?

    不能。原子操作(如AtomicInteger的decrementAndGet())的优势是“操作不可打断”,适合简单的加减、赋值等单步操作,但如果涉及复杂逻辑(比如“查库存→判断是否大于0→扣减→记录日志→更新订单状态”多步操作),原子操作就无能为力了。这时候需要用锁(本地锁或分布式锁)来保证整个逻辑块的原子性。比如支付系统中“扣减余额+生成交易记录+发送通知”的组合操作,必须用锁来包裹,仅靠原子类无法确保整体逻辑的正确性。

    竞态条件和死锁是一回事吗?如何区分?

    不是一回事。竞态条件是“线程操作顺序错乱导致结果异常”,比如两个线程同时扣减库存导致超卖;死锁是“多个线程互相等待对方释放资源,导致永久阻塞”,比如线程A持有锁1等锁2,线程B持有锁2等锁1,双方永远卡住。区分的关键:竞态条件会导致结果错误但线程不会阻塞;死锁会导致线程卡住,系统无响应。比如线上系统如果出现“部分请求超时但服务器CPU不高”,可能是死锁;如果出现“数据错乱(如库存负数、订单重复)但请求能正常返回”,更可能是竞态条件。

    线上环境遇到疑似竞态条件的问题,如何快速定位和复现?

    定位竞态条件可以从两方面入手:一是在共享资源操作的关键步骤打印详细日志,包含线程ID、操作前的资源值、操作后的值(比如“线程123:扣减前库存=5,扣减后=4”),通过日志中线程操作的交错顺序判断是否有“插队”;二是用压力测试工具(如JMeter)模拟高并发场景(比如1000个线程同时调用接口),放大问题出现的概率。复现后,再通过加锁、原子操作等方案修复,修复后重新压测验证结果是否稳定(比如库存扣减后不再出现负数,订单不会重复提交)。

    0
    显示验证码
    没有账号?注册  忘记密码?