ARTICLE DETAIL

资讯详情

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

JUC并发编程实战:线程池、锁与并发容器的核心原理与避坑指南

JUC并发编程实战:线程池、锁与并发容器的核心原理与避坑指南 聊到 JUCjava.util.concurrent很多同学第一反应是“面试八股文”但真正写业务代码时又容易把线程池参数背得滚瓜烂熟、一用就废。JUC 的核心价值不是给你一堆高大上的类而是把并发编程里最容易翻车的几个环节——锁的获取与释放、线程的生命周期管理、共享数据的可见性——用一批设计良好的工具帮你挡住。这篇文章我打算从实际项目出发把线程池、锁、并发容器、异步编排这些最常用的 JUC 组件讲透同时穿插我踩过的坑和压测数据。适合刚入门并发的同学系统建立认知也适合写过一段时间的同学查漏补缺。1. JUC并发编程的核心思路与方案选型1.1 从synchronized到JUC的演进逻辑很多人觉得 JUC 只是“synchronized 的替代品”这个理解其实有点窄了。synchronized 是 JVM 内置的关键字锁它能解决基本的互斥问题但使用方式比较粗放拿锁和放锁完全交给 JVM程序员没法在获取锁时设置超时时间也没法在等待过程中响应中断更没法手动把一个锁拆成多个条件队列。比如你要实现“多个线程各自等待不同条件、分别唤醒”这种逻辑用 synchronized 就得在外层套 Object.wait / notifyAll一不留神就互相唤醒错了。JDK 5 引入 java.util.concurrent本质是把并发场景里高频出现的“通用部件”做成了标准品可重入锁、读写锁、信号量、线程池、并发容器、原子变量、异步编排工具。JUC 真正解决的并不是“抢锁”这一件事而是从三个维度同时发力锁的细粒度控制locks 包、共享数据的高效访问volatile/CAS/原子类、任务执行的资源管理Executor 框架。到了 JDK 8 往后synchronized 经过偏向锁、轻量级锁和锁消除等一系列优化简单互斥场景下两者性能已经非常接近但 JUC 覆盖的场景边界远超 synchronized这也是它至今仍是并发编程核心的原因。深入一点看并发编程所有问题都能收敛到三条互斥同一个时刻只有一个线程操作共享资源、可见性一个线程的修改能被另一个线程立即看到、有序性编译器/CPU 重排不能影响语义。synchronized 通过管程模型解决前两条而 JUC 在此基础上引入了 CAS 与 volatile 的组合、AQS 队列同步器、无锁数据结构让你在不同场景下有更精细的选型空间。搞清楚这条演进逻辑你才能理解为什么有的场景该选 lock 而不是 synchronized为什么有的场景用 ConcurrentHashMap 比 HashTable 强出一个量级。1.2 并发三板斧锁、容器、异步工具我自己习惯把 JUC 的核心能力概括成“并发三板斧”锁、容器、异步工具。锁负责互斥与协作对应 ReentrantLock、ReadWriteLock、StampedLock、CountDownLatch、Semaphore 这类同步器容器负责在高并发下安全地存储和读取数据对应 ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue 家族异步工具负责把大任务切碎、编排、组合对应 ExecutorService、Future、CompletableFuture。这三板斧不是彼此独立的实际项目里它们经常互相配合。举个例子一个典型的消息处理服务外部流量打进来先用线程池接收任务然后用 Semaphore 做流量控制任务中间要往共享缓存里写数据需要用 ConcurrentHashMap最后多个子任务的结果要汇总又轮到 CountDownLatch 或 CompletableFuture 出场。可以这么说前端流量越大、业务链路越长JUC 的参与面就越广。理解这套组合拳还有一个好处你会慢慢建立“选型驱动设计”的思维方式。遇到并发问题时第一反应不应该是“给方法加个 synchronized 拉倒”而是先拆解清楚是资源竞争问题、任务调度问题还是数据一致性问题然后再从 JUC 工具箱里挑最匹配的零件。很多项目的并发 Bug 其实不是某个类用错了而是压根没搞清楚自己在解决什么问题。1.3 什么时候该用JUC什么时候不该用虽然 JUC 很强大但“遇事不决线程池”也是病。我见过不少项目只有几百 QPS 就强行上线程池加异步化结果排查问题时要多翻一层线程栈反而把简单系统做复杂了。JUC 不是银弹它本身也有学习成本和运维成本。单机内短临界区、低锁冲突的场景原生 synchronized 完全够用如果请求本身是串行化的业务逻辑引入并发不会提升吞吐只会增加心智负担。需要 JUC 出场的信号也很明确第一单线程处理速度已经成为瓶颈且任务之间没有强依赖第二共享资源的访问开始出现明显的等待与冲突第三你需要对执行资源做精细化控制比如限流、超时、排队、异步编排。反过来如果是跨进程的分布式场景比如多个应用实例同时改同一份数据库记录那 JUC 就管不了了得靠数据库唯一约束、分布式锁这类外部机制。我这里说句实在话能用数据库约束解决的并发问题就不要在代码里加锁能在单机内用 JUC 解决的问题就不要一上来就搞分布式事务。2. 线程池JUC里最容易被用错的“资源管家”2.1 ThreadPoolExecutor 七参数拆解线程池是 JUC 里出场率最高的成员也是事故高发区。很多人创建线程池时对着 IDEA 自动提示填参数填完就觉得自己会了其实七个参数每个都有脾气。ThreadPoolExecutor 的核心构造参数一共有七个参数作用易踩的坑corePoolSize核心线程数即使空闲也保留设置过大会导致线程频繁创建设置过小会导致任务排队堆积maximumPoolSize最大线程数超过队列容量后才会扩建很多人以为线程会立即从 core 扩到 max其实中间还隔着一个 workQueuekeepAliveTime非核心线程空闲存活时间设置太短线程反复创建销毁unitkeepAliveTime 的时间单位常被忽略建议用秒或毫秒workQueue任务等待队列不同队列类型直接改变线程池行为threadFactory创建线程的工厂不自定义的话出问题都认不出是哪个池的线程handler拒绝策略默认 AbortPolicy 会抛异常业务里必须想清楚任务不能丢怎么办队列这块是最容易被误读的地方。线程池的执行顺序不是“核心线程满了就扩到最大”而是核心线程先跑 → 核心线程满了新任务进队列 → 队列满了才创建非核心线程 → 线程数达到最大后触发拒绝策略。这个流程直接决定了你选的队列类型LinkedBlockingQueue默认无界队列几乎不会满线程数永远涨不到 max表面上“稳定”实际上任务堆积会撑爆内存SynchronousQueue不存任务每一对 put/take 必须同时出现适合任务量小、执行快的场景ArrayBlockingQueue有界但需要你根据业务量估算容量。自定义 threadFactory 看起来是小事但排查线程池问题时帮助巨大。我强烈建议给线程池命名比如new ThreadFactoryBuilder().setNameFormat(order-pool-%d)这样jstack里一眼就能看出是哪条线程在阻塞。否则满屏pool-1-thread-3你根本没法快速定位是哪个业务池出问题。2.2 实战配置核心线程数到底怎么算网上流传的“CPU 密集型就配 N1、IO 密集型就配 2N”其实只是个起点不是标准答案。N 指的是服务器可用 CPU 核数N1 的“1”是为了应对偶发的页缺失或 GC 停顿不要问为什么是 1这是大量实践拍出来的经验值。IO 密集型任务因为大部分时间在等待网络或磁盘CPU 处于空闲可以多配线程但到底多少倍取决于你的“等待时间/计算时间”比例。更靠谱的公式是线程数 N * (1 等待时间 / 计算时间)。假设一个请求平均计算耗时 20ms远程调用等待 80ms核数是 8那么合理线程数约为8 * (1 80/20) 40。这个公式比“2N”更贴合实际因为它把任务特征量化了。不过公式算出来依然只是初值最终一定要压测验证。以我常用的一个订单处理线程池为例ThreadPoolExecutor orderPool new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(500), new ThreadFactoryBuilder().setNameFormat(order-handler-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() );这里核心线程设成 8最大 16队列容量 500。为什么 max 是 core 的两倍因为订单处理涉及数据库和第三方接口属于典型的 IO 密集型短暂的流量尖峰需要更多线程去并行等待 IO。队列容量 500 是为了在流量超过处理能力时把任务暂存而不是立刻打满线程。CallerRunsPolicy 意味着任务满了之后由提交任务的线程自己执行这样可以天然实现反向压测让生产者感受到系统压力。2.3 拒绝策略与线程池隔离任务被拒绝不一定是要报错的关键在于业务能不能接受丢任务。JDK 提供了四种拒绝策略AbortPolicy默认策略直接抛 RejectedExecutionException。适合能容忍失败、并有兜底操作的场景。CallerRunsPolicy由提交任务的线程自己执行任务。适合不能丢任务、允许调用方阻塞降温的场景。DiscardPolicy静默丢弃任务。适合日志、统计等可丢失场景。DiscardOldestPolicy丢弃队列里最老的任务然后重新提交。适合追求实时性的场景但可能会把重要任务丢了。我个人最常用的是 CallerRunsPolicy因为它的行为最温和既不会抛异常打断主流程又能在高负载时自然地把流量压回上游。但要注意如果生产者的线程本身也是线程池里的工作线程CallerRunsPolicy 可能造成连锁阻塞需要评估链路。线程池隔离是我特别想强调的一点。很多人一个应用只有一个全局线程池所有业务共用结果某个慢接口把线程占满连下单这种核心链路也跟着超时。正确做法是把核心业务和非核心业务拆成不同线程池比如“支付处理池”“消息推送池”“日志上报池”各自独立配置核心线程数和拒绝策略。这样即使日志池被流量打爆也不会拖累交易链路。这就是服务治理里常说的“故障隔离”在 JUC 层面同样成立。3. 锁与同步工具从ReentrantLock到AQS原理3.1 ReentrantLock vs synchronized怎么选ReentrantLock 和 synchronized 都能实现临界区互斥但 ReentrantLock 提供的控制粒度明显更细。它支持可中断获取锁、支持获取锁超时、支持公平锁与非公平锁切换、支持多个 Condition 条件队列。这些能力在复杂的并发协作里非常有用。举个例子我用 synchronized 实现一个“等待队列不满再入队”的逻辑通常只能配合 wait/notify而且 notifyAll 会唤醒所有等待线程可能导致“惊群”。如果用 ReentrantLock 多个 Condition可以分别维护“队满”和“队空”两个条件队列生产者只唤醒消费者消费者只唤醒生产者不会做无用功。使用 ReentrantLock 有一个铁律必须在 finally 块里解锁否则异常路径下锁永远无法释放。Lock lock new ReentrantLock(); lock.lock(); try { // 临界区业务逻辑 } finally { lock.unlock(); }你可能会问既然 synchronized 在 JDK 8 之后性能已经追上来为什么还要用 ReentrantLock我的答案是性能差距确实很小但“能不能定时、能不能中断”是结构化差异。如果你需要等待锁的过程中定时放弃或者用户取消操作时要中断线程只能选 ReentrantLock。反过来如果只是保护一段很短的代码synchronized 语法更简洁、更不容易出错JVM 还会做锁消除和偏向锁优化直接用也不吃亏。3.2 CountDownLatch / CyclicBarrier / Semaphore 的实际用法这三个同步工具经常被放在一起比较功能也确实容易混淆。CountDownLatch 是“倒计时门闩”适合主线程等待多个子任务完成。比如一个报表服务需要并行查用户、订单、商品三份数据全部返回后再聚合我一般这样写CountDownLatch latch new CountDownLatch(3); executor.submit(() - { try { queryUser(); } finally { latch.countDown(); } }); executor.submit(() - { try { queryOrder(); } finally { latch.countDown(); } }); executor.submit(() - { try { queryProduct(); } finally { latch.countDown(); } }); latch.await(10, TimeUnit.SECONDS);这里有几个关键细节countDown 必须放在 finally 里防止子任务异常导致主线程永远等待await 必须带超时时间否则一个子任务卡死整个主线程跟着陪葬。CyclicBarrier 是“循环栅栏”让一组线程互相等待全部到达后再一起放行。它和 CountDownLatch 最大的区别是CountDownLatch 是一次性用品CyclicBarrier 可以重置复用而且 CyclicBarrier 支持在所有线程到达时触发一个额外动作。适合“多线程并发分批处理”的场景比如分批拉取数据后统一提交。Semaphore 是“信号量”本质是一个计数器加许可证池。我常用它做接口级限流允许同时最多 10 个请求处理超过就等待。Semaphore semaphore new Semaphore(10); if (semaphore.tryAcquire(200, TimeUnit.MILLISECONDS)) { try { // 处理业务逻辑 } finally { semaphore.release(); } } else { // 快速失败 }tryAcquire 带上超时时间是个好习惯不然你的线程可能无限期等令牌最终把线程池里的线程全部挂住。3.3 读写锁与StampedLockReadWriteLock 是读写分离思想的落地读读不互斥、读写互斥、写写互斥。它非常适合“读多写少”的缓存型场景比如一个配置表每小时更新一次但每秒被读取上千次。用独占锁的话所有读线程都串行化完全没必要用 ReentrantReadWriteLock所有读线程可以并行持有读锁只有写线程到来时才会堵住新的读请求。StampedLock 比 ReadWriteLock 更进一步它提供了一种乐观读模式读线程不加锁只是记录一个 stamp戳记读取完再检查这个 stamp 是否有效。如果期间没有写操作发生读线程就省去了加锁/解锁的开销如果写操作发生了版本号变了则升级为真正的读锁重读一遍。long stamp lock.tryOptimisticRead(); int currentCount this.count; if (!lock.validate(stamp)) { // 有写操作发生升级为悲观读锁 stamp lock.readLock(); try { currentCount this.count; } finally { lock.unlockRead(stamp); } }这个设计的性能在“读极多、写极少”的场景下非常可观但有两个坑必须记住StampedLock 是不可重入的同一个线程再次获取会死锁它也不支持条件变量。所以一般来说只在你能明确确认“几乎没有写就算写也很快”的场景才值得用 StampedLock其他情况老老实实选 ReentrantReadWriteLock 更稳妥。4. 并发容器与原子类如何避免并发下的数据不一致4.1 ConcurrentHashMap的正确打开方式ConcurrentHashMap 是 JUC 容器里最出圈的类但很多人的用法其实还停留在“把 HashTable 换成 ConcurrentHashMap”。JDK 8 之后它内部改成 CAS synchronized Node 数组的结构放弃了 JDK 7 的分段锁设计锁粒度从段降到单个桶并发度大幅提升。与此同时它对外表现出弱一致性迭代器遍历过程中允许其他线程修改不会抛 ConcurrentModificationException。这里必须强调一个很多人踩过的坑ConcurrentHashMap 的单个操作都是线程安全的但复合操作不是。比如“判断 key 不存在则插入”if (!map.containsKey(key)) { map.put(key, value); }两个线程完全可能同时通过 containsKey然后都执行 put其中一个覆盖另一个。正确做法是用它的原子方法map.computeIfAbsent(key, k - buildValue(k));还有 size() 在高并发下只是一个估算值不是精确值。如果你要用它做精确判断比如“map 里有 100 个元素时触发退款”多半会出问题。我前两年就因为在并发下单场景里依赖 map.size() 做幂等判断结果本地测试没事线上偶尔出现重复处理后来改成使用内部计数器和原子类才解决。4.2 CopyOnWriteArrayList的适应场景CopyOnWriteArrayList 的核心思想是“写时复制”读操作不加锁直接读当前数组写操作先把原数组复制一份在新数组上修改然后用 volatile 语义替换引用。这样写线程之间用锁互斥读线程完全无锁读多写少时会非常流畅。但它的代价也很大每次写都会复制整个底层数组内存开销高频繁写会导致频繁 Full GC。我见过有人用它管理在线用户列表用户每次上线、下线、心跳都触发 add/remove结果列表只有几百个对象却因为每秒钟几十次复制把老年代撑爆了。后来我把“在线状态”拆成 ConcurrentHashMapuserId, lastHeartbeatCopyOnWriteArrayList 只保留需要遍历全量的核心列表问题立刻缓解。所以记住这句话CopyOnWriteArrayList 适合“遍历远多于修改”的场景比如黑白名单、路由表、监听器列表不适合频繁变更的热数据。用之前先看一眼写频率写超过读的 1/10就要慎重。4.3 原子类的ABA问题与LongAdder原子类基于 CAS 实现无锁更新最常用的是 AtomicInteger、AtomicLong、AtomicReference。它们的核心优势是轻量、免阻塞在低竞争场景下性能碾压 synchronized。但 CAS 有一个知名问题——ABA线程 A 读到值是 1线程 B 把 1 改成 2 又改回 1线程 A 的 CAS 依然能成功可它并不知道期间发生过变化。解决 ABA 通常用带版本号的原子引用AtomicStampedReference 或 AtomicMarkableReference。前者保存一个 int 版本号后者保存一个 boolean 标记。每次修改时版本号加 1CAS 时同时比较引用和版本号就能识别出“值虽然回来了但已经被动过手脚”的情况。还有一个容易被忽视的类是 LongAdder。它内部把单一热点计数器拆成多个单元Cell不同线程更新时分散到不同 cell 上减少 CAS 冲突。我做流量统计和接口 QPS 计数时基本都用 LongAdder因为它就是为高并发累加设计的。但它也有个特点sum() 方法会遍历所有 cell 求和最终结果在高并发下可能不完全一致属于弱一致快照。如果你的业务需要绝对精确的一次性计数还是用 AtomicLong 更合适。要记住“无锁”不等于“免费”只是把竞争成本从阻塞换成了重试竞争越激烈CAS 的重试成本越高。5. 实操记录一个订单超时关闭场景的JUC改造过程5.1 原始方案的问题订单超过 30 分钟未支付需要自动关闭这是电商和票务项目都绕不开的需求。早期项目里最常见的方案是定时任务扫描订单表每 10 分钟扫一次把超过时间阈值的订单批量关闭。看起来简单但两个问题很明显一是用户体验差明明超时 31 分钟了还要等到下一个定时周期才关闭二是数据库压力很集中每次扫描都要全表扫或走索引扫大范围数据订单量上来后一次扫描就把 DB CPU 打满了。还有一个容易忽略的问题定时任务扫描的“大事务”会把大量订单锁在一批一旦中间某个订单关闭失败整批回滚其他本来可以成功关闭的订单也被拖住。这种场景其实就是 JUC 异步编排和阻塞队列的用武之地不需要引入消息队列单机内就能完成一个很漂亮的改造。5.2 基于CompletableFuture 延迟队列的改造我的设计思路很简单订单创建成功后把它封装成一个带过期时间的延迟任务扔进一个 JUC 的 DelayQueue同时一个消费者线程专门从队列里取“到期”的订单取出来之后丢给线程池去异步执行关闭操作。这样订单到期后几乎立刻被处理不再依赖定时扫描。延迟任务这个类需要实现 Delayed 接口核心是 getDelay 和 compareTopublic class OrderDelayTask implements Delayed { private final long orderId; private final long expireTime; public OrderDelayTask(long orderId, long delayMillis) { this.orderId orderId; this.expireTime System.currentTimeMillis() delayMillis; } Override public long getDelay(TimeUnit unit) { return unit.convert(expireTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS); } Override public int compareTo(Delayed other) { return Long.compare(this.expireTime, ((OrderDelayTask) other).expireTime); } }消费者线程负责 take 到期任务take 在没有到期任务时会阻塞不会空转浪费 CPUwhile (!shutdownFlag.get()) { OrderDelayTask task delayQueue.take(); CompletableFuture.runAsync(() - closeOrder(task.getOrderId()), closeExecutor); }这里用 CompletableFuture.runAsync 而不是直接同步执行是想让消费者线程可以立即继续 take 下一个任务关闭订单的耗时操作交给独立的线程池处理。CompletableFuture 在这里承担的是异步任务编排的角色返回值用 exceptionally 处理失败CompletableFuture.runAsync(() - closeOrder(orderId), closeExecutor) .exceptionally(ex - { log.error(close order failed, orderId{}, orderId, ex); return null; });改造之后订单关闭的延迟从平均 5 分钟以上降到了秒级数据库的定时扫描任务也撤掉了。整个方案只在单机内用 JUC 组件没有引入额外的中间件部署和排查成本都很低。5.3 改造后的压测表现与心得压测的时候我模拟了 20 万笔待关闭订单每隔 5 秒再混入一批新订单。改造前用定时任务DB 侧的慢查询数量能看到明显毛刺改造后Consumer 线程的 CPU 占用几乎可以忽略真正干活的是 8 个线程的关闭线程池单机整体吞吐提升了三倍左右关闭延迟从最大 10 分钟稳定到 1 到 2 秒。这个案例给我最大的启发是很多并发优化不一定非得上消息队列和分布式平台JUC 本身已经提供了足够强大的单机构建块。DelayQueue 相当于一个简单的“时间轮”CompletableFuture 相当于一个“任务编排器”线程池相当于“资源池”三者组合起来就是一个功能完整的异步延时处理链路。当然如果业务需要多实例水平扩展那还是要引入消息队列的延迟消息或 Redis 过期事件这个要在架构层面单独评估。6. 常见问题与排查技巧实录6.1 线程池队列满了怎么办线程池队列满的表现通常是任务被拒绝、抛 RejectedExecutionException或者任务等待时间变长。第一步要分清是“短时尖峰”还是“持续超载”。短时尖峰可以适当增加队列容量或调大 maximumPoolSize持续超载则说明线程数配少了或下游处理能力跟不上单纯扩容线程池只会把压力传导到数据库或第三方接口。排查看 jstack 是最直接的。先找到线程池对应的线程名所以前面我强调自定义 threadFactory看看这些线程是 RUNNABLE 一直在干活还是 BLOCKED 在等锁还是 WAITING 在队列取任务。如果是大量线程处于 RUNNABLE 但任务完不成大概率是下游变慢如果大量线程处于 WAITING说明任务太少线程配多了。另外强烈建议在监控里加上线程池活跃线程数、队列大小、任务完成数、拒绝次数这四个指标拒绝数一旦出现就不是小问题。6.2 锁竞争导致性能下降并发性能下降很多时候不是线程数不够而是锁竞争太激烈。症状是 CPU 使用率很高但吞吐上不去或者线程频繁 BLOCKED。用 jstack 抓线程栈如果看到很多线程卡在同一个 lock 的 acquire 方法上基本可以断定是热点锁。解决思路一般是按顺序尝试缩小临界区代码、用读写锁分离读和写、用 LongAdder 替代 AtomicLong 减少 CAS 竞争、用 ConcurrentHashMap 替代全局锁。如果锁竞争仍然严重还可以考虑分段锁或完全无锁的数据结构。最怕的是拿到 jstack 后不分析直接加服务器那是用机器堆出来的假性能治标不治本。6.3 使用JUC容器时的坑关于 JUC 容器最常被问的问题就是“ConcurrentHashMap 是不是绝对安全”。答案是否定的它只保证单操作安全复合操不保证CopyOnWriteArrayList 的迭代器虽然不会抛异常但可能看不到实时数据LinkedBlockingQueue.size() 在并发下是个准确但昂贵的操作频繁调用会影响性能。还有一些隐性坑比如用 ConcurrentHashMap 做缓存时如果 value 是可变对象那并发修改 value 内部字段依然有可见性问题需要把 value 设计成不可变对象或者加上 volatile 字段。再比如使用 CompletableFuture 时如果没指定线程池它会用 ForkJoinPool.commonPool这个池子是全 JVM 共享的一旦被某个阻塞任务占满所有用到默认池的异步任务都会排队。所以只要涉及阻塞操作就老老实实传一个业务专用线程池不要偷懒用默认的。6.4 避坑速查表场景推荐方案避坑要点简单方法加锁synchronizedJDK8 后性能够用语法更简洁需要超时/中断/多条件ReentrantLock必须 finally 中 unlock等待多个子任务完成CountDownLatchcountDown 放 finallyawait 带超时多线程互相等待后执行CyclicBarrier可循环复用超时会破坏状态限制接口并发数SemaphoretryAcquire 带超时快速失败优先读多写少数据缓存ReadWriteLock / StampedLockStampedLock 不可重入高并发容器存储ConcurrentHashMap复合操作用 compute/merge读多写少列表CopyOnWriteArrayList频繁写会带来大量复制开销高并发计数器LongAddersum() 弱一致适合统计场景异步任务编排CompletableFuture显式传入线程池避免 commonPool 占满最后说一个我自己的习惯每次接手并发相关的项目我都会先画一张“资源关系图”标出哪些是共享状态、哪些是任务边界、哪些是阻塞点然后再对着图决定用哪一组 JUC 组件。这个习惯帮我避免了好几次“凭感觉写并发、线上量一上来就翻车”的处境。并发编程没有万能银弹但只要你把线程池、锁、容器和异步工具这四类组件吃透再配合压测和线程栈分析绝大多数业务场景都能找到一套稳妥的解法。希望这篇 JUC 实战总结能让你少踩几个坑也欢迎在留言区聊聊你遇到过的最奇怪的并发问题。
返回列表