接到一个外部数据聚合的改造任务:上游渠道一共有60多个,单渠道响应时间在150到800毫秒不等,业务要求3秒内把所有数据拉齐后返回。最开始自然是顺序调用,按平均耗时算下来要跑小半分钟,完全没法看。我当时的处理方式很直接——把每个渠道的请求封装成独立任务,丢进线程池并发执行,整体耗时立刻变成“最慢的那个渠道”的耗时,基本都压在1秒左右。这个场景在后台开发里太常见了:批量上报、异步通知、并行聚合查询、消息消费……只要涉及Java服务端,多线程就是绕不开的技术底座。
它同时也是面试现场出现频率最高的热点词之一。面试官通常从“创建线程有哪几种方式”这类基础问题切入,一路问到线程生命周期、原子性可见性、生产者消费者、线程池参数,直到你答不上来为止。所以这篇我打算按“理解原理 → 会写代码 → 能排故障 → 扛得住追问”的顺序,把Java多线程里真正值得掌握的东西完整过一遍。无论你是准备java面试的在校生,还是已经写了几年业务代码、一碰到并发就心里没底的开发,这篇文章应该都能帮你把知识体系梳理得更扎实。
1. 多线程到底在解决什么问题:先想清楚需求再动手
1.1 并发与并行:看起来像、其实是两件事
先纠正一个最容易被混淆的概念:并发和并行并不是同义词。并行的核心是“同一时刻有多个任务真在同时执行”,它依赖多核CPU;并发的核心是“多个任务在一段时间内都有推进”,底层靠时间片切换实现,哪怕只有一个核也可以并发。
我用一个生活化的例子来记:一个人做饭,锅里炒着菜又抽空去切葱,这叫并发;两个人分工,一个切菜一个炒菜,这叫并行。Java里我们new出来的Thread,本质是操作系统线程的映射,你开200个线程跑在8核机器上,绝大多数时刻它们并不是真的同时执行,而是被调度器拆成一个个时间片快速轮换。理解这一点很重要,因为很多性能预期落空,都是误以为“开多线程就等于占用更多核”。
1.2 多线程的收益与代价:没有免费的午餐
多线程带来的收益很直观:系统吞吐量上去了,单位时间内能处理更多请求;整体延迟也可能降下来,就像聚合接口那样。但代价同样真实存在,而且常常被低估:
- 上下文切换开销。线程切换要保存和恢复寄存器、程序计数器、栈信息,切换频率高了,CPU大量时间花在切换上而不是干活上。
- 内存占用。每个线程都有自己的虚拟机栈,默认栈大小在1MB左右,开1000个线程光栈就吃掉1GB内存。
- 数据竞争与不确定性。多个线程同时读写共享变量,结果不可预测,这是最折磨人的部分。
- 死锁、活锁、饥饿等协作问题。
- 调试复杂度上升。并发bug往往不稳定复现,线上偶发,本地复现不出来。
所以,不是所有场景都该上多线程。任务本身执行时间极短、任务之间有强串行依赖、或者真正的瓶颈在某个无法并发缩短的资源上,这时候引入多线程反而会让系统更慢、更难维护。我自己的判断标准是:先算清楚“串行耗时是否超过业务容忍线”,如果没超过,不要为了技术花哨去加并发。
2. 创建线程的四条路:从继承Thread到线程池
2.1 继承Thread和实现Runnable:两条传统路线及各自的坑
创建线程最原始的方式是继承Thread类,重写run方法:
public class MyThread extends Thread { @Override public void run() { System.out.println("线程执行中:" + Thread.currentThread().getName()); } public static void main(String[] args) { MyThread t = new MyThread(); t.start(); } }这条路的缺点很明显:Java是单继承,你一旦继承了Thread就不能再继承其他业务类;而且任务逻辑直接写在线程类里,把“要执行的任务”和“执行任务的线程”耦合死了。
于是有了第二种方式:实现Runnable接口,将任务本身做成一个独立的类或者Lambda:
public class RunnableDemo { public static void main(String[] args) { Thread t = new Thread(() -> System.out.println("任务执行:" + Thread.currentThread().getName())); t.start(); } }Runnable把任务和线程解耦了,也解决了继承限制。但它仍然有两个历史遗留短板:run方法没有返回值,而且不能抛出受检异常。如果你需要线程执行完带回结果,就得换第三条路。
这里还有一个面试高频陷阱:调用start()和直接调用run()有什么区别?很多人会答错。直接调用run()其实只是普通方法调用,还是在当前线程里同步执行;只有调用start()才会真正创建新线程,并由新线程去执行run()里的逻辑。
2.2 Callable与Future:让线程带回结果
业务里大量场景需要并发计算后汇总结果,比如同时请求三个服务,把它们的结果拼在一起。Runnable干不了这个活,所以要请出Callable:
ExecutorService pool = Executors.newFixedThreadPool(3); Future<String> f1 = pool.submit(() -> { // 模拟远程调用 Thread.sleep(300); return "订单服务结果"; }); Future<String> f2 = pool.submit(() -> { Thread.sleep(500); return "库存服务结果"; }); // 汇总 String result = f1.get(2, TimeUnit.SECONDS) + f2.get(2, TimeUnit.SECONDS);注意get()方法是阻塞的,也就是说调用它会一直等到任务执行完。所以线上代码里强烈建议给get()加超时,否则一个远程调用卡死,整个聚合线程就一起挂住。我见过太多因为没设超时导致线程池线程被占满的事故。
2.3 线程池:生产环境真正的主力
虽然前面的例子用了线程池,但这里必须强调:生产环境不要裸new Thread。原因不复杂:线程的创建和销毁开销很大,频繁创建会拖垮系统;线程数量不受控,容易把内存和CPU打满;而且没有统一的拒绝策略和生命周期管理。线程池就是用来解决这些问题的,它提前创建一批线程,任务来了直接派发,任务结束后线程不销毁而是继续复用。
Java里最常用的是ThreadPoolExecutor,后面第6章会专门讲参数和坑。这里先给一个基本形态:
ThreadPoolExecutor executor = new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(100), new ThreadPoolExecutor.CallerRunsPolicy() );这里要说一句:阿里巴巴Java开发手册一直建议不要用Executors工具类直接创建线程池,因为它的默认参数有隐患,比如newFixedThreadPool用的是无界队列,任务积压多起来可能导致OOM。手动new ThreadPoolExecutor虽然代码啰嗦一点,但每个参数都是显式的,出问题的时候一眼能看出来。
3. 线程生命周期:看懂状态才能看懂排错日志
3.1 六个状态的迁移路径
Java线程一共就六个状态,面试时能把这六个状态和迁移条件完整说出来,基本就能过这一关:
| 状态 | 含义 | 进入条件 |
|---|---|---|
| NEW | 新建 | new Thread()后,还没调用start() |
| RUNNABLE | 可运行 | 调用start()后,正在运行或者等待CPU时间片 |
| BLOCKED | 阻塞 | 竞争synchronized锁失败,等待进入同步代码块 |
| WAITING | 无限等待 | 调用了wait()、join()、LockSupport.park() |
| TIMED_WAITING | 限时等待 | 调用了sleep(ms)、wait(timeout)、join(timeout) |
| TERMINATED | 终止 | run()正常执行完或抛出未捕获异常 |
很多人以为RUNNABLE就是“正在运行”,实际不是。Java把“等待CPU时间片”也归入RUNNABLE,所以一个线程处于RUNNABLE并不代表它此刻在跑。真正被阻塞在锁竞争时是BLOCKED,在等待某个条件变成WAITING或TIMED_WAITING。区分这些状态对排查问题极其有用。
3.2 sleep、wait、yield、join:四个让新手头晕的方法
这四个方法看起来都是“让线程歇一下”,实际机制完全不同。
sleep来自Thread类,调用后会让当前线程暂停指定的毫秒数,但它不释放任何锁。如果在一个synchronized代码块里sleep,其他线程照样进不来。
wait来自Object类,它必须先持有对象的监视器锁(即必须在synchronized代码块或方法里调用),调用后会释放锁,让其他线程有机会进入同步区。区别就在这里:sleep是“抱着锁睡”,wait是“放开锁等”。
yield是Thread类的静态方法,作用是礼貌性地让出当前CPU时间片,让同优先级的其他线程有机会执行。但它只是建议,调度器完全可以不理会,所以不要用yield来控制执行顺序。
join用于等待另一个线程执行完。比如主线程调用了child.join(),主线程会阻塞,直到child线程终止。它的底层实现其实依赖wait机制,面试被问到可以点一句。
这里给出一个面试最常考的对比,直接背下来就够用:
| 对比项 | sleep | wait |
|---|---|---|
| 属于谁 | Thread静态方法 | Object实例方法 |
| 是否需要锁 | 不需要 | 必须在synchronized中调用 |
| 是否释放锁 | 不释放 | 释放锁 |
| 唤醒方式 | 时间到自动恢复 | notify/notifyAll或时间到 |
| 使用场景 | 简单的暂停 | 线程间协作 |
3.3 通过线程转储分析卡死状态
理论看再多,最终都要落到排查上。线上应用卡住时,我最常用的第一板斧是jstack把线程转储拉下来看一眼:
jstack <pid> > dump.txt然后重点看大量线程堆积在什么状态。如果看到一堆线程处于BLOCKED,并且都在等待同一个锁,说明锁竞争非常严重,基本可以定位到热点代码;如果大量线程处于WAITING,并且停在某个池的get()调用上,通常是任务队列饥饿或外部依赖卡死;如果出现两个线程互相持有一把锁、同时等待对方释放另一把锁,日志里会有明显的“Found one Java-level deadlock”提示,那就是经典死锁。
有一次排查生产问题,我打开转储发现业务线程几乎全部WAITING在一个Future.get()上,进一步查是下游接口响应超时,但代码里没给get加超时,导致线程池被慢请求占满。这个案例再次说明:所有阻塞等待外部资源的操作,都必须有超时兜底。
4. 线程安全的三大难题:原子性、可见性、有序性
4.1 从i++字节码看原子性
先看一个再经典不过的例子:多线程同时对同一个int变量执行i++,结果是不可控的。很多人背过结论,但不理解为什么。把这段代码反编译看字节码就明白了:
public class Counter { private int count = 0; public void increment() { count++; } }对着字节码看,count++其实被拆成了好几步:
- 读取count当前值
- 将其加1
- 把新值写回count
两个线程可能同时读到旧值比如5,分别加完都写回6,结果本该是7。这个就是原子性被破坏:因为操作不是不可分割的整体。类似的还有check-then-act(先检查再操作)和复合操作,都是原子性问题的重灾区。
4.2 JMM与可见性:为什么volatile不够
除了原子性,还有个隐蔽得多的坑:可见性。Java内存模型规定,每个线程有自己的工作内存,变量计算时先从主内存拷贝一份到工作内存,操作完再刷回主内存。那么问题来了:一个线程修改了变量,另一个线程可能还一直读着旧值。
我写一个非常典型的复现场景:
public class VisibilityDemo { private static boolean flag = true; public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { while (flag) { // 空转 } System.out.println("worker退出"); }); worker.start(); Thread.sleep(1000); flag = false; // 主线程修改flag } }这段代码在多数机器上会一直死循环,因为worker线程看不到主线程对flag的修改。解决办法很简单:给flag加上volatile关键字。volatile保证两件事:线程修改变量后立即刷回主内存;其他线程读取时强制从主内存读最新值。这解决了可见性。同时volatile还能禁止指令重排,解决一部分有序性问题。
但注意:volatile不解决原子性。前面对i++的场景,即使把count声明为volatile,三个字节码步骤依然不是原子的,并发i++照样丢数据。所以volatile适合“一个线程写、多个线程读”的状态标志,不适合复合操作。面试里很多候选人脱口而出“volatile能保证原子性”,这是明显的知识漏洞。
4.3 synchronized与Lock:两代锁方案怎么选
解决原子性最直接的方案就是加锁。synchronized有三种用法:修饰实例方法,锁是当前实例对象;修饰静态方法,锁是类的Class对象;修饰代码块,锁是指定对象。它的核心思想是:同一时刻只有一个线程能持有锁进入临界区,其他线程在锁外阻塞等待。
从JDK 6开始,synchronized经过锁升级优化(偏向锁→轻量级锁→重量级锁),性能已经和ReentrantLock相差不多。既然这样,什么时候用Lock?我的习惯是:默认优先synchronized,因为它最简单,出了异常JVM会自动释放锁;需要公平锁、可中断、超时获取锁、或者多个Condition时,再换ReentrantLock。
ReentrantLock的使用有一点必须注意:解锁必须放在finally里,否则中间抛异常锁就永远不会释放:
ReentrantLock lock = new ReentrantLock(); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }有人觉得synchronized是悲观锁,Lock也是悲观锁,那有没有别的思路?有的:CAS(Compare And Swap)就是乐观锁的典型实现。它不加锁,而是在更新时比较当前值是不是预期的旧值,是则替换为新值,不是则重新读取再重试。Java的AtomicInteger等原子类底层就是这个机制。CAS避免了线程阻塞,但要注意ABA问题,以及高竞争下自旋会消耗CPU。
4.4 数据一致性:Java里保证并发的核心思路
从热搜词里可以看到,大家非常关心“java怎么保证数据一致性”。聊到这里其实已经把拼图集齐了,可以给一个归拢:
- 操作复合步骤(i++、check-then-act)需要原子性,用synchronized、Lock或Atomic类。
- 多线程可见性用volatile,或者锁(加锁的代码天然带可见性)。
- 线程内部数据不想被共享污染,用ThreadLocal做线程隔离。
- 存在竞态条件的代码,用悲观锁或乐观锁串行化临界区。
记住一个原则:没有银弹。锁能解决大部分问题,但会牺牲并发度;CAS并发度高但忙等伤CPU;ThreadLocal避免共享但消耗内存。所谓方案选型,就是在这些约束里找到当前场景最平衡的那个点。
5. 线程间协作:从wait/notify到生产者消费者模型
5.1 wait/notify的正确姿势:为什么必须在synchronized里
线程之间不是永远各干各的,经常需要“你生产了我消费,你没生产我等着”。老牌协作机制是Object的wait/notify。有一个面试必问的问题:为什么wait/notify必须放在synchronized代码块里?
原因在于wait方法要释放锁,而只有持有锁的线程才有资格释放锁。同时,wait的语义是“在某个条件不满足时挂起自己,并让别人有机会修改条件”,如果不在锁保护下检查条件,就会发生经典的“先检查后等待”竞态:两个线程同时发现条件满足,同时进入临界区。所以规范写法是:
synchronized (lock) { while (!condition) { // 用while,不要用if lock.wait(); } // 条件满足,继续执行 }为什么条件检查必须用while而不是if?因为线程被唤醒后,条件可能已经被其他线程改回去了。如果只用if判断一次就直接往下走,可能执行的是错误逻辑。用while就是让唤醒后重新检查一遍,这个细节在生产者消费者模型里尤其重要。
5.2 手写一个生产者消费者模型
用wait/notify完整实现一个最简单版本:
class SharedQueue { private final LinkedList<Integer> queue = new LinkedList<>(); private final int capacity = 5; public synchronized void produce(int value) throws InterruptedException { while (queue.size() == capacity) { wait(); // 队列满了,生产者等待 } queue.addLast(value); System.out.println("生产:" + value + ",当前数量:" + queue.size()); notifyAll(); } public synchronized int consume() throws InterruptedException { while (queue.isEmpty()) { wait(); // 队列空了,消费者等待 } int value = queue.removeFirst(); System.out.println("消费:" + value + ",当前数量:" + queue.size()); notifyAll(); return value; } }这里有一个常见误区:notify和notifyAll怎么选?notify只唤醒一个等待线程,如果唤醒的是同类型的线程(比如唤醒了生产者,但队列还是满的),它检查条件后又睡回去了,相当于白唤醒。多生产者多消费者场景下,稳妥做法是notifyAll,让所有等待线程重新竞争,避免线程饥饿。
5.3 Lock + Condition与阻塞队列:现代更推荐的写法
wait/notify模型能跑通,但粒度太粗:notifyAll会唤醒所有线程,很多唤醒是无意义的。ReentrantLock提供的Condition可以让我们精确唤醒某一类线程:
ReentrantLock lock = new ReentrantLock(); Condition notFull = lock.newCondition(); Condition notEmpty = lock.newCondition(); // 生产者 lock.lock(); try { while (queue.size() == capacity) { notFull.await(); } queue.addLast(value); notEmpty.signal(); } finally { lock.unlock(); } // 消费者 lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } int value = queue.removeFirst(); notFull.signal(); } finally { lock.unlock(); }生产者通知的是“队列有空位”的notFull,消费者通知的是“队列有数据”的notEmpty,互相不打扰。
但在真正的生产代码里,手写这些等待通知逻辑已经很少见了。JDK提供的BlockingQueue把这些都封装好了:put在队列满时阻塞,take在队列空时阻塞。用ArrayBlockingQueue实现生产者消费者,核心代码浓缩到几行:
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5); // 生产者线程 queue.put(value); // 消费者线程 int value = queue.take();所以我的建议是:理解wait/notify是为了应付面试和理解底层原理,日常开发直接用BlockingQueue,别重复造轮子。
6. 线程池参数与坑:每天在用却很少人会配
6.1 七个参数到底怎么理解
ThreadPoolExecutor构造方法有七个核心参数,每个都是面试重点:
| 参数 | 含义 |
|---|---|
| corePoolSize | 核心线程数,线程池常驻线程数量 |
| maximumPoolSize | 最大线程数,允许创建的线程上限 |
| keepAliveTime | 非核心线程空闲存活时间 |
| unit | keepAliveTime的时间单位 |
| workQueue | 任务等待队列 |
| threadFactory | 线程工厂,用于创建线程 |
| handler | 拒绝策略 |
线程池的执行流程一定要背熟:新任务进来,先判断当前线程数是否小于核心线程数,是则直接开新线程执行;不是则尝试放入工作队列,等核心线程空闲;如果队列也满了,再看当前线程数是否小于最大线程数,是则创建非核心线程执行;如果线程数已经到上限,就执行拒绝策略。
很多人在这里有个误解:认为“核心线程数满了之后会先扩到最大线程数,再去填队列”。实际恰恰相反,是先填队