资讯详情

资讯详情

JUC并发编程核心:从AQS原理到线程池实战

如果你用 Java 写过并发程序那你一定听过 JUC 这个名字。JUC 的全称是 java.util.concurrent它是 JDK 5 开始引入的一套并发工具包也是国内 Java 面试绕不开的“重镇之地”。很多人在面试的时候被问“JUC 了解吗”于是开始背 ReentrantLock、ConcurrentHashMap、CountDownLatch 这些名词但真到了生产环境线上线程卡死、任务堆积、数据错乱翻开线程栈却不知道从哪里下手才发现自己只是“听过”而不是“会用”。这篇内容我想换个角度把 JUC 背后真正解决的问题讲透AQS 是怎么当地基的显式锁和 synchronized 的区别在哪里并发容器为什么不能乱用线程池参数到底在配什么还有我实际项目中踩过的几个坑。适合已经写过一些多线程代码、想系统理解 JUC、或者准备面试想要深层理解的 Java 开发者。全文不念文档直接讲人话。1. JUC到底在解决什么问题——从synchronized的痛点说起很多教程一上来就堆概念反而容易让人忘掉 JUC 本来要干什么。回到最开始Java 1.0 就有 synchronized 关键字按理说它已经能解决“多线程互斥”的问题那为什么还要造一个 java.util.concurrent 出来原因很简单光靠 synchronized 这一个大锤很多现实场景根本敲不动。1.1 synchronized 的三大硬伤第一个硬伤是“只能独占”。synchronized 天然是互斥锁同一时刻只允许一个线程进入临界区。可现实里相当多的场景是“读多写少”比如一个配置缓存十个线程同时在读只有一个线程偶尔更新。用 synchronized 硬锁等于把“同时只读”这件事也禁掉了十个读线程强行排队吞吐量直接变成原来的十分之一。读操作明明不产生竞争却因为“锁的粒度只有一把”而付出串行化的代价。第二个硬伤是“等不到、收不了”。synchronized 的阻塞等待是不能被中断的。也就是说线程 A 拿到锁之后卡住了比如在临界区内发起了一个永久阻塞的网络调用线程 B 在锁上等着外部想去打断 B、让它赶紧去做别的事是做不到的。另一个问题是等待没有超时机制。如果两个线程互相持有对方想要的锁形成死锁synchronized 会让大家永远抱着等待服务只能重启。对线上系统来说“无限等待”比“报错”可怕得多。第三个硬伤是“锁的边界不够灵活”。synchronized 的加锁、解锁必须框在一个代码块或者方法结构里锁的获得和释放天生绑定语法。很多场景需要“在一个方法里加锁在另一个方法里释放”比如从队列里取一批任务先锁住队列扫描完再解锁中间可能跨过多个方法调用这种代码用 synchronized 写起来非常别扭强行拆成 synchronized 块又会破坏操作的原子性。1.2 JUC 的解题思路从“强制互斥”走向“精细协调”synchronized 的问题不是“互斥”错了而是它把问题想得太简单。JUC 的设计思路是把“同步”从一个语法级关键字抽象成一组更精细的原语锁只是其中的一种队列、信号量、栅栏、阶段器、原子变量、并发容器每一件都解决特定场景下的协作问题。我自己的理解是JUC 做了三件 synchronized 做不了的事锁可以排队通过 AQS 维护等待队列支持公平与非公平策略。锁可以商量支持超时等待、可中断等待线程在等待时能主动退出。锁可以分工读写锁让读读并行CountDownLatch 让多个线程一起等一个信号CyclicBarrier 让一组线程彼此等待Semaphore 直接控制同时执行的线程数量。所以 JUC 不是“synchronized 的替代品”而是对“什么时候需要互斥、什么时候需要协作”这个问题的重新梳理。理解这一点之后下面讲 AQS 时你就不会觉得它只是一个“处理锁的底层工具”了它其实是整个并发协作逻辑的中枢神经。2. AQS一块地基如何撑起整个并发工具包AQS 全称 AbstractQueuedSynchronizer如果你去看 ReentrantLock、Semaphore、CountDownLatch 这些类的源码会发现它们底层都委托给一个继承 AQS 的内部类。AQS 之于 JUC就像地基之于整栋楼——楼里每一间房的功能各不相同但承重结构是同一套。2.1 状态位与等待队列AQS 的两个核心零件AQS 内部维护了两种东西一个 volatile int 类型的 state和一个双向等待队列。这两样东西几乎解释了 JUC 所有并发工具的行为。state 可以理解为一个“状态计数器”。ReentrantLock 用它记录锁被获取了几次重入多少次Semaphore 用它记录剩余许可证数量CountDownLatch 用它记录还有多少事件没完成。AQS 本身不定义 state 的具体含义它只提供 getState、setState、compareAndSetState 这三个方法至于这个状态代表什么完全由子类决定。这正是 AQS 能支撑多种并发工具的原因——它提供了“状态 队列”的框架却不绑架你的业务语义。等待队列是一个基于 Node 的双向链表。每当一个线程尝试获取“某种同步状态”失败它会被包装成一个 Node挂到队列尾部然后通过 LockSupport.park 挂起自己。当拥有状态的线程释放时它会从队列头部唤醒后继节点。这套机制解决了一个很关键的问题如果一个线程获取不到锁就原地死循环自旋CPU 会被浪费在无意义的忙等上。AQS 的做法是让失败的线程真正“睡过去”等条件可能满足时再被唤醒省下的 CPU 时间可以用来做别的事。2.2 独占模式与共享模式AQS 支持两种获取方式独占模式exclusive和共享模式shared。最直观的理解是独占模式是“只有一个人能拿到”共享模式是“大家可以一起拿但不能拿得太多”。ReentrantLock 就是独占模式的代表。同一时刻只有一个线程能成功把 state 从 0 改成 1其他线程都进队列。Semaphore 是共享模式每个线程来 acquire 时把 state 减一只要 state 还大于 0多个线程可以同时成功。CountDownLatch 也属于共享模式所有等待线程可以同时被唤醒不需要排队“一个一个出去”。这个区分在实际使用中很有价值。比如说你要写一个限流组件希望允许 10 个线程同时执行某个任务。如果你错误地用了独占锁那效果就不是限流而是串行化用 Semaphore 这种共享语义的实现才能同时放过 10 个线程第 11 个才等待。2.3 一个零基础的实验用 AQS 手写一把锁理解 AQS 最好的方式是自己实现一把简单的锁。下面这段代码是我早期学习时写的非常简陋但足够说明问题。import java.util.concurrent.locks.AbstractQueuedSynchronizer; public class SimpleLock { private final Sync sync new Sync(); // 继承 AQS只实现独占模式的获取和释放 private static final class Sync extends AbstractQueuedSynchronizer { // 尝试获取锁把 state 从 0 改成 1成功就返回 true Override protected boolean tryAcquire(int acquires) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } // 尝试释放锁直接把 state 改回 0 Override protected boolean tryRelease(int releases) { if (getState() 0) { throw new IllegalMonitorStateException(); } setExclusiveOwnerThread(null); setState(0); return true; } } public void lock() { sync.acquire(1); } public void unlock() { sync.release(1); } }这段代码能跑但有一个明显的问题它不是可重入的。同一个线程调用 lock 两次第二次的 compareAndSetState 会失败线程会把自己挂起然后死锁。ReentrantLock 之所以支持重入是因为它在 tryAcquire 里加了“如果当前线程已经是持有者就把 state 加一”的逻辑。看到没有AQS 只是提供了框架具体的同步规则完全由子类写。从这个小实验里你可以直观感受到 AQS 的强项获取和释放的模板流程失败入队、挂起唤醒、队列维护已经被抽象好了你只需要关心“怎么改变 state 的值”这一个问题。这也是 JUC 这么多并发工具代码量看起来不算大却能覆盖大量场景的根本原因。3. 显式锁ReentrantLock和它的兄弟们synchronized 在 JDK 6 之后做了大量优化锁膨胀、偏向锁、轻量级锁、自旋性能已经不像早年那么差。那还要 ReentrantLock 干嘛核心因素不是性能而是功能可中断、可超时、公平策略、条件变量。ReentrantLock 是 JUC 里面最常用、也最容易出细节问题的显式锁。3.1 重入机制锁是怎么“记住”自己的主人的ReentrantLock 的“重入”指同一个线程持有锁后可以再次获取而不会把自己锁死。实现方式不神秘AQS 的 state 每次 acquire 加 1每次 release 减 1只有当 state 减到 0 时锁才真正释放。同时 AQS 里记录了一个 exclusiveOwnerThread 字段指向当前持有锁的线程。当线程发现自己就是持有者就直接 state1不再走入队挂起的路。这个设计解决了一个被很多人忽略的真实痛点一个方法如果调用了另一个同样需要加锁的方法比如一个事务方法内部调用了另一个同样被锁保护的方法如果没有重入能力第二次加锁就会死锁。synchronized 天然支持重入ReentrantLock 也支持但如果你自己用 AQS 写锁不小心就会把重入逻辑写丢——我见过有人手写锁把系统锁出死锁的排查半天发现是重入计数被忽略了。3.2 公平锁与非公平锁别被“公平”二字骗了ReentrantLock 的构造函数可以传一个 fair 参数true 为公平锁false 为非公平锁。默认是非公平。很多人一看到“公平”就觉得更好但在并发场景里公平不一定对应“更正确”。非公平锁的优点是吞吐量通常更高。原因在于当一个线程释放锁时队列尾部恰好来了一个新线程这个线程如果直接尝试抢锁有可能在队首等待线程被唤醒之前就已经拿到了锁省掉了一次线程上下文切换。代价是队首线程可能被“插队”极端情况下存在饥饿风险但实际中概率很低。公平锁则严格按队列顺序来等待越久的线程越先拿到锁。它更适合“处理器时间要尽量均匀分配”的批处理场景比如定时任务调度、事件处理系统。我在实践中建议没有明确公平性要求时用默认的非公平锁就可以了不要为了“名字好听”而选公平锁那可能白付性能代价。有一个细节值得记一下tryLock() 方法默认不会遵守公平策略它就像插队通道只要有锁空闲就尝试抢占哪怕你创建的是公平锁。如果你用 tryLock 来避免死锁就要知道这种“插队”行为是预期内的不要觉得是 bug。3.3 ReentrantReadWriteLock读多写少场景的正解ReentrantReadWriteLock 维护了两把锁读锁和写锁。读锁之间可以共享写锁是独占的读锁和写锁互斥写锁和写锁也互斥。这正好解决了本文开头提到的问题读多写少场景下多个读线程不必互相排队。它的核心价值是提升读多写少系统的并发度。比如一个缓存 Map绝大多数请求是读只有定时任务偶尔写。如果用 ReentrantLock 整体加锁读线程全部串行QPS 会非常难看换成读写锁读读并行QPS 能提高好几倍。使用读写锁有一个容易搞错的地方写锁可以降级为读锁但读锁不能升级为写锁。降级的意思是在拥有写锁时先获取读锁再释放写锁这样当前线程仍然有读保护但其他写线程可以进来了。读锁升级为写锁则是死锁的经典来源两个读线程都持有读锁然后都尝试获取写锁互相等对方释放读锁永远等不到。记住同一把锁写可以降级读不能升级。4. 并发容器HashMap到ConcurrentHashMap的进化和BlockingQueueJUC 不只是提供锁还提供了一整套并发容器。如果你还在用 HashMap 加锁去模拟并发访问或者还在用 Vector、Hashtable 这种老旧方案那 JUC 的很多工作都被白费了。并发容器的核心价值在于容器内部的并发控制已经做完了你不需要在外面再加锁也不需要接受整表锁死带来的低性能。4.1 从 HashMap 到 ConcurrentHashMap一次安全改造HashMap 线程不安全最著名的问题是在 JDK 7 头插法扩容时可能形成环形链表导致下一次查询死循环CPU 飙到 100%。JDK 8 改成尾插法环的问题缓解了但并发 put 仍然可能丢数据因为多个线程同时写同一个桶位时前面的写覆盖了后面的写。最简单的解决方案是 Collections.synchronizedMap但它本质上是给每个方法套了一层全表锁读和读之间都互斥并发度极低。ConcurrentHashMap 则是把锁的粒度精细到“桶位级别”。JDK 7 的实现用了分段锁把整个 Map 分成若干段每段一把锁JDK 8 改成了 CAS 加 synchronized 锁单个桶的头节点。为什么改分段锁需要两次 hash 定位段和桶内存开销也更大而锁单个桶的做法在大多数场景下已经足够精细而且实现更轻。关于 ConcurrentHashMap不少人有个误解觉得它是“绝对线程安全的 Map”。其实它只是保证单个操作的原子性和内部结构的完整性。比如先判断 containsKey 再 put这两步之间可能有别的线程插进来用 size() 获取元素个数时在高并发下也只是一个尽力而为的估计值不能拿来做精确的业务判断。如果你需要“先检查再插入”这个复合操作原子化应该用 putIfAbsent 或者 computeIfAbsent而不是在外面套锁。4.2 BlockingQueue搭起生产者-消费者桥梁BlockingQueue 是 JUC 并发容器里的另一个核心家族。它不只是“线程安全的队列”更重要的是提供了阻塞能力当队列满时put 操作会阻塞当队列空时take 操作会阻塞。这种天然的生产者-消费者语义让很多协作代码变得非常简洁。常用的实现有这么几个队列类型有界性特点适用场景ArrayBlockingQueue有界底层循环数组容量固定效率高生产者消费者数量比较确定的系统LinkedBlockingQueue默认无界可指定有界链表结构吞吐较高请求缓冲、线程池任务队列SynchronousQueue不存储元素每个 put 必须等待一个 take直接交接、无中间缓冲PriorityBlockingQueue无界按优先级出队任务带优先级处理的场景使用阻塞队列时put 和 take 是“会等”的操作offer 和 poll 是“不等”的操作offer 还可以传超时时间。实际业务里我一般建议生产环境使用 offer 带超时来放入任务避免线程被永久阻塞。如果队列满到一直放不进去那说明系统已经积压严重应该走拒绝策略或者告警而不是让生产者线程无限期等下去。4.3 CopyOnWriteArrayList读多写极少时的选择CopyOnWriteArrayList 是并发 List 里的一个特殊选手。它的策略是每次写操作都把底层数组复制一份在副本上修改然后把引用指向新数组读操作完全不加锁直接在旧数组上读。那如果读发生在写的过程中呢读到的还是旧数组它不会被修改所以不会读到“写到一半”的数据。这个策略的本质是用“复制”换取“读无锁”。适合读非常多、写非常少的场景比如缓存黑白名单、系统配置项。代价也非常明显每次写都要复制整个数组如果数组很大写操作会非常昂贵而且读操作虽然不加锁但可能读不到最新的写结果。注意 CopyOnWriteArrayList 的迭代器是“弱一致性”的不支持迭代过程中 remove如果你指望用它来做实时统计那种要不停看最新数据的场景那思路就错了。5. 线程池省心背后的心机线程池是 JUC 里最容易被“用”却最难被“用对”的组件。很多团队直接 Executors.newFixedThreadPool 一把梭线上出问题时才发现线程池比想象中复杂得多。线程池的核心并不只是“复用线程”而是一套完整的资源管理和状态控制机制。5.1 七个参数和一个状态机ThreadPoolExecutor 的骨架ThreadPoolExecutor 的构造参数有七个corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。理解它们的关系是设置线程池的第一步。线程池的工作逻辑简单说就是当提交任务时如果当前线程数小于 corePoolSize就新建线程处理如果大于 corePoolSize 但队列未满就放入队列如果队列满了且线程数小于 maximumPoolSize就继续新建线程如果队列满了且线程数已到 maximumPoolSize就触发拒绝策略。这里有一个常见的误解maximumPoolSize 不是“一开始就建这么多线程”而是“队列塞满之后才允许额外增加的线程数”。所以队列的长度决定了你什么时候才会开始扩容线程。很多人配了 maximumPoolSize200但用了无界队列结果线程数永远只停在 corePoolSize任务全堆在队列里内存被消耗殆尽。线程池内部还有一个状态机表示生命周期RUNNING 正常运行SHUTDOWN 不接受新任务但继续处理队列任务STOP 立刻停掉所有任务TIDYING 整理中TERMINATED 终结。调用 shutdown 和 shutdownNow 分别对应这两个不同的停止策略shutdown 是温柔停止shutdownNow 是强制打断。5.2 队列选型哪些坑是 Executors 帮你埋好的Executors 提供了若干快捷方法但我强烈建议生产环境不要直接使用。最大的坑是 newFixedThreadPool 和 newSingleThreadExecutor 使用了无界队列 LinkedBlockingQueue。无界队列意味着队列永远装不满线程数永远不需要扩容到 maximumPoolSize任务无限堆积最终导致 OOM。另一种容易踩的坑是 newCachedThreadPool它的 corePoolSize 为 0maximumPoolSize 为非常大队列是 SynchronousQueue。只要提交速度快于处理速度它就会不断创建新线程线程数量直接打到系统上限造成频繁上下文切换甚至崩溃。在我的实践中哪怕是一些短任务场景也要注意给这种池加一个速度上限或者干脆用有界队列加拒绝策略。推荐的做法是手动创建 ThreadPoolExecutor显式指定有界队列和拒绝策略。比如下面这份配置是一个比较稳妥的出发点ThreadPoolExecutor executor new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(1000), new NamedThreadFactory(order-process), new ThreadPoolExecutor.CallerRunsPolicy() );有界队列 1000可以让任务等待一定时间又不至于无限堆积。拒绝策略 CallerRunsPolicy 会让提交任务的线程自己执行该任务等于把压力回传给了生产者不会无声无息丢任务。5.3 关于线程工厂、预热和超时的三个细节线程池的默认线程工厂叫 DefaultThreadFactory它创建的线程名是 pool-1-thread-1 这种无意义的名字。线上排查问题时你打开线程栈看到一堆 pool-1-thread-1根本不知道是哪个业务在运行。自定义一个 ThreadFactory 给线程起业务名是排查效率利器。稍微讲究一点的做法是给线程设置 UncaughtExceptionHandler以及设置 daemon 属性。另外两个细节分别是 prestartAllCoreThreads 和 allowCoreThreadTimeOut。前者会在线程池创建后立即把 corePoolSize 个线程全部拉起来避免第一个任务到达时才慢吞吞创建线程适合对延迟敏感的系统。后者允许核心线程在空闲超过 keepAliveTime 后销毁适合流量有明显波谷的系统能减少空闲线程占用。6. 同步工具三个老搭档的选型经CountDownLatch、CyclicBarrier、Semaphore 这三个类表面功能有些相似实际上语义和使用场景差得很远。经常有同学在面试的时候被问到“三者区别”然后开始背Latch 是倒数门闩Barrier 是栅栏Semaphore 是信号量。但真要写代码时却不知道该用哪个。6.1 CountDownLatch一次性的闸门CountDownLatch 用来实现“一组线程等待若干个事件完成”。比如一个主线程要等待三个子线程分别从三个上游接口拉数据都拉完了才继续汇总。此时可以初始化 new CountDownLatch(3)三个子线程完成后各调用一次 countDown主线程调用 await 被挂起直到计数器归零。它的两个特性要注意第一不可复用计数器归零后这个实例就废了想再等下一批任务就要 new 一个新的第二countDown 和 await 的线程可以不是同一批。比如一个主线程 await另外多个工作线程 countDown这完全没问题。底层同样是 AQScountDown 对应共享状态减一await 对应共享模式获取。如果担心 await 被无限期挂起可以用 await(long, TimeUnit) 的版本设置超时时间。实际项目中我几乎总是用带超时的版本因为“等待无上限”对线上服务来说是一个风险点。6.2 CyclicBarrier可循环的栅栏CyclicBarrier 的语义是“所有线程互相等待等大家都到达了再一起继续”。和 CountDownLatch 不一样它等待的主角是一组线程本身而且要齐头并进。举个例子三个线程各自处理一批数据每处理完一个批次都要等另外两个也处理完再同时进入下一批次。这种场景用 CyclicBarrier 就非常自然。它和 CountDownLatch 最大的区别有两个一是可循环使用batch 结束后可以 reset 接着用下一轮二是支持一个可选的 barrierAction也就是当所有线程都到达屏障时由最后一个到达的线程执行这个动作常用于“每次批次完成后汇总一下分片结果”。使用 CyclicBarrier 时注意如果其中某个线程发生异常或超时barrier 会进入 broken 状态其他等待线程会抛出 BrokenBarrierException。实际写代码时要给这个异常留下处理逻辑否则等待线程可能永久挂起或异常满天飞。我在项目里见过因为没处理 BrokenBarrierException 导致整个任务组卡死的案例定位的时候才发现是这个隐藏的依赖关系。6.3 Semaphore独立的限流器Semaphore 管理一组许可证。线程执行前 acquire 拿一个许可证执行完 release 归还。拿不到许可的线程就等待。它和线程池不同它不限制“有几个线程在跑”只限制“有几个线程同时通过许可”所以可以被用在很多不需要池化的限流场景。比如你要控制某个上游接口的全局并发度不希望同时超过 5 个请求打上去就可以在调用前 Semaphore.acquire()调用结束后 release()。它可以跨线程使用也就是说一个线程 acquire另一个线程 release这在线程池任务传递的场景里很有用。它同样支持公平和非公平模式默认非公平。信号量还有一个 acquireUninterruptibly如果等不到许可就不响应中断这是一个容易被忽略的阻塞形态使用时要评估清楚。6.4 一张表看清三者的差异工具核心语义复用性典型用法底层类型CountDownLatch等 N 个事件完成后放行不可复用主线程等待多任务完成AQS 共享CyclicBarrier等 N 个线程互相到齐可 reset 复用分片数据批量处理基于 ReentrantLock 和 ConditionSemaphore控制同时通过的线程数可复用限流、连接池模拟AQS 共享7. 我踩过的JUC深坑问题排查实录理论说再多不如实战踩坑。最后这一节分享我在真实项目里遇到过的几个并发问题每一个都是可以复现的、值得警惕的。7.1 循因溯源一个锁顺序死锁的排查过程那一次我负责的某个服务在高峰期突然大面积请求超时。打开线程栈发现大量线程卡在 ReentrantLock.lock() 的调用上互相等待明显是死锁。但代码里用了 ReentrantLock为什么还会死锁顺着线程栈往下看线程 A 持有锁 L1等待锁 L2线程 B 持有锁 L2等待锁 L1。原因是两个业务方法加锁顺序不一致方法甲先锁 L1 再锁 L2方法乙先锁 L2 再锁 L1。锁顺序不一致是死锁的经典配方。修复方式不复杂强制统一所有场景下加锁的顺序全部先 L1 后 L2。上线后问题消失。这个排查过程给我几个经验第一用 ReentrantLock 也要考虑锁顺序它并不免疫死锁第二排查死锁最快的方式还是 jstack 看线程栈栈里会把“waiting to lock”和“held lock”清清楚楚列出来第三代码审查时如果发现多处加锁一定要盯住加锁顺序这是最隐蔽的死锁来源之一。我后来会在跨方法加锁的地方专门写注释标明锁的层级顺序。7.2 共享变量没加 volatile 的可见性事故另一个案例更隐蔽。当时的代码有一个 while 标志位控制后台任务循环一个线程检查标志位另一个线程负责在收到停止信号后把标志位改成 false。看起来逻辑没问题但实测中,线程 A 一直不退出。原因是标志位没有用 volatile 修饰线程 A 在自己线程的缓存里一直看到“旧值”永远读不到线程 B 的修改。Java 内存模型里volatile 保证的是可见性和禁止重排序不保证复合操作的原子性。如果一个标志位只是被单线程修改、被另一线程读取用 volatile 就够如果多个线程同时修改同一个计数应该用 AtomicInteger 或者加锁。这个例子让我记住了一条经验在 JUC 的世界里不只是锁要管变量本身的可见性也要管ATOMIC 系列和 volatile 是 JUC 里最基础但最容易被忽视的零件。7.3 Future.get 阻塞等不到的调用使用线程池时很多人习惯用 Future 获取任务结果。我也踩过坑某个任务里调用了远程接口没有设置超时时间线程池中一个线程在 Future.get() 上永久阻塞调用方也调用了 get()同样被永久阻塞。一个超时传染成整条调用链阻塞。后来我给所有 Future.get 都加了超时参数get(5, TimeUnit.SECONDS)。如果超时就取消任务并记录告警。另外要记住FutureTask 超时后线程并不会自动中断任务本身它只是让调用者不再等待。如果任务是 CPU 密集型且不响应中断线程其实还在后台跑。所以超时取消后还要检查任务本身是否真的终止了必要时给任务提供取消逻辑。7.4 线程池参数拍脑袋压测才是唯一标准最后想说的经验是线程池参数绝对不要拍脑袋定。我见过太多团队把 corePoolSize 设为 8、maximumPoolSize 设为 16但没人说得清为什么是这两个数字。核心线程数、队列长度、拒绝策略必须结合你的业务类型——CPU 密集型、IO 密集型还是纯异步任务——进行估算和压测验证。CPU 密集型的经验值一般是 CPU 核数 1目的是让每个线程都跑满一个核不引入太多上下文切换。IO 密集型的线程数要适当放大因为线程大部分时间阻塞在等待 IO 上可以多开一些线程来掩藏延迟。但这只是一个出发点真正的参数要靠压测定。线上系统改线程池参数后一定要观察任务堆积量、线程空闲率、拒绝次数这三个指标任何一个指标异常都要回去重新调。JUC 是一个工具包更是一套并发编程的思维方式。如果你能理解 AQS 是骨架、同步工具是语义、容器是优化、线程池是资源管理那么再遇到并发问题就不会只想“加个锁”或者“换个大一点的线程池”了你会有条件反射般的排查思路和选型直觉。以后再看源码或者做系统设计的时候建议回过头来想一个问题这个类解决的到底是哪个 synchronized 解决不了的问题想清楚了才算真正入门 JUC。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →