说起Java多线程,我最早踩坑是在一个库存扣减模块里。当时并发量一上去,库存偶尔多扣,白天压测复现不出来,晚上数据一对账就出问题。折腾好几天才定位到是多个线程同时读一个变量、再写回去,中间被其他线程插了一脚,典型的“读改写”竞争。从那之后我才意识到,多线程不是“new Thread再start”就完事,它背后是CPU调度、内存可见性、锁竞争、线程协作这一整套机制。
这篇文章把Java多线程从入门到实战的关键点串起来讲清楚,包括线程生命周期、创建方式、并发安全、线程池参数、并发工具、死锁排查和性能调优。适合刚接触并发的读者,也适合已经写过一段时间并发代码、想系统把底层逻辑补一遍的人。我不打算按教科书顺序铺开,而是按平时写代码时“先建线程、再保证安全、然后管线程池、最后排查调优”这条实际路径来讲,里面会穿插我自己踩过的坑和现在还在用的做法。
1. 线程到底是什么:从CPU调度理解Java线程的六种状态
1.1 一条线程在CPU眼里是什么样子
先明确一个基本事实:Java里的一条线程,在主流虚拟机上基本对应操作系统的一条原生线程。也就是说,你在代码里new一个线程,操作系统就要给它分配内核资源;线程执行的时候,是CPU把一个物理核心的时间片轮流分给不同线程来用的。
这就引出一个很关键的词:上下文切换。CPU从一个线程切到另一个线程时,要把当前线程的寄存器、程序计数器、栈指针这些执行现场保存下来,再加载下一个线程的现场,这个动作是有开销的。我常用的类比是厨房里多个厨师共用一个灶台:换人的时候,不仅要把手上的菜放下,还得把砧板位置、调料摆放都记清楚,下一个厨师才能接着干。如果一分钟换十次人,大部分时间都花在交接上了。
所以“线程越多越好”是个误区。线程数超过核心数过多,系统会花大量时间做上下文切换,吞吐量反而下降。这也是后面讲线程池参数为什么不能乱设的底层原因。
1.2 NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED的迁移细节
线程状态是面试常考、写代码却容易忽视的东西。Java的Thread.State枚举定义了六种状态,但很多人对RUNNABLE的理解是错的。
| 状态 | 含义 | 进入方式 | 离开方式 |
|---|---|---|---|
| NEW | 线程对象已创建,未调用start | new Thread(...) | 调用start |
| RUNNABLE | 可运行/正在运行 | start之后 | 被调度/阻塞/等待/终止 |
| BLOCKED | 阻塞在锁上,等监视器锁 | 进不了synchronized块 | 拿到锁 |
| WAITING | 无限期等待 | wait、join、park | 被notify/notifyAll/unpark |
| TIMED_WAITING | 限时等待 | sleep、wait(timeout)、join(timeout) | 时间到或被唤醒 |
| TERMINATED | 已终止 | run方法执行完或异常 | 无 |
最容易被误解的是RUNNABLE。Java的RUNNABLE其实把操作系统里的“运行中”和“就绪”合并了。一个线程在Java里显示RUNNABLE,可能它正被CPU执行,也可能只是排在可运行队列里等时间片。所以看到RUNNABLE不代表它一直在跑。
还有几个迁移细节我在实际排查中经常用到:
Thread.sleep()进入TIMED_WAITING,但它不会释放已经持有的锁。很多人以为sleep会让锁,实则不会,这在设计临界区时容易造成无意义的长时间持锁。wait()必须在synchronized块或方法里调用,否则抛IllegalMonitorStateException;wait会释放锁。- 一个线程不能重复调用
start(),第二次会抛IllegalThreadStateException。 Thread.stop()、suspend()、resume()都是废弃方法,不要用。stop会在任意位置强制终止线程,可能让数据写到一半,破坏对象状态。
我在看线上dump时,常用jstack输出的大多是WAITING、TIMED_WAITING、BLOCKED三种阻塞状态。如果一个线程长时间停在BLOCKED,说明它在等锁且一直没等到,下一步就是找出谁持着锁不撒手,通常是死锁或某段临界区里有慢操作。
2. 创建线程的几种姿势:我为什么最后选择了任务模型
2.1 从Thread、Runnable到Callable的演进
早期学多线程,最常见的写法是继承Thread类,重写run方法:
public class MyTask extends Thread { @Override public void run() { System.out.println("执行任务: " + Thread.currentThread().getName()); } } // 使用 MyTask task = new MyTask(); task.start();这种写法把“要执行的任务”和“执行任务的线程”耦合在一起了。一个类只能继承一个父类,扩展性差,而且任务逻辑和线程生命周期混在一处,后期想改用线程池得改代码。
后来大家习惯改成实现Runnable:
Runnable task = () -> System.out.println("执行任务: " + Thread.currentThread().getName()); Thread thread = new Thread(task); thread.start();Runnable把任务和线程拆开,配合Lambda写起来简洁多了。但它有个硬伤:run方法没有返回值,也不能抛受检异常。如果任务里要计算一个结果,或者网络调用失败后想让调用方感知,Runnable就不好用了。
于是就有了Callable和FutureTask:
Callable<Integer> callable = () -> { // 模拟耗时计算 Thread.sleep(100); return 42; }; FutureTask<Integer> futureTask = new FutureTask<>(callable); new Thread(futureTask).start(); Integer result = futureTask.get(); // 获取结果,会阻塞等待 System.out.println(result);Callable的call方法有返回值,还能抛异常。FutureTask.get()会阻塞到任务完成,任务异常时会抛ExecutionException。这套组合虽然解决了返回值问题,但仍绕不开“手动管理线程”的麻烦。
2.2 用ExecutorService和CompletableFuture组织并发任务
我现在写并发任务,默认不会直接new Thread,而是交给线程池。原因很直接:线程的创建和销毁成本高,频繁创建销毁会拖垮性能;同时系统里线程数量必须受控,否则无限制创建线程,早晚把内存和CPU耗尽。
ExecutorService pool = Executors.newFixedThreadPool(8); Future<Integer> future = pool.submit(() -> { Thread.sleep(200); return 100; }); Integer result = future.get(); pool.shutdown();如果任务之间还有依赖,我会用CompletableFuture把异步流程串起来,比手动用Future去get清晰不少:
ExecutorService pool = Executors.newFixedThreadPool(8); CompletableFuture.supplyAsync(() -> queryPrice("商品A"), pool) .thenApply(price -> price * 0.9) .thenAccept(finalPrice -> System.out.println("最终价格: " + finalPrice)) .exceptionally(ex -> { System.out.println("计算失败: " + ex.getMessage()); return null; }); pool.shutdown();supplyAsync负责异步执行,thenApply把结果串行加工,exceptionally处理异常,整条链路由CompletableFuture内部协作,不需要我自己维护Future列表。
JDK 21之后虚拟线程是另一个值得关注的方向。虚拟线程由虚拟机调度,成本很低,适合大量阻塞型任务。但要注意:虚拟线程并不解决锁竞争和共享数据安全问题,该加锁的地方还是得加;线程池、ThreadLocal这类模型在不同场景下的取舍也需要重新评估。
3. 并发安全的底层契约:JMM、volatile、synchronized与Lock的取舍
3.1 JMM与可见性:为什么加了volatile还是不对
并发问题概括起来就三类:原子性、可见性、有序性。Java内存模型(JMM)规定线程之间的共享变量存储在主内存,每个线程自己有一份工作内存(相当于缓存)。线程读变量时先读到自己工作内存,写变量时也是先改工作内存,再由某条 flush 路径同步回主内存。
如果两个线程同时读写同一个变量,又没有同步机制,一个线程的修改可能对另一个线程不可见。这就是“加了volatile还是不对”的常见根源:volatile能保证可见性,也能禁止指令重排序,但它不保证复合操作的原子性。
举个例子:
private volatile int count = 0; // 多线程并发执行 count++;count++不是一条CPU指令,而是“读旧值、加1、写回”三步。即使count是volatile,两个线程可能同时读到旧值100,各自加1,写回101,正确结果应该是102,实际上丢了一次更新。volatile只保证这次写操作对其他线程可见,不保证这个“读改写”过程不被交错。要保证原子性,得用synchronized、Lock,或者AtomicInteger这类CAS原子类。
JMM里的happens-before规则是理解可见性的核心。一条规则是一个线程内写操作先行发生于后续读操作;加锁的解锁操作先行发生于后续获取同一把锁的加锁操作;volatile变量的写先行发生于后续对该变量的读。这些规则不是玄学,而是虚拟机在执行代码时必须遵守的排序约束。
3.2 synchronized、ReentrantLock与CAS的选择矩阵
synchronized经过这么多年的优化,性能已经不是早年那种一用就心疼的状态了。它实现了偏向锁、轻量级锁、重量级锁的膨胀升级路径;锁竞争不激烈时开销很低,代码也简洁。我现在的选择习惯是:能用一个synchronized包住简单临界区,就不引入更复杂的Lock。Lock只有在确实需要“超时等待锁、响应中断、多个条件队列、公平锁”时才值得用。
ReentrantLock的典型用法:
ReentrantLock lock = new ReentrantLock(); // 带超时的加锁,避免死锁式等待 boolean locked = lock.tryLock(2, TimeUnit.SECONDS); if (locked) { try { // 临界区 } finally { lock.unlock(); } }tryLock带超时是我特别推荐的做法。它给“拿不到锁”设了一个上限,不像synchronized那样无限等下去。另一个有价值的特性是Condition,可以精确唤醒某一类线程,比如队列满时通知生产者、队列空时通知消费者,比wait/notifyAll一窝蜂唤醒高效得多。
CAS(Compare And Swap)则走的是另一条路:不在临界区里做操作,而是先试,改之前对比期望值和当前值,一致才更新。Java里的原子类就是基于CAS实现的:
AtomicInteger counter = new AtomicInteger(0); counter.incrementAndGet(); // 原子自增CAS适合竞争不激烈、临界区很短的场景;竞争特别激烈时,CAS会反复重试,反而消耗CPU。LongAdder就是为高竞争设计的,内部把计数分散到多个单元,减少CAS冲突,但它的值是近似值,不适合需要强一致读写的场景。
选型上我一般按这个矩阵来:
| 场景特点 | 推荐方案 | 理由 |
|---|---|---|
| 简单临界区、低竞争 | synchronized | 代码简单,JDK已深度优化 |
| 需要超时/中断/多条件队列 | ReentrantLock | 灵活性远超synchronized |
| 单变量计数、状态标记 | AtomicInteger/AtomicBoolean | 无锁、轻量 |
| 高并发统计,允许近似值 | LongAdder | 分段减少CAS冲突 |
| 读多写少 | ReadWriteLock/StampedLock | 读写分离,读线程不互斥 |
3.3 锁的对象与粒度:static synchronized是个隐藏陷阱
synchronized锁的是对象。普通方法锁的是当前实例(this),静态方法锁的是当前类的Class对象。两者不是同一把锁。我见过有人写一个工具类,实例方法加了synchronized,但业务代码用new 工具类()分别调用,等于每把锁各锁各的,完全没互斥。
锁的粒度也是一门学问。很多人为了省事,把整个方法括起来:
public synchronized void process() { // 大量只读操作 // 少量写操作 }如果方法里有耗时很长的只读操作,或者调了远程接口,锁一直被占着,其他线程全堵在外面,吞吐量惨不忍睹。更好的做法是缩小临界区范围,把锁只放在真正修改共享数据的那几行代码上,只读部分完全可以在锁外执行。
4. 线程池:参数怎么定、坑怎么避
4.1 ThreadPoolExecutor参数怎么定:别背公式,先想清楚任务类型
线程池的核心类是ThreadPoolExecutor,它接受七个参数。很多人想背一个公式一劳永逸,但实际工程里没有万能公式,得先分清任务类型:
- CPU密集型任务:线程池大小可以设为Ncpu + 1,Ncpu是可用核心数,多出来的1是为了弥补偶尔的等待或调度偏差。
- IO密集型任务:线程在做计算之外会大量等待IO(数据库查询、远程调用),这时可以开更多线程,常见估算思路是 Ncpu * (1 + 等待时间 / 计算时间)。等待占比越高,线程数越可以放大。
- 混合型任务:最好拆成CPU密集和IO密集两部分,用不同线程池,避免互相拖累。
但公式只是起点。我现在的习惯是先按估算值设一个初始参数,再用压测调整:把QPS、RT、CPU占用率拉出来看,线程池大小从估算值开始逐档加,观察什么数值下RT曲线开始劣化。调优不是一次性的,每次上线都要留监控。
核心线程数和最大线程数的配合也要理解透彻。ThreadPoolExecutor的处理顺序是:请求先交给核心线程;核心线程满了,任务进入工作队列;队列满了,才创建非核心线程;线程数到了最大值且队列也满,才触发拒绝策略。很多人以为“先到最大线程,再进队列”,这个顺序记反了,参数怎么调都不会对。
我常用的一个有界队列配置:
ThreadPoolExecutor executor = new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 60L, TimeUnit.SECONDS, // 非核心线程空闲存活时间 new ArrayBlockingQueue<>(200), // 有界队列 new ThreadFactory() { private final AtomicInteger index = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setName("order-check-thread-" + index.getAndIncrement()); t.setDaemon(false); return t; } }, new ThreadPoolExecutor.AbortPolicy() );这里有三个点我需要单独解释:
- 为什么用有界队列而不是无界队列?
Executors.newFixedThreadPool()默认用的是无界LinkedBlockingQueue,任务排不满,最大线程数这个参数永远不会生效,一旦任务堆积,队列无限增长,内存迟早顶不住。有界队列能逼着你在“加线程”和“拒绝任务”之间做选择。 - 为什么自定义线程工厂?默认线程名字是
pool-1-thread-1,线上看线程dump时很难对到业务。把线程名改成带业务含义,排查效率会高很多。 - 拒绝策略选
AbortPolicy还是CallerRunsPolicy?AbortPolicy直接抛RejectedExecutionException,适合任务不能丢;CallerRunsPolicy让提交任务的线程自己执行,适合不希望任务丢弃但可以反向压制的场景。每种策略都有适用前提,不要照搬。
4.2 线程池里最容易踩的坑
第一个坑是execute和submit的异常处理不一样。通过execute提交任务,任务抛出的异常会被线程池内部捕获并打印到日志;通过submit提交任务,异常会被封装在返回的Future里,如果你不调用future.get(),异常就悄无声息丢了。我排查过一个线上问题,某个定时任务从不报错,数据库里却少了数据,最后发现就是用submit提交但没人get(),异常全被吞了。
第二个坑是线程池关闭。shutdown()会等已提交任务执行完,而shutdownNow()会尝试中断正在执行的任务并返回未执行的任务列表。强制中断不保证任务真的停止,因为如果任务不响应中断,它还是会继续跑。正确做法通常是先shutdown(),再awaitTermination限时等待,超时后还没结束再考虑进一步处理。
第三个坑是ThreadLocal在线程池里的“脏数据”和内存泄漏。普通线程用完就销毁,ThreadLocal的生命周期随线程结束;线程池里的线程是复用的,如果业务代码在任务结束前没调用remove(),ThreadLocal里的值会被下一个任务读到,造成数据串味。更隐蔽的是,如果ThreadLocal里放了一个大的对象,又没remove,线程一直存活,大对象就一直挂在线程上,内存占用越来越高。我的习惯是:在线程池任务里用ThreadLocal,就一定要在finally里remove:
threadLocal.set(userContext); try { // 业务逻辑 } finally { threadLocal.remove(); }线程池参数和用法的细节很多,但核心思路就是“线程是稀缺资源,必须受控;任务可以被丢弃,但必须是你明确选择的丢弃方式”。
5. 高并发场景的趁手工具:协调器与并发容器
5.1 用CountDownLatch、CyclicBarrier、Semaphore协调任务
多线程不只是各自跑,很多时候需要彼此配合。Java并发包里几个协调工具,我做过的项目基本都用到过。
CountDownLatch适合“主线程等N个子任务都完成再继续”的场景。典型例子是并行调用多个外部接口,全部返回后再聚合结果:
int taskCount = 5; CountDownLatch latch = new CountDownLatch(taskCount); ExecutorService pool = Executors.newFixedThreadPool(5); for (int i = 0; i < taskCount; i++) { int id = i; pool.submit(() -> { try { // 调用外部接口 } finally { latch.countDown(); // 一定要放在finally里 } }); } boolean completed = latch.await(10, TimeUnit.SECONDS); System.out.println("全部完成? " + completed);这里有两个关键细节:countDown()必须放在finally里,否则任务异常时latch计数永远减不满,主线程会一直阻塞;await要带超时,不能无限等下去,否则子任务卡死会连累主线程。
CyclicBarrier和CountDownLatch的区别在于,它是“一组线程互相等待,都到齐后一起继续”,而且可以重置后重复使用。适合分阶段并行计算的场景,比如多线程处理分批数据,每批都等待所有线程处理完再进入下一批。
Semaphore是信号量,更像一个“许可证池”。我在限流场景里用过它,控制同时最多只有N个线程执行某段逻辑:
Semaphore semaphore = new Semaphore(3); for (int i = 0; i < 10; i++) { pool.submit(() -> { try { semaphore.acquire(); // 同时最多3个线程进入 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { semaphore.release(); } }); }Semaphore还有一个容易被忽略的点:acquire可被中断,捕获InterruptedException后最好重新设置中断标志位,不要吞掉中断信号。
5.2 并发容器与原子类:从ConcurrentHashMap到BlockingQueue
集合类的线程安全是重灾区。早期的Hashtable和synchronizedMap相当于所有方法加一把大锁,并发时所有线程抢同一把锁,性能很差。ConcurrentHashMap的设计是分段锁或CAS+Synchronized对桶加锁,读操作基本无锁,写操作只锁当前桶,并发度大幅提升。
Java 8之后的ConcurrentHashMap,在并发度要求高的场景几乎是默认选择。但它也有不适用的时候:如果业务需要“先查再改”的复合操作,比如“如果key不存在就放入”或者“给某个key的value累加”,单独调用putIfAbsent、compute这些方法能保证原子性,可是“组合多个方法调用”本身不是原子的。需要用compute、merge这类原子方法,或者自己加锁。
BlockingQueue则是生产-消费模型的核心。它的put和take会阻塞,队列满时生产者等待,队列空时消费者等待,天然解决了线程间的速率匹配问题。三种常用实现各有侧重:
| 实现 | 特点 | 适用场景 |
|---|---|---|
| ArrayBlockingQueue | 有界、基于数组,公平性可选 | 需要限制队列容量 |
| LinkedBlockingQueue | 可选有界,基于链表 | 吞吐量优先,无界/有界均可 |
| SynchronousQueue | 不存数据,直接交接 | 任务少、需要直接转交 |
我用ArrayBlockingQueue实现过一个最简单的任务分发:生产者把任务放进队列,消费者从队列取任务执行,队列大小就是系统的背压阈值。队列一旦满了,生产者被阻塞,等于系统自动放慢了任务生成速度,这种设计比“生产者拼命堆积、消费者慢慢消费”健康得多。
原子类方面,AtomicInteger、AtomicLong、AtomicReference覆盖了大部分单变量原子操作。AtomicInteger的自增和自减是基于CAS完成的,在低竞争下性能很好;高竞争下我更推荐LongAdder,它把计数分散到多个cell里,写操作只更新其中一个cell,读取时汇总,虽然读到的不是绝对精确的当前值,但对统计类场景完全够用。
6. 排查与调优:死锁定位、锁竞争与我的优化清单
6.1 死锁的完整排查链路
死锁是并发问题里最让人头疼的一种,症状是程序卡住、CPU占用很低、线程全部BLOCKED。形成死锁需要四个条件同时满足:互斥、持有并等待、不可剥夺、循环等待。工程里最常见的是“循环等待”,也就是两个线程各自持有一把锁,又反过来等对方手里的锁。
我用一个模拟的转账场景演示过这个经典问题:两个账户互相转账,线程A持有账户1的锁,想申请账户2的锁;线程B持有账户2的锁,想申请账户1的锁。两边都等对方释放,程序就僵住了。
public class TransferDeadlockDemo { private static final Object lockA = new Object(); private static final Object lockB = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (lockA) { System.out.println("t1 持有 lockA"); try { Thread.sleep(100); } catch (InterruptedException ignored) {} synchronized (lockB) { System.out.println("t1 获得 lockB"); } } }); Thread t2 = new Thread(() -> { synchronized (lockB) { System.out.println("t2 持有 lockB"); try { Thread.sleep(100); } catch (InterruptedException ignored) {} synchronized (lockA) { System.out.println("t2 获得 lockA"); } } }); t1.start(); t2.start(); } }排查时我的链路是固定的:先用jps找到进程ID,再用jstack <pid>导出线程栈,搜索deadlock关键字,或者看Found one Java-level deadlock这段输出。jstack会直接指出哪两个线程各自持有哪把锁、正在等待哪把锁。如果没有这类信息,就把长时间处于WAITING或BLOCKED的线程栈打出来,沿着栈帧里的锁信息往上追。
避免死锁有几个方向:
- 加锁顺序全局统一。比如多个锁必须按固定顺序获取,所有线程都先拿编号小的锁,再拿编号大的锁,循环等待就破除了。
- 使用
tryLock(timeout),拿不到锁就放弃或重试,而不是无限等。 - 缩小临界区,减少持锁时间,降低死锁概率。
上面那个转账场景,把“按账户ID排序后再加锁”即可解决:两个线程都先锁ID小的账户,不会形成循环等待。
6.2 锁竞争与无锁化:性能调优的几个方向
锁竞争是并发性能最大的敌人。当一个线程持锁时间过长,其他线程只能原地等待,CPU时间片被白白浪费。我调优时通常按下面这个清单逐步排查:
| 症状 | 可能原因 | 优化方向 |
|---|---|---|
| 线程大量BLOCKED,CPU利用率低 | 锁竞争严重 | 缩小临界区、减少锁持有时间 |
| CPU高但吞吐上不去 | CAS重试多/任务太碎 | 分段计数、批处理任务 |
| 某变量读写频繁但一致性要求低 | 过度同步 | 用ThreadLocal或副本数据 |
| 多线程读一个数组/字段 | 伪共享 | 补齐缓存行或重新设计数据结构 |
| 某线程池大量线程创建销毁 | 参数不合理 | 调大核心线程数、用有界队列 |
锁分离是我很常用的手段。比如一个缓存结构,读多写少,就适合用ReadWriteLock:多个读线程可以同时持有读锁,只有写线程需要独占写锁。更极端一点,能把数据按key分片,每片一把锁,竞争进一步分散。
无锁化是更高级的方向。核心思路是用CAS配合不可变对象,让线程之间不共享可变状态。比如状态机里的状态流转,用AtomicReference做CAS更新,比每次加锁优雅得多。但是无锁代码对细节要求很高,CAS的ABA问题、循环重试的CPU消耗都要考虑,我不会为了炫技硬上无锁。
伪共享是另一个容易被忽略的性能杀手。CPU缓存是以缓存行为单位加载数据的,大约64字节。如果两个变量恰好落在同一个缓存行,又被不同线程频繁修改,CPU缓存一致性协议会让这两个核心不断互相通知缓存失效,性能损耗极大。JDK里的@Contended注解可以给字段加上填充,把变量放到不同缓存行,但只能在JDK内部或特定JDK版本里用,自己项目里用需要在启动参数加--add-exports相关配置,所以一般不建议业务代码直接依赖。
6.3 一次实际调优的复盘
之前优化一个定时汇总任务,最初用synchronized锁住整个汇总方法,10个线程跑,大量时间花在等待锁上。我先用jstack看线程状态,发现几乎所有线程都在BLOCKED,只有一条线程在执行。这说明锁粒度太大。
第一步把“只读配置加载”移出临界区,锁只保护最终写结果的部分;第二步把汇总逻辑改成用ConcurrentHashMap的compute做原子累加,不再用全局锁;第三步把单一大任务拆成小块,扔进线程池并行处理。优化后整体耗时降到原来的三分之一左右,线程的BLOCKED比例也基本消失。
那次复盘给我留下一个很深的体会:并发优化不是一上来就换工具,而是先用监控和线程dump看清瓶颈到底在锁上、在CPU上还是在IO等待上。手段用错方向,越优化越糟糕。
最后分享一个小技巧:排查并发问题,不要只看代码,要看线程现场。代码里再复杂的逻辑,线程dump一出来,谁在等谁、谁持锁不撒手、谁在空转,往往一眼就能看出来。平时在测试环境里导入一个并发压测脚本,故意把线程数量调大,然后把线程dump留档,等出了问题再对照正常时的状态,排查速度会快很多。多线程这个东西,理论学得再多,都不如亲手把问题复现一遍、再看着线程栈把它解开,来得印象深刻。