ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Java多线程进阶实战:从线程池到并发编排的完整学习路径

Java多线程进阶实战:从线程池到并发编排的完整学习路径 作为Java后端开发几乎逃不开多线程这个坎。很多人学Java基础时还能靠背概念混过去但一旦开始接触高并发、性能优化、中间件源码就会发现多线程才是真正拉差距的地方。这篇博客我想把自己的java多线程进阶学习路径和踩坑记录整理出来不空谈理论尽量用实际能跑的例子和面试题反推知识点帮正在学多线程或者准备java面试的朋友少走弯路。这篇内容适合这么几类人已经写完JavaSE基础准备进阶的初学者工作一到三年想系统梳理并发知识的后端开发以及正在刷java面试八股文尤其盯着多线程面试题看的备考党。我会从整体认知讲起再拆解线程生命周期、线程安全、锁、线程池、任务编排最后补充问题排查经验。每个模块都配合代码和踩坑笔记读完可以直接对着练。1. 学习多线程前先把全局框架搭起来1.1 多线程到底解决了什么问题很多人一上来就背“线程是CPU调度的最小单位”“进程是资源分配的最小单位”背完还是不会用。我自己的体会是学多线程第一件事不是背定义而是搞清楚它解决什么现实问题。在单线程时代一个请求进来CPU要等数据库查询、等网络IO、等磁盘读写。这些等待时间里CPU大部分是空闲的。多线程的核心价值就是把这部分空闲时间用起来让多个任务交替占用CPU从而提升系统吞吐量。用生活里的例子说你去食堂打饭如果只有一个窗口前面有人刷卡慢后面所有人都得等多开几个窗口人均等待时间就下来了。但这几个窗口之间要协调好不能两个人抢同一份菜也不能忘了通知大家哪个窗口有空位——这就是线程安全、锁、通信机制要解决的事。理解了这点你就知道为什么企业里那么看重并发编程能力。面试题里反复出现的“进程和线程区别”“synchronized和Lock区别”“线程池参数怎么定”本质上都是在考察你能否把CPU资源、共享资源、任务调度这三件事处理好。所以进阶学习时不要只盯着语法要带着“如何让系统更高效又不出错”的视角去学。1.2 先建立线程模型和调度认知Java里的线程最终会映射到操作系统线程所以理解操作系统层面的线程模型很有必要。这里我不展开太多底层细节但有几个关键认知必须建立Java里Thread对象并不是线程本身它只是操作线程的入口。真正干活的是操作系统内核线程JVM通过内部调度器把Java线程映射到内核线程上。线程切换是有开销的。上下文切换意味着保存当前线程的执行状态、加载下一个线程的状态。所以不是线程开得越多越好开多了反而会因为频繁切换导致性能下降。CPU密集型任务和IO密集型任务适合的线程数完全不同。这个在第三章讲线程池时再细说。我见过不少新手犯一个典型错误为了提升速度无脑new Thread(...)扔几百个任务出去。结果CPU忙不过来内存也被线程栈撑爆。所以进阶第一步就是要意识到“并发不是并行”多线程的目标是合理复用CPU而不是创建一堆互相争抢的线程。1.3 Java进阶学习路线里线程处于什么位置如果你在网上搜“java学习路线”会发现多线程一般被放在集合框架之后、JVM和框架源码之前。这个顺序是有道理的学多线程前最好先把Java集合、异常、泛型用熟因为后面要写并发容器、处理异步异常学完多线程再去看JVM内存模型、看Spring等框架的异步机制会顺畅很多。我建议把学习拆成四个阶段第一阶段掌握Thread、Runnable、Callable、生命周期、synchronized目标是能写出正确的多线程代码第二阶段掌握线程池、Lock、并发容器、CountDownLatch、CyclicBarrier、Semaphore目标是会做任务编排第三阶段理解volatile和内存可见性、happens-before、AQS等原理目标是能应对深度面试题第四阶段结合项目实践做压测和排查目标是真的在生产环境解决问题。下面内容基本围绕前三个阶段展开第四个阶段会给出常用的排查思路。2. 核心细节线程创建、生命周期与线程安全2.1 创建线程的几种方式及选择Java里创建线程的“正统”方式有几种继承Thread类、实现Runnable接口、实现Callable接口以及通过ExecutorService配合FutureTask提交任务。还有很多人会提ForkJoinPool但那个更偏分治场景先放一放。直接写代码看区别// 方式一继承Thread class MyThread extends Thread { Override public void run() { System.out.println(继承Thread: Thread.currentThread().getName()); } } // 方式二实现Runnable推荐因为更灵活 class MyTask implements Runnable { Override public void run() { System.out.println(实现Runnable: Thread.currentThread().getName()); } } // 方式三实现Callable可以拿到返回值 class MyCallable implements CallableInteger { Override public Integer call() throws Exception { return 42; } } // 使用示例 public static void main(String[] args) throws Exception { new MyThread().start(); Thread t1 new Thread(new MyTask()); t1.start(); ExecutorService pool Executors.newSingleThreadExecutor(); FutureInteger future pool.submit(new MyCallable()); System.out.println(future.get()); pool.shutdown(); }这三个方式怎么选我的标准是尽量不继承Thread因为Java是单继承继承后没法再继承别的类需要返回值或抛出受检异常用Callable只是普通任务用Runnable最省事。实际项目中所有线程都要交给线程池管理new Thread这种方式只适合写Demo。这一点在很多java面试题里也是考点回答时说一句“项目里一般通过线程池提交任务而不是手动创建线程”会加分不少。注意start()和run()的区别也是高频面试题。直接调run()只是在当前线程执行普通方法不会创建新线程只有调start()才会触发JVM创建线程并让新线程执行run()。我见过有人把start()写成run()结果任务卡住了主流程排查半天发现根本没起线程。2.2 线程生命周期与状态切换线程状态在Thread.State枚举里定义了六种NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。NEWnew出来了还没调start()。RUNNABLE调了start()线程可运行。注意Java里RUNNABLE包含了操作系统层面的“就绪”和“运行中”只要没有阻塞状态就是RUNNABLE。BLOCKED线程在竞争synchronized锁时如果没拿到锁会进入BLOCKED。WAITING线程调用了obj.wait()、thread.join()、LockSupport.park()会一直等到被通知。TIMED_WAITING带超时的等待比如sleep(1000)、wait(1000)、join(1000)。TERMINATEDrun()正常结束或抛出未捕获异常。很多人分不清sleep和wait这里重点说一下sleep是Thread的静态方法调用后线程进入TIMED_WAITING不会释放锁wait是Object的方法必须在synchronized块里用调用后会释放锁让出CPU给其他线程。如果面试官问“sleep和wait的区别”答到“sleep不释放锁wait会释放锁sleep到处都能用wait必须在同步块里”基本上就踩中得分点了。我实际操作中遇到最坑的情况是死锁排查时jstack打印出来一堆WAITING状态的线程线程之间互相等待对方释放锁这时候光看代码很难一眼看出谁先等谁。后来习惯在代码里加上有意义的线程名排查效率高很多。所以从一开始写多线程代码就要养成给线程命名的习惯别偷懒用默认的Thread-0。2.3 线程安全synchronized、volatile、Lock怎么选线程安全问题的根源在于多个线程同时读写共享变量导致数据不一致或读到中间状态。解决思路主要有三种互斥同步、非阻塞同步、无同步方案。互斥同步最常见的就是synchronized和Lock。synchronized是JVM内置锁用法简单自动释放。可以用在实例方法、静态方法和代码块上public class Counter { private int count 0; public synchronized void increment() { count; } public void decrement() { synchronized (this) { count--; } } }Lock需要手动加锁解锁典型实现是ReentrantLockLock lock new ReentrantLock(); lock.lock(); try { // 临界区 } finally { lock.unlock(); }两者选型的参考标准如果只是简单的同步需求优先synchronized代码更简洁不容易出错需要尝试获取锁、可中断、公平锁、多个条件队列等功能时选ReentrantLock。volatile则是另一种思路它不保证原子性但保证可见性禁止指令重排。它的核心场景是一个变量只有一个线程写其他线程读。比如开关标志、状态标记class TaskRunner { private volatile boolean running true; public void stop() { running false; } public void run() { while (running) { // 循环处理 } } }这个例子是面试八股文里常考的很多人会问“为什么running要加volatile”。因为不加的话子线程可能一直在CPU缓存里读到旧的runningtrue永远停不下来。加了volatile后每次读都强制从主内存读写之后立即刷新到主内存保证可见性。但如果是count这种“读-改-写”操作volatile解决不了必须用锁或AtomicInteger。关于锁还有一个很实用的点锁的粒度。别图省事把整个方法都加synchronized那样并发度太低。我见过一个案例一个查询方法里有两次网络调用第一次是本地缓存第二次是远程服务结果整个方法加了锁缓存命中时也要等锁吞吐量直接拉胯。正确做法是只锁需要保护的那一小段代码远程调用尽量挪到锁外面。3. 实操过程与核心环节实现从线程池到等待所有线程完成3.1 线程池的设计与参数计算Java中线程池的顶级接口是ExecutorService最常用的实现是ThreadPoolExecutor。很多人图省事直接用Executors.newFixedThreadPool()但在生产环境我不建议直接用因为Executors封装的几个快捷方法各有隐患newFixedThreadPool和newSingleThreadExecutor的队列是LinkedBlockingQueue默认无界任务堆积多了可能OOMnewCachedThreadPool最大线程数是Integer.MAX_VALUE容易创建过多线程导致CPU和内存耗尽newScheduledThreadPool同样有最大线程数无界的问题。所以更稳妥的做法是直接用ThreadPoolExecutor构造函数自己控制核心参数。看一个核心构造方法public ThreadPoolExecutor( int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueueRunnable workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler )这几个参数是整个多线程面试里出现频率最高的部分。核心池大小、最大池大小、非核心线程空闲保活时间、任务队列、线程工厂、拒绝策略每个都要能说出含义。关于线程数怎么定我有一个不算绝对但很好用的经验公式CPU密集型任务核心线程数约等于CPU核数1或者直接Runtime.getRuntime().availableProcessors()因为任务不涉及IO线程太多反而增加上下文切换。IO密集型任务核心线程数可以大一些常见估算是CPU核数 * 2也有人按“CPU核数 / (1 - 阻塞系数)”来算阻塞系数通常在0.8到0.9之间。如果是一次请求里既有CPU计算又有IO等待建议先压测再调参数别纯靠公式。举例说我最近写的一个批量导出任务每个线程要从数据库读取数据、组装Excel、写磁盘属于典型IO密集型。4核机器上我把核心线程设为16最大线程设为32队列容量设为500实测比核心线程4时吞吐提升了快3倍但再往上调就没什么变化反而内存和线程数上去了。这说明参数要结合机器和任务实测不是越大越好。拒绝策略也很关键。内置的有四种策略行为适用场景AbortPolicy直接抛RejectedExecutionException默认策略任务丢失时快速失败CallerRunsPolicy提交任务的线程自己执行该任务不想丢任务想降低提交速度DiscardPolicy静默丢弃任务允许丢任务不推荐DiscardOldestPolicy丢弃队列中最旧的任务重试提交新任务看重最新任务的场景我实际项目里最常用CallerRunsPolicy因为它不会丢任务还能让提交任务的线程帮忙干活起到天然限流作用。但要注意如果主线程也扛不住会导致整个调用链路变慢这个得靠监控去发现。3.2 多线程任务编排Future、CompletableFuture与等待完成线程池提交任务后怎么拿到结果、怎么等所有任务结束这是开发中特别常见的需求。很多java面试题会问“如何等待所有线程执行完毕”我至少会给出三种方案。第一种是用Thread.join()但前提是你手动管理线程。第二种是用CountDownLatch这个非常常用。第三种是使用ExecutorService.submit()返回的Future批量提交后遍历Future.get()。先用CountDownLatch举个例子import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class WaitAllTaskDemo { public static void main(String[] args) throws InterruptedException { int taskCount 5; CountDownLatch latch new CountDownLatch(taskCount); ExecutorService pool Executors.newFixedThreadPool(3); for (int i 0; i taskCount; i) { final int taskNo i; pool.submit(() - { try { Thread.sleep(500); System.out.println(task taskNo done); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { latch.countDown(); } }); } latch.await(); System.out.println(all tasks done); pool.shutdown(); } }这里有个容易踩的坑countDown()必须放在finally里。如果任务执行中途抛异常导致没调用countDown()主线程会永远卡在latch.await()。这种问题还不容易复现但线上会莫名其妙挂起。我第一次写的时候就踩过后来养成习惯凡是CountDownLatch的countDown()都放进finally。再来看Future的批量等待ExecutorService pool Executors.newFixedThreadPool(4); ListFutureInteger futures new ArrayList(); for (int i 0; i 10; i) { int taskNo i; futures.add(pool.submit(() - taskNo * taskNo)); } for (FutureInteger future : futures) { try { Integer result future.get(); System.out.println(result); } catch (ExecutionException e) { // 这里拿到的异常是任务内部异常 e.getCause().printStackTrace(); } } pool.shutdown();Future.get()是阻塞的如果任务一直不结束主线程也会一直等。所以遇到可能超时的任务一定要用带超时参数的get(timeout, unit)。这也是面试官喜欢追问的优化点“如果某个任务卡住了怎么处理”至于CompletableFuture它是Java 8引入的在处理异步编排时比Future方便太多。比如要并发请求三个服务全部完成后合并结果传统写法要搞一堆Future和回调用CompletableFuture可以链式写CompletableFutureString f1 CompletableFuture.supplyAsync(() - queryService1()); CompletableFutureString f2 CompletableFuture.supplyAsync(() - queryService2()); CompletableFutureString f3 CompletableFuture.supplyAsync(() - queryService3()); String result CompletableFuture.allOf(f1, f2, f3) .thenApply(v - f1.join() f2.join() f3.join()) .join();allOf会等待所有CompletableFuture完成join()获取结果时不抛受检异常代码更清爽。我后来做多接口并发聚合基本都是用它。要注意的是supplyAsync默认使用ForkJoinPool.commonPool()线程数不太好控制如果是高并发压测环境最好显式传入自己的线程池例如ExecutorService executor Executors.newFixedThreadPool(8); CompletableFuture.supplyAsync(() - queryService1(), executor);3.3 一个接近真实场景的案例并发统计数据我们用一个贴近后端业务的例子把前面的知识点串起来。假设需求是根据一批用户ID并发查询他们各自的订单数量然后汇总总数。数据源是远程接口单次查询耗时可能几百毫秒。如果串行查100个用户可能要几十秒用多线程并发查能把时间压到几秒。代码我写一个简化版本import java.util.ArrayList; import java.util.List; import java.util.concurrent.*; public class QueryOrderCountDemo { // 模拟远程查询单个用户订单数 private static int queryOrderCount(long userId) throws InterruptedException { Thread.sleep(200); // 模拟IO耗时 return (int) (userId % 10); } public static void main(String[] args) { ListLong userIds new ArrayList(); for (long i 1; i 20; i) { userIds.add(i); } int total parallelSumOrderCount(userIds); System.out.println(total: total); } private static int parallelSumOrderCount(ListLong userIds) { int cpu Runtime.getRuntime().availableProcessors(); ThreadPoolExecutor pool new ThreadPoolExecutor( cpu * 2, cpu * 2, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(200), r - { Thread t new Thread(r, order-query- r.hashCode()); t.setDaemon(true); return t; }, new ThreadPoolExecutor.CallerRunsPolicy() ); ListFutureInteger futures new ArrayList(); for (Long userId : userIds) { futures.add(pool.submit(() - queryOrderCount(userId))); } int total 0; for (FutureInteger future : futures) { try { total future.get(5, TimeUnit.SECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(interrupted, e); } catch (ExecutionException e) { // 这里记录单个任务失败但不影响汇总其它结果 System.err.println(task failed: e.getCause().getMessage()); } catch (TimeoutException e) { System.err.println(task timeout); } } pool.shutdown(); return total; } }这个例子包含了线程池创建、自定义线程工厂、带超时的等待、异常隔离。有两个细节值得说。第一我把线程池线程设置为daemon线程。这是一个有争议的选择。生产环境一般不建议把业务线程设为daemon因为JVM退出时daemon线程直接被终止可能丢失任务。但在这个Demo里如果不设daemon主线程结束后线程池默认非daemon的线程还会阻止JVM退出。更好的做法其实是在主流程结束后调用pool.shutdown()真正生产环境还会加awaitTerminationpool.shutdown(); try { if (!pool.awaitTermination(10, TimeUnit.SECONDS)) { pool.shutdownNow(); } } catch (InterruptedException e) { pool.shutdownNow(); }第二遍历Future.get()有一种隐性风险如果前面的Future超时了后面排队的任务虽然已完成但获取结果时仍会阻塞等待。这个场景可以考虑用CompletionService。CompletionService会把先完成的任务结果放到阻塞队列里谁先完成就先取谁的结果可以避免这种木桶效应。举个例子CompletionServiceInteger completionService new ExecutorCompletionService(pool); for (Long userId : userIds) { completionService.submit(() - queryOrderCount(userId)); } for (int i 0; i userIds.size(); i) { FutureInteger future completionService.take(); total future.get(); }如果你的业务是“等所有任务完成再汇总”用Future列表没毛病如果是“先把已完成的结果处理掉不等最慢的那个”用CompletionService更合适。4. 常见问题与排查技巧实录4.1 多线程面试题高频点结合我搜集的java面试题和真实面试反馈多线程这块被问得最多的大概有这些进程和线程的区别Runnable和Callable的区别run()和start()的区别sleep()和wait()的区别synchronized和Lock的区别synchronized锁升级过程volatile的作用及原理ThreadLocal原理及内存泄漏风险线程池的核心参数和拒绝策略如何实现一个延迟任务队列如何等待所有线程执行完成什么是死锁怎么避免和排查AQS是什么ReentrantLock基于它怎么实现ConcurrentHashMap的并发机制这里面很多都是“八股文”式的知识点但我建议别死背而是结合代码去理解。比如面试官问ConcurrentHashMap对应到你能说出JDK 1.7的Segment分段锁和JDK 1.8的CASsynchronized锁节点底层用的Node数组虽然是volatile修饰保证可见性但扩容、树化等细节很复杂。如果你真写过并发场景下用ConcurrentHashMap做统计会更有底气。我自己面试别人时最反感只会背“volatile保证可见性和有序性”却说不清“为什么不能保证原子性”。所以你们复习时一定要追问自己每个结论能不能用代码验证能不能解释一个线上例子4.2 常见的死锁、内存可见性和异常处理死锁是最典型的多线程问题。它发生需要四个条件互斥条件、持有并等待、不可剥夺、循环等待。要避免死锁最简单的方法是让所有线程按固定顺序获取锁。比如两个线程都要拿锁A和锁B就让它们都先拿A再拿B不要交叉获取。排查死锁时我一般这样操作jps查进程号jps -ljstack打印线程快照jstack {pid} dump.txt在dump文件里找“Found one Java-level deadlock”字样会直接指出两个线程互相等待的锁和代码行号这种方式比盯着代码看有效太多。如果你们项目用的是Spring Boot也可以暴露/actuator/health接口配合ThreadMXBean检测死锁但那是后话。内存可见性问题比死锁更隐蔽。举个真实场景一个配置开关主线程在某个时机改了static boolean enabled true但后台工作线程迟迟不生效。原因是工作线程一直读到自己CPU缓存里的旧值。解决办法就是加volatile或者用AtomicBoolean。这里特别强调一下volatile不是银弹。如果你有多个线程同时改同一个变量比如计数器那必须用AtomicInteger原子类或加锁。volatile更适合“一写多读”场景。多线程异常处理也容易被忽略。线程池里跑的任务如果抛异常不会影响整个线程池但异常很难被发现。submit()和execute()不一样execute()直接跑异常会打印到控制台或由UncaughtExceptionHandler处理submit()会把异常封装到Future里你如果不调用future.get()异常就被吞掉了。很多线上bug就是这么来的。我的建议是所有提交到线程池的任务都包一层统一异常处理pool.submit(() - { try { doBusiness(); } catch (Exception e) { log.error(task execute failed, e); } });如果用的是Future也一定要在获取结果时处理ExecutionException别光调get()不处理。4.3 排查工具与实战建议除了jstack我日常还会用jstat看JVM状态用visualvm看线程数和CPU占用用arthas做线上诊断。对于多线程问题经常遇到的现象是CPU飙升但不知道哪个线程在烧。这时候可以先用top -H -p {pid}找到CPU占用最高的线程ID再转成十六进制去jstack的dump里找对应的线程栈就能定位到具体代码。举个例子线程ID换算printf %x\n {tid}然后在jstack输出里搜索nid0x...。这个技能我强烈建议学一下比起瞎猜快得多。如果你手里测试环境能复现也可以在代码里用ThreadMXBean定期打印线程栈ThreadMXBean mxBean ManagementFactory.getThreadMXBean(); ThreadInfo[] infos mxBean.dumpAllThreads(true, true); for (ThreadInfo info : infos) { System.out.println(info); }最后给几条实战建议都是我踩过坑换来的写多线程代码前先画清楚数据流谁写谁读会不会并发写同一个集合。直接ArrayList在多线程下add会出并发修改问题应该用CopyOnWriteArrayList或加锁。全局配置类对象尽量设计成不可变对象或者用volatile引用替换避免内部字段被并发修改。线程池一定要设置线程名前缀日志里才能快速定位是哪个池。Executors的快捷方法不是不能用而是要知道它们各自的容量限制。小项目自己把控得住没问题大项目还是显式创建ThreadPoolExecutor。不要过度加锁。很多同学学了锁以后什么变量都加synchronized结果性能比单线程还差。可以先从AtomicInteger、ConcurrentHashMap这类并发容器开始它们已经做了很多优化。说到扩展还有一个方向值得深入研究Java虚拟线程。JDK 21正式引入了虚拟线程它把线程从操作系统线程里解放出来非常适合大量阻塞IO场景。如果你现在是学传统多线程学的比较透再去看看虚拟线程的用法会发现之前纠结的线程池参数可能都不再是瓶颈。不过虚拟线程也不是万能CPU密集场景下虚拟线程和平台线程差距不大这块面试官也开始问了可以提前了解一下。我实际操作中的体会是多线程学习不能只看不练。你可以找一些真实的小需求去改造比如把一串串行调用改造成CompletableFuture并发调用然后用jstack观察线程状态再用压测工具看看吞吐量变化。这个过程比刷十道面试题都管用。如果一开始调试多线程程序总是感觉“运气不好”别气馁把它当成和“并发”这个老朋友打交道的过程慢慢就会摸透它的脾气了。
返回列表