尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

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

并发 14 · 收官:虚拟线程与结构化并发

并发 14 · 收官:虚拟线程与结构化并发 第 8 篇讲线程池时我们花了很大篇幅回答一个问题“到底该开多少线程” 答案是一套要权衡 CPU 核数、IO 等待占比、还得靠压测反复迭代的经验公式。第 13 篇讲异步编排时我们又用CompletableFuture把一段本来很直白的业务逻辑拆成了一串thenCompose、thenCombine的链式回调——为的是在等 IO 的时候别把宝贵的线程占着。这两件事其实都是在为同一个约束买单平台线程太贵了所以不能多开不能多开就必须靠异步 回调把线程榨干。而 JDK 21 转正的虚拟线程Virtual ThreadsProject LoomJEP 444直接把这个约束本身拆掉了——如果线程便宜到可以随手创建几百万个那该开多少线程就不再是个需要精心调参的问题为了不阻塞线程而写成回调也不再必要同步阻塞的写法可以重新变成又好读又高吞吐的写法。这是并发领域这几年最大的一次变化用它给整个系列收尾正合适。这一篇按这条线索展开先算清楚平台线程贵在哪、贵出了什么后果再看虚拟线程靠挂载/卸载这一招怎么解决问题接着讲实际用起来必须知道的几条规则尤其是不要池化和什么情况下会 pinning然后介绍配套的结构化并发和ScopedValue最后把 14 篇串成一张全景地图收官。目录平台线程贵在哪thread-per-request 的天花板虚拟线程是什么挂载、卸载与载体线程怎么创建和使用虚拟线程Pinning虚拟线程被钉在载体线程上使用规则不要池化慎用 ThreadLocal结构化并发让并发任务有明确的作用域ScopedValue虚拟线程时代的上下文传递虚拟线程 vs 线程池 vs 响应式怎么选全系列回望14 篇串成一张地图一、平台线程贵在哪thread-per-request 的天花板先明确一个术语。JDK 21 之后我们过去说的线程有了正式名字叫平台线程Platform Thread——new Thread()创建出来的那种它是操作系统内核线程的一层薄包装一个 Java 平台线程对应一个 OS 线程一对一。它贵在两处都是硬成本内存贵。每个平台线程要预留一块栈空间64 位 HotSpot 上默认约1MB-Xss可调但调小会有StackOverflowError风险。这块栈是在创建线程时就向操作系统申请的虚拟内存随线程一直占着。开 1 万个线程光栈就要占掉近 10GB 虚拟内存——这在多数机器上已经不现实了。切换贵。平台线程的调度由操作系统内核负责一次上下文切换要陷入内核态、保存/恢复寄存器现场、还可能连带 TLB 和 CPU 缓存失效第 1 篇算过这笔账微秒级的直接开销 更隐蔽的缓存失效成本。线程数远超核数时CPU 大量时间花在搬现场而不是干活。这两条成本合起来给后端最主流的编程模型判了个上限。这个模型叫thread-per-request一请求一线程Tomcat 收到一个 HTTP 请求从线程池里取一个线程这个线程从头到尾负责这次请求——查数据库、调下游 RPC、组装结果、返回响应。它的最大优点是代码好写好读好调试一次请求的完整逻辑就是一段顺序执行的代码栈追踪是完整的try-catch是正常工作的断点打下去就是你想看的那个上下文。问题在于后端服务的绝大多数时间根本不在算而在**“等”**——等数据库返回、等下游接口、等 Redis。假设一次秒杀下单请求耗时 200ms其中 190ms 都在等 IO只有 10ms 在真正计算。那么这个线程 95% 的时间是阻塞状态什么活也没干但它的 1MB 栈和 OS 线程资源全程被占着。想支撑 1 万 QPS按 200ms 的响应时间算需要约 2000 个线程同时在跑并发数 QPS × 响应时间。2000 个平台线程已经是相当吃力的规模了约 2GB 栈内存加上远超核数的调度压力。而这 2000 个线程真正需要的 CPU其实只有2000 × 5% 100个线程的量。硬件明明还有余力但线程数先撞了墙。面对这个墙过去只有两条路路一加线程池 调参。这就是第 8 篇的内容——线程池复用线程摊掉创建成本靠核数 × (1 等待/计算)这类公式估个起点再压测调优。但它只是把成本摊薄没有消除一个阻塞的请求就占死一个 OS 线程这个根本约束天花板还在那儿。路二改成异步/响应式。这就是第 13 篇的CompletableFuture以及更彻底的 Reactor / RxJava / Netty 那一套。核心思路是遇到 IO 就不阻塞把回调注册上去然后把线程还回去等 IO 完成再由某个线程接着跑后续逻辑。这条路确实能用很少的线程扛住极高的并发代价是代码从一段顺序逻辑变成一堆碎片化的回调栈追踪断裂异常堆栈里看不到完整的业务调用链、调试困难、ThreadLocal失效一次请求会横跨多个线程、心智负担陡增。业界管这个代价叫异步的传染性——一处改异步调用链上所有人都得跟着改成异步。虚拟线程走的是第三条路不改编程模型而是把线程本身变便宜。让你继续写 thread-per-request 那种好读的同步阻塞代码但阻塞的时候不再占用 OS 线程。二、虚拟线程是什么挂载、卸载与载体线程虚拟线程是由 JVM 而不是操作系统调度的线程。它同样是java.lang.Thread的实例这点很关键——所有接受Thread的现有 API 都能直接用但它不再一对一绑定 OS 线程。它的运行需要三个角色配合虚拟线程Virtual Thread你的任务代码所在的那个线程非常轻量。它的栈不是操作系统栈而是 JVM 在堆上管理的一段结构叫 continuation/stack chunk初始只有几百字节到几 KB按需增长。所以创建几十万、上百万个虚拟线程是可行的。载体线程Carrier Thread真正干活的平台线程。虚拟线程要执行代码必须先骑到某个载体线程上。调度器SchedulerJVM 内部一个专门的ForkJoinPool注意不是commonPool是虚拟线程专用的另一个池负责把虚拟线程分派到载体线程上。默认并行度等于 CPU 核数可用系统属性jdk.virtualThreadScheduler.parallelism调整但通常不需要动。这里正好复用了第 13 篇讲过的工作窃取调度。整个机制的核心就是两个动作挂载mount调度器把一个虚拟线程分配到某个载体线程上开始执行此时虚拟线程的栈帧被搬到载体线程的 OS 栈上跑。卸载unmount当虚拟线程执行到阻塞操作时比如socket.read()等网络响应、Thread.sleep()、等一把ReentrantLock、BlockingQueue.take()JVM 不会让载体线程跟着一起阻塞而是把这个虚拟线程的栈保存回堆里然后让它从载体线程上卸载下来。载体线程立刻被释放去执行下一个就绪的虚拟线程。等 IO 完成后这个虚拟线程重新变为就绪被调度器再挂载到可能是另一个载体线程上从上次的位置继续跑。这一进一出就是虚拟线程的全部魔法。回到上一节的账190ms 的 IO 等待期间虚拟线程处于卸载状态只占着堆上那一小块栈数据不占用任何 OS 线程载体线程在这 190ms 里可以轮流服务成千上万个其他虚拟线程。于是2000 个并发请求不再需要 2000 个 OS 线程几个等于核数载体线程就够了。阻塞的成本从占住一个 1MB 的 OS 线程降到了占住堆上几 KB。值得点明的是这件事之所以能对业务代码透明是因为 JDK 把标准库里几乎所有阻塞点都改造了一遍。java.net的 Socket、java.nio.channels、Thread.sleep()、java.util.concurrent里的锁和队列……这些原本会阻塞 OS 线程的地方现在都能识别当前跑在虚拟线程上进而触发卸载而不是真阻塞。你写的还是jdbcTemplate.query(...)这样的同步调用卸载发生在你看不见的地方。版本提示虚拟线程在JDK 19JEP 425首次以预览特性出现JDK 20JEP 436二次预览JDK 21JEP 444正式转正。所以生产使用请以JDK 21 及以上为准19/20 上需要--enable-preview且 API 可能变动。三、怎么创建和使用虚拟线程API 设计得非常克制主要就三种用法。用法一Thread.ofVirtual()直接起一个。// 起一个虚拟线程并立即执行ThreadvtThread.ofVirtual().name(seckill-worker).start(()-{deductStock(productId);// 里面该阻塞就阻塞不用改成异步});vt.join();// 和平台线程一样 join// 对比平台线程的写法Thread.ofPlatform() 是 new Thread() 的新式等价写法ThreadptThread.ofPlatform().start(()-deductStock(productId));用法二Executors.newVirtualThreadPerTaskExecutor()最推荐。名字已经说明了一切——每个任务一个新的虚拟线程不是池。它实现了ExecutorService所以第 8、13 篇讲的submit()、Future、CompletableFuture那套东西全部照旧可用。// 注意用 try-with-resourcesExecutorService 在 JDK 19 实现了 AutoCloseable// close() 会等所有任务跑完相当于 shutdown() awaitTermination()try(varexecutorExecutors.newVirtualThreadPerTaskExecutor()){for(LonguserId:userIds){// 假设有 10 万个用户请求executor.submit(()-{checkRiskControl(userId);// 阻塞调风控接口deductStock(productId);// 阻塞查数据库createOrder(userId,productId);// 阻塞写数据库});}}// 这里会等到 10 万个任务全部完成这段代码会创建 10 万个虚拟线程。换成Executors.newFixedThreadPool(10_000)试试多半直接 OOM 或者把机器拖垮但 10 万个虚拟线程只是堆上多了几十 MB跑在几个载体线程上。这就是线程便宜了以后代码能有多直白的最好例子——没有回调、没有thenCompose就是最朴素的顺序逻辑。用法三让 Web 框架整体切过来。实际项目里更常见的做法不是自己起虚拟线程而是把容器的请求处理线程换成虚拟线程一行配置的事。Spring Boot 3.2需要 JDK 21spring.threads.virtual.enabledtrue打开之后Tomcat 会用每个请求一个虚拟线程来处理你所有的 Controller / Service 代码一行不用改就获得了远超原来的并发能力。这正是虚拟线程最大的实用价值它不要求你重写业务代码。关于虚拟线程的几个固定属性用之前知道一下免得踩坑虚拟线程永远是守护线程daemonsetDaemon(false)会抛UnsupportedOperationException。也就是说它不会阻止 JVM 退出需要等它跑完就得自己join()或者用上面 try-with-resources 的写法。优先级固定为NORM_PRIORITYsetPriority()调了也没效果不报错但无作用。不支持stop()/suspend()/resume()这些方法本来也早已废弃。中断interrupt()是正常支持的第 1 篇讲的协作式中断那套照旧。虚拟线程没有线程名的默认值Thread.ofVirtual().start()出来的名字是空字符串排查问题时建议像上面那样显式.name(...)。四、Pinning虚拟线程被钉在载体线程上前面说遇到阻塞就卸载但有几种情况卸载不了——虚拟线程会被钉住pinned在载体线程上此时它阻塞就意味着那个宝贵的载体线程也跟着一起阻塞。这是虚拟线程唯一需要你主动留心的性能陷阱。在JDK 21上导致 pinning 的主要有两种情况在synchronized块/方法里执行阻塞操作。这是最常见、也最容易中招的一种。执行本机方法native method或 FFM 外部函数调用时。本机栈帧无法被 JVM 搬到堆上保存所以只能钉着。第一种为什么值得警惕因为synchronized在存量代码里随处可见包括很多第三方库的内部实现// ✗ JDK 21 上的隐患synchronized 块里做阻塞 IO会把载体线程一起钉住阻塞publicsynchronizedvoiddeductStock(LongproductId){stockMapper.updateStock(productId);// 阻塞的数据库操作此时无法卸载}// ✓ JDK 21 上的写法换成 ReentrantLock等锁和锁内阻塞都能正常卸载privatefinalReentrantLocklocknewReentrantLock();publicvoiddeductStock(LongproductId){lock.lock();try{stockMapper.updateStock(productId);// 阻塞时可以正常卸载载体线程}finally{lock.unlock();}}为什么ReentrantLock行而synchronized不行因为ReentrantLock是第 5、6 篇讲的那套纯 Java 实现AQS LockSupport.park()JDK 能够识别并配合虚拟线程做卸载而synchronized是由 JVM 在虚拟机层面实现的第 3 篇讲的 monitorenter/monitorexit 对象头 Mark WordJDK 21 时它的 monitor 状态与载体线程的栈绑在一起没法把虚拟线程摘下来。要注意的是pinning 本身并不必然出问题如果synchronized块里只是几个内存操作很快就出来了钉一小会儿没有影响。真正的问题是钉住 长时间阻塞的组合——载体线程总数默认只有核数个如果几个载体线程都被钉在等 IO 上整个应用的虚拟线程就都推不动了这是虚拟线程场景下最典型的明明用了虚拟线程但吞吐上不去的原因。版本提示重要这个限制在JDK 24JEP 491Synchronize Virtual Threads without Pinning中已经解决——虚拟线程在synchronized内阻塞时也能正常卸载了因此JDK 24 及以上不再需要为了虚拟线程而把synchronized改写成ReentrantLock。但本机方法/FFM 调用中的 pinning 依然存在。如果你在 JDK 21~23 上使用虚拟线程上面那条改写建议仍然适用诊断上可以用-Djdk.tracePinnedThreadsfullJDK 21 可用打印被钉住的栈或者用 JFR 的jdk.VirtualThreadPinned事件观测。五、使用规则不要池化慎用 ThreadLocal虚拟线程虽然对业务代码基本透明但有几条心智模型上的转变必须建立起来否则很容易写出用了虚拟线程但没得到好处的代码。规则一不要池化虚拟线程。这是最重要的一条也是最容易犯的错——毕竟我们被线程池训练了二十年。// ✗ 错得很典型把虚拟线程当稀缺资源池化等于自己给自己重新加上了并发上限ExecutorServicewrongExecutors.newFixedThreadPool(200,Thread.ofVirtual().factory());// ✓ 正确每个任务一个虚拟线程用完就丢ExecutorServicerightExecutors.newVirtualThreadPerTaskExecutor();线程池存在的唯一理由是平台线程创建昂贵、必须复用摊薄成本。虚拟线程创建极其廉价就是分配一个小对象复用它没有任何收益反而把最多只能同时处理 200 个任务这个限制又请回来了。记住这句话虚拟线程不是稀缺资源而是一次性的、用完即弃的任务载体——需要多少就创建多少。那么限流怎么办答案是限流应该由专门表达配额的工具来做而不是靠线程数量隐式实现。用第 11 篇的Semaphore显式表达数据库最多扛 20 个并发语义清晰得多privatefinalSemaphoredbPermitsnewSemaphore(20);// 显式表达资源配额try(varexecutorExecutors.newVirtualThreadPerTaskExecutor()){for(LonguserId:userIds){executor.submit(()-{dbPermits.acquire();// 拿不到就在虚拟线程上等会正常卸载几乎零成本try{deductStock(productId);}finally{dbPermits.release();}});}}这个写法在虚拟线程下代价极低等许可的虚拟线程会卸载不占载体线程。过去线程池大小这一个参数同时承担了复用线程和限制并发两个职责虚拟线程把它们拆开了——复用不再需要限流交给Semaphore。规则二慎用ThreadLocal尤其别指望线程少所以复用缓存那套。第 12 篇讲的ThreadLocal在虚拟线程上功能是正常的每个虚拟线程有自己独立的一份但有两个新问题数量级变了。过去 200 个线程各存一份缓存一共 200 份现在可能有 100 万个虚拟线程如果每个都往ThreadLocal里塞一个大对象内存直接爆掉。虚拟线程时代ThreadLocal里绝对不能放重对象。复用线程做缓存的套路失效了。有些代码利用线程池线程长期存活的特性把SimpleDateFormat、缓冲区之类的重对象缓存在ThreadLocal里复用。虚拟线程一个任务一个、用完即弃这种缓存每次都要重建收益归零反而变成了纯开销。好消息是第 12 篇那个忘记remove()导致内存泄漏的经典坑在虚拟线程下反而不那么致命了——虚拟线程不复用线程结束时它的threadLocals整个被回收。当然写代码时该remove()还是要remove()别指望这一点。传递请求上下文的更好选择是ScopedValue第七节讲。规则三虚拟线程只对阻塞型任务有意义。虚拟线程解决的是线程在等 IO 时被浪费的问题。如果你的任务是纯 CPU 计算图像处理、大数据量排序、加解密那它压根不会阻塞、不会卸载虚拟线程一点便宜也占不到——这种场景该用的还是固定大小约等于核数的平台线程池或者第 13 篇的 Fork/Join。用虚拟线程跑 CPU 密集任务不会出错但也不会更快反而多一层调度开销。一句话分工CPU 密集用平台线程池 / Fork-JoinIO 密集用虚拟线程。六、结构化并发让并发任务有明确的作用域虚拟线程让随手起一堆线程变得可行但也放大了一个老问题这些线程之间没有任何父子关系生命周期是散的。看第 13 篇那种写法的隐患// 用 Future 并行查两份数据看着没问题实则有几个漏洞FutureUserInfouserFuturepool.submit(()-queryUser(userId));FutureStockInfostockFuturepool.submit(()-queryStock(productId));UserInfouseruserFuture.get();// 如果这一步就抛异常……StockInfostockstockFuture.get();// ……stockFuture 还在后台白跑没人管它也没人取消它漏洞有三个① 泄漏——第一个get()抛异常后直接跳出方法第二个任务成了没人管的孤儿继续消耗资源直到自己跑完② 无法整体取消——外层请求超时了想把这两个子任务一起停掉得自己记住所有Future挨个cancel()③ 关系不可见——从代码结构上看不出这两个任务属于同一次请求、是一组的出问题时线程转储里也是一堆互不相干的线程。结构化并发Structured Concurrency的核心主张是让并发任务的生命周期服从代码块的结构。这个思想和try-with-resources保证资源不泄漏是一模一样的——如果任务是在某个代码块里 fork 出去的那么在离开这个代码块之前所有子任务必然都已经结束完成、失败或被取消不可能有任务偷偷活到块外面去。JDK 21JEP 453预览特性的写法// 秒杀下单前的并行校验查用户风控 查库存两者必须都成功try(varscopenewStructuredTaskScope.ShutdownOnFailure()){SubtaskUserInfouserscope.fork(()-queryUser(userId));// fork 出子任务SubtaskStockInfostockscope.fork(()-queryStock(productId));// 每个子任务跑在自己的虚拟线程上scope.join()// 等所有子任务结束.throwIfFailed();// 只要有一个失败就抛异常另一个会被自动取消returnplaceOrder(user.get(),stock.get());// 到这里两个结果都保证可用}// 出了这个块两个子任务一定都结束了不会有孤儿线程对比上面Future的写法三个漏洞全被堵上了ShutdownOnFailure这个策略的语义是一个失败全体取消业界叫 short-circuitingqueryUser抛异常时queryStock会被自动取消不会白跑作用域结束时保证所有子任务已终结不可能泄漏而且代码结构本身就表达了这两个任务是一组的这层关系——JDK 甚至会把这个父子关系反映到线程转储里排查问题时能看到清晰的任务树。另一个常用策略是ShutdownOnSuccess任意一个成功就取消其余的对应第 13 篇anyOf那类多数据源竞速查询的场景但比anyOf多了自动取消输家这个关键行为。// 本地缓存和远程缓存同时查谁先返回用谁的另一个自动取消try(varscopenewStructuredTaskScope.ShutdownOnSuccessStockInfo()){scope.fork(()-queryFromLocalCache(productId));scope.fork(()-queryFromRedis(productId));scope.join();returnscope.result();// 拿到最先成功的那个结果}版本提示重要结构化并发截至 JDK 26 仍是预览特性且 API 在多个版本间反复调整过——JDK 19/20 是孵化器JEP 428/437JDK 21 起进入预览JEP 453此后经历多轮预览JDK 25JEP 505把 API 重新设计为StructuredTaskScope.open(...)配合Joiner策略替代直接new ShutdownOnFailure()的子类式写法JDK 26 又以 JEP 525 进行第六次预览。因此上面的 JDK 21 代码只用于理解思想实际编写时必须对照目标 JDK 的文档并使用--enable-preview不建议把仍在变化的 API 直接固化到长期生产接口中。虚拟线程已转正和结构化并发仍在演进的成熟度差别很大这一点要分清。七、ScopedValue虚拟线程时代的上下文传递第 12 篇的核心场景是用ThreadLocal传递当前登录用户上面第五节也说了它在虚拟线程时代的尴尬。JDK 为此配套设计了ScopedValue作用域值思路和结构化并发一脉相承上下文的有效范围也应该由代码块的结构决定而不是设置了就一直有效直到你记得清理。// 声明一个作用域值对比 ThreadLocal 的声明方式privatestaticfinalScopedValueUserInfoCURRENT_USERScopedValue.newInstance();// 绑定 在绑定范围内执行不需要也没有set/removeScopedValue.where(CURRENT_USER,user).run(()-{placeOrder(productId);// 这个调用链里的任何地方都能读到 CURRENT_USER});// 出了 run() 的范围绑定自动失效——没有忘记 remove 导致泄漏这回事// 调用链深处读取publicvoiddeductStock(LongproductId){UserInfouserCURRENT_USER.get();// 直接读只读不可改}和ThreadLocal的三个关键差别不可变。ScopedValue只能在where(...).run(...)的入口处绑定作用域内无法被中途修改没有set()方法。这消除了ThreadLocal调用链上任何一层都能偷偷改掉上下文的隐患。生命周期由结构界定。绑定只在run()的动态作用域内有效出了作用域自动解绑从根上消除了忘记remove()导致内存泄漏这个第 12 篇讲的经典坑。共享而非复制对海量虚拟线程友好。配合结构化并发时子任务能继承父作用域的绑定且实现上倾向于共享不可变数据而不是给每个线程复制一份——这对100 万个虚拟线程的场景是必要的。版本提示ScopedValue的路线是 JDK 20 孵化JEP 429→ JDK 21 起多轮预览JEP 446 等→JDK 25JEP 506正式转正。它的 API 在预览期间也有过调整例如绑定入口的写法实际使用同样请对照你的 JDK 版本文档。在 JDK 21 上做生产项目传递请求上下文仍然以ThreadLocal为主虚拟线程上它是可用的只要守住别放重对象这条即可。八、虚拟线程 vs 线程池 vs 响应式怎么选三种模型放在一起对比边界就清楚了维度平台线程池虚拟线程响应式Reactor/Netty编程模型同步阻塞好读同步阻塞好读异步回调/流式心智负担大单个线程成本约 1MB 栈 OS 线程堆上几百字节起按需增长无线程概念任务是回调可支撑的并发量几百到几千几十万到百万极高调度者操作系统内核JVM专用 ForkJoinPool事件循环栈追踪 / 调试完整清晰完整清晰断裂难调试ThreadLocal正常可用可用但别放重对象基本失效适合场景CPU 密集型任务IO 密集型的业务服务极致吞吐、流处理、背压场景改造成本—极低配置级高全链路改造选型结论IO 密集的普通业务服务绝大多数 Web/微服务后端JDK 21 直接上虚拟线程。它给了你响应式的吞吐 同步代码的可读性且几乎不需要改业务代码。这是虚拟线程真正的主战场。CPU 密集型计算继续用大小约等于核数的平台线程池或第 13 篇的Fork/Join。虚拟线程在这里没有收益。已经用响应式且跑得很好的系统不必为了虚拟线程而重写。响应式在背压backpressure、流式数据处理、复杂的事件编排上仍有虚拟线程给不了的表达力。但新项目如果只是为了扛并发而考虑响应式现在应该先考虑虚拟线程——这是虚拟线程带来的最实际的决策变化。一个容易被忽略的收益虚拟线程让第 13 篇那种为了不阻塞线程而写成CompletableFuture链的代码有了退回同步写法的可能。如果你的thenCompose链纯粹是为了避免阻塞而不是为了表达任务编排关系在虚拟线程上它可以还原成朴素的顺序代码可读性会有质的提升。九、全系列回望14 篇串成一张地图到这儿14 篇走完了。回头看整个系列其实是在回答一个问题的四个层次第一层地基并发问题的根源是什么01-02。第 1 篇把三个幽灵——原子性、可见性、有序性——现了形并给了一把贯穿全系列的钥匙“每个并发工具都是在治这三者中的某几个”。第 2 篇的JMM给出了这三个幽灵背后的规则体系happens-before 是可见性承诺而非时间先后重排序分编译器/指令级/内存系统三层内存屏障是兑现承诺的手段。这一层是理论根后面所有工具的行为都能从这里推导出来。第二层基础武器怎么保证原子性和可见性03-06。volatile只管可见性和有序性、不管原子性synchronized三者全管代价是互斥靠锁升级无锁→偏向→轻量级→重量级来降低无竞争场景的开销。往轻的方向走是CAS——用事后重试替代事前防范的乐观思路代价是 ABA 和自旋开销LongAdder用分段把热点打散。往重的方向走是AQS——state CLH 队列 LockSupport.park()这三大件是ReentrantLock、ReadWriteLock、CountDownLatch、Semaphore共同的骨架理解了它第 6、11 篇的一大半就是填空。第三层工程实践怎么在真实项目里写对、写好07-12。死锁的四个必要条件指明了预防方向按序加锁破坏循环等待最实用线程安全三策略有明确的优先级——不可变 线程封闭 同步能不共享就不共享是最省心的并发。线程池是所有并发任务的入口核心线程→队列→最大线程→拒绝这个顺序是高频易错点。并发容器ConcurrentHashMap从分段锁到 CAS 桶级synchronized、阻塞队列生产者-消费者的标准答案、同步工具类协调节奏而非互斥、ThreadLocal线程隔离以及那个弱引用不对称导致的泄漏坑——这一层是日常写代码真正天天用到的部分。第四层异步与未来怎么把线程用得更值13-14。CompletableFuture用链式编排替代阻塞等待Fork/Join用工作窃取跑分治计算。而这一篇的虚拟线程某种意义上是对前面很多权衡的釜底抽薪线程池大小的调参艺术、为躲避阻塞而付出的回调复杂度其前提都是线程昂贵——这个前提松动之后很多复杂度就没有存在的必要了。但要强调的是虚拟线程没有让前面 13 篇过时反而让它们更重要。虚拟线程只解决了线程贵、不能多开这一个问题它完全没有解决共享可变状态的那三个幽灵100 万个虚拟线程同时count照样超卖——原子性问题原样存在该用 CAS/锁还得用。虚拟线程之间的可见性照样归 JMM 和 happens-before 管——第 2 篇的规则一条没变。死锁照样会发生synchronized照样是互斥的而且在 JDK 21 上还多了个 pinning 要提防ConcurrentHashMap照样是那个ConcurrentHashMap。甚至因为并发度暴涨了几个数量级原来因为线程数少、窗口窄而侥幸没暴露的竞态 bug现在会被更频繁地撞上。所以更准确的说法是虚拟线程改变的是并发的规模和写法而不是并发的难点。难点从来都是共享可变状态——这正是第 1 篇开篇就点明、也是整个系列一直在解决的东西。系列的最后把整条主线收成三句话① 一切并发问题的根源是共享可变状态一切并发工具都是在三个方向上做取舍——不共享不可变、线程封闭、ThreadLocal、不可变final、copy-on-write、协调访问锁、CAS、原子类。遇到新的并发问题先问这三条哪条走得通而且优先级就是这个顺序往往比直接想该加什么锁更快找到好方案。② 判断一个并发工具就看它治哪个幽灵、代价是什么。volatile治可见性和有序性、不治原子性CAS 治原子性、代价是自旋和 ABA锁三者全治、代价是互斥和阻塞ThreadLocal用不共享绕过全部三者、代价是跨线程传值的麻烦。这把钥匙能让你在面对一个陌生的并发类时快速定位它的位置。③ 并发的难是可以被拆解的。代码不按顺序执行、改了的值别人看不见、一行自增被插队——这些反直觉的现象背后是多核、缓存一致性、指令重排这些非常讲道理的机制。把机制看清之后并发就从玄学变成了可以推理的工程这也是这个系列从第 1 篇开始就想带你抵达的地方。写完这 14 篇最想说的其实是并发领域的知识密度很高但它的结构并不复杂——一个根源共享可变状态、三个表现原子性/可见性/有序性、几套武器volatile/CAS/AQS 锁、一层工程封装线程池/并发容器/同步工具、一个正在发生的变革虚拟线程。把这张地图记住具体 API 的细节忘了随时可以查地图丢了背下来的八股很快就会散成一地碎片。
返回列表