Java 多线程避坑指南:3个常见问题与实战解决方案
就像餐馆根据客流量调整厨师数量,我们给@Async配置一个合适的线程池,设置 “核心线程数”“最大线程数”“队列大小”。自定义线程池代码// 线程池配置类,Spring Boot启动时会加载这个配置 @Configuration public class ThreadPoolConfig {
实际开发中,多线程就像 “多人协作干活”—— 没协调好就会出乱子。这篇文章用生活里的常见场景,带你看懂多线程最常踩的 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 () 超时放弃”
。
这些问题看似复杂,但只要结合 “生活类比” 和 “实际业务场景”,再对照带注释的代码,慢慢就能找到规律 —— 多线程的本质是
“协调多人干活”,只要规则明确(比如锁顺序、线程数),就能避免大部分坑
更多推荐
所有评论(0)