java案例认为半场领先能保持到终场吗?

wen java案例 3

Java并发编程实战:半场领先的“优势”为何在竞态条件下瞬间崩塌?


目录导读

  1. 引言:从体育赛事到Java多线程的“领先幻觉”
  2. 案例拆解:一个典型的“半场领先”Java程序
  3. 竞态条件:比分牌为何在终场前被改写?
  4. 内存可见性:领先数据为何“迟到”或“丢失”?
  5. 原子性与锁:如何用synchronizedAtomic守住胜局?
  6. 实战重构:从“侥幸领先”到“确定性胜利”的代码演进
  7. 问答环节:关于Java并发,你必须避开的三个深坑
  8. 真正的“终场哨”是JMM(Java内存模型)

引言:从体育赛事到Java多线程的“领先幻觉”

在足球或篮球比赛中,“半场领先”往往被视为心理优势,但在Java并发编程的世界里,这种“领先”极其脆弱,许多开发者默认:“只要我先修改了共享变量,其他线程最终一定会看到这个新值。” Java内存模型(JMM)告诉我们,没有同步的共享变量,其更新顺序对另一个线程而言可能是乱序、延迟甚至永不可见的。

java案例认为半场领先能保持到终场吗?

本文通过一个真实案例,剖析为什么“半场领先”的代码在终场前会突然“翻盘”,并给出基于java.util.concurrent包的黄金解决方案。


案例拆解:一个典型的“半场领先”Java程序

假设我们有一个多线程售票系统,核心逻辑如下(半场领先的代码):

public class TicketCounter {
    private int tickets = 100; // 共享变量
    public void sellTicket() {
        if (tickets > 0) {  // 判断是否有票
            // 模拟耗时操作,放大竞态窗口
            Thread.sleep(10);
            tickets--;      // 售出一张
        }
    }
}

运行场景:100个线程同时调用sellTicket(),我们期望打印剩余票数为0,但实际运行结果往往是剩余票数在10~30之间,这就是典型的“超卖”问题——半场时线程A看到tickets=50,自以为领先,但终场时它写入的结果覆盖了其他线程的更新。


竞态条件:比分牌为何在终场前被改写?

核心概念:竞态条件(Race Condition)发生在“检查-执行”组合操作(check-then-act)中。

  • 检查阶段:线程A读取tickets为50。
  • 执行阶段:在线程A准备执行tickets--之前,线程B也读取了50,并且执行了tickets--,此时tickets变为49。
  • 终场阶段:线程A执行tickets--,将49覆盖为48,但实际应该卖出两张票,变成48吗?不!默认逻辑下,线程B的修改被丢了——这相当于你把比分从50改写成48,但记分牌上其实应该是47

教训:半场领先(看到剩余票>0)不意味着你能安全地执行扣减操作,因为中间有时间差


内存可见性:领先数据为何“迟到”或“丢失”?

JMM规定,每个线程有自己的工作内存(缓存),当线程A修改tickets时,它首先写入自己的工作内存,并等待某个时机(如锁释放、volatile写)刷新到主内存,如果没有同步机制,线程B可能一直读自己工作内存中过期的值(比如始终为100)。

经典案例

boolean stop = false;
// 线程A
new Thread(() -> { stop = true; }).start();
// 线程B(主线程)
while (!stop) { i++; } // 可能永远不退出循环!

Java 8之前:B线程可能永远看不到A的修改,因为JIT编译器将stop优化为寄存器局部常量。半场领先的stop=true永远传不到终场的检查循环中。


原子性与锁:如何用synchronizedAtomic守住胜局?

正确的“控球”方式

  • synchronized(悲观锁)
    sellTicket方法同步化,保证同一时间只有一个线程进入判断-扣减逻辑。

    public synchronized void sellTicket() { ... }

    缺点:性能损耗大,锁竞争激烈时系统吞吐量下降。

  • AtomicInteger(CAS乐观锁)
    利用CPU的CAS指令,实现无锁更新。

    private AtomicInteger tickets = new AtomicInteger(100);
    public void sellTicket() {
        while (true) {
            int current = tickets.get();
            if (current > 0 && tickets.compareAndSet(current, current-1)) {
                break; // 成功扣减
            }
        }
    }

    这是“终场哨”最响亮的解法——CAS保证比较并交换的原子性


实战重构:从“侥幸领先”到“确定性胜利”的代码演进

原始版(错误):使用int,无同步,结果随机。
进阶版(部分正确):加volatile修饰tickets,解决可见性,但不解决复合操作的原子性。
最终版(推荐)

import java.util.concurrent.atomic.AtomicInteger;
public class SafeTicketCounter {
    private final AtomicInteger tickets = new AtomicInteger(100);
    public boolean trySellOne() {
        while (true) {
            int current = tickets.get();
            if (current <= 0) return false; // 已无票
            if (tickets.compareAndSet(current, current - 1)) {
                return true; // 售票成功
            }
            // 如果CAS失败,说明有另一线程抢先修改,重试
        }
    }
}

测试结果:100个并发线程调用,最终剩余票数恒为0,无超卖、无死锁、性能远优于synchronized


问答环节:关于Java并发,你必须避开的三个深坑

Q1:既然有synchronized,为何还需要Atomic类?
A:synchronized是阻塞式锁,线程会进入阻塞/唤醒状态,高并发下上下文切换开销大。Atomic基于CAS,无锁竞争,适合低冲突场景(如计数器),但如果冲突激烈(CAS自旋次数过多),反而性能下降。

Q2:volatile能不能解决一切可见性问题?
A:不能。volatile只能保证可见性和有序性,不能保证复合操作的原子性,例如tickets++是“读-改-写”三步,volatile无法防止中间被插入其他线程的修改。

Q3:如何测试一段并发代码是否正确?
A:写压力测试,固定线程数,循环多次,最后断言不变量(如售出票数总数为N)。必须使用CountDownLatch让所有线程同时起跑,模拟真实竞争,或者使用工具如jcstress(Java并发压力测试框架)。


真正的“终场哨”是JMM(Java内存模型)

回到最初的疑问:“半场领先能保持到终场吗?”——在Java并发中,没有同步的“半场领先”就是幻想,JMM规定了变量更新的最小安全发布规则:要么通过锁,要么通过volatile,要么通过Atomic原子类。

行动建议

  • 任何跨线程的共享可变变量,必须加上“三件套”之一:synchronizedLockAtomic
  • 优先考虑无锁方案(CAS),减少线程挂起。
  • 时刻反问自己:“如果这个变量被另一个线程同时读写,我的逻辑还能保证最终结果正确吗?”

记住:在并发世界里,没有终场哨响,只有死锁与崩溃的休止符,让你的代码在JMM的规则下“足篮双修”,才能每场比赛都稳赢。

抱歉,评论功能暂时关闭!