ARTICLE DETAIL

资讯详情

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

Java线程基础全解:从生命周期到线程池的实战避坑指南

Java线程基础全解:从生命周期到线程池的实战避坑指南 1. 聊聊线程这个基础到底为什么值得再啃一遍毫不夸张地说我入行前三年写代码都是野路子——遇到性能问题就开线程线程多了就报错报错了就加锁加锁完又死锁死锁了就只能重启。直到有一天一个前辈指着我的代码说了一句你根本不懂线程你只是在用线程。这句话挺扎心的但也点醒了我。Thread基础这四个字听起来像是教科书第一章、培训班第一课好像没什么可讲的。但恰恰是这些基础中的基础决定了你在并发场景下到底是游刃有刃还是提心吊胆。我见过太多工作三五年的人能流畅说出进程和线程的区别真到线上排查问题的时候连线程的状态流转都说不清楚更别说搞清楚线程池参数为什么这么配。这篇内容的核心是想把Thread这个底层的执行单元彻底掰开揉碎从它到底是怎么跑起来的到线程生命周期里每个状态之间是怎么切换的再到多线程协作最常用的几个机制最后落到工程里真正高频踩坑的细节上。它适合三类人刚学到线程、想系统建立知识框架的初学者写过一阵子并发代码、但总觉得会写不会说的开发者以及需要带新人、想把这块讲清楚的技术组长。我不打算把这篇写成JDK源码的逐行注释也不打算照搬任何一本教材的目录。我会按照自己实际排查问题和写工程代码的顺序来组织先搞清楚线程机制本身再讲状态和协作工具然后聊线程池这个最常见的线程管理方式最后把我踩过的那些坑全部交代清楚。这样读完你不仅能应付面试更重要的是线上出问题的时候你知道从哪儿下手。2. 线程到底是个什么东西一次轻量级进程的完整旅程2.1 从进程讲到线程为什么需要更小的执行单元先把最基础的概念对齐。进程是操作系统分配资源的基本单位它拥有独立的内存空间、文件描述符、环境变量等一整套资源。而线程是进程内部的一条执行路径它是CPU调度的基本单位同一进程里的多个线程共享进程的资源包括堆内存、全局变量、打开的文件等等。为什么一定要引入线程假设你要做一个下载器同时下载三个文件。如果用多进程方案你得创建三个子进程每个进程有独立的地址空间它们之间通信得靠管道、共享内存这类IPC机制开销大、代码写起来也痛苦。但如果用多线程三个线程共享同一个进程的堆空间直接把任务拆给三个线程就行传参、取结果都简单得多创建线程的开销也比创建进程小一个数量级。不过共享是一把双刃剑。资源共享带来了通信的便利也带来了数据竞争的问题。两个线程同时修改同一个变量后写的人会覆盖先写的人最终结果完全取决于操作系统线程调度的顺序这就是并发Bug最经典的来源。所以学Thread本质上学的不是怎么开线程而是共享的资源如何安全地被多个执行单元访问。2.2 一条线程从创建到消亡操作系统背后干了哪些活当你调用线程创建接口时操作系统并不会像很多人想象的那样立刻开一条新路那么随意。它要做一连串事情分配线程控制块TCB记录线程的ID、状态、优先级、寄存器上下文分配独立的栈空间线程私有的局部变量都放在这里将线程加入就绪队列等待调度器选中它获得CPU执行权真正被调度执行时完成上下文切换保存上一个线程的寄存器状态加载当前线程的寄存器状态。这里面有两个关键开销点一是线程创建本身涉及系统调用和资源分配不是零成本的二是上下文切换切换次数越多CPU消耗在存档读档上的时间就越多真正干业务的时间比例就越低。Java里创建一条线程大概要消耗几十万到上百万个CPU周期具体取决于操作系统和JVM实现所以生产环境没有任何人敢来一个任务就new一个线程。这里有个很反直觉的点线程被创建之后并不是马上开跑。它只是进入了就绪状态具体什么时候跑、跑多久完全由操作系统的调度器决定。调度的基本策略是时间片轮转每个线程被分配一小段CPU时间时间片到了就被换下去这种抢占式调度意味着你永远不能假设自己的代码会连续执行多久——两行紧挨着的代码之间线程完全可能被切走。2.3 Java里的ThreadAPI只是表象栈和调度才是里子在Java里面Thread类的start()方法负责把一条线程拉起来。这里必须明确一件事start()和run()是完全不同的两码事。我面试的时候几乎每次都会问这个问题但能一次答对的候选人不到三成。直接调用run()只会在当前线程里同步执行run方法不创建任何新线程调用start()会向底层JVM和操作系统申请创建一条真正的线程然后由这条新线程去执行run方法。为什么会有人在这里混淆因为Java的Thread对象本身只是个包装壳真正干活的是操作系统层面的原生线程。在绝大多数主流JVM实现里Java线程和操作系统线程是一一对应的也就是1:1线程模型。你new一个Thread对象并没有创建操作系统线程只有调用start()之后JVM才会通过底层调用创建一个原生线程并把Java层的run方法作为这个原生线程的入口。线程启动之后私有的栈空间就归它独享了。栈上存放的是栈帧——每次方法调用都会压入一个栈帧里面保存局部变量、中间计算结果、方法返回地址等信息。线程执行结束栈空间被释放。这也是为什么线程局部变量ThreadLocal天然具有线程隔离性因为它本质上是附着在线程栈和线程对象上的存储空间。3. 线程生命周期与上下文切换六种状态和一张不能记错的流转图3.1 六种状态逐个拆解别再睡觉时是阻塞Java线程的六种状态是面试高频考点也是排查线上问题的基础语言NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。很多人在这个环节的最大误区是一看到线程没在跑就觉得是阻塞甚至把等待和阻塞混为一谈。这两个状态在Java里泾渭分明背后的锁机制完全不同。NEW线程对象创建了但还没调start()。此时线程的底层资源都还没分配它只是一张设计图纸。RUNNABLE线程调用了start()之后进入的状态。注意Java里RUNNABLE是个组合概念它既包含正在被CPU执行的状态也包含在就绪队列排队等CPU的状态。也就是说从Java角度看只要线程没在等锁、没在主动休眠它就是RUNNABLE哪怕它此刻并没有被调度到。BLOCKED线程试图进入一个synchronized修饰的方法或代码块但锁被别的线程持有它进不去这时候进入BLOCKED。这个状态是被动等待等的是锁的释放。WAITING线程主动调用了Object.wait()、Thread.join()或者LockSupport.park()进入无限期等待。要等别人显式唤醒notify/notifyAll/unpark才能回到RUNNABLE。TIMED_WAITING带超时的等待比如Thread.sleep(1000)、wait(1000)、join(1000)、parkNanos()。超时之后系统自动唤醒不需要别人踢一脚。TERMINATEDrun方法正常结束或者执行过程中抛了未捕获异常导致线程退出。这里要特别强调一下Thread.sleep()的状态归属。很多人背状态背得滚瓜烂熟但一看到sleep就条件反射回答阻塞。sleep期间线程并不持有任何对象的锁也不跟锁机制有半点关系它就是单纯地让出CPU并按指定时间休眠状态是TIMED_WAITING不是BLOCKED。这两个概念的混淆在线上排查时危害极大我后面会详细讲。3.2 状态的切换路径以及哪些动作是主动、哪些是被动状态流转的核心规律是除了NEW到RUNNABLE这一步靠start()触发之外其余绝大多数切换都发生在线程主动让出CPU和被锁挡住这两个方向上。典型的流转路径是这样的RUNNABLE遇到synchronized锁被占 → BLOCKED锁被释放后被系统唤醒回RUNNABLERUNNABLE主动调用wait() → WAITING被notify/notifyAll唤醒后重新回到RUNNABLE的排队队列RUNNABLE主动调用sleep(ms) → TIMED_WAITING时间到自动回RUNNABLERUNNABLE执行完run方法 → TERMINATED。有一个细节很多人从没注意过从WAITING被唤醒之后线程并不会立刻执行wait()后面的代码而是重新去竞争synchronized锁。因为wait()本身要求先持有锁才能调用而wait()被调用的一瞬间线程会释放这把锁。唤醒之后线程必须重新拿到这把锁才能从wait()的下一条语句继续。这就产生了一个很有意思的结论notify()只是把线程从等待队列挪回锁的竞争队伍并不代表它立刻被调度执行。好在Java 5之后引入了java.util.concurrent包的Lock体系LockSupport.park()/unpark()不依赖synchronized语义更干净编码上也少了很多A线程唤醒B线程却发现B还没资格拿锁的边界心智负担。但synchronized仍然是绝大多数场景的首选因为它的锁可以在异常路径上自动释放而Lock必须手动unlock一旦忘了就等着死锁。3.3 上下文切换的成本为什么贵怎样才能少切换上下文切换是并发程序的隐形杀手。一次上下文切换的开销大约在几十纳秒到几微秒之间看起来很快但乘上几千个线程、每秒上万次切换损耗就很可观了。开销主要来自几个方面寄存器状态和程序计数器的保存与恢复线程栈的切换涉及内存地址空间的变更CPU缓存失效——这是最伤的一环。线程被换走后它之前在各级缓存里留下的热数据可能全部失效切回来之后又得重新加载。减少切换的手段在Thread这个维度上就是三件事一是不要创建过多的线程线程数和CPU核数严重不匹配时大量线程其实都在排队等待白白增加调度压力二是减少阻塞阻塞会让出CPU导致切换三是合理使用锁粒度锁的范围越大其他线程排队的时间越长切换也越频繁。举个实际感受过的例子我曾经负责一个网关服务线程池配了2000个线程CPU核数只有8核。看起来线程池大能抗高并发实际上一遇到峰值性能反而不如128个线程的时候。原因就是大量线程在那边排队切换CPU全耗在调度上了。后来把线程池压到CPU核数两倍以内配合队列缓冲整体吞吐反而上去了。这就是线程不是越多越好最直观的经验证据。4. 线程间协作的底层工具wait/notify、join、ThreadLocal的源码级行为4.1 wait和notify的正确姿势为什么必须在同步块里调用Object.wait()和Object.notify()是多线程协作最古老的手段。它们解决的核心问题是一个线程需要等待某个条件成立才能继续而这个条件由另一个线程去改变。典型的应用场景是生产者-消费者模型里缓冲区满了生产者要等待缓冲区空了消费者要等待。这里有两个强制约束很多人写错代码就是因为没理解它们背后的原因wait()和notify()必须在持有该对象monitor也就是synchronized锁的前提下调用否则抛IllegalMonitorStateExceptionwait()调用之后会立刻释放当前线程持有的对象锁让其他线程有机会进入同步块去notify。为什么必须这么设计根本原因是避免失序唤醒的竞态条件。设想没有锁约束线程A检查缓冲区发现为空决定调用wait()等待与此同时线程B往缓冲区放了一个数据调用notify()想唤醒等待者。如果A检查和wait之间没有原子性保护就可能出现A还没来得及睡着B就已经通知完了通知落在空等待队列上A再睡着就没人能唤醒它了程序永远卡住。把wait/notify放进同一个synchronized块内就是保证检查条件和进入等待是一个原子操作不可被其他线程穿插。这是理解wait/notify机制的关键也是面试官最爱追问的为什么。用的时候还有两个讲究一是判断条件要用while循环包住wait不能用if。原因是线程被唤醒后还需要重新检查条件有可能条件又被其他线程改回去了或者发生了虚假唤醒。二是尽量用notifyAll而不是notify。notify只会随机唤醒一个等待线程如果被唤醒的线程发现条件还是不满足它只能继续等待可能导致活锁notifyAll把所有人都喊起来让它们自己去竞争和复查。从性能上说notifyAll可能稍重一点但正确性优先。4.2 join的本质把异步变成同步等待结果join()是另一个被低估的协作工具。它的语义是当前线程调用t.join()那么当前线程会一直等待线程t执行完毕然后才继续往下走。通常用于把一个计算任务拆给子线程之后主线程必须等子线程算完才能用结果。join底层也是wait机制——join()内部会不断检查目标线程是否存活如果存活就调用wait()进入等待目标线程终止后JVM会自动调用notifyAll唤醒等待者。这个细节很有意思看起来join是靠轮询存活状态实现的实际上它是经典的wait/notify应用只是wait的唤醒不用你手动管。有几个join的变体值得注意join(0)表示无限等待直到目标线程结束join(2000)表示最多等两秒超时后无论目标线程是否结束当前线程都会恢复执行。这个超时版本在生产代码里非常实用尤其是在调用外部服务、但不想无限期等下去的场景可以优雅地设置一个最大等待预算。不过工程上现在很少有人直接用Thread对象去join了因为更常见的是用线程池提交任务然后通过Future.get()拿结果。Future.get()也给调用方提供了超时能力语义上和join(ms)类似但更灵活——它可以对提交的一批任务中任何一个完成就返回这类需求用invokeAny来实现而join只能傻等某一条线程。4.3 ThreadLocal看似简单翻车点全在生命周期上ThreadLocal解决的是每个线程独享一份数据的问题。它不解决线程安全问题它直接绕开共享——每个线程访问ThreadLocal时操作的是各自线程私有的变量副本。看源码你会发现ThreadLocal的核心其实不在ThreadLocal本身而在于Thread类里那张ThreadLocalMap。当线程第一次调用set()时就在自己私有的ThreadLocalMap里插入了一个键值对key是当前ThreadLocal对象的弱引用value是你要存的数据。之后这个线程不管在哪里调用get()都是从自己线程的Map里取值自然不存在竞争。这里的坑在于ThreadLocalMap的key是弱引用value是强引用。一旦ThreadLocal对象不再被外部强引用它会被GC回收但线程里遗留的value对象还占着内存而且没人能再通过这个key访问到它——这就是ThreadLocal内存泄漏的根源。尤其要注意的是线程池场景线程池里的线程是复用的永远不销毁如果某个任务往ThreadLocal里写了大对象又没清理那么这个对象会一直挂在那条线程上直到线程池被关闭。反复提交任务的场景下这个泄漏会被无限放大最终导致OutOfMemoryError。正确姿势是在任务结束时用try-finally块调用ThreadLocal.remove()确保线程归还线程池之前它私有的Map里不再残留任何数据。很多人问我用完后设置成null行不行不行set(null)只是把value换成nullMap里的Entry还在必须remove()才会把整个Entry从Map里摘掉。4.4 轻量级协作的现代解法从wait/notify到Lock与Condition有了上面的基础你再看java.util.concurrent包就会觉得顺理成章。Lock体系把wait/notify这套基于对象监视器的协作机制重构成了两层Lock替代synchronized负责互斥Condition替代wait/notify负责线程间协作。一个Lock可以创建多个Condition对象相当于把等待队列拆成了多条。比如一个生产者消费者队列你可以用notEmpty这个Condition通知消费者有货了用notFull这个Condition通知生产者有空位了而不像synchronized时代只有一个隐式等待队列只能通过一个wait通道喊话经常把不相关的等待线程也一起唤醒。Condition的await()和signal()在语义上对应wait()和notify()但它有一个前置步骤必须先lock.lock()然后await()await内部会释放锁并等待被signal()唤醒后再次尝试获取锁获取成功才从await()返回。这套机制的代码模式更清晰也更容易扩展到复杂的协作模型上。5. 线程池生产环境里Thread的正规军用法5.1 为什么必须用线程池以及ThreadPoolExecutor的七个参数裸用Thread的场景在生产代码里几乎没有生存空间。核心原因我在前文已经提了一部分每条线程的开销包括内存栈、创建时间、系统调用更麻烦的是无限制创建会导致系统资源耗尽。线程池本质是一个复用线程控制并发上限的管理框架它把线程生命周期从随任务即生即死改成了常驻复用空闲回收。Java里线程池的标准实现是ThreadPoolExecutor它的构造函数有七个参数网上教程一抓一大把但真正理解它们之间怎么联动的人并不多corePoolSize核心线程数。这个怎么理解呢我用一个餐厅类比就是正式员工人数。即使没有客人任务这些员工也不会被辞退线程常驻。默认情况下核心线程不设超时回收。maximumPoolSize最大线程数相当于正式员工临时工的上限。keepAliveTime和unit非核心线程临时工空闲多久会被清退。workQueue任务队列相当于餐厅的等位区。核心线程都在忙时新任务先排队不立刻开新线程。threadFactory创建线程的工厂用来统一设定线程名、daemon标志等。handler拒绝策略。当队列也满了、线程数也到上限了新任务怎么处理。5.2 任务提交后的完整决策链路搞清楚线程池行为最关键的是记下这个决策顺序核心线程数未满直接创建核心线程执行任务核心线程已满任务先进workQueue队列等待队列已满创建一个非核心线程执行任务非核心线程数也到了maximumPoolSize触发拒绝策略。这个顺序跟很多人的直觉是反的。好多人以为线程满了才排队实际恰好相反——线程池优先让任务排队而不是优先开新线程。只有当队列都塞不下了才会去开非核心线程。这么设计的目的很明确线程创建和切换都有成本让任务排队往往比临时多开线程更划算。拒绝策略有四种内置实现AbortPolicy直接抛RejectedExecutionException默认策略适合要明确发现系统过载的场景CallerRunsPolicy在提交任务的线程里直接执行这个任务相当于谁提交谁执行起到了天然背压的效果DiscardPolicy静默丢弃任务不推荐容易造成业务无感知丢失DiscardOldestPolicy丢弃队列头的旧任务给新任务腾位置适合允许丢弃旧数据的场景。5.3 生产环境参数配置的微积分CPU密集还是IO密集线程池参数没有银弹但有明确的计算基线。我们需要区分两种任务类型CPU密集型任务任务几乎不阻塞一直在消耗CPU做计算比如图像处理、数值计算、加解密。这种场景线程数超过CPU核数不仅没用反而因为上下文切换陷入内耗。经验公式是线程数 CPU核数 1多出来的一个线程用于补偿偶尔的系统停顿。IO密集型任务任务大量时间在等待网络响应、磁盘读写、数据库查询线程实际上是睡着等结果的状态CPU很闲。这时候可以大胆提高线程数经验公式是线程数 CPU核数 * (1 等待时间 / 计算时间)。这个等式的意义很直接如果等I/O的时间是计算时间的九倍那线程数就可以放到CPU核数的十倍左右因为每个线程大部分时间都在等待不占CPU。线上实践时我通常的做法是先按经验公式给出初值然后用压测工具比如JMeter、wrk或者公司自研的压测平台跑不同并发梯度观察几个核心指标吞吐量、平均响应时间、线程池活跃线程数、任务队列积压情况。如果线程池长期处于队列堆积而核心线程空闲的状态通常说明核心线程数太小如果活跃线程数一直顶着maximumPoolSize跑说明系统已经过载盲目加线程没用要考虑队列策略、生产者限流甚至横向扩容。5.4 线程池关闭的讲究shutdown与shutdownNow的差异关线程池这个环节同样暗藏细节。shutdown()和shutdownNow()的区别是shutdown()通知线程池不再接收新任务但已经在执行的任务和已入队的任务会继续处理完是一个体面退出shutdownNow()尝试立即终止所有在跑的任务并返回队列里还没开始执行的任务列表。注意尝试两个字——正在执行的任务会被发送中断信号但任务本身如果忽略中断还是可能继续跑。生产环境的经验是非紧急情况下优先用shutdown()关闭前把队列任务清点清楚。如果业务要求停机不丢任务应该在shutdown前先把队列里的任务持久化或转移等恢复后再重新提交。这也是为什么很多人喜欢把线程池封装成可以被Spring管理生命周期的Bean由容器统一负责shutdown避免服务下线时线程池还挂着。6. 多线程编程实战中的高频坑死锁、可见性与误用场景6.1 死锁的四个必要条件与经典案例复现死锁是每个用Thread的人迟早要撞上的问题。教科书给出的四个必要条件是互斥条件、持有并等待、不可抢占、循环等待。理论很好背但排查起来往往要靠经验和工具。我复现一个非常典型的死锁场景线程A持有了锁1等待锁2线程B持有了锁2等待锁1。两边互不撒手于是系统进入僵局。这在实际代码里最常见的样子是转账场景里A账户给B账户转账、B账户给A账户转账同时发生两个线程分别锁住了自己的账户对象又都在等对方的账户对象锁。查死锁的手段在Java里很成熟先jps找到Java进程ID再jstack把这个进程的线程快照打出来。jstack会明确打印出Found one Java-level deadlock以及相关的线程栈和锁信息一般有几条循环等待链。如果项目里有条件也可以直接用JConsole或者VisualVM的线程面板动态观察。修复死锁的常见思路有四种调整加锁顺序让所有线程都按同一个全局顺序获取锁使用tryLock超时机制拿不到锁就退让重试缩小锁粒度降低锁竞争的交集用并发工具替代手工加锁比如直接使用ConcurrentHashMap、AtomicLong这些内部已经处理锁的类。6.2 可见性问题的本质为什么加了锁还是读到旧值多线程还有一个很隐蔽的坑可见性。一个线程修改了共享变量另一个线程不一定能立刻看到。这跟CPU缓存和指令重排有关系——每个CPU核心都有自己的高速缓存线程A修改的变量可能还待在A所在核心的缓存里没写回主内存线程B从自己的缓存读读到的还是旧值。解决可见性的手段synchronized、Lock、volatile以及final。synchronized和Lock的语义是进入同步块前读最新值退出同步块时把修改刷回主内存volatile则是每次读写都直接操作主内存禁止指令重排。它适合做状态标志位不适合做复合操作的原子性保障。一个最常见的误用是用volatile修饰计数器多个线程执行count。volatile只能保证读到的count是最新的但count这个操作本身包含读取-加法-写回三步两步之间线程可能被切换导致两个线程同时读到一个相同的旧值各加一次写回结果只增加了1。所以计数器的正确解法是AtomicInteger或者干脆用synchronized包住整个自增逻辑。6.3 阻塞状态判断的线上排错姿势最后讲一个线上排查的真实场景。有一阵我们的服务接口偶尔超时查线程快照发现大量线程处于BLOCKED状态全挤在一个synchronized方法上。看起来是锁竞争严重等锁的线程排成长队。我们当时的第一反应是把锁粒度缩小但仔细看锁的持有方是一条线程长时间占用这个锁不释放查它的执行栈发现它在做远程HTTP调用网络超时设置又特别长等于持锁死等。这个问题如果想当然地归为锁竞争激烈加锁就完事方向就歪了。正确的解决路径有两层第一层锁内不要做耗时操作尤其是网络IO、磁盘IO、数据库调用这种高延迟操作要挪到锁外第二层如果是外部调用实在没法避免给外部调用设一个合理的超时时间不让线程无限期等下去。还有一个判断技巧要养成习惯线程快照里看到大量TIMED_WAITING和WAITING不一定代表有问题看到大量BLOCKED且持锁线程长时间不释放才是需要警惕的信号。BLOCKED出现本身不可怕可怕的是谁持锁、持了多久、在锁里做了什么。7. 结语前的一次总结把Thread知识串成一套自己的调试直觉Thread基础这块内容说多不多说少不少。它不像框架源码那样千变万化也不像新特性那样每个月都在更新它的核心机制几十年没有本质变化。但恰恰是这份稳定让它值得每个写并发代码的人真正花时间扎进去。我自己的体会是学Thread不要只停留在API层面要去想我调的这句话操作系统背后到底做了什么。启动线程看起来只是把start()调了一下背后涉及系统调用、资源分配、调度器排队锁看起来只是让一段代码互斥了背后是缓存一致性、阻塞唤醒、上下文切换的一连串连锁反应。能把每个API背后的机制串起来线上遇到诡异问题的时候脑子里自然就能浮现出可能性清单这一步是任何速成资料都给不了你的。如果你还在学习阶段我建议你亲自动手把这几个实验做一遍写一个死锁程序然后用jstack抓它写一个volatile可见性失败的例子跑一跑写一个生产者消费者模型分别用wait/notify和Condition实现一遍。做完这三个实验你再看Thread相关的内容会觉得所有的概念都长在自己身上了。最后再分享一个小习惯生产环境里我几乎不会直接new Thread而是统一走线程池线程池里所有线程都设置成有业务含义的线程名比如order-async-processor-1出现线程相关问题时从线程名就能直接定位到业务模块省掉大量翻代码的时间。线程安全没有银弹但良好的习惯和扎实的基础能让你少踩至少一半的坑。
返回列表