ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java多线程核心机制与实战:从线程状态到线程池全解析

Java多线程核心机制与实战:从线程状态到线程池全解析 工作这些年Java多线程算是让我又爱又恨的一个方向。面试的时候它是必考题平时写业务代码的时候它又像一颗定时炸弹——平时跑得好好的一到高并发场景就冒出各种诡异问题。我记得第一次正式接触多线程是在做一个数据迁移工具单线程跑一个表要四个小时改成多线程后四十分钟搞定那个提速的震撼感直到现在都还记得。但紧接着就是各种间歇性Bug数据对不上、偶尔死锁、日志乱成一团当时真的是一边查资料一边薅头发。这篇内容我打算围绕Java多线程的核心知识点展开从线程生命周期、并发三大特性、锁机制、线程池、生产者消费者模型到面试高频题的底层逻辑每块都会结合我在实际项目中踩过的坑和处理思路来讲。不管你是刚学多线程的新手还是准备面试的求职者或者正在排查线上并发问题的开发者应该都能从里面找到有价值的东西。1. 线程的六种状态与状态切换比教科书多走一步才算学会1.1 线程状态机其实不是五态是六态很多初学Java多线程的同学最先接触的就是线程的五种状态新建、就绪、运行、阻塞、死亡。但如果你打开JDK源码看一眼Thread.State这个枚举会发现Java官方定义的是六种状态NEW新建、RUNNABLE可运行、BLOCKED阻塞、WAITING等待、TIMED_WAITING计时等待、TERMINATED终止。这里有个容易绕晕的点教科书里的就绪和运行在JVM层面被合并成了RUNNABLE。因为JVM把CPU时间片分配这件事交给了操作系统Java层面无法区分线程是正在被CPU执行还是排队等待CPU执行干脆统一归为RUNNABLE。所以当你看到线程状态长时间停在RUNNABLE不代表它一定在干活也可能是就绪队列里排队。实际工作中线上排查线程状态最常用的命令就是jstack。我之前排查过一个接口响应变慢的问题dump出线程栈后发现大量业务线程处于BLOCKED状态都在等同一把锁。结合代码定位到是个静态方法里的synchronized块数据库查询写在了同步块里导致所有请求串行化。这个案例让我彻底记住了状态图只是表象锁的粒度才是性能瓶颈的关键。1.2 最常见的状态误解sleep 和 wait 到底有什么区别面试问sleep和wait区别的概率极高但很多人只背了标准答案一个不释放锁一个释放锁。真正要理解的是它们背后的设计意图。sleep是Thread的静态方法它的语义是让出CPU但不让出锁纯粹是为了暂停当前线程的执行。而wait是Object的方法它的语义是我需要等某个条件满足先把锁释放掉让别的线程有机会改状态。所以wait必须配合synchronized使用因为它要先持有锁才能释放锁而sleep没有这个约束。还有一个容易被忽视的点wait的线程需要被notify或者notifyAll唤醒否则会一直等下去而且等待期间线程是WAITING状态。sleep时间到了自己醒来进入TIMED_WAITING后恢复成RUNNABLE。这里有个典型的低级错误我曾经在项目里见过同事用while(true)加sleep(100)去模拟定时任务轮询看似能跑但峰值请求时线程全在睡觉积压而且没法精细控制。后来换成了ScheduledExecutorService的scheduleAtFixedRate简洁也可靠。1.3 一个经典场景为什么 main 方法里 new Thread().start() 后不等于立即执行写过无数遍的代码但真正问起start和run的区别依然有人含糊。start是启动一个线程让这个线程进入RUNNABLE状态等CPU调度run就是普通方法调用在当前线程里同步执行根本没起新线程。所以在main方法里直接调run()输出会在主线程执行无法实现并发。这里还有一个面试加分点Thread里有个isAlive()方法它在start()之后、run()执行完毕之前返回true。但hasCode()之类的细节大家反而更好奇。我印象比较深的是有一次在线排查一个线程已经执行完了但对象引用还存活着查看状态是TERMINATED这种线程虽然死了但对象没被回收的情况实际上线程对象可以像普通对象一样被GC只是Thread对象内部有和操作系统线程的绑定关系释放会稍微慢一些。现在项目里基本都用ExecutorService管理线程手动new Thread的场景已经少了很多但理解状态切换对这些仍是基础中的基础。2. 并发三大特性的底层逻辑JMM、volatile 与 Happens-Before2.1 从缓存一致性说起一个变量在两个线程里的不同命运先从一个我真实的线上故障说起。业务里有一个开关配置一个线程定时从配置中心拉取开关值更新到一个static boolean变量其他业务线程读这个变量来决定是否执行某个逻辑。上线当天一切正常第二天下午突然出现了一批错误请求查了半天发现是开关明明在配置中心改了业务线程读到的却一直是旧值。这就是典型的可见性问题。Java内存模型JMM规定每个线程有自己的工作内存线程修改变量后不会立刻写回主内存读的时候也不一定强制从主内存读。两个线程各持一份副本互相之间看不到对方的修改就出现了缓存不一致。JMM为了解决这个问题定义了主内存和工作内存之间的抽象模型所有变量存在主内存线程操作变量时必须先拷贝到自己的工作内存操作完再写回。这个模型对应到真实的计算机体系结构就是CPU多级缓存和内存之间的关系。所以并发编程的三大特性——原子性、可见性、有序性——本质上都是在约束这个拷贝-修改-写回的过程。2.2 volatile 能保证什么、不能保证什么volatile是Java里最轻量的同步机制它做的事情有两件保证可见性、禁止指令重排序。保证可见性可以这么理解每次读volatile变量都强制从主内存读每次写都强制写回主内存。相当于是给JMM的拷贝-修改-写回流程加了一道强制刷新指令。但这里必须说清楚一个误区——volatile不保证原子性。典型的例子就是volatile int count做count这行代码在字节码层面至少拆成三条指令读取、加一、写回。即使加了volatile三个线程同时读到旧值加完写回最后还是只加了一次计数丢失。所以volatile在两种场景下是安全的一是对变量的写入不依赖当前值比如单纯的开关赋值二是该变量是真正的不可变状态比如发布一个不可变对象。我自己的习惯是凡是需要读改写操作的一律不用volatile改用AtomicInteger或者synchronized否则就是给自己埋雷。2.3 Happens-Before 规则判断线程安全的终极大法很多开发者搞不定这个线程安全不安全的判断其实就是没掌握Happens-Before规则。这是一组规则用来确定一个操作在另一个操作之前是否对他可见。规则本身八条但常用的核心就几条程序顺序规则、锁规则、volatile规则、传递性。举个典型例子。两个线程线程A对一个普通变量赋值然后释放锁线程B获得同一把锁然后读这个变量。由于锁规则A的释放锁操作对B的获取锁操作可见而A在释放锁之前的所有写操作也会一并可见所以B能看到A写入的值。这就是为什么锁能保证进入临界区不止能看到锁本身的状态还能看到持锁线程之前所有的修改。传递性更重要如果A操作对B可见B操作对C操作可见那么A操作对C也可见。这套规则的价值在于遇到并发问题你可以推演出理论上是否安全而不是查半天资料靠猜。面试时把Happens-Before讲清楚可靠性比死记硬背八股文强很多。3. 锁的进化与选型synchronized、Lock 与 CAS 的实战对比3.1 synchronized 的锁升级之路很多Java开发者都听说过锁升级但真正把它理解透的并不多。JDK 1.6之后synchronized做了大量优化锁的运行路径从无锁到偏向锁到轻量级锁再到重量级锁逐级升级而且这个过程通常只有升级没有降级。偏向锁解决的是一个线程反复获取同一把锁的场景。如果只有单线程访问同步块锁记录会存线程ID后续进入时直接比对即可连CAS都不做。一旦有第二个线程竞争偏向锁撤销升级为轻量级锁。轻量级锁通过自旋获取锁尝试几十次自适应如果还拿不到就升级为重量级锁进入内核态阻塞队列。实际业务里锁竞争激烈程度决定性能。我之前调过一个并发扣减库存的接口刚开始直接synchronized整个方法压测时吞吐量低得可怜。把同步范围缩小到仅扣减库存的一行代码后性能立刻翻倍。这是最实用的一条经验优先保证并发正确然后通过缩小临界区范围而不是换高级锁来优化性能。3.2 CAS 为什么快ABA 问题怎么解CASCompare And Swap是Java并发包的基石AtomicInteger、ConcurrentHashMap等工具都依赖它。它的核心思路是三行指令比较内存值如果等于预期值就更新为新值否则放弃这次操作。整个过程是硬件级别的原子操作不需要加锁因此在高并发下性能很好。但CAS有个著名的坑——ABA问题。线程1读取内存值A线程2把值改成B又改回A线程1再次CAS时发现还是A就成功更新了但中间其实发生过变化。对于有些场景比如链表操作这会引发严重的BugJava里对应方案是AtomicStampedReference带上版本号来区分。实际项目中我很推荐在核心的并发数据结构上用带版本号的CAS方案尤其是涉及多步更新的时候。顺便提一个容易踩的坑CAS循环在高竞争下会一直自旋尝试白白消耗CPU。我现在做高争用场景会选择LongAdder而不是AtomicLong它内部做了分段累加用空间换时间在写多读少的计数器场景下优势非常明显。3.3 ReentrantLock 比 synchronized 强在哪如果问为什么有了synchronized还要ReentrantLock标准答案能列出一堆可中断、可限时、可公平、可多条件、更细粒度的锁控制。但我实际用下来最看重的是可限时获取锁的能力。lock.tryLock(2, TimeUnit.SECONDS)这种写法可以避免无限期阻塞。在高并发接口里如果拿不到锁就快速失败返回错误提示总比让请求一直卡着好。synchronized做不到这一点——一旦进入等待只有拿到锁才能继续。用的过程中还有个体验比较好的点ReentrantLock是显式锁必须手动lock()然后unlock()通常配合try/finally使用。很多人刚开始容易忘记unlock()导致死锁或者线程泄漏我见过线上事故就是因为异常路径没解锁线程全部阻塞在锁申请处。现在项目里的规范是锁处理必须单独封装避免散落各处。3.4 读写锁和 StampedLock读多写少场景的优化对于读多写少的场景ReentrantReadWriteLock是经典方案。读读不互斥、读写互斥、写写互斥允许多个线程同时读但一有写线程进来读线程必须等写完成。这个设计对缓存类场景特别合适多个线程同时读缓存写缓存时互斥进行。但ReentrantReadWriteLock有个痛点——读锁和写锁之间是强互斥的写线程可能被大量读线程饿死。Java 8之后提供了StampedLock引入乐观读的概念读线程不需要获取读锁而是先读取版本戳操作完再验证版本戳有没有变没变就成功变了则升级为悲观读锁再读一次。实测在读多写少的场景下StampedLock的乐观读能比ReentrantReadWriteLock高出不少。不过要提醒的是StampedLock不支持重入使用时要格外小心并且它的API更偏向底层用之前一定要想清楚场景是否值得。4. 线程池参数拆解核心线程数不是拍脑袋定的4.1 七个参数各自的职责Java里创建线程池最标准的方式是直接用ThreadPoolExecutor的构造方法。七个参数每个都有自己的职责但它们之间是联动的corePoolSize核心线程数线程池会保持这个数量的线程一直存活。maximumPoolSize最大线程数当任务队列满了之后线程池会继续创建线程直到这个上限。keepAliveTime非核心线程的空闲存活时间超时会被回收。unit存活时间的时间单位。workQueue任务队列核心线程满了之后新任务先进队列。threadFactory线程工厂用来给线程命名、设置是否守护线程等。handler拒绝策略当队列和最大线程数都满了新任务如何处理。之前踩过一个坑是直接用Executors.newCachedThreadPool()它允许创建Integer.MAX_VALUE个线程结果流量高峰时无限制地创建线程最后把机器的CPU打满。这是经典的错误用法。现在团队规范强制要求必须是new ThreadPoolExecutor()手动创建并且附上命名规范——用threadFactory给线程起有业务含义的名字排查问题时jstack一眼能看出来是哪个业务的线程池。4.2 核心线程数如何计算CPU 密集与 IO 密集的差异关于核心线程数网上流传各种公式但核心逻辑很简单。如果是CPU密集型任务核心业务就是计算线程过多只会增加上下文切换开销所以经验值是CPU 核心数 1。如果是IO密集型任务线程大部分时间在等待IO阻塞时CPU是空闲的可以多开线程来利用这些时间经验值通常是2 * CPU 核心数更精细一点可以用CPU 核心数 / (1 - 阻塞系数)阻塞系数通常取0.8到0.9。以上是理论值但实际项目里我不会直接套公式。更靠谱的方法是用压测调参设置一个初始值然后用JMeter或wrk打流量观察CPU利用率和线程池任务积压情况逐步往上调。比如我之前做的一个文件批处理服务16核机器理论IO密集推算32线程压测后发现50线程时吞吐最高再往上CPU飚高且吞吞吐下降所以最终定格在50。这套方法比拍脑袋可靠得多。4.3 拒绝策略四种策略背后的设计取舍当任务队列满了线程数也到上限了ThreadPoolExecutor提供了四种拒绝策略AbortPolicy直接抛RejectedExecutionException默认策略。CallerRunsPolicy不抛异常把任务丢回调用方线程执行。DiscardPolicy静默丢弃新任务。DiscardOldestPolicy丢弃队列中最旧的任务再尝试提交新任务。我在交易类系统里用的最多的是CallerRunsPolicy它的好处是由调用线程去执行被拒绝的任务相当于天然做了背压不会丢数据代价是调用线程变慢。但要注意如果调用线程本身是接口请求线程这可能导致响应时间拉长需要权衡。DiscardPolicy和DiscardOldestPolicy我基本不碰丢任务这种事情在业务系统里想想都可怕。4.4 线程池实操中的三个坑第一线程池用完不关。很多开发者在Spring管理的场景下往往忘了shutdown()线程池一直占着内存和线程。应用关闭时如果线程池还没关非守护线程会阻止JVM退出。所以在声明周期管理的代码里ExecutorService必须配合shutdown()处理。第二队列用完LinkedBlockingQueue默认是无界的可能导致任务无限堆积最终引起OOM。我曾经在异步消息处理里因为这个失误线上内存直线上升最后GC频繁到进程假死。现在队列一律显式指定容量不给无界队列留机会。第三execute()和submit()的异常处理不一样。execute时异常会直接抛给线程的UncaughtExceptionHandler而submit的异常是封装在Future里的你如果不调用get()异常就不会暴露非常隐蔽。我的经验是凡是submit(Callable)的地方Future结果必须处理要么get()捕获ExecutionException要么注册回调不能让异常静默消失。5. 生产者-消费者模型的四种写法从 wait/notify 到 CompletableFuture5.1 最原始的 wait/notify 实现生产者-消费者是Java多线程最经典的模型面试也常考手撕。最基础的方法是wait/notify配合synchronized。生产者和消费者共享一个队列生产者往队列里放数据队列满了就wait()等消费者消费消费者从队列里取数据队列空了就wait()等生产者生产。这里有一个非常容易出错的地方判断条件必须用while而不是if。原因是线程被唤醒后条件可能已经被其他线程改变比如两个消费者同时被唤醒一个消费了最后一条数据另一个继续执行时就发现队列空了。用while可以保证唤醒后重新检查条件。这段写法虽然是基本功但现代项目里几乎不会再用。原因是太脆弱notify和notifyAll选错会死锁条件判断写错会出并发问题调试也困难。它的价值是帮你理解锁、等待、唤醒的本质适合面试手写和教学使用。5.2 BlockingQueue一行代码解决同步问题在实际项目中我强烈推荐用LinkedBlockingQueue和ArrayBlockingQueue这类BlockingQueue做生产消费。put()在队列满时自动阻塞take()在队列空时自动阻塞内部的锁和条件队列都封装好了一行代码实现同步。用BlockingQueue后生产者代码就剩下构造数据然后queue.put(data)消费者就是while(true){ data queue.take(); process(data); }简洁到不需要思考并发问题。我之前的日志采集系统就是用它做缓冲多个生产线程写日志到队列单个消费线程批量刷盘既能削峰又能解耦。队列容量设置成多少要根据实际吞吐量估算默认10万容量撑不住再调。5.3 使用并发工具类实现无锁化生产者消费者如果想追求更高吞吐可以用ConcurrentLinkedQueue配合原子操作实现无锁队列或者用Disruptor这种环形缓冲区框架。无锁的代价是代码复杂度显著上升两个或以上消费者时需要自己处理幂等消费、消费失败重试等逻辑。Spark或流式处理消费者我会更推荐用Exchanger或CompletableFuture来做编排。比如有个任务需要先拉取数据再解析再入库每个阶段延迟不同可以用CompletableFuture把三个阶段串成异步流水线每一阶段都不阻塞主线程。这套思想和生产者-消费者模型本质一致只是粒度从数据变成了任务。5.4 CompletableFuture 的异步编排思路CompletableFuture是Java 8引入的组合式异步编程工具它把生产者和消费者解耦在函数式风格里。supplyAsync生产数据thenApply对结果做转换thenAccept消费最终结果任一步骤出错还有exceptionally兜底。我用它重写过消息推送模块一个任务从消息队列取消息supplyAsync异步获取消息体然后thenApplyAsync里调第三方推送接口再thenAccept更新发送记录。整体链路异步化之后原先差不多三千行的同步回调代码缩到了一百多行可读性反而更好。但是要注意CompletableFuture默认用的公共ForkJoinPool会和其他任务共用线程池网络IO阻塞会拖垮整个池子。正确用法是显式传入自定义的Executor哪怕只指定一两行代码也别让它用公共池。6. 面试高频题的应对逻辑八股背后的原理6.1 死锁产生的四个条件与排查工具死锁是并发编程里最经典的面试题也是线上事故的常客。产生死锁需要四个条件同时成立互斥、持有并等待、不可剥夺、循环等待。回答的时候能背出这四个条件只是及格能结合例子讲出如何破坏其中一个条件才是加分项。我和同事排查过一起数据库死锁两个事务分别持有一行锁又各自去请求对方的锁数据库死锁检测机制介入后其中一个事务被回滚。这个场景对应到Java面试往往是用两个线程各自持有锁A、锁B后交换请求来现场演示。如果在代码里能避免循环等待——比如所有线程都按同一顺序加锁——死锁概率会大幅降低。线上排查死锁常用jstack导出线程堆栈搜索Found one Java-level deadlock就能看到具体两个线程各持有什么锁、在等什么锁。现在开发规范里有个硬性要求跑完压测一定抓线程快照排查死锁和线程泄漏这比等生产事故才发现要划算得多。6.2 ThreadLocal 的隐患内存泄漏与传递问题ThreadLocal面试出现频率极高但它是个看起来简单用起来容易出事的类。每个线程内部维护一个ThreadLocalMapset的值是存在当前线程对象里的线程结束后整块内存都会成为垃圾回收目标所以看似没有跨线程问题。实际隐患出现在线程池场景线程池里的线程会复用线程结束了但线程对象没有销毁ThreadLocalMap还保留着上一次请求设置的变量。下次同一个线程处理新请求时读到的还是旧值数据就串了。正确做法是在请求处理完之后必须显式调用remove()我在团队里把它写进了代码评审的必查项。还有一个场景是ThreadLocal的传递子线程里默认无法读取父线程设置的ThreadLocal值。如果确实需要父子线程传递要用InheritableThreadLocal但它在真实并发环境里的复制行为仍然有很多坑。现在的异步框架比如transmittable-thread-local专门解决这种传递问题如果项目里大量使用异步编排建议直接引入。6.3 多线程下如何保证数据一致性面试时经常被问多线程环境下怎么保证数据一致性很多人第一反应是加锁。实际上数据一致性分为好多个层次数据库的ACID、缓存的最终一致性、JVM内部的对象状态一致性每种场景的手段都不一样。我处理的订单系统写入统一走数据库事务事务内的组件通过数据库行锁保证一致性这个层面跟Java多线程关系不大。真正需要JVM内线程安全的是那些内存缓存、计数器和状态机这些场景怎么选简单的原子变量用Atomic*复合的读改写操作用锁缓存并发读多写少用读写锁优化列表结构用ConcurrentHashMap和CopyOnWriteArrayList。核心心态是能不用锁就不用锁能缩小临界区就缩小临界区。锁是最后一道防线但并不是唯一的方案。业务层面要知道哪些状态必须强一致哪些允许最终一致这个判断比任何并发工具都重要。结尾写到这里回头看自己踩过的坑其实大多数问题绕来绕去还是那几个底层原理可见性、原子性、有序性。工具再多synchronized也好、ReentrantLock也好、CompletableFuture也好都是建立在这些原理之上的。我个人的经验是学Java多线程不要赶进度先把JMM、Happens-Before、线程状态切换这些基础啃扎实再上手用并发工具包后面踩的坑会少很多。还有一点就是多给自己一些故障场景演练的机会理论上推演一百遍不如线上真实排查一次记得牢。最后分享一个实用小建议新写并发代码时一定开着线程dump排查一遍再上线这个习惯帮我躲过不少事故。
返回列表