ARTICLE DETAIL

资讯详情

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

Java多线程核心知识全梳理:从线程安全到线程池实战

Java多线程核心知识全梳理:从线程安全到线程池实战 做Java开发这些年如果说让我挑一个“看起来会、一用就翻车、面试还总被问”的技术点我肯定投Java多线程一票。Java多线程不是背几个API就行它牵扯内存模型、锁机制、线程调度、并发容器一堆东西任何一个环节想当然线上就会以奇奇怪怪的方式给你颜色看。这篇文章我不想写那种教科书式的概念罗列而是把线程从创建、生命周期到线程安全、线程池、并发工具、问题排查的完整链路用我实际踩坑的视角重新串一遍。适合刚学多线程想建立完整知识体系的新人更适合马上要面试、想快速查缺补漏、把零散知识点串成线的Java开发。我会把每个关键点都落到“为什么”上重点讲我曾经翻过的车和事后总结出来的排查方法。看完你会明白多线程难的不是某个单独知识点而是它们之间的配合关系。死锁怎么预防、线程池参数怎么定、ConcurrentHashMap为什么快、AQS到底解决了什么问题这些东西真正串起来之后你会发现多线程其实是一套有逻辑的体系。1. 多线程的基础认知分清概念才能少走弯路1.1 进程和线程的本质区别咱们先回到最底层的概念。进程是操作系统分配资源的基本单位每个进程都有独立的内存空间、文件描述符、环境变量这些资源。线程是CPU调度的基本单位同一个进程里的多个线程共享进程的堆和方法区但每个线程有自己的虚拟机栈、程序计数器和本地方法栈。我经常用一个类比来说这件事进程就像一家独立的餐厅有自己的厨房、仓库、门店线程就像餐厅里的服务员大家共用同一个厨房和仓库但是每个人手里都拿着一张自己的订单记录本。服务员之间如果同时去改仓库的库存数字就会出现数据不一致——这也正是线程安全问题的根源。理解了这点就能明白为什么多线程在Java里这么流行创建线程的开销远小于创建进程线程间切换的代价也比进程切换小得多因为共享了大部分内存空间通信也方便。但代价就是共享资源需要额外加锁保护否则数据就被搞乱了。1.2 并发与并行你混淆了吗并发和并行这两个词经常被混着用但在多线程体系里是完全不同的概念。并行Parallelism是同一时刻多个任务真的同时在多个CPU核心上运行这就需要硬件支持并发Concurrency是同一时间段内多个任务交替执行看起来像同时发生实际可能是单核CPU快速切换线程实现的。写多线程代码时我们追求的是并发安全问题上的正确性而能不能真正并行取决于机器有多少核。你在八核机器上开了两百个线程真正同时跑的也就八个其余的是在排队等待CPU。这就是为什么线程数不是越多越好——线程多了上下文切换的开销反而可能让性能变差。我实际工作中见过不少同事一上来就开几百个线程处理任务结果CPU利用率没上去反而吞吐量下降了。后面我养成了一个习惯先看机器核数再算线程池大小而不是拍脑袋。1.3 Java多线程典型应用场景理解概念之后再想想Java多线程到底用在哪。最常见的就是IO密集型任务比如HTTP调用、数据库读写、文件操作线程在等待IO响应时CPU其实闲着多线程可以让CPU在等待期间去处理别的任务。然后是计算密集型任务比如大批量数据处理、图片处理这时候线程数一般设置为核心数加一避免过多线程频繁切换。还有一类典型场景是异步化比如用户下单后需要发短信、发邮件、写日志这些操作如果同步处理接口RT会变得很难看。拆成多线程异步执行之后主流程快速返回次要操作放到后台线程池去完成。再比如定时任务、消息队列消费端本质上都是多线程在背后支撑。如果你接触过Kafka消费端应该知道它默认是单线程拉取消息的很多团队为了提高吞吐量会开多个消费者线程或者用线程池处理消息但这时候就要面对一个很实际的问题如何保证消息的顺序性。这就是多线程从入门到进阶的门槛也是今天后面要聊的重点内容之一。2. 线程的创建与生命周期先搞清楚线程从哪里来、到哪里去2.1 四种创建方式的优劣对比Java里创建线程最基础的方式有四种继承Thread类、实现Runnable接口、实现Callable接口配合FutureTask、使用线程池。很多初学者纠结选哪个我的建议很简单能不自己new Thread就别new正常开发中使用线程池是首选。继承Thread类这种方式适合快速写个Demo但Java类是单继承的继承Thread之后就无法继承其他类了而且继承方式把任务代码和线程控制代码耦合在一起可维护性差。实现Runnable接口就更灵活一些任务逻辑和线程控制分离但Runnable的run方法没有返回值也抛不出受检异常。如果任务需要有返回值或者需要抛出异常就用Callable接口配合FutureTask或者线程池的submit方法可以拿到执行结果。Callable的call方法有泛型返回值还能抛出异常但调用Future的get方法获取结果时会阻塞如果任务一直不结束主线程就卡住了这个要注意。2.2 线程生命周期六种状态详解线程不是一生下来就跑的Java里Thread.State枚举定义了六种状态NEW新建、RUNNABLE可运行、BLOCKED阻塞、WAITING等待、TIMED_WAITING计时等待、TERMINATED终止。NEW状态就是线程对象创建了但还没调用start方法。调用start之后进入RUNNABLE状态注意这个状态包含两种子情况正在CPU上运行或者排着队等CPU调度。BLOCKED状态是线程在等待进入synchronized同步块时发生的也就是在争抢锁。WAITING状态是线程主动调用了wait、join或LockSupport.park等方法等待其他线程唤醒。TIMED_WAITING则是带时间限制的等待比如sleep、wait(long)、join(long)等。TERMINATED就是线程执行完或者抛出未捕获异常后终止了。我在面试中经常被问到一个细节调用yield方法会让线程从RUNNABLE回到RUNNABLE的等待队列不会进入WAITING或者BLOCKED。另一个细节是线程调用了start之后就不能再调用start了否则会抛IllegalThreadStateException。2.3 聊聊线程的启动与停止启动线程很简单调用start方法就行。但停止线程是个大学问很多人踩过坑。Thread类有个stop方法但已经被标记为废弃了因为直接强制终止线程会让它来不及释放锁可能导致数据不一致。正确的停止方式是通过中断标志。调用线程的interrupt方法会把它内部的中断标志位设为true但要注意如果线程正在sleep或wait会抛出InterruptedException并清除中断标志如果是普通循环需要在线程内部主动检查isInterrupted来判断是否退出。我写过很多次这种协作式取消的代码核心就一句话不要强行从外部杀死线程而是发个信号让线程自己决定什么时候退出。2.4 创建方式的实操选择我实际工作的习惯是这样的如果是定时任务的固定后台线程直接用Thread或者Runnable就够了如果任务执行结果需要追踪用Callable加线程池的submit如果是一批同类型的任务直接用线程池批量提交配合Future批量获取结果。还有一种情况是任务之间有关联依赖比如先查用户信息再查订单信息再组装返回这时候用CompletableFuture做异步编排更合适后面专门讲。总之线程创建只是入口真正难的是把握生命周期不要让线程一直存活而不干活更不要让线程提前死掉造成可用性事故。3. 线程安全问题多线程最容易翻车的地方3.1 先从一段翻车代码看竞态条件我先说一个真实案例。之前我维护过一个库存扣减接口代码大概是这样的if (stock 0) { stock stock - 1; // 写回数据库 }这只在单线程下没问题。一旦并发起来两个线程同时读到stock为1都判断大于0都执行减一结果两个请求都扣减成功了库存却变成0。这就是典型的竞态条件多个线程同时访问共享数据结果依赖于执行时序。要解决这个问题最直接的方式是加锁让同一时刻只有一个线程能进入临界区。Java提供的锁有两类内置锁synchronized和显式锁Lock。它们解决的核心问题都是三个原子性、可见性、有序性。3.2 synchronized的三种用法与锁机制synchronized可以修饰实例方法、静态方法、代码块。修饰实例方法时锁的是当前实例对象修饰静态方法时锁的是类的Class对象修饰代码块时锁的是括号里指定的对象。很多面试题喜欢问这个锁对象到底是什么锁的范围是多大。锁的范围其实有个经验法则锁的粒度越小并发度越高但也要保证临界区的操作确实需要同步。我之前见过有人把整个大方法都加上synchronized结果接口直接变成串行执行心里觉得“安全了”其实性能掉了好几个量级。正确做法是尽量缩小同步代码块只锁需要保护的那几行代码。JDK 6之后synchronized经过了优化引入了偏向锁、轻量级锁、重量级锁的升级过程。简单理解刚开始只有一个线程访问时走偏向锁成本很低多个线程竞争时升级为轻量级锁通过CAS自旋获取自旋达到阈值还抢不到就升级为重量级锁线程进入阻塞状态。所以synchronized并没有网上说的那么“重”只是很多人还在用旧观念衡量它。3.3 Lock接口与ReentrantLock实战ReentrantLock是显式锁的典型代表用法是lock.lock()加上try-finally中的lock.unlock()。为什么必须在finally里解锁因为一旦中间代码抛出异常锁没有被释放其他线程就永远拿不到这个锁了轻则卡死重则线上故障。ReentrantLock比synchronized多了几个能力可中断锁lockInterruptibly、可超时获取tryLock、公平锁与非公平锁切换、多个Condition条件变量。其中tryLock是避免死锁的好工具我尝试获取锁如果等不到就做别的事不会一直卡着。关于公平锁我有一个个人建议默认情况下别开公平锁。公平锁按请求顺序分配锁听起来很美好但代价是线程切换更频繁吞吐量下降。除非场景上确实要求先来先得比如一些任务分发场景大多数业务用非公平锁就够了。3.4 volatile的作用边界volatile是Java里最容易被误解的关键字。它解决的是可见性问题一个线程修改了变量的值其他线程能立刻看到。它的底层实现是通过内存屏障在写操作之后强制把本地内存的值刷回主内存在读操作之前强制从主内存重新读取。但volatile不保证原子性。最经典的例子是count这个操作不是原子的包含读、加、写三步两个线程同时执行就会丢更新。所以volatile适用于一个线程写、多个线程读的状态标志不适用于多个线程都更新的计数器场景。有个匿名内部类捕获外部变量的场景也很经典在Java 8之前局部变量在匿名内部类中使用必须声明为final这是因为内部类捕获的是变量的值拷贝。新版Java里只要变量没有被重新赋值编译器就默认它是effectively final。这和volatile没有直接关系但都是并发和变量可见性里的高频细节。3.5 原子类与CAS原理解决count这类问题除了加锁更高效的方式是使用原子类比如AtomicInteger、AtomicLong、AtomicReference。它们的核心是CASCompare And Swap比较当前值和期望值如果一致就替换为新值否则重试。CAS的底层是Unsafe类提供的compareAndSwapInt方法通过CPU指令实现原子操作不加锁所以性能比synchronized好很多。但CAS有一个著名的ABA问题某线程把A改成B再改成A另一个线程看到A以为没有变化就执行了更新实际上中间已经变过。解决方法是使用带版本号的AtomicStampedReference。我实际用CAS很少出问题但要注意CAS在竞争激烈时会导致大量的自旋空转白白消耗CPU。所以高并发下尤其是写操作密集的场景AtomicInteger并不总是比LongAdder更优后者的分段累加策略能显著降级竞争。4. 线程间的通信与协作让线程配合起来4.1 wait/notify机制与生产者消费者多线程之间不只是竞争锁还需要协作。经典的协作模式是生产者消费者生产者线程负责生产数据消费者线程负责消费数据中间有个缓冲区。Java里最原始的协作方式是Object类的wait和notify。调用wait前必须先持有该对象的锁调用wait后线程会释放锁并进入WAITING状态其他线程调用notify会唤醒一个正在等待的线程notifyAll则唤醒全部。这里有个经典陷阱while循环判断条件不能用if。原因是线程被唤醒后条件可能又被其他线程改掉了必须重新检查。synchronized (queue) { while (queue.isEmpty()) { queue.wait(); } // 消费 }这段代码看着简单但缺少了wait/notify的配套逻辑生产者那边也必须在同步代码块里notifyAll。真正的工作中我几乎不会直接用wait/notify都用接下来说的并发工具类因为wait/notify不好控制容易死锁或者产生漏唤醒问题。4.2 CountDownLatch、CyclicBarrier、SemaphoreCountDownLatch是倒计时门闩初始化时指定计数值主线程await等待其他线程完成一个任务就countDown一次计数到0门闩打开。我常用它来等一组并行任务全部完成后再做汇总。不过要注意CountDownLatch是一次性的用完了就没了不能重置。CyclicBarrier是循环屏障和CountDownLatch不同它是让一组线程相互等待等大家都到达屏障点之后再一起放行而且可以重复使用。一个典型场景多个线程并行计算数据需要等所有线程都算完当前批次才能开始下一批次。Semaphore是信号量控制同时访问某个资源的线程数量。比如数据库连接池最多只有10个连接就new Semaphore(10)。acquire获取许可证release释放许可证。Semaphore还能做限流比随便new线程更可控。我用这三个工具时总结了一个经验CountDownLatch适合“主线程等待多个任务完成”CyclicBarrier适合“多个线程互相等待一起做下一个阶段”Semaphore适合“限制并发数”。模式选对了代码就清晰选错了逻辑绕来绕去。4.3 CompletableFuture异步编排Java 8引入的CompletableFuture是我现在最常用的异步工具。它可以串联多个异步任务也可以并行执行再合并结果比Future好用的地方是支持回调、不阻塞主线程。最常见的用法是thenApply、thenCompose、thenCombine、allOf。thenApply用于任务依赖上一个结果继续处理thenCombine用于两个结果合并allOf用于等待所有任务完成。配合自定义线程池可以灵活编排复杂的异步流程。CompletableFutureUser userFuture CompletableFuture.supplyAsync(() - userService.getUser(id), executor); CompletableFutureListOrder orderFuture CompletableFuture.supplyAsync(() - orderService.getOrders(id), executor); CompletableFutureUserDetail detailFuture userFuture.thenCombineAsync(orderFuture, (user, orders) - buildDetail(user, orders), executor);这里面最坑的一个点是默认使用ForkJoinPool.commonPool()如果任务里都是阻塞IO公共线程池的线程数不够会拖垮整个应用里所有依赖CompletableFuture的地方。所以我的习惯是永远显式传入线程池绝不依赖默认池。5. 线程池生产环境并发能力的核心5.1 为什么必须用线程池线程的创建和销毁是有代价的每次new Thread都要分配栈空间、初始化线程对象线程多了还会导致频繁切换。线程池的核心思想是复用一组线程把任务提交给队列由线程池里的线程轮流执行。线程池除了复用线程还能控制最大并发数避免资源耗尽。我见过一个真实的线上事故某系统没有用线程池每次请求来都new一个线程去处理第三方接口调用高峰期线程数飙到几千最终内存溢出应用直接挂掉。用线程池给并发度加个上限再配合拒绝策略系统就算过载也不会被打死。5.2 核心参数与执行流程以ThreadPoolExecutor为例它的核心参数有七个核心线程数corePoolSize、最大线程数maximumPoolSize、空闲存活时间keepAliveTime、时间单位unit、工作队列workQueue、线程工厂threadFactory、拒绝策略handler。任务提交流程是这样的先判断当前线程数是否小于核心线程数是则创建新线程执行否则放入队列队列满了再判断是否小于最大线程数是则创建新线程如果已经达到最大线程数就执行拒绝策略。我在面试时经常问别人一个问题核心线程数满了、队列没满这时候新任务放哪里答案是放队列不会新建线程。很多人以为核心线程满了就直接扩张到最大线程数这是错误的理解。5.3 线程池参数怎么定线程池参数没有标准答案但如果按任务类型分还是有一套经验公式的。CPU密集型任务线程数建议设置为核心数加一IO密集型任务线程数可以设为核心数乘以二因为线程在IO等待时可以让出CPU给其他线程。更精确的公式是线程数 CPU核心数乘以1 等待时间/计算时间。我自己的习惯是先用这个公式算个基准值再通过压测调整观察CPU利用率、RT、队列积压情况逐步逼近最优值。不要指望一次参数配到完美线程池参数需要持续监控和调整。队列的选择也很关键。有界队列比如ArrayBlockingQueue可以控制任务堆积量配合拒绝策略保护系统无界队列则可能让任务无限堆积内存被积压任务撑爆。我强烈建议生产环境使用有界队列。5.4 拒绝策略与自定义线程池ThreadPoolExecutor有四种内置拒绝策略AbortPolicy直接抛异常、CallerRunsPolicy让提交任务的线程自己执行任务、DiscardPolicy直接丢弃任务、DiscardOldestPolicy丢弃最老的任务。默认是AbortPolicy对于生产系统直接抛异常容易导致用户请求失败CallerRunsPolicy是降级保护不让任务丢失但会拖慢提交方。我这里有个实战建议自定义线程池时一定要给线程设置有意义的名字比如“order-pool-thread-1”否则线上排查问题时你看到一堆“pool-1-thread-1”根本分不清是哪个业务在占用资源。我给线程工厂命名踩过好几次坑后来养成了习惯用guava的ThreadFactoryBuilder或者自己实现ThreadFactory。另外我在业务代码里一般不会直接用Executors提供的静态方法比如newFixedThreadPool、newCachedThreadPool因为它们的参数不能满足所有场景。newFixedThreadPool用了无界队列newCachedThreadPool最大线程数是Integer.MAX_VALUE都可能带来隐患。自己new ThreadPoolExecutor更可控。6. JUC体系与并发容器站在源码层面看并发6.1 ConcurrentHashMap为什么这么快Java多线程用到Map时最常用的就是ConcurrentHashMap。它的核心设计是分段锁到CAS加局部锁的演进JDK 7版本是Segment分段锁JDK 8改为CAS加synchronized锁链表头节点并发度高了很多。读操作大部分不加锁利用volatile读取写操作通过CAS尝试插入冲突了就对桶的头节点加synchronized。这样不同锁的桶之间互不影响并发度大幅提高。size方法也不直接加锁而是分段统计加CAS重试。我在实际项目里踩过一个坑误用Hashtable或Collections.synchronizedMap它们直接把整个Map加锁并发写一高就成瓶颈。换成ConcurrentHashMap之后吞吐量立刻上来了。记住一句话并发场景首选ConcurrentHashMap不是因为它永远更快而是因为它在并发读写下表现更稳。6.2 CopyOnWriteArrayList的适用边界CopyOnWriteArrayList是一种读写分离思想的实现读操作不加锁直接读数组写操作先复制一个新数组在新数组上修改然后把引用指向新数组。因为写操作加锁所以多个写线程是串行的。它最大的优点是读多写少场景下读线程完全无锁性能很高。但缺点是每次写都要复制整个底层数组如果数组太大或者写频繁内存和CPU开销都不小。所以我只在配置读取、监听器列表这类读多写极少极少的场景用它生产环境我见过有人用它存高频写入的日志对象结果频繁Full GC性能惨痛。6.3 AQS是JUC的基石AQSAbstractQueuedSynchronizer可能听起来陌生但它就是ReentrantLock、Semaphore、CountDownLatch、ThreadPoolExecutor里的Worker等一堆同步器的共同基石。它的核心是一个volatile的state变量加一个CLH变体线程等待队列。拿ReentrantLock来说lock方法就是尝试通过CAS把state从0改成1成功了表示拿到锁失败了就放入等待队列挂起。unlock方法把state改回0并唤醒队列里第一个线程。公平锁与非公平锁的区别就在于新来的线程是先排在队尾还是先尝试一次CAS抢锁。很多人看AQS源码觉得难我的建议是先不看细节只盯三条state表示什么、CAS怎么改state、队列怎么排队和唤醒。一旦理解了这三个点再去读ReentrantLock、Semaphore的源码就会事半功倍。面试里问AQS不是想听你背诵源码而是考察你能否讲清楚它解决了什么问题。7. 线上问题排查与多线程避坑指南7.1 死锁的产生与排查思路死锁是多线程里最严重的故障之一一旦发生相关线程全部卡死任务积压接口超时。死锁产生的四个必要条件互斥、持有并等待、不可剥夺、循环等待。只要破坏其中一个死锁就不会发生。排查死锁的常规手段是先拿到线程转储jstack看哪些线程处于BLOCKED状态再顺着锁的持有关系找有没有循环等待。比如线程A持有锁1等锁2线程B持有锁2等锁1线程转储里会出现“Found one Java-level deadlock”的提示同时列出相关锁信息。预防死锁的办法有几种尽量使用tryLock加超时获取不到锁就不一直等多把锁时固定加锁顺序缩小锁的持有范围用显式锁中的lockInterruptibly支持中断。我在代码审查时会专门盯加锁顺序尤其是两个或多个锁嵌套的时候。7.2 线程池任务积压与隐藏问题线程池使用中的最大隐藏问题就是任务堆积。当生产速度远大于消费速度有界队列被填满新任务触发拒绝策略如果策略是DiscardPolicy用户请求就会悄无声息地丢失这是最危险的。我的排查经验是先看监控指标线程池活跃线程数、队列大小、拒绝次数、任务执行耗时。一般积压的根因有三种任务耗时上涨比如数据库变慢、线程数配置不合理、上游QPS暴涨。定位到原因后再对症下药而不是盲目调大线程池参数。还有一个容易出现的问题线程泄漏。使用CompletableFuture或者线程池时如果任务内部有阻塞操作没有超时比如HTTP调用没设置连接超时线程会被一直占住最终整个线程池的线程都被占满系统看似没死但完全不处理新任务。这时候jstack能看到大量线程WAITING在同一个地方那是典型的线程池耗尽。7.3 高并发场景的调优经验多线程调优没有银弹但有几个方向是屡试不爽的减少锁的粒度、减少锁的持有时间、使用无锁数据结构、将串行操作并行化、合理使用异步化。比如用LongAdder替代AtomicLong在高并发写计数场景下性能更好用读写锁ReentrantReadWriteLock在“读多写少”场景下能提升并发度用分段的思想把大锁拆成多个小锁用ThreadLocal减少线程内共享变量的竞争但同时要注意ThreadLocal的内存泄漏问题使用完后一定要remove。我调过一个订单系统原本用一个全局锁保证订单状态流转后来发现大量请求都在等同一把锁。后来改成按照订单号哈希分散到多个锁桶并发能力提升了近10倍。这个优化思路可以迁移到很多场景先把冲突分散再把锁粒度降下来。7.4 常见面试题解决思路Java多线程面试题看起来五花八门其实核心就几个线程安全怎么保证、锁机制的原理、线程池参数怎么选、并发容器怎么选、AQS的底层逻辑、线程通信方案。把今天这些内容串起来大部分面试题都有了解题框架。比如问“Kafka消费端多线程如何保证消息顺序性”我的思路很明确分区是顺序的让同一个分区的消息只被同一个消费线程处理不要跨线程并发处理同一个分区或者用带顺序号的队列按序号排序后再提交。这个问题考察的本质就是你自己对多线程并发控制和消息有序性之间矛盾的理解深度。8. 聊几个容易被忽视的Java版本变化8.1 源发行版17相关的警告最近很多人升级到JDK 17会遇到这样一个编译警告“警告: 源发行版 17 需要目标发行版 17”。这个话题看起来跟多线程没关系但升级JDK版本往往会影响并发行为所以值得提一句。这个警告的本质是javac默认认为源版本和目标版本一致但项目里用了Java 17的语法特性比如switch表达式、文本块而编译目标没有设置成17。解决方式很简单在Maven或Gradle里统一设置maven.compiler.source和maven.compiler.target为17或者用release参数。不要在代码里回避新语法来压制警告那样没意义。8.2 虚拟线程与未来方向JDK 21正式引入了虚拟线程Virtual Threads这是Java并发领域最近几年最重要的变化之一。虚拟线程由JVM调度而不是操作系统调度创建成本极低可以创建几十万甚至上百万个这彻底改变了高并发服务的设计思路。但我的态度一直是新特性虽好生产环境上不上要谨慎。如果你的项目还在Java 8先把今天这些多线程基础打好如果已经用上Java 21可以尝试用虚拟线程替代部分线程池的IO密集型场景但一定要做充分的压测和监控。虚拟线程也不意味着线程池和锁都不需要了它们是互补的关系不是替代的关系。我在实际使用中的体会是Java多线程之所以让人头疼不是因为某一个API有多难而是因为并发问题往往在高负载下才暴露复现和排查的成本都很高。平时写代码多问自己几个问题这个共享变量需要可见性还是原子性锁的粒度是不是太大了线程池参数有没有可能被流量打穿这几个问题能想清楚多线程至少不会成为你负责的系统里的短板。最后再分享一个小技巧生产环境一定要给线程池加监控指标线程活跃数、队列积压、任务耗时这三个指标能救你命。
返回列表