Java多线程从基础到实战:线程池、线程安全与线上排查全解析

发布时间:2026/10/14 4:17:07
Java多线程从基础到实战:线程池、线程安全与线上排查全解析
那是一个普通的周五晚上我负责的服务突然收到大量用户反馈页面打开慢、接口时不时超时。看监控CPU不高、内存不紧张、磁盘IO也很正常可请求的响应时间就是下不去。折腾了两个小时才发现问题出在项目里一个线程池——核心线程全卡在等待锁上后续任务一股脑涌进无界队列越积越多。那一刻我真切感受到Java多线程这块如果只停留在“会写”的层面线上早晚要还债。这篇文章我想把Java线程从基础到进阶的完整链路梳理一遍内容包括线程与进程的关系、线程状态迁移、四种创建方式、线程安全与三大特性、线程协作工具、线程池参数拆解以及我亲自踩过的线上问题排查思路。适合有Java基础、想系统补一轮并发编程的开发者也适合正在准备面试、想理解“为什么”而不是背概念的读者。1. 线程到底是什么先理清底层逻辑1.1 进程和线程的差异很多初学者把进程和线程的概念背得滚瓜烂熟但一落到代码里就模糊了。简单说进程是操作系统分配资源的基本单位线程是CPU调度的基本单位。一个进程内部可以有多个线程这些线程共享进程的内存空间但各自拥有独立的程序计数器、栈和局部变量。打个比方进程像一家餐厅线程就是在餐厅里干活的服务员。餐厅有自己的地址、装修、厨房设备这是进程的资源服务员们一起使用这些资源但每个人手里有自己的下单记录本这就是线程私有的栈空间。如果服务员A看到客人招手去帮忙点菜然后服务员B又去确认一次两个人同时操作一张菜单就很容易出乱子——这就是多线程共享资源的并发问题。在JVM里每个Java程序启动后至少会运行main线程和GC线程这已经是多线程了。只不过绝大多数代码逻辑跑在main线程里多线程的复杂性和威力都没有体现出来。当你开始手写new Thread(...).start()时才真正进入并发编程的领域。1.2 为什么需要多线程一切为了不浪费有人可能会问单线程写起来多简单为什么非要自找麻烦归根结底是三个字性价比。假设一个接口要做这样几件事查数据库耗时300ms调远程接口耗时500ms本地计算耗时100ms。如果单线程串行执行总耗时900ms。可这三个操作相互没有依赖完全可以并行理想情况下总耗时只需要500ms。这就是多线程最直观的价值——把“等”的时间省下来。再深一层说现代服务器CPU都多核了单线程程序无论怎么优化也只能利用一个核。多线程才能把多核CPU的算力真正用起来属于硬件层面逼着你走的路。还有一点容易被忽略线程切换成本远低于进程切换。进程切换需要切换内存地址空间、页表等一大堆东西而同一进程内线程切换只换栈和寄存器开销小得多。这也是为什么高并发服务普遍采用多线程而不是多进程模型。但这里必须提前泼一盆冷水多线程带来性能的同时引入了竞态条件、死锁、内存可见性等一系列问题。很多人写并发代码性能没提上去故障倒先来了一堆。多线程是工具不是目的。如果并发加锁设计不合理锁竞争本身的消耗可能超过并行带来的收益反而更慢。2. 线程状态迁移一张图理解六种状态2.1 六种状态逐一说透Java的线程状态在Thread.State枚举里定义了六种分别是NEW线程创建了但还没调用start()。此时线程对象已经存在但底层操作系统的线程还没创建。RUNNABLE调用start()之后的状态。很多人不理解的是这个状态在Java里统包了操作系统层的“运行中”和“就绪”。线程抢到CPU在跑是RUNNABLE没抢到CPU在排队也是RUNNABLE。如果在Linux上用top看CPU占用率100%但线程是RUNNABLE说明它一直在跑不是在等。BLOCKED线程想进入synchronized同步块或方法但锁被别的线程持有它只能阻塞等待。这是多线程程序里最常见的等待状态之一。WAITING线程主动等待其他线程的通知。调用Object.wait()、Thread.join()、LockSupport.park()都会进入这个状态。处于WAITING的线程必须等其他线程显式唤醒。TIMED_WAITING带超时时间的等待。Thread.sleep(1000)、wait(1000)、join(1000)都会进入这个状态超时后自动唤醒。TERMINATED线程的run()或call()方法执行完毕或者抛出未捕获异常导致线程结束。我的记忆技巧是BLOCKED永远和synchronized锁绑定WAITING和TIMED_WAITING的区分看有没有超时时间RUNNABLE是线程在CPU上干活或排队干活的统称。2.2 用代码让线程“亲口说出”自己的状态光看定义不够我习惯把线程的状态打印出来对照着看。下面这段代码可以完整体验状态流转public class ThreadStateDemo { private static final Object LOCK new Object(); public static void main(String[] args) throws Exception { // 线程A演示 NEW、RUNNABLE、TIMED_WAITING、WAITING、TERMINATED Thread threadA new Thread(() - { synchronized (LOCK) { try { Thread.sleep(2000); // 持锁睡2秒 - TIMED_WAITING LOCK.wait(); // 释放锁并等待 - WAITING } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }, 线程A); System.out.println(刚创建未启动 threadA.getState()); // NEW threadA.start(); System.out.println(启动之后立即看 threadA.getState()); // RUNNABLE Thread.sleep(200); System.out.println(执行sleep中 threadA.getState()); // TIMED_WAITING // 线程B专门演示 BLOCKED Thread threadB new Thread(() - { synchronized (LOCK) { // 什么都不干就是占着锁 } }, 线程B); Thread.sleep(3000); // 等线程A进入wait并释放锁 threadB.start(); Thread.sleep(100); System.out.println(等待A的锁 threadB.getState()); // 可能被A意外占锁这里实际大概率是RUNNABLE // 重新构造一个锁竞争场景更直观 synchronized (LOCK) { Thread threadC new Thread(() - { synchronized (LOCK) { // 等待主线程释放锁 } }, 线程C); threadC.start(); Thread.sleep(200); System.out.println(等锁中 threadC.getState()); // BLOCKED } Thread.sleep(3000); System.out.println(线程A最终状态 threadA.getState()); // WAITING } }真实项目中排查线程问题最常用的工具是jstack。拿到Java进程的PID后执行jstack PID每个线程都能看到当前状态和调用栈。比如看到大量线程处于BLOCKED说明锁竞争很激烈大量WAITING可能是任务线程都在空等某个通知这时要检查业务逻辑是不是漏了唤醒。2.3 状态迁移中最容易踩的两个坑第一个坑线程结束后不允许重新start()。一个线程从TERMINATED状态不能回到NEW。我见过有人想复用线程在一个线程跑完后再次调用start()直接抛IllegalThreadStateException。线程就是一次性用品想重复执行任务正确的做法是用线程池。第二个坑sleep()不会释放锁wait()会释放锁。这个区别极其关键。sleep期间线程仍然持有synchronized锁别人进不来wait则把锁释放让其他线程有机会执行。很多死锁问题就出在把sleep当wait用一个线程睡死过去还攥着锁不放。3. 创建线程的四种方式以及怎么选3.1 继承Thread类与实现Runnable接口先看最基础的两种写法// 方式一继承Thread class MyThread extends Thread { Override public void run() { System.out.println(继承Thread运行当前线程 Thread.currentThread().getName()); } } MyThread t1 new MyThread(); t1.start(); // 方式二实现Runnable推荐 Runnable task () - System.out.println(Runnable运行当前线程 Thread.currentThread().getName()); Thread t2 new Thread(task); t2.start();理论上两种都能跑但实际开发里我更推荐实现Runnable。原因有两条一是Java是单继承一旦继承了Thread就不能再继承其他类扩展性直接被锁死二是继承Thread把“任务代码”和“执行任务的线程”耦合在一起而Runnable只描述任务本身执行方式可以随意换——想放线程池就交给线程池想手动建线程就手动建灵活得多。3.2 Callable、FutureTask与线程池Runnable有个硬伤run()方法没有返回值也抛不出受检异常。如果你需要线程计算一个结果再把这个结果拿回来用就得用Callable配合FutureTaskCallableInteger callable () - { // 模拟耗时计算 Thread.sleep(1000); return 42; }; FutureTaskInteger futureTask new FutureTask(callable); new Thread(futureTask, 计算线程).start(); // 主线程可以干别的事最后再取结果 Integer result futureTask.get(); System.out.println(计算结果 result);注意get()是阻塞的结果没出来之前主线程会一直等着。如果不想无限等用get(3, TimeUnit.SECONDS)加超时超时抛TimeoutException。业务代码里直接new Thread非常少见基本都会用线程池。线程池的好处不用多说——复用线程、控制并发数、方便管理生命周期。稍后的第6章会专门拆线程池这里先记住一点能交给线程池的任务就不要自己new Thread。3.3 选型对比我把四种方式的权衡列个表方便对照创建方式获取返回值任务与执行解耦是否适合大量任务推荐程度继承Thread不能否否不推荐实现Runnable不能是配合线程池推荐CallableFutureTask能是配合线程池较推荐线程池任务能完全解耦是最推荐实际项目里我几乎只用“线程池 Runnable/Callable”的组合。Runnable处理不需要返回值的任务Callable处理需要返回值的任务自定义线程池控制资源上限。直接用new Thread的场景基本只剩单元测试和非常简单的演示。4. 线程安全的三座大山原子性、可见性、有序性4.1 JMM内存模型先看懂这张简化图线程安全问题千变万化但追根溯源都是三个特性被破坏原子性、可见性、有序性。Java内存模型规定所有变量存在主内存中每个线程有自己的工作内存可以理解为CPU缓存。线程操作变量时先把主内存的值拷贝到工作内存修改完再写回主内存。这个过程中就有两个隐患一是可见性问题。线程A改了变量但还没写回主内存或者写回了但线程B工作内存里的旧值还没失效B读到的就是旧数据。二是原子性问题。比如count这个操作底层要经过“读取-修改-写回”三步两个线程同时执行就会互相覆盖最后结果比预期小。还有有序性。编译器和CPU为了优化会重排指令单线程下不影响结果但多线程下可能导致意想不到的执行顺序。拿一个最经典的例子说明private int count 0; // 两个线程同时执行10000次 count // 结果通常不是20000两个线程同时做count由于操作不是原子的会丢更新。这就是并发下“要加锁”的根本原因。4.2 synchronized与Lock到底怎么选日常开发要保证原子性最直接的手段是加锁。Java里有两套方案synchronized关键字和java.util.concurrent.locks.Lock接口。synchronized用法很简单它可以加在实例方法、静态方法或代码块上。本质是拿一把内置锁进入同步块时获取锁退出时释放锁即使异常也能自动释放。private final Object lock new Object(); public void safeIncrement() { synchronized (lock) { count; } }Lock接口的典型实现是ReentrantLock需要手动加锁和解锁所以一定要在finally里释放private final Lock lock new ReentrantLock(); public void safeIncrement() { lock.lock(); try { count; } finally { lock.unlock(); } }什么时候用synchronized什么时候用Lock我的判断标准是大多数场景synchronized就够了它简洁可靠JDK还在持续优化锁升级机制让它性能也不差。ReentrantLock的优势在于支持超时获取锁tryLock(1, TimeUnit.SECONDS)、可中断、可公平排队、支持多条件变量。当这些高级特性真正用得着时再上Lock。4.3 volatile、ThreadLocal与实战场景volatile是面试常客它的核心作用是保证可见性和有序性但不保证原子性。典型的正确用法是布尔状态标志private volatile boolean stopped false; public void stop() { stopped true; } public void run() { while (!stopped) { // 干活 } }一个线程写stopped另一个线程读用volatile能保证读线程及时看到最新值。但如果是volatile int count两个线程同时count该丢的更新照样丢——因为复合操作不原子。另一个实战场景是ThreadLocal。每个线程往ThreadLocal里放数据只会存在自己的线程里天然隔离。我遇到最多的是SimpleDateFormat的线程安全问题。这个类不是线程安全的多个线程共享同一个实例解析日期会得到错乱结果保险的做法就是每个线程一个副本private static final ThreadLocalSimpleDateFormat DATE_FORMAT ThreadLocal.withInitial(() - new SimpleDateFormat(yyyy-MM-dd)); public Date parse(String source) throws ParseException { return DATE_FORMAT.get().parse(source); }不过ThreadLocal有个隐蔽坑线程池中的线程是复用的如果用完不清理数据会一直留在线程里下个任务可能读到上个任务的残留数据。所以用完务必调用remove()。另外ThreadLocalMap的key是弱引用value是强引用如果线程存活时间长而key被回收value就永远无法被垃圾回收造成内存泄漏。remove()能同时清掉key和value是治本的办法。5. 线程协作的手段从wait/notify到并发工具5.1 wait/notify最原始也最容易写错线程之间经常需要相互配合最简单的协作是等待与通知。Java Object类提供了wait()、notify()、notifyAll()。wait()让当前线程释放锁并进入等待notify()随机唤醒一个等待线程notifyAll()唤醒所有等待线程。关键约束是这三个方法必须在synchronized代码块或方法里调用否则抛IllegalMonitorStateException。原因很简单——防止lost wakeup。如果没有锁保护一个线程先判断条件不满足准备wait()但还没执行wait()时另一个线程已经notify()了等第一个线程真正进入等待就没人再唤醒它了。synchronized能保证条件的检查和wait()是原子的。最经典的场景是生产者消费者模型。用synchronized wait/notifyAll可以实现但代码很容易写错尤其要注意用while而不是if检查条件防止虚假唤醒synchronized (queue) { while (queue.size() capacity) { queue.wait(); // 队列满了生产者等待 } queue.add(data); queue.notifyAll(); // 通知消费者 }不过实际工作里我已经很少手写wait/notify了因为BlockingQueue把这些逻辑都封装好了。它自带线程安全的入队出队队列空时take()自动阻塞队列满时put()自动阻塞省掉一大片容易出错的代码。5.2 Concurrent包的利器CountDownLatch、CyclicBarrier、Semaphorejava.util.concurrent包提供了几个非常实用的协作工具。CountDownLatch适合“等N个任务都完成后再继续”的场景。比如批量处理100个任务分10个线程各处理10个主线程等全部完成后汇总结果。初始值设为10每个线程结束时countDown()一次主线程await()等待计数归零。CyclicBarrier适合“N个线程互相等待齐了再一起往下走”的场景。比如多线程模拟并发压测希望所有线程同时开始。Barrier可以重用屏障打开后自动复位这是和CountDownLatch最大的区别——CountDownLatch是一次性的计数归零后就废了。Semaphore是信号量适合限流场景。它维护一个许可数线程执行前acquire()拿许可执行完release()归还。比如一个服务最多允许50个线程同时访问某资源超过的就得等。选型时我的经验是主线程等子线程用CountDownLatch一组线程互相等齐步调一致用CyclicBarrier控制并发流量用Semaphore。三者用途完全不同别搞混。5.3 BlockingQueue与CompletableFuture的实战组合除了上述工具CompletableFuture是处理异步任务编排的一把好手。它把异步回调、组合、异常处理都封装好了写起来比手动管理线程优雅得多。举个例子一个页面需要并行调用用户服务、订单服务、商品服务三个接口之间互不依赖全部返回后再组装数据。CompletableFutureUserInfo userFuture CompletableFuture.supplyAsync(() - userService.getUser(id)); CompletableFutureOrderInfo orderFuture CompletableFuture.supplyAsync(() - orderService.getOrder(id)); CompletableFutureProductInfo productFuture CompletableFuture.supplyAsync(() - productService.getProduct(id)); CompletableFuture.allOf(userFuture, orderFuture, productFuture).join(); UserInfo user userFuture.get(); OrderInfo order orderFuture.get(); ProductInfo product productFuture.get(); // 组装并返回supplyAsync默认跑在ForkJoinPool.commonPool()里但生产环境我更建议显式传入自定义线程池避免公共池被长任务拖垮。allOf等待所有Future完成anyOf则是任意一个完成就返回。要注意get()和join()都是阻塞的如果在响应式接口里滥用会占用线程等待结果。异步任务本身的耗时和等待线程的开销要综合评估不是所有场景用CompletableFuture都划算。6. 线程池核心参数、拒绝策略与线程数估算6.1 七大核心参数逐个拆解线程池是所有Java进阶绕不开的核心知识点也是线上问题高发区。ThreadPoolExecutor的构造函数有七个参数我逐个说透new ThreadPoolExecutor( corePoolSize, // 核心线程数常驻线程数量 maximumPoolSize, // 最大线程数包含核心非核心 keepAliveTime, // 非核心线程空闲存活时间 TimeUnit.SECONDS, // 存活时间单位 workQueue, // 任务队列 threadFactory, // 线程工厂 handler // 拒绝策略 );核心线程数是线程池保底会保留的线程即使空闲也不回收。任务队列用于缓存暂时处理不过来的任务。最大线程数是线程池能扩到的上限。这里很多人理解错执行顺序我画个简单的逻辑提交任务时如果当前线程数小于核心线程数新建核心线程执行任务。如果核心线程数已经饱和任务放入队列。如果队列也满了才创建非核心线程去执行。如果线程数已经到最大线程数队列也满了触发拒绝策略。这个顺序特别关键。很多人以为“核心线程不够就扩线程”实际上线程池优先把任务塞队列队列满了才扩线程。所以队列大小直接决定了线程池什么时候扩容也决定了积压任务的缓冲能力。6.2 四种拒绝策略到底意味着什么队列和线程都满了再来的任务只能拒绝。JDK提供了四种策略AbortPolicy默认直接抛RejectedExecutionException。最粗暴但能第一时间暴露问题。生产环境通常不建议直接抛异常让用户看到除非有全局异常处理兜底。CallerRunsPolicy不抛异常而是让提交任务的线程自己执行被拒绝的任务。这是一种天然的背压机制——提交线程被拖住自然减少了提交速度。打印日志或对实时性要求不高的场景可以用。DiscardPolicy静默丢弃任务直接没了。最危险因为没有任何反馈。除非能承受任务丢失否则别用。DiscardOldestPolicy丢弃队列最前面的任务腾出空间给新任务。适合追求“新鲜任务”的场景比如实时数据老任务错过也就错过了。我自己的选择标准很明确要保证任务不丢用CallerRunsPolicy要快速失败报警用AbortPolicyDiscard系列慎用。线上一般还要配一个自定义拒绝策略把被拒绝的任务写入日志或发送告警否则出了问题连线索都没有。6.3 Executors的陷阱与线程数到底怎么定很多教程喜欢让人用Executors.newFixedThreadPool()因为它一行代码就能创建线程池非常方便。但生产环境我强烈不建议直接用Executors的快捷方法原因如下newFixedThreadPool的队列是LinkedBlockingQueue容量是无界的。如果任务生产速度大于消费速度任务会无限堆积内存被撑爆最终OOM。newCachedThreadPool的最大线程数是Integer.MAX_VALUE来了任务就创建线程一旦任务量大线程数爆炸式增长直接把机器拖垮。newScheduledThreadPool同样是无界队列。所以规范的做法是自己用ThreadPoolExecutor的构造函数创建线程数、队列、拒绝策略全部显式控制。那线程数怎么定通用经验公式CPU密集型任务线程数 CPU核数 1。比如8核机器设9个线程刚好跑满CPU不浪费。IO密集型任务线程数 CPU核数 / (1 - 阻塞系数)。阻塞系数一般取0.8~0.9。比如8核机器、阻塞系数0.8算下来是40个线程。这只是起点。真实项目里最好做压测观察CPU利用率、任务队列增速、响应时间再微调。我见过一个团队把IO密集型线程池从10个调整到30个吞吐直接翻倍但再往上加CPU就吃满了响应时间反而恶化。线程不是越多越好上下文切换成本摆在那。一个自定义线程池的示例private static final ThreadPoolExecutor EXECUTOR new ThreadPoolExecutor( 8, // 核心线程 16, // 最大线程 60, TimeUnit.SECONDS, new ArrayBlockingQueue(1000), // 有界队列防止OOM new ThreadFactory() { private final AtomicInteger idx new AtomicInteger(); Override public Thread newThread(Runnable r) { Thread t new Thread(r, biz-pool- idx.incrementAndGet()); t.setDaemon(false); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() );有界队列ArrayBlockingQueue是最重要的选择——它给了系统一个明确的积压上限队列满了就让提交线程自己干活形成自然的流量控制。7. 线上问题排查实录死锁、CPU飙升与队列积压7.1 死锁jstack一眼识破死锁堪称并发编程最经典的故障。两个或多个线程互相持有对方需要的锁谁都不让全部卡死。典型的代码如下// 线程1持有锁A等待锁B // 线程2持有锁B等待锁A死锁发生时CPU占比不高但业务完全停滞。排查方式很简单先用jps找到Java进程PID然后执行jstack PID输出里如果出现Found one Java-level deadlock后面会明确列出两个线程各自持有的锁和正在等待的锁一眼就能看出死锁环路。平时预防死锁有几个原则按固定顺序加锁、使用tryLock超时放弃、尽量减少锁的持有时间。7.2 CPU飙升与上下文切换的排查路径CPU突然飙到接近100%最可能的原因是某个线程在空转死循环或者有大量计算。排查步骤如下先用top -Hp PID查看进程内哪个线程占用CPU最高拿到线程ID。但jstack输出的线程ID是十六进制的需要转换printf %x\n 线程ID然后执行jstack PID | grep -A 20 nid0x转换后的十六进制就能看到这个线程正在执行的代码栈定位到死循环的地方。我遇到过最典型的一次是有人在锁里做耗时接口调用把一个线程池的线程全部占满其他任务排队响应时间直接上天。还有一个隐蔽问题是上下文切换过高。线程数量太多、锁竞争激烈、频繁阻塞唤醒都会导致切换成本大。用vmstat命令观察cs列如果每秒上下文切换次数非常高说明线程调度开销已经成为瓶颈。这时候优先检查线程池线程数是不是配太多以及锁粒度是不是太大。7.3 从表象到根因的排查思路我整理了一张速查表线上遇到问题可以按图索骥现象可能原因主要排查手段CPU高死循环、大量计算top -Hp、jstack业务卡顿、线程BLOCKED多锁竞争激烈jstack统计BLOCKED线程接口超时、任务堆积队列积压、核心线程不够监控任务队列深度内存持续增长无界队列、ThreadLocal泄漏堆转储分析程序无响应死锁jstack查找deadlock排查线程问题最重要的是保留现场。线上出现异常时先别急着重启先用jstack多抓几次线程栈间隔几秒抓一份看看线程状态是在变化还是静止。如果连续多次完全静止大概率是死锁如果在慢速变化可能是资源竞争。没有现场数据事后很难复现这类问题。我个人这几年最大的体会是线程问题是最难复现、最难定位、最消耗精力的Bug来源之一。很多看似偶发的卡顿背后都藏着并发设计的疏漏。所以写代码时宁可多花时间把锁的边界想清楚也不要在线上靠猜来排查。所有线程池参数、队列长度、超时时间都建议显式配置并加上监控告警任务排队超过阈值就报警。毕竟多线程是一把双刃剑用好了是性能利器用不好就是定时炸弹。