【024】面试被问原子性?别只答 synchronized!3 招根治并发金额错乱


问题: Java并发编程中,如何保证操作的原子性?
答:

在Java并发编程中保证操作的原子性的方法:
请添加图片描述
1、synchronized关键字: 通过同步代码块或方法来保证操作的原子性。

2、Lock接口: 使用ReentrantLock等锁实现提供的锁机制。

3、原子变量: 使用java.util.concurrent.atomic包中的原子变量类。


零、引入

“财务又来找茬了!100 个用户充值,系统少了 800 块!” 王二的吼声差点掀翻技术部天花板,他负责的会员储值系统刚上线,就爆出资金漏洞。领导把流水表拍在他桌上,指关节敲得桌面砰砰响:“今天下班前搞定,不然这个月绩效别要了!”

王二扒着代码急得冒冷汗,核心逻辑就一行balance += 100,本地测着分毫不差,一到线上并发就 “缺斤少两”。隔壁工位的哇哥叼着保温杯凑过来,扫了眼屏幕就笑了:“这是原子性的坑,你以为代码是一步,JVM 早给你拆成三块了。别说绩效,搞不好得自己赔这笔钱。”

点赞 + 关注,跟着哇哥和王二,用食堂打饭的例子搞懂原子性,3 套可直接抄的代码方案,下次面试被问,你能把面试官讲服!
请添加图片描述

一、破案!1 行代码 = 3 步操作,原子性被撕成了碎片

王二的储值系统逻辑很简单:用户充值 100 元,余额就加 100。可 100 个用户同时充,预期余额该多 10000,实际却只多了 9200,800 块凭空 “消失” 了。

📢 王二的 “裸奔” 代码(必踩坑版)

请添加图片描述

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

// 会员储值系统——原子性漏洞版
public class AtomicityBugDemo {
    // 初始余额100元
    static int balance = 100;
    // 100个并发充值任务,每个充100元
    static final int TASK_COUNT = 100;
    // 等待所有线程执行完
    static CountDownLatch latch = new CountDownLatch(TASK_COUNT);

    public static void main(String[] args) throws InterruptedException {
        // 10个线程的线程池,模拟线上并发
        ExecutorService pool = Executors.newFixedThreadPool(10);

        for (int i = 0; i < TASK_COUNT; i++) {
            pool.submit(() -> {
                try {
                    // 核心逻辑:余额加100(看似没问题,实则巨坑)
                    balance += 100;
                } finally {
                    latch.countDown();
                }
            });
        }

        // 等待所有充值完成
        latch.await();
        pool.shutdown();
        System.out.println("实际余额:" + balance);  // 线上大概率是9300之类的错误值
        System.out.println("预期余额:10100");
    }
}

➡️ 哇哥拆穿真相:原子性就是 “不能拆的操作”

请添加图片描述
“你把balance += 100当‘一口闷’,JVM 却把它拆成了‘读 - 改 - 写’三步曲,” 哇哥拿笔在草稿纸上画,边画边说:

  • 读:线程 A 从主内存把 balance=100 读到自己的 “小本本”(工作内存);
  • 改:A 在小本本上把 100 改成 200;
  • 写:A 把 200 写回主内存;

“这三步中间只要被其他线程插一脚,就出乱子,” 哇哥举了个场景:

  • 线程 A 刚读完 balance=100,还没改,线程 B 也读走 100;
  • A 改完 200 写回主内存;
  • B 不管主内存已经变了,照样把自己的 200 写回去;
  • 两次充值,余额只加了 100—— 这 800 块就是这么 “丢” 的!

🎸 用食堂打饭理解原子性
请添加图片描述
“原子性就像你去食堂打饭,‘刷 10 块钱 + 拿一份套餐’必须是一个整体,” 哇哥打比方,“不能刷了钱没拿到饭,也不能拿了饭没刷钱。代码里的原子操作,就是 CPU 不会在中间切到其他线程,要么完整执行,要么干脆不执行。”

二、方案一:synchronized—— 给代码上 “独占锁”,新手也能上手

“最简单的办法,就是给原子操作加把‘独占锁’,” 哇哥指着代码,“synchronized 就像食堂的‘VIP 打饭窗口’,只有一个线程能进去操作,其他线程都得排队,三步操作就不会被打断了。”

修复后的 synchronized 版代码

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class AtomicityWithSync {
    static int balance = 100;
    static final int TASK_COUNT = 100;
    static CountDownLatch latch = new CountDownLatch(TASK_COUNT);
    // 锁对象:所有线程必须抢同一把锁才管用,不能每个线程建一个新对象
    static final Object LOCK = new Object();

    public static void main(String[] args) throws InterruptedException {
        ExecutorService pool = Executors.newFixedThreadPool(10);

        for (int i = 0; i < TASK_COUNT; i++) {
            pool.submit(() -> {
                try {
                    // 关键:把原子操作包在synchronized里,独占执行
                    synchronized (LOCK) {
                        balance += 100;
                    }
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        pool.shutdown();
        System.out.println("实际余额:" + balance);  // 必为10100,完美对得上
        System.out.println("预期余额:10100");
    }
}

🥰 哇哥划重点:synchronized 的 “傻瓜式优势”

synchronized 不用你管锁的释放,就算抛异常,JVM 也会自动把锁放了,” 哇哥补充,“就像你用完 VIP 窗口,门会自动锁上,不用手动关门。适合新手,或者简单的原子操作,比如单个变量增减、简单业务逻辑。”

王二跑起代码,看着控制台输出 10100,长舒一口气:“终于对了!那为什么不所有场景都用它?” 哇哥笑了:“你试试如果业务需要‘抢锁超过 3 秒就放弃’,synchronized 就抓瞎了 —— 这时候得用更灵活的 Lock。”

三、方案二:Lock—— 可超时、可中断的 “智能锁”

果然,没过半天,王二又愁眉苦脸地来找哇哥:“支付场景里,线程全堵在 synchronized 上,导致用户下单超时,客服电话被打爆了!”

请添加图片描述

📲 用 ReentrantLock 修复的代码(支持超时抢锁)

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class AtomicityWithLock {
    static int balance = 100;
    static final int TASK_COUNT = 100;
    static CountDownLatch latch = new CountDownLatch(TASK_COUNT);
    // 可重入锁:最常用的Lock实现,和synchronized一样支持重入
    static Lock smartLock = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        ExecutorService pool = Executors.newFixedThreadPool(10);

        for (int i = 0; i < TASK_COUNT; i++) {
            int userId = i;
            pool.submit(() -> {
                boolean locked = false;
                try {
                    // 核心:尝试抢锁,3秒没抢到就放弃,不死等
                    locked = smartLock.tryLock(3, TimeUnit.SECONDS);
                    if (locked) {
                        balance += 100;
                        System.out.println("用户" + userId + "充值成功,余额:" + balance);
                    } else {
                        // 抢锁失败,执行降级逻辑,比如返回“系统繁忙,请稍后再试”
                        System.out.println("用户" + userId + "充值失败:系统繁忙");
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    System.out.println("用户" + userId + "充值中断");
                } finally {
                    // 必须手动释放锁!放在finally里,防止异常导致锁泄露
                    if (locked) {
                        smartLock.unlock();
                    }
                    latch.countDown();
                }
            });
        }

        latch.await();
        pool.shutdown();
        System.out.println("最终余额:" + balance);
    }
}

王二拍腿叫好:这功能太刚需了!

“这样就算线程抢不到锁,也不会一直堵着,直接返回系统繁忙,用户体验好多了,” 王二眼睛亮了,“那 Lock 还有啥优势?”

“还能中断等待的线程,” 哇哥补充,“比如系统要停机维护,能把堵在锁上的线程‘喊醒’,让它们赶紧结束,不用等锁释放。但记住,Lock 必须手动解锁,不然锁会一直占着,其他线程全进不来 —— 这是新手最容易踩的坑。”

💯 适用场景:复杂业务的 “救星”

哇哥总结:“如果你的业务需要超时控制、尝试加锁、公平锁(按排队顺序抢锁),或者需要中断等待线程,就用 Lock。比如支付、下单这些核心场景,灵活度比 synchronized 高太多。”

四、方案三:原子类 —— 无锁高效的 “终极杀招”

请添加图片描述
“那如果只是简单的变量增减,比如库存、计数器,用 Lock 是不是有点小题大做?” 王二举一反三。
“问得好!” 哇哥点赞,“Java 给简单变量准备了‘专用工具’—— 原子类,底层用 CAS 实现,不用加锁也能保证原子性,效率比锁高 3 倍都不止,就像食堂的‘自动打卡机’,不用人工守着,全自动化。”

‼️ 用 AtomicInteger 修复的代码(无锁版)

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

public class AtomicityWithAtomic {
    // 原子类:替代普通int,自带原子操作,线程安全
    static AtomicInteger balance = new AtomicInteger(100);
    static final int TASK_COUNT = 100;
    static CountDownLatch latch = new CountDownLatch(TASK_COUNT);

    public static void main(String[] args) throws InterruptedException {
        ExecutorService pool = Executors.newFixedThreadPool(10);

        for (int i = 0; i < TASK_COUNT; i++) {
            pool.submit(() -> {
                try {
                    // 原子操作:balance += 100,底层CAS保证不会被打断
                    balance.addAndGet(100);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        pool.shutdown();
        System.out.println("实际余额:" + balance.get());  // 必为10100
        System.out.println("预期余额:10100");
    }
}

✅ 用 “打卡机” 讲透 CAS 原理

“原子类的核心是 CAS(比较并交换),就像食堂的自动打卡机,” 哇哥比喻:

  • 打卡机先记着你上次的余额(旧值,比如 100);
  • 你要充值 100,打卡机会先查现在的余额是不是还 100(确认没被别人改);
  • 如果是,就改成 200;如果不是,就重新查最新余额,再试一次;
  • 整个过程不用人工干预,也不用锁,效率特别高。

🚀 王二的最终方案

请添加图片描述
结合储值系统的场景,王二最终用了 “原子类 + Lock” 的组合:

  1. 余额增减用 AtomicInteger,保证高效无锁;
  2. 整个支付流程(查余额→扣钱→写日志)用 Lock 加锁,保证复杂逻辑的原子性。
// 支付核心逻辑:复杂业务的原子性保证
public void pay(AtomicInteger balance, int amount, Lock lock) {
    boolean locked = false;
    try {
        locked = lock.tryLock(3, TimeUnit.SECONDS);
        if (locked) {
            int current = balance.get();
            if (current < amount) {
                throw new RuntimeException("余额不足");
            }
            balance.addAndGet(-amount); // 原子扣钱
            logService.recordPayLog();  // 写日志,和扣钱在同一锁内
        }
    } finally {
        if (locked) lock.unlock();
    }
}

五、总结:保证原子性的 “核心心法”

哇哥拍了拍王二的肩膀,把核心要点缩成三句 “顺口溜”,记牢就不会踩坑:

  1. 原子操作别拆分:“读 - 改 - 写” 必须捆成一团,不能被线程打断;
  2. 简单用锁选 sync:新手、简单场景直接上 synchronized,省事儿;
  3. 复杂场景用 Lock,简单变量用原子,按需选型不盲从。

六、最后说句实在的

请添加图片描述

原子性是 Java 并发的 “基础防线”,你踩过的金额错乱、库存超卖坑,本质都是没守住这道防线。记住:单线程看逻辑,多线程看原子性,把关键操作做成 “不可拆分” 的原子操作,并发问题就解决了一大半。

如果这篇救了你的项目,点赞 + 分享给你那还在写 “裸奔” 并发代码的同事!

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值