ARTICLE DETAIL

资讯详情

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

Java多线程进阶:从synchronized到线程池,穿透并发协作核心

Java多线程进阶:从synchronized到线程池,穿透并发协作核心 1. 从Thread.start()到死锁多线程进阶到底进阶在哪1.1 为什么你学完创建线程的五种方式依然写不好并发代码刚接触JavaSE多线程的朋友大概率经历过这样的阶段能背出继承Thread、实现Runnable、实现Callable、使用线程池这几种创建线程的方式也看得懂synchronized基本用法但真要上手写一个带共享数据的业务模块线程一多还是会出现数据错乱、卡死甚至OOM。原因很简单创建线程只是入门动作线程之间的通信、协作、调度、资源隔离才是真正的核心。进阶和初级的差别不在API用得有多花哨而在对共享资源竞争这件事的理解深度。多线程问题的本质是多个执行流同时访问同一份数据时如何保证数据的一致性和程序的确定性。这里先打个比方单线程像一条单人流水线工人按顺序干活永远不会出现两个工人抢同一个零件的情况。多线程就是开了几条流水线零件、工具、工作台全都是共享的如果每个工人都按自己的节奏伸手去拿轻则拿错零件重则把机器卡死。多线程进阶知识学的就是怎么给这些流水线制定协作规则。所以这篇文章我不会从头讲线程怎么创建也不会贴大段API文档而是按实际项目中从简单到复杂的演进路径把JavaSE多线程进阶中最关键的内容拆开讲透从线程生命周期开始到锁的底层模型再到生产者-消费者这种经典协作场景然后是JUC工具类和线程池最后加一点多线程对比的视角和实战中容易踩的坑。1.2 线程生命周期里隐藏的进阶线索很多教程会把线程生命周期画成一张六状态图看一眼觉得懂了但实际排查问题时才发现图是图代码是代码。这里不复制那张图只讲几个进阶必须吃透的关键点。线程的状态从NEW新建、RUNNABLE可运行、BLOCKED阻塞、WAITING等待、TIMED_WAITING限时等待、TERMINATED终止这几个状态之间流转。注意Java里的RUNNABLE实际包含了操作系统层面的正在运行和排队等待CPU时间片两个情况所以RUNNABLE状态居高不下不一定说明线程真的在跑也可能在抢CPU。进阶时最容易忽略的是BLOCKED和WAITING的区别。BLOCKED是因为没抢到同步锁被挡在门外WAITING是拿到了锁之后主动调用wait()或者join()让出CPU等待别的线程来唤醒。这两个状态在生产环境排查线程问题时非常关键。你用jstack导出的线程快照里如果大量线程卡在BLOCKED说明锁竞争激烈如果卡在WAITING往往是等通知等不到可能出现死锁或者信号丢失这类逻辑问题。另外补充一个我在实际排查中养成的习惯查看线程快照时先看有没有成对出现的Waiting to lock信息再看线程栈顶部的调用方法。如果是wait()、notify()这类方法相关重点检查等待条件有没有在异常路径上漏掉notify如果全是lock、synchronized相关重点检查锁的粒度是不是太大了。这套路定位过好几次线上问题比对着文档翻API有用得多。提示真正的进阶是看到状态流转时能立刻联想到这段代码在什么场景下会卡在这里以及谁在什么时机把它唤醒或解除阻塞。2. synchronized底层藏着的Monitor模型搞懂它才算真正入门进阶2.1 从字节码看synchronized的实现很多Java开发者把synchronized当成玄学关键字加了之后线程安全了但问原理就说不清。其实synchronized的底层实现和JVM的Monitor管程模型绑定在一起。用javap -c反编译一段简单的同步方法你会发现同步代码块在字节码层面是通过monitorenter和monitorexit两条指令实现的。进入monitorenter时线程尝试获取Monitor的所有权退出时执行monitorexit释放。这就是为什么synchronized是可重入的同一个线程可以多次进入同一把锁因为Monitor内部记录着持有者的线程ID和重入计数。高版本JDK对synchronized做了大量优化锁对象头里用Mark Word记录锁状态存在无锁、偏向锁、轻量级锁、重量级锁几条升级路径。偏向锁针对的是只有一个线程访问同步块的场景轻量级锁针对的是多线程交替访问的场景重量级锁才走到底层的互斥量。换句话说JVM用一堆优化手段目的是让synchronized在低竞争场景下尽量不阻塞线程。不过这里要提醒一句不要因为JVM有优化就完全不在意锁性能。偏向锁在高版本JDK里其实已经在逐步废弃而且一旦出现真正的竞争锁升级成重量级之后性能开销依然不小。关键还是设计层面减少竞争而不是依赖JVM兜底。2.2 synchronized、wait、notify是一条协作链路进阶阶段容易卡住的一个点是没有把synchronized和wait()/notify()当成一套协作机制来理解。很多人只把synchronized当成互斥锁忽视了它同时也是协作锁。wait()的语义是当前线程必须持有某个对象的Monitor也就是处于该对象的synchronized代码块内然后释放Monitor并进入WAITING状态。notify()的语义是唤醒一个正在该对象Monitor上等待的线程被唤醒的线程需要重新竞争Monitor才能继续往下走。这两句话合在一起产生了多线程协作的基本框架。举个生活化的例子厨房里有一个灶台共享资源厨师A负责做菜生产者厨师B负责收盘子消费者。如果两者同时用灶台厨房就乱了这是互斥问题用synchronized解决如果菜做完了要通知收菜的来端走这是协作问题用wait()/notify()解决。一个完整的厨房运作系统必须同时处理互斥和协作两件事缺一不可。我在带新人时经常拿出来考的一个问题是wait()和sleep()有什么区别进阶的人必须脱口而出wait()会释放Monitor锁sleep()不会释放任何锁只是让线程暂时让出CPU。这个区别直接决定了你在什么场景用哪个。需要让出锁让别的线程有机会修改共享状态的用wait()仅仅是想让当前线程停一停的用sleep()。3. 生产者-消费者从手写wait/notify到BlockingQueue的三层演进3.1 手写经典版本时最容易踩的坑是信号丢失生产者-消费者模式是多线程进阶绕不开的经典案例标题热词里它也占了重要位置。这个模式之所以经典是因为它几乎涵盖了多线程编程的所有核心要素共享缓冲区的并发安全、生产者和消费者的速度匹配、任务队列的容量控制。先看一个很常见的错误示范。假设用ArrayList当缓冲区synchronized保证互斥然后生产者往里加数据消费者往外取数据。问题出在条件判断上synchronized (queue) { if (queue.size() MAX_CAPACITY) { queue.wait(); // 错误点用if而不是while } queue.add(item); queue.notify(); }如果用if等线程被唤醒后会直接往下执行不会重新检查条件。真实场景里存在伪唤醒spurious wakeup而且多个生产者消费者互相唤醒时经常出现被唤醒后条件已经不满足了于是取不到数据或者覆盖数据。正确做法是用while循环重新检查条件这是Java并发编程里一条铁律等待条件要放在循环里不能在if里。再一个坑是notify()和notifyAll()的选择。notify()只随机唤醒一个等待线程如果被唤醒的线程类型不对比如又唤醒了生产者但缓冲区其实是满的它会重新等回去可能会导致信号丢失——本应被唤醒的消费者永远叫不醒。实践中我基本只用notifyAll()让所有等待线程都竞争虽然浪费一点性能但安全性高得多。3.2 用BlockingQueue替换手写逻辑简单到令人怀疑手写版本写完你会发现核心逻辑就是队列满就等队列空就等加一个通知一个。这套逻辑在JDK里早就封装好了就是BlockingQueue。用到它之后生产者消费者代码会简化到让人怀疑是不是写漏了什么public class Producer implements Runnable { private final BlockingQueueTask queue; public Producer(BlockingQueueTask queue) { this.queue queue; } Override public void run() { while (true) { try { Task task createTask(); queue.put(task); // 队列满时自动阻塞 } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } } }消费者那边无非是queue.take()队列空时自动阻塞。put()和take()内部已经处理好了等待通知逻辑不需要你手动写wait()/notify()。但我建议进阶者别急着用BlockingQueue先手写一遍经典版本因为只有手写过你才真正理解while循环检查和notifyAll()的意思后面排查问题时才能看穿封装。常用的BlockingQueue实现各有侧重ArrayBlockingQueue有界、基于数组适合需要限制容量的场景LinkedBlockingQueue有界无界均可吞吐量通常更高SynchronousQueue不存储元素每个put()必须等待一个take()适合线程间直接交接PriorityBlockingQueue支持优先级排序适合按优先级处理的任务。选型时核心看两点队列要不要有界以及吞吐量优先级高不高。3.3 生产环境里生产者-消费者模式的几个真实压测数据这里分享一组我去年压测一个消息转发模块时的实际数据场景是8个生产者线程、4个消费者线程任务对象是大小约2KB的消息。用synchronized 手写ArrayList缓冲队列长度限制20008个生产线程全部启动后吞吐量大约在每秒1.2万条CPU使用率一度冲到90%锁竞争非常明显换成LinkedBlockingQueue同样配置吞吐量提升到每秒2.8万条左右CPU使用率降到60%多因为LinkedBlockingQueue内部用了两把锁分别控制队头和队尾的入队出队操作生产者和消费者之间竞争大大降低再叠加ArrayBlockingQueue有界且容量2000吞吐量约每秒2.1万条没有LinkedBlockingQueue高但胜在容量上限可控内存占用不会无限膨胀。这几个数字不一定照搬到别的机器上但能说明一个趋势锁竞争是多线程性能的第一杀手。数据结构和算法选得好比盲目堆线程数有效得多。生产者-消费者模式在真实项目中往往不是简单的两方而是链条式的上游消息进来先入队列一组线程处理第一道逻辑再放到下一个队列另一组线程处理第二道逻辑。这种流水线式的设计能让每段逻辑独立伸缩这是进阶阶段值得重点掌握的架构思路。4. 你以为学了synchronized就够了JUC里这些工具才是真进阶4.1 CountDownLatch一个火箭发射倒计时的故事synchronized解决的是互斥和协作但如果协作场景更复杂一点光靠它写起来非常吃力。比如主线程要等5个子线程全部把任务做完再汇总结果。用join()也能实现但问题在于join()是死等你想加超时控制就很难受。CountDownLatch就是为解决这种多个任务完成后再继续的场景设计的。CountDownLatch的用法像火箭发射倒计时先指定一个计数N每有一个线程完成任务就调用countDown()把计数减一主线程调用await()等待计数归零。关键是这个归零事件是不可逆的计数一旦到0就不能重置。所以它只适合用一次的场景比如程序启动时等待多个初始化任务全部就绪。有个细节是await(long timeout, TimeUnit unit)这个带超时的版本。生产环境里我几乎都会用它因为如果某个子线程因为异常没有执行countDown()主线程会被卡死这在线上是致命问题。加上超时至少能把控制权抢回来然后记录日志、走降级逻辑。这是实战里很重要的一课所有阻塞等待都要有超时除非你有绝对把握。4.2 CyclicBarrier所有人到齐了才能出发如果说CountDownLatch是倒计时那CyclicBarrier就是全员到齐再出发。一个典型的场景是并发测试多个线程同时准备测试数据准备完成后必须所有人都就位才能同时开始发请求模拟真正的并发峰值。CyclicBarrier的核心API是barrier.await()每个线程执行到这里都会阻塞直到指定数量的线程全部到达然后一起放行。和CountDownLatch最大的区别是它可以循环使用一次计数完成后自动重置下一次继续拦截。这也决定了它的典型应用是分阶段同步比如每个线程跑完一轮任务在下一轮开始前统一对齐一次。选CountDownLatch还是CyclicBarrier我提供一个简单判断标准如果你是等待N个任务完成用CountDownLatch如果你是N个线程互相等待、齐头并进用CyclicBarrier。前者偏向上游等下游后者偏向同级互相等概念搞清楚了就不容易混。4.3 Semaphore限流许可证机制前面讲的都是多线程协作Semaphore则是多线程限流。它可以看作一个发放许可证的信号灯初始化时指定许可证数量线程执行前先acquire()拿许可证没有许可证就阻塞等待执行完后release()归还。我实际用得最多的场景是数据库连接池的保护。某个服务有50个数据库连接但并发的调用方可能有几千个如果全放过去数据库直接被打崩。用Semaphore限制同时只有40个请求能拿到连接其余排队等待就能对数据库起到保护作用。相比BlockingQueue做限流Semaphore优点在于许可证本身和任务队列解耦你可以在多个入口分别acquire()同一个信号量实现全局限流。Semaphore还有一个值得注意的公平性问题。构造时传入new Semaphore(10, true)可以启用公平模式让等待时间最长的线程先获得许可证。但公平模式会带来额外的性能开销一般场景非公平就够用除非你的业务对饥饿敏感。4.4 三个工具一个表格看清差异把三个工具放一起对比方便记忆工具核心语义可否复用典型场景CountDownLatch倒计数门闩不可重置主线程等待多个任务完成后继续CyclicBarrier栅栏/分阶段同步可循环利用多线程对齐后同时出发Semaphore许可证限流永久可用控制并发访问数据库/接口的线程数还有一个容易被忽略的小组成员是Exchanger它能让两个线程在某个汇合点交换数据。实践中用得不多但有时用来实现两个线程间的数据传递比队列更轻量了解即可。5. 线程池背后的设计哲学从参数到底层队列5.1 线程池七个参数每个都是项目调优的抓手项目里用线程池几乎成了标配但很多人是从网上抄一段线程池工具类就完事参数含义似是而非。线程池的七个核心参数每一个都对应一种生产决策corePoolSize核心线程数线程池保持存活的最小线程数量maximumPoolSize最大线程数线程数超过核心数且队列满了之后才会创建新线程到最大值keepAliveTime非核心线程的空闲存活时间超过这个时间没有新任务就会被回收workQueue任务队列核心线程都忙时新任务先排队threadFactory线程工厂控制线程命名、是否为守护线程handler拒绝策略线程池满且队列满时新任务怎么处理。日常选型时最纠结的是workQueue。LinkedBlockingQueue默认是无界队列意味着maximumPoolSize形同虚设因为任务永远不会排队满ArrayBlockingQueue有界容量则直接影响拒绝策略触发的时机SynchronousQueue不缓存任务每个任务必须立刻有一个线程来处理适合任务量小且频繁的场景。5.2 核心线程数到底配多少一个经验公式反而害了很多人网上流传一个公式CPU密集型线程数 CPU核数 1IO密集型线程数 CPU核数 * 2。这个公式是很好的入门起点但不能当金科玉律。真实项目里绝大多数任务都是混合型的有计算也有等待单纯套公式容易配出要么浪费内存、要么排队严重的线程池。我个人在实践中的做法是先按任务性质估一个初始值再拿压测数据校准。比如一个处理HTTP回调的服务任务大部分时间在等下游响应属于IO密集初始值给到CPU核数的3到4倍另一个做图片压缩的服务任务几乎全是计算和内存操作属于CPU密集初始值就给CPU核数加1。配合JVisualVM观察线程池的活跃线程数和队列积压趋势一两个星期就能调到一个相对合理的档位。拒绝策略的选择也需要提前规划。默认的AbortPolicy是抛RejectedExecutionException如果业务方没有捕获任务会直接丢掉并打断调用线程。CallerRunsPolicy是我在很多内部系统里倾向用的线程池满了就让提交任务的线程自己执行这个任务天然形成反压不让任务无限积压。DiscardOldestPolicy适合丢弃最旧任务、保最新任务的场景但一般用于日志类非关键任务。5.3 ThreadLocal与线程池的组合一个容易水灵灵踩坑的组合压轴讲一个线程池进阶时非常隐蔽的坑ThreadLocal。ThreadLocal本身的机制是每个线程一个变量副本本来没问题。但搭配线程池使用时线程是复用的上一个任务往ThreadLocal里塞的数据会被下一个任务读到造成严重的数据串扰。经典场景是网关服务用ThreadLocal存储当前请求的用户ID如果某个任务没在finally里清理下一个请求拿到的就是前一个用户的身份。解决办法只有一个在任务执行完的finally块中调用remove()清空。没有捷径没有侥幸。ThreadLocal的值通常还强引用着大对象如果不及时清理线程池里常驻的线程会一直引用这些对象也可能出现无法被GC回收的内存泄漏问题。这两重风险叠加起来已经足够让每一个使用线程池的团队把它写进代码审查清单。6. Java多线程和Python/Qt多线程从对比中更懂Java的设计6.1 Python多线程为什么被吐槽鸡肋GIL是怎么回事看到热搜里频繁出现Python多线程就多说两句对比。Python因为GIL的存在同一时刻只有一个线程能执行Python字节码所以纯计算型的多线程任务在Python里并不能真正利用多核CPU顶多做到并发I/O。很多人因此说Python多线程是鸡肋其实这话只对了一半在I/O密集型场景网络请求、文件读写下Python多线程依然有很好的性能因为在等待I/O时线程会释放GIL让别的线程执行。Java没有GIL这种全局锁多线程可以直接跑满多核所以Java并发编程的复杂度和重要性都比Python高一大截。Java的锁、内存模型、JUC工具本质上都是在和真正的并行环境打交道而Python的很多多线程默认就安全的感觉其实只是GIL带来的托底。理解这点你就明白为什么JavaSE阶段的多线程知识要学得这么细。6.2 Qt多线程和Java多线程框架线程模型的不同视角Qt多线程和Java多线程表面上都是创建线程、加锁、发信号但设计哲学差异很大。Qt的核心是信号槽机制线程之间通过信号-槽的连接来通信而且默认连接类型会让信号在不同的线程间安全排队分发开发者不需要手写wait/notify。Java这边的对应物是BlockingQueue、Future、CompletableFuture这些但它们不是语言内建的通信机制需要开发者自己选择合适的工具并注意边界。从教学角度看Qt的信号槽是框架帮你管线程通信Java是语言给你并发原语你自己组装。这没有绝对的优劣Java的灵活性更高但犯错面也更大。我见过不少Qt出身转Java的同事上手写生产者-消费者时会习惯性地去找类似信号槽的自动通信机制找半天发现Java里得自己组装队列和锁于是开始理解Java并发编程的学习曲线为什么陡峭。反过来Java程序员去看Qt多线程会觉得到处都是现成的护具但一旦涉及高性能底层数据共享还是要回到锁和原子操作这些本质问题。7. 进阶路上我踩过最深的几个坑希望你直接绕开7.1 死锁定位jstack不是万能的但它是第一步死锁是进阶者必经的坎。我印象很深的一次是在一个分布式调度模块里两个线程分别持有A锁和B锁又互相等待对方的锁整个模块卡死。当时第一反应是看日志结果日志停在某一行不动了第二反应才是上服务器执行jstack 进程ID导出线程快照。jstack输出里有一种明确的死锁标志会看到类似Found one Java-level deadlock的提示并且给出两个线程各自持有的锁和等待的锁。但养成习惯之后我更重视的是从快照里看出谁在等谁的链路因为很多死锁并不是传统的两个锁互等而是三个、四个锁形成环形等待。这类死锁在jstack里不会直接标出deadlock字样需要你人工顺着线程栈里每一条Waiting to lock去串成环。后来我吸取教训把获取多把锁时必须全链路按同一顺序写进了团队规范。多把锁的获取顺序是所有死锁问题的总根源没有例外。7.2 原子性、可见性、有序性volatile只能解决三分之一volatile在多线程进阶里是个高频词但很多人对它有过高期待——甚至有人以为加了volatile就线程安全了。必须说清楚volatile解决的是可见性和有序性它保证一个线程对变量的修改会对其他线程立即可见并且禁止指令重排序但它不解决原子性问题。对一个volatile变量做i操作依然是线程不安全的因为读-改-写这三步不是原子的。判断一个并发问题该用volatile还是synchronized还是AtomicInteger标准很简单如果是多个线程同时写一个变量用AtomicInteger或锁如果是一个线程写、多个线程读可以用volatile如果读操作依赖当前值做复合计算老老实实加锁。volatile的经典搭配是状态标志位比如volatile boolean running控制线程启停这才是它的主场。7.3 线程数与性能加线程不总是提速这是很多人的认知误区接触多线程一段时间后很容易产生线程越多越快的错觉。实际上线程多了CPU上下文切换开销、锁竞争概率、内存占用都会同步上升性能可能在某个点之后反而急剧下降。压测过一套数据处理管线线程数从4调到8吞吐量提升明显从8调到16提升幅度变小从16调到32吞吐量不升反降CPU大量消耗在上下文切换和锁等待上。从这个教训出发我现在评估线程数量时会同时关注两个指标CPU使用率和线程阻塞率。如果CPU使用率还没吃满但线程大量BLOCKED说明锁竞争是瓶颈加线程没什么用如果CPU已经接近100%而线程基本都在RUNNABLE说明是计算密集型加到CPU核数附近就该收手。多线程的进阶很大程度是学会观察系统而不是堆配置。最后再分享一个细节线上排查多线程问题别急着改代码先完整保留线程快照、GC日志和压测数据三样东西。有了这三样大多数问题都能定位到根因缺一样排查就会变成猜谜。这算是我在多线程这条路上摸爬滚打多年最想送给后来者的一句话。
返回列表