实际开发中,多线程就像 “多人协作干活”—— 没协调好就会出乱子。这篇文章用生活里的常见场景,带你看懂多线程最常踩的 3 个坑,这些例子也会让你对多线程以及解决方案的理解更深刻一点

一、问题 1:线程安全问题 —— 多人抢改同一数据

1.1 生活类比:多人抢着改同一个账本

餐馆里有个 “今日营业额” 账本,两个收银员同时记账:

收银员 A 看到账本写着 “1000 元”,准备加 “200 元”(想改成 1200);
还没等 A 写完,收银员 B 也看到 “1000 元”,加 “300 元”(想改成 1300);
最后 A 写 1200,B 写 1300—— 账本最终是 1300,少算了 A 的 200 元,数据错了。

这就是线程安全问题:多个线程同时改 “同一份共享数据”,导致结果不符合预期。


1.2 代码场景:电商库存超卖(最常见的业务问题)

假设某商品库存 10 件,100 个用户同时抢购,每个用户下单时库存减 1。如果没做线程安全处理,会出现 “库存变负数” 的超卖问题。

问题代码

import org.springframework.stereotype.Service;

@Service
public class StockService {
    // 共享数据:商品库存(相当于餐馆的账本)
    private int stock = 10;

    // 下单减库存的方法(非线程安全)
    public void reduceStock() {
        // 1. 先判断库存是否足够(收银员看账本当前金额)
        if (stock > 0) {
            // 模拟实际业务中的耗时操作:比如查订单、写日志(让线程有时间互相干扰)
            try {
                Thread.sleep(50); // 暂停50毫秒,给其他线程“抢着改数据”的机会
            } catch (InterruptedException e) {
                // 线程被中断时,恢复中断状态(规范操作,避免后续逻辑异常)
                Thread.currentThread().interrupt();
            }
            // 2. 库存减1(收银员改账本)
            // 问题核心:这行代码不是“一步完成”,而是“读库存→减1→写回库存”三步
            // 多个线程会同时读、同时改,导致数据错乱
            stock--;
            System.out.println("当前线程:" + Thread.currentThread().getName() + ",库存剩余:" + stock);
        }
    }

    // 获取当前库存(用于测试查看结果)
    public int getStock() {
        return stock;
    }
}

测试代码(模拟 100 个用户抢购)

public class StockTest {
    public static void main(String[] args) throws InterruptedException {
        StockService stockService = new StockService();

        // 模拟100个用户同时下单(100个线程)
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                stockService.reduceStock();
            }, "用户线程-" + i).start();
        }

        // 等待所有线程执行完(给10秒时间)
        Thread.sleep(10000);
        // 打印最终库存(预期是0,实际可能是-5、-3等负数,超卖了)
        System.out.println("最终库存:" + stockService.getStock());
    }
}

问题结果
运行后会发现,最终库存可能是 **-5 而不是 0 —— 因为多个线程同时 “读库存→减 1”,比如线程 A 和线程 B 都读 “库存 = 1”,然后都减 1,导致库存变成 -1。

1.3 解决方案:给 “改数据” 的步骤加把锁

就像餐馆给账本加个锁,谁拿到锁谁才能改,其他人排队等。Java 里最常用的是 synchronized 关键字,简单直接。
修复代码

import org.springframework.stereotype.Service;

@Service
public class StockService {
    private int stock = 10;

    // 方案1:给方法加synchronized锁,同一时间只有一个线程能进方法
    // 锁的作用:把“判断库存+减库存”变成“原子操作”(一步完成,不会被其他线程打断)
    public synchronized void reduceStock() {
        if (stock > 0) {
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            stock--;
            System.out.println("当前线程:" + Thread.currentThread().getName() + ",库存剩余:" + stock);
        }
    }

    // 方案2:如果只需要锁“减库存”的核心代码(更灵活,性能更好)
    // 可以用synchronized代码块,只锁共享数据相关的逻辑
    public void reduceStockWithBlock() {
        // 锁对象:用当前对象(this)作为锁,确保所有线程抢同一把锁
        synchronized (this) {
            if (stock > 0) {
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                stock--;
                System.out.println("当前线程:" + Thread.currentThread().getName() + ",库存剩余:" + stock);
            }
        }
    }

    public int getStock() {
        return stock;
    }
}

修复后结果
再运行测试代码,最终库存会稳定在 0—— 因为 synchronized 确保同一时间只有一个线程能执行 “判断库存 + 减库存” 的逻辑,不会出现多线程抢改的问题。


二、问题 2:线程池耗尽问题 —— 干活的人不够,新任务排队到崩溃

2.1 生活类比:餐馆厨师不够,客人等不及走了

餐馆只有 3 个厨师(线程池最大线程数 = 3),每个厨师做一道菜要 10 分钟。如果同时来 10 桌客人(10 个任务):

前 3 桌客人的菜能马上做;
第 4-10 桌客人只能排队;
如果后面又来 20 桌客人,排队的客人太多,新客人会直接走掉(任务被拒绝)。

这就是线程池耗尽问题:线程池里的线程全在忙,新任务排满队列后,后续任务会被拒绝,导致程序响应变慢或报错。


2.2 代码场景:@Async 异步发送短信,线程池满了

Spring Boot 里用 @Async 做异步任务(比如用户注册发短信),如果没配置线程池,默认线程数很少。短时间大量用户注册,线程池会被占满,短信发不出去。

问题代码(默认线程池)

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class SmsService {
    // @Async默认用Spring自带的线程池,核心线程数少(默认可能只有1-5个)
    // 发送一条短信要2秒(调用第三方接口慢),大量任务会占满线程池
    @Async
    public void sendSms(String phone) {
        try {
            // 模拟调用第三方短信接口的耗时(2秒)
            Thread.sleep(2000);
            System.out.println("当前线程:" + Thread.currentThread().getName() + ",给" + phone + "发送短信成功");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.out.println("给" + phone + "发送短信失败,线程被中断");
        }
    }
}

测试代码(模拟 100 个用户注册发短信)

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@EnableAsync // 必须加这个注解,@Async才会生效
@RestController
public class SmsTestApplication {

    @Autowired
    private SmsService smsService;

    // 模拟用户注册,调用发送短信接口
    @GetMapping("/register/send-sms")
    public String register(@RequestParam String phone) {
        // 调用异步方法发短信
        smsService.sendSms(phone);
        // 主线程直接返回,不等待短信发送完成(这是异步的优势)
        return "验证码已发送,请查收";
    }

    public static void main(String[] args) {
        SpringApplication.run(SmsTestApplication.class, args);

        // 模拟100个用户同时调用接口(实际场景是前端多用户请求)
        SmsTestApplication app = new SmsTestApplication();
        app.smsService = new SmsService(); // 简化测试,实际用@Autowired
        for (int i = 0; i < 100; i++) {
            String phone = "1380013800" + i; // 模拟不同手机号
            app.register(phone);
        }
    }
}

问题结果
运行后会发现:

前几个短信能正常发送(线程池有空闲线程);
后面会出现 “线程池耗尽” 的日志(比如 Task rejected);
很多短信发不出去,用户收不到验证码。

2.3 解决方案:自定义线程池,合理配置参数

就像餐馆根据客流量调整厨师数量,我们给 @Async 配置一个合适的线程池,设置 “核心线程数”“最大线程数”“队列大小”。

自定义线程池代码

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;

// 线程池配置类,Spring Boot启动时会加载这个配置
@Configuration
public class ThreadPoolConfig {

    // 定义一个线程池Bean,名称叫"asyncSmsExecutor"(后续@Async指定用这个)
    @Bean("asyncSmsExecutor")
    public Executor asyncSmsExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

        // 1. 核心线程数:线程池常驻的“固定厨师”数量(根据CPU核数或业务调整)
        // IO密集型任务(如发短信、查数据库):线程数可以设大一些,比如 CPU核数 × 2
        // 这里假设CPU是4核,核心线程数设8
        executor.setCorePoolSize(8);

        // 2. 最大线程数:线程池能临时增加的“兼职厨师”数量(最多16个厨师)
        // 当核心线程忙不过来,且队列满了,会创建兼职线程,任务完后兼职线程会销毁
        executor.setMaxPoolSize(16);

        // 3. 队列容量:核心线程忙时,任务先存到队列(最多存50个任务排队)
        executor.setQueueCapacity(50);

        // 4. 空闲线程存活时间:兼职线程空闲超过60秒后销毁(避免占资源)
        executor.setKeepAliveSeconds(60);

        // 5. 线程名称前缀:日志中能区分不同线程池的线程(比如“Async-Sms-1”)
        executor.setThreadNamePrefix("Async-Sms-");

        // 6. 拒绝策略:队列满了+最大线程都忙,怎么处理新任务?
        // CallerRunsPolicy:让“调用者线程”自己执行任务(比如主线程帮忙发短信),避免任务丢失
        // 其他策略:AbortPolicy(抛异常)、DiscardPolicy(丢弃任务)、DiscardOldestPolicy(丢弃最老的任务)
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

        // 初始化线程池(必须调用,否则线程池不生效)
        executor.initialize();

        return executor;
    }
}

用自定义线程池发送短信

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class SmsService {
    // @Async指定用我们自定义的线程池“asyncSmsExecutor”
    @Async("asyncSmsExecutor")
    public void sendSms(String phone) {
        try {
            Thread.sleep(2000);
            System.out.println("当前线程:" + Thread.currentThread().getName() + ",给" + phone + "发送短信成功");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.out.println("给" + phone + "发送短信失败,线程被中断");
        }
    }
}

修复后结果
再运行测试代码,100 个短信任务会:

先由 8 个核心线程处理;
核心线程忙时,任务存入队列(最多 50 个);
队列满了,创建兼职线程(最多到 16 个);
即使超过 16+50=66 个任务,剩余任务会由主线程帮忙处理,不会被丢弃 —— 短信都能发出去,用户不会收不到验证码。

三、问题 3:死锁问题 —— 互相抢东西,谁都不让

3.1 生活类比:两个厨师互相抢工具

厨师 A 拿着菜刀,想抢厨师 B 的砧板;
厨师 B 拿着砧板,想抢厨师 A 的菜刀;
两人都不放手,最后都没法做菜,订单全卡住。

这就是死锁问题:两个或多个线程互相等待对方的资源(比如锁),永远无法继续执行,程序卡住。


3.2 代码场景:订单处理时互相抢锁

电商处理订单时,需要同时操作 “用户余额”“商品库存”,如果两个线程抢锁的顺序不一样,就会触发死锁。
问题代码(带注释)

import org.springframework.stereotype.Service;

@Service
public class OrderService {
    // 两个共享资源的锁:用户余额锁、商品库存锁(相当于菜刀和砧板)
    private final Object balanceLock = new Object(); // 用户余额锁
    private final Object stockLock = new Object();  // 商品库存锁

    // 线程1执行:先抢用户余额锁,再抢商品库存锁
    public void processOrder1(String userId, String productId) {
        // 1. 先锁用户余额(厨师A先拿菜刀)
        synchronized (balanceLock) {
            System.out.println("线程1:拿到用户余额锁,准备拿商品库存锁");
            
            // 模拟查询用户余额的耗时(让线程2有时间抢另一个锁)
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }

            // 2. 再锁商品库存(厨师A想拿砧板,但砧板被线程2拿了)
            synchronized (stockLock) {
                System.out.println("线程1:拿到商品库存锁,开始处理订单");
                // 实际业务:扣用户余额、减商品库存
            }
        }
    }

    // 线程2执行:先抢商品库存锁,再抢用户余额锁
    public void processOrder2(String userId, String productId) {
        // 1. 先锁商品库存(厨师B先拿砧板)
        synchronized (stockLock) {
            System.out.println("线程2:拿到商品库存锁,准备拿用户余额锁");
            
            // 模拟查询商品库存的耗时(让线程1有时间抢另一个锁)
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }

            // 2. 再锁用户余额(厨师B想拿菜刀,但菜刀被线程1拿了)
            synchronized (balanceLock) {
                System.out.println("线程2:拿到用户余额锁,开始处理订单");
                // 实际业务:扣用户余额、减商品库存
            }
        }
    }
}

测试代码(模拟两个线程同时处理订单)

public class DeadLockTest {
    public static void main(String[] args) {
        OrderService orderService = new OrderService();

        // 线程1:执行processOrder1(先抢余额锁,再抢库存锁)
        new Thread(() -> {
            orderService.processOrder1("用户1", "商品1");
        }, "订单线程1").start();

        // 线程2:执行processOrder2(先抢库存锁,再抢余额锁)
        new Thread(() -> {
            orderService.processOrder2("用户2", "商品2");
        }, "订单线程2").start();
    }
}

问题结果
运行后控制台会打印:

线程1:拿到用户余额锁,准备拿商品库存锁
线程2:拿到商品库存锁,准备拿用户余额锁

然后程序就卡住了 —— 线程 1 拿着balanceLock(菜刀),一直等stockLock(砧板);线程 2 拿着stockLock(砧板),一直等balanceLock(菜刀)。两个线程永远在等对方放手,既不会继续执行,也不会报错,就像 “僵持住了”,这就是死锁的典型现象。

3.3 问题原因:满足死锁的 4 个 “必要条件”

死锁不是随便就能发生的,必须同时满足以下 4 个条件,缺一不可:

  • 互斥条件:资源(比如锁)只能被一个线程持有(菜刀只能被一个厨师拿);
  • 持有并等待:线程拿到一个资源后,不放手,还在等另一个资源(厨师 A 拿了菜刀,不放下,还等砧板);
  • 不可剥夺:线程持有的资源不能被强行抢走(没人能从厨师 A 手里抢菜刀);
  • 循环等待:线程之间形成 “你等我、我等你” 的循环(线程 1 等线程 2 的锁,线程 2 等线程 1 的锁)。

3.4 解决方案:打破 “循环等待”(最常用、最有效)

死锁的 4 个条件里,“循环等待” 是最容易打破的 —— 只要让所有线程都按同一个顺序抢锁,就不会出现 “互相等” 的情况。比如规定:所有线程都必须 “先抢商品库存锁,再抢用户余额锁”。
修复代码(带注释)

import org.springframework.stereotype.Service;

@Service
public class OrderService {
    private final Object balanceLock = new Object(); // 用户余额锁
    private final Object stockLock = new Object();  // 商品库存锁

    // 线程1执行:按“先库存锁、再余额锁”的顺序抢锁
    public void processOrder1(String userId, String productId) {
        // 第一步:先抢商品库存锁(所有线程统一先拿砧板)
        synchronized (stockLock) {
            System.out.println("线程1:拿到商品库存锁,准备拿用户余额锁");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }

            // 第二步:再抢用户余额锁(再拿菜刀)
            synchronized (balanceLock) {
                System.out.println("线程1:拿到用户余额锁,开始处理订单");
                // 实际业务:扣用户余额、减商品库存
            }
        }
    }

    // 线程2执行:同样按“先库存锁、再余额锁”的顺序抢锁
    public void processOrder2(String userId, String productId) {
        // 第一步:先抢商品库存锁(统一顺序,避免循环等待)
        synchronized (stockLock) {
            System.out.println("线程2:拿到商品库存锁,准备拿用户余额锁");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }

            // 第二步:再抢用户余额锁
            synchronized (balanceLock) {
                System.out.println("线程2:拿到用户余额锁,开始处理订单");
                // 实际业务:扣用户余额、减商品库存
            }
        }
    }
}

修复后结果
再运行测试代码,控制台会打印:

线程1:拿到商品库存锁,准备拿用户余额锁
线程1:拿到用户余额锁,开始处理订单
线程2:拿到商品库存锁,准备拿用户余额锁
线程2:拿到用户余额锁,开始处理订单

线程 1 先拿到stockLock,处理完后释放两个锁;
线程 2 等线程 1 释放stockLock后,再按顺序抢锁;
没有了“循环等待”,死锁彻底消失,两个线程都能正常处理订单。

3.5 额外技巧:用工具排查线上死锁

如果线上程序卡住,怀疑是死锁,可以用 JDK 自带的工具快速定位:

第一步:查 Java 进程 ID
打开命令行,输入 jps(Java Process Status),找到你的项目进程 ID(比如 1234);
第二步:查线程堆栈
输入 jstack 1234(jstack 是查看线程堆栈的工具),JVM 会自动检测死锁,并在日志中标记出来,比如:

Found one Java-level deadlock:
=============================
"订单线程1":
  waiting for monitor entry [0x000000001e66f000]
  - waiting to lock <0x000000076b5a2060> (a java.lang.Object) // 等stockLock
  - locked <0x000000076b5a2050> (a java.lang.Object) // 已持有balanceLock
"订单线程2":
  waiting for monitor entry [0x000000001e770000]
  - waiting to lock <0x000000076b5a2050> (a java.lang.Object) // 等balanceLock
  - locked <0x000000076b5a2060> (a java.lang.Object) // 已持有stockLock

从日志能清晰看到两个线程互相等对方的锁,快速定位死锁问题。

四、总结:多线程问题的核心规律

  • 线程安全问题:核心是 “多线程抢改共享数据”,解决方案是 “加锁(synchronized/ReentrantLock)”
    “用线程安全类(AtomicInteger)”
  • 线程池耗尽问题:核心是 “线程不够用 + 任务排满”,解决方案是 “自定义线程池,合理配置核心线程数、队列大小”
  • 死锁问题:核心是 “线程抢锁顺序不一致”,解决方案是 “统一锁顺序”“用 tryLock () 超时放弃”

这些问题看似复杂,但只要结合 “生活类比” 和 “实际业务场景”,再对照带注释的代码,慢慢就能找到规律 —— 多线程的本质是
“协调多人干活”,只要规则明确(比如锁顺序、线程数),就能避免大部分坑

Logo

惟楚有才,于斯为盛。欢迎来到长沙!!! 茶颜悦色、臭豆腐、CSDN和你一个都不能少~

更多推荐