1. 从“学妹已收藏”聊起:为什么JUC是Java工程师的硬通货?
看到这个标题,估计不少朋友会心一笑。“厂长爆肝”、“学妹已收藏”,这些网络梗背后,其实反映了一个非常现实的问题:在当今的Java技术面试和实际开发中,Java并发编程(JUC)已经从一个“加分项”变成了“必答题”。无论是应对大厂面试官连环炮似的并发八股,还是处理实际业务中动辄每秒数万请求的高并发场景,不懂JUC,就像开车不懂交规,迟早要出事故。
我经历过不少项目,从早期的单机Tomcat扛几百QPS,到后来分布式架构下处理百万级日活,线程安全问题、性能瓶颈、死锁幽灵总是如影随形。很多初级甚至中级开发者,对synchronized的认识可能还停留在“加个锁就安全了”的层面,面对ConcurrentHashMap和CopyOnWriteArrayList的选择时一脸茫然,更别提AQS、ThreadPoolExecutor的七大参数这些深层原理了。结果就是,线上系统在流量洪峰下频频告警,数据错乱、服务雪崩,复盘时才发现是并发控制的锅。
所以,这篇内容我们不玩虚的,不堆砌API文档。我会结合自己踩过的坑和解决过的真实问题,把JUC里那些最核心、最常用、也最容易出错的组件和思想,掰开揉碎了讲清楚。目标很简单:让你不仅能通过面试,更能写出健壮、高效、可维护的高并发代码。我们从最基础的线程安全理念出发,逐步深入到JUC工具包的实战应用。
2. 线程安全的基石:重新理解“锁”与“同步”
在深入JUC之前,我们必须把基础打牢。很多并发问题,根源在于对“线程安全”和“同步”的理解有偏差。
2.1 线程安全的本质:可见性、原子性与有序性
线程不安全,归根结底是这三个方面出了问题:
- 可见性:一个线程修改了共享变量的值,另一个线程不能立即看到。这可不是网络延迟,而是由于现代CPU的多级缓存架构和编译器优化导致的。比如,线程A在CPU核心1的缓存里更新了变量
flag=true,但线程B在CPU核心2上读到的可能还是自己缓存里的旧值false。 - 原子性:一个或多个操作,在执行过程中不被任何因素打断,要么全部完成,要么都不执行。
i++这个看似简单的操作,在字节码层面是“读-改-写”三步(读取i,增加1,写回i),在多线程环境下,这三步可能被交错执行,导致最终结果不符合预期。 - 有序性:程序执行的顺序不一定就是代码编写的顺序。编译器和处理器为了优化性能,可能会对指令进行重排序,只要在单线程环境下结果不变。但在多线程环境下,这种重排序可能导致其他线程看到“匪夷所思”的程序状态。
synchronized关键字和volatile关键字,就是Java语言层面为解决这三个问题提供的原生武器。
synchronized:能同时保证原子性(互斥执行)、可见性(解锁前会将变量刷新到主内存)和有序性(阻止其内部的指令重排序)。volatile:主要保证可见性和有序性(禁止指令重排序),但不保证原子性。它就像是给变量加了一个“透明”的屏障,任何线程的写操作都会立刻被其他线程感知。
注意:很多人误以为
volatile能保证i++的原子性,这是大错特错的。i++的非原子性源于操作步骤本身,volatile只能保证每次读到的i都是最新的,但两个线程可能同时读到最新的100,然后都加1写回101,最终结果丢失了一次加法。
2.2 Synchronized的深度剖析:从用法到锁升级
synchronized的用法大家都会:修饰实例方法、静态方法、代码块。但它的底层实现和优化机制才是面试和性能调优的关键。
锁到底存在哪里?在Java对象头里。每一个Java对象都可以作为“锁”,对象头中的Mark Word字段存储了锁的状态信息。这解释了为什么任何对象都能用在synchronized括号里。
锁升级过程(偏向锁 -> 轻量级锁 -> 重量级锁)这是JVM为了减少锁操作开销做的巨大优化,理解它才能明白在低竞争和高竞争场景下synchronized的性能表现。
- 无锁状态:初始状态。
- 偏向锁:假设锁总是由同一个线程获得。当一个线程首次获得锁时,JVM会在对象头和栈帧中记录线程ID,以后该线程再进入同步块时,只需简单检查一下ID,无需CAS等原子操作。适用于几乎没有竞争的场景(例如,大部分时间只有一个线程使用的局部缓存)。
- 轻量级锁:当有另一个线程来尝试获取锁(发生竞争),偏向锁会升级为轻量级锁。线程会在自己的栈帧中创建锁记录(Lock Record),并通过CAS操作尝试将对象头的Mark Word指向自己的锁记录。如果成功,则获得锁;如果失败,说明存在竞争,会自旋(循环尝试)一小段时间。
- 重量级锁:如果轻量级锁自旋失败(或者自旋超过一定次数,或等待线程数太多),锁会升级为重量级锁。此时,未获得锁的线程会进入阻塞状态,被放入一个等待队列,由操作系统进行线程调度(挂起和唤醒)。这个操作涉及到用户态到内核态的切换,开销最大。
实操心得:
- 明确你的同步块是低竞争还是高竞争。对于像“全局配置读取”这种读多写少且竞争不激烈的场景,
synchronized经过优化后性能并不差,代码也更简洁。 - 避免在
synchronized块内执行耗时操作(如IO、网络调用),这会极大地增加锁的持有时间,导致其他线程长时间等待,系统吞吐量骤降。 synchronized是可重入锁,同一个线程可以多次获取同一把锁,锁的计数器会递增,解锁时需递减到0才会真正释放。这避免了线程自己把自己锁死。
3. JUC核心武器库(一):原子类与CAS——无锁化的高性能之道
如果synchronized是“重型机枪”,那么JUC的原子类就是“精准狙击步枪”。它们通过硬件级别的CAS(Compare-And-Swap)指令,实现了无锁化的线程安全更新,在竞争适中的场景下性能远超synchronized。
3.1 CAS原理:乐观锁的硬件基石
CAS操作包含三个参数:内存位置(V)、预期原值(A)和新值(B)。它的逻辑是:“我认为位置V的值应该是A,如果是,那么将它更新为B;否则,什么都不做,并告诉我现在的值是多少。”
这个过程是原子性的,由CPU指令(如x86的CMPXCHG)保证。它避免了使用互斥锁,属于乐观锁策略:先进行操作,如果没有其他线程争用,那就成功了;如果发现值被改了(A != V),那就失败重试(自旋)。
ABA问题:这是CAS的一个经典陷阱。线程1读到V的值为A,准备将其改为B。但在此期间,线程2将A改成了C,然后又改回了A。线程1执行CAS时,发现V的值还是A,于是成功更新为B。对于只关心最终结果的场景(如计数器),这没问题;但对于依赖状态连续性的场景(如链表节点的版本),这可能出问题。解决方案:使用带版本号的原子引用,如AtomicStampedReference。它在更新时不仅比较值,还比较一个整型的版本戳(Stamp)。
3.2 原子类家族与应用场景
JUC提供了丰富的原子类,覆盖了基本类型、数组、引用类型和字段更新。
- 基本类型:
AtomicInteger,AtomicLong,AtomicBoolean。最常用,比如全局计数器、状态标志位。// 代替 synchronized 的计数器 private AtomicInteger counter = new AtomicInteger(0); public void safeIncrement() { counter.incrementAndGet(); // 原子性+1并返回新值 // 等价于 counter.getAndIncrement(); // 原子性+1返回旧值 } - 引用类型:
AtomicReference,AtomicStampedReference,AtomicMarkableReference。用于原子更新对象引用,实现无锁栈、队列等数据结构。 - 数组类型:
AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray。原子性地更新数组中的某个元素。 - 字段更新器:
AtomicIntegerFieldUpdater,AtomicLongFieldUpdater,AtomicReferenceFieldUpdater。以反射方式原子更新某个类的volatile字段,适用于已存在大量对象实例,不想为每个对象都包装成原子类的场景,能节省内存。
避坑指南:
- 高竞争下的性能陷阱:在极端高并发(如上百个线程同时CAS一个变量)的场景下,大量线程自旋重试会消耗大量CPU资源,性能可能反而不如让线程阻塞的
synchronized。此时需要考虑降低竞争粒度(如使用LongAdder)或换用其他同步机制。 - 复合操作的局限:原子类只能保证对单个变量的单一操作是原子的。如果你需要“读取-修改-写入”作为一个整体原子操作,原子类提供了
compareAndSet或accumulateAndGet等方法。但对于更复杂的逻辑(如“若A>B则更新C”),仍需在外层用锁(如synchronized)或结合while(true)循环与CAS来实现无锁算法,代码复杂度会上升。
4. JUC核心武器库(二):并发容器——告别手动同步的集合
Vector和Hashtable是线程安全的,但它们的实现方式是在所有方法上加synchronized,性能很差,基本已被淘汰。JUC提供了一套高性能的并发容器,它们的线程安全不是通过粗粒度锁实现的,而是各有精妙的设计。
4.1 ConcurrentHashMap:分段锁与CAS的杰作
这是面试绝对的重点。它如何做到高并发下仍能高效读写?
- JDK 7及之前:分段锁(Segment)。将整个Map分成多个Segment(默认为16个),每个Segment独立加锁。put操作只锁住对应的Segment,其他Segment仍可被访问。这降低了锁的粒度,提升了并发度。
- JDK 8及之后:数组+链表/红黑树+CAS+synchronized。这是一个更精巧的设计:
- 数据结构:Node数组(table),每个位置可能是链表或红黑树(当链表长度超过阈值,默认为8)。
- put操作:
- 如果数组位置为空,直接用CAS插入新节点,成功则返回。
- 如果数组位置不为空(有链表或树),则只对这个桶(bucket)的头节点加
synchronized锁。锁的粒度从Segment细化到了一个桶,并发度更高。
- get操作:完全无锁!因为Node的
val和next指针都用volatile修饰,保证了可见性。通过Unsafe类提供的原子性操作来读取,性能极高。 - 扩容:支持多线程协同扩容,非常复杂但高效。
与Collections.synchronizedMap(new HashMap())的对比: 后者只是用synchronized包装了每个方法,锁的是整个Map对象,并发性能远低于ConcurrentHashMap。
使用注意:
ConcurrentHashMap的size()、mappingCount()方法返回的是一个近似值,因为在并发环境下统计精确值开销太大。如果需要强一致性计数,需自行维护。- 它的迭代器是弱一致性的,反映的是创建迭代器那一刻或之后的数据状态,但不会抛出
ConcurrentModificationException。
4.2 CopyOnWriteArrayList:读多写少的极致优化
它的思想是“写时复制”。当需要修改容器时(add, set, remove),并不直接在原数组上操作,而是先复制一份内部数组的副本,在副本上进行修改,修改完成后,再将原数组的引用指向新的副本。
优点:
- 读操作完全无锁,性能极高。因为读操作总是在一个不变的数组快照上进行。
- 迭代器也永远不会抛出
ConcurrentModificationException。
缺点:
- 内存占用大:每次写操作都会复制整个底层数组,如果数组很大,频繁写操作会导致内存压力和GC频繁。
- 数据一致性弱:读操作可能读到旧数据,因为读和写作用在不同的数组上。
适用场景:读操作远远多于写操作,且数据量不大的场景。比如,监听器列表、内存缓存的黑/白名单(更新不频繁)。
4.3 阻塞队列(BlockingQueue):生产者-消费者模式的核心
这是实现线程间协作、解耦生产与消费速度的利器。核心方法:
put(e): 队列满时阻塞,直到有空间。take(): 队列空时阻塞,直到有元素。offer(e, timeout, unit): 带超时的插入。poll(timeout, unit): 带超时的取出。
常见实现:
ArrayBlockingQueue: 有界队列,基于数组,内部用一把ReentrantLock和两个Condition(notEmpty, notFull)控制阻塞。LinkedBlockingQueue: 可选有界或无界(默认Integer.MAX_VALUE),基于链表。读写各用一把锁(takeLock和putLock),在高并发下吞吐量通常优于ArrayBlockingQueue。SynchronousQueue: 一个不存储元素的队列。每个put操作必须等待一个take操作,反之亦然。用于直接传递任务,吞吐量很高。PriorityBlockingQueue: 支持优先级的无界队列。DelayQueue: 元素只有在其延迟到期时才能被取出。用于定时任务调度。
实战场景:线程池的任务队列、日志缓冲队列、订单处理流水线等。选择哪种队列,取决于你的需求是有界/无界、是否需要优先级、以及对吞吐量和延迟的权衡。
5. JUC核心武器库(三):同步工具类——控制线程的执行节奏
如果说原子类和并发容器是“武器”,那么同步工具类就是“战术指挥棒”,它们用于协调多个线程之间的执行顺序。
5.1 CountDownLatch:多线程任务的发令枪与终点线
想象一个赛跑场景:所有运动员(子线程)在起跑线等待,发令枪响(主线程调用countDown)后同时开跑;主线程在终点线等待,直到所有运动员都冲线(子线程完成)。
- 构造:
CountDownLatch latch = new CountDownLatch(int N);N代表需要等待完成的任务数。 - 子线程:完成任务后调用
latch.countDown(),计数器减1。 - 主线程:调用
latch.await(),阻塞直到计数器归零。
典型应用:
- 并行任务初始化:主线程等待所有依赖服务(如数据库连接池、缓存、配置加载)初始化完成后再启动。
- 多线程计算汇总:将一个大数据集分片给多个线程处理,主线程等待所有分片处理完成后再汇总结果。
注意:CountDownLatch的计数器是一次性的,用完即废,不能重置。
5.2 CyclicBarrier:可循环使用的线程栅栏
CyclicBarrier更像是一个团队集合点。一组线程互相等待,直到所有线程都到达某个屏障点,然后屏障打开,所有线程继续执行,并且屏障可以重置后再次使用。
- 构造:
CyclicBarrier barrier = new CyclicBarrier(int parties, Runnable barrierAction);parties是参与的线程数,barrierAction是所有线程到达后,由最后一个到达的线程执行的回调任务(可选)。 - 线程:调用
barrier.await(),表示自己已到达屏障,并等待其他线程。
与CountDownLatch的区别:
CountDownLatch是一个线程(或多个)等待其他N个线程完成;CyclicBarrier是N个线程互相等待。CountDownLatch计数器单向减少,不可重置;CyclicBarrier计数器可重置(reset()方法),用于循环任务。
应用场景:多轮迭代计算,每轮计算需要所有线程完成自己的部分后再同步进入下一轮。
5.3 Semaphore:控制并发访问的许可证
信号量用来控制同时访问特定资源的线程数量。它维护了一组“许可证”。
- 构造:
Semaphore semaphore = new Semaphore(int permits, boolean fair);permits是许可证数量,fair决定是否公平(等待队列FIFO)。 - 获取许可:
semaphore.acquire(),如果没有可用许可则阻塞。 - 释放许可:
semaphore.release()。
应用场景:
- 限流:数据库连接池(限制最大连接数)、API网关(限制每秒请求数)。
- 控制资源访问:比如只有3台打印机,
Semaphore(3)可以控制同时打印的线程数。
5.4 Exchanger:两个线程间的数据交换点
一个简单的同步点,两个线程在此交换数据。每个线程调用exchange(V x)方法,提供自己的数据,并阻塞等待另一个线程也调用此方法,然后双方获取对方的数据。应用场景:较少,可用于校对工作(如一个线程生产数据,一个线程消费数据,定期交换缓冲区)。
6. 线程池精讲:为什么说“线程池的创建是门艺术”?
直接new Thread()然后start()是万恶之源。线程的创建和销毁开销很大,无限制创建会导致系统资源耗尽。线程池是管理和复用线程的最佳实践。
6.1 ThreadPoolExecutor的七大核心参数
这是理解线程池的钥匙。Executors工厂类提供的newFixedThreadPool、newCachedThreadPool等,内部都是ThreadPoolExecutor的不同参数组合。直接使用ThreadPoolExecutor构造函数能给你最大的控制权。
public ThreadPoolExecutor( int corePoolSize, // 核心线程数:即使空闲也会保留的线程数量,除非设置了allowCoreThreadTimeOut int maximumPoolSize, // 最大线程数:线程池允许创建的最大线程数 long keepAliveTime, // 空闲线程存活时间:当线程数超过corePoolSize,多余的空闲线程在等待新任务时的最长存活时间 TimeUnit unit, // 存活时间单位 BlockingQueue<Runnable> workQueue, // 工作队列:用于存放等待执行的任务 ThreadFactory threadFactory, // 线程工厂:用于创建新线程,可以定制线程名、优先级等 RejectedExecutionHandler handler // 拒绝策略:当线程池和队列都满了,如何处理新提交的任务 )工作流程(重点):
- 提交一个任务。
- 如果当前运行的线程数 <
corePoolSize,则创建新线程来执行任务(即使有其他空闲核心线程)。 - 如果运行的线程数 >=
corePoolSize,则将任务放入workQueue。 - 如果队列已满,且运行的线程数 <
maximumPoolSize,则创建新的非核心线程来执行任务。 - 如果队列已满,且运行的线程数已达
maximumPoolSize,则触发RejectedExecutionHandler(拒绝策略)。
6.2 关键参数配置与避坑
- 核心与最大线程数:
- CPU密集型任务(计算、加密、压缩):线程数建议设置为
CPU核心数 + 1。过多线程会导致频繁的上下文切换,降低性能。 - IO密集型任务(网络请求、数据库操作、文件读写):线程数可以设置得多一些,因为线程大部分时间在等待IO。经验公式:
CPU核心数 * (1 + 平均等待时间 / 平均计算时间)。这个比例(等待时间/计算时间)很难精确估算,通常可以设置为2 * CPU核心数或更高,并通过压测调整。 - 混合型任务:可以拆分为两个线程池,或者根据偏重按上述原则估算。
- CPU密集型任务(计算、加密、压缩):线程数建议设置为
- 工作队列选择:
LinkedBlockingQueue(无界):FixedThreadPool使用。任务可以无限堆积,可能导致OOM。慎用无界队列。SynchronousQueue:CachedThreadPool使用。不存任务,来一个任务,如果没有空闲线程就创建新线程。适合大量短生命周期的异步任务。ArrayBlockingQueue(有界):最常用的安全选择。需要合理设置队列容量。
- 拒绝策略:
AbortPolicy(默认):直接抛出RejectedExecutionException异常。CallerRunsPolicy:由提交任务的线程自己执行。这是一个很好的反馈机制,能减缓任务提交速度。DiscardPolicy:默默丢弃任务,不抛异常。DiscardOldestPolicy:丢弃队列中最老的任务,然后尝试提交新任务。- 自定义策略:通常结合降级、报警、持久化等逻辑。
一个我踩过的坑:线上有一个使用newFixedThreadPool(内部是无界队列)的服务,在流量突增时,任务堆积速度远超处理速度,导致队列不断增长,最终引发OutOfMemoryError。教训:生产环境尽量使用有界队列,并配合合理的拒绝策略(如CallerRunsPolicy)和监控报警。
6.3 线程池的关闭与监控
- 关闭:
shutdown(): 平缓关闭,不再接受新任务,但会执行完已提交的任务和队列中的任务。shutdownNow(): 立即关闭,尝试中断所有正在执行的任务,并返回队列中未执行的任务列表。它通过调用线程的interrupt()方法尝试中断,但如果任务不响应中断,则无法停止。
- 监控:通过
ThreadPoolExecutor提供的方法获取运行状态至关重要。getTaskCount(): 已执行+未执行的任务总数。getCompletedTaskCount(): 已完成的任务数。getPoolSize(): 当前线程数。getActiveCount(): 正在执行任务的线程数。getQueue().size(): 队列中的任务数。 将这些指标接入你的监控系统(如Prometheus),可以清晰地看到线程池的负载、瓶颈,便于动态调整参数或扩容。
7. AQS(AbstractQueuedSynchronizer):JUC同步器的灵魂
ReentrantLock、Semaphore、CountDownLatch,这些强大的工具类,其内部同步的核心都依赖于一个共同的抽象类——AQS。理解AQS,你才能看懂这些同步器是如何工作的。
7.1 AQS的核心思想:CLH队列与状态管理
AQS维护了一个volatile int state(同步状态)和一个FIFO线程等待队列(CLH变体)。
- state:不同的同步器对其含义解释不同。对于
ReentrantLock,state=0表示锁空闲,>0表示被持有,且数值代表重入次数;对于Semaphore,state代表可用许可证数量;对于CountDownLatch,state代表剩余计数。 - CLH队列:一个双向链表,将未能获取到同步状态的线程封装成节点(Node)加入队列尾部排队等待。
AQS采用了模板方法模式,它定义了获取和释放同步状态的核心排队机制,而把具体的“如何获取状态”、“如何释放状态”的逻辑留给子类实现。子类需要重写tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared等方法。
7.2 以ReentrantLock为例看AQS工作流程
ReentrantLock有公平锁和非公平锁两种模式(通过构造参数指定)。
- 非公平锁(默认)
lock()方法:- 直接尝试用CAS将state从0改为1(快速获取)。
- 如果成功,设置当前线程为独占线程。
- 如果失败,调用AQS的
acquire(1)方法。 acquire会先调用子类的tryAcquire再次尝试获取(非公平锁的tryAcquire逻辑里,会先检查state,如果为0,不管队列里有没有等待的线程,都尝试CAS抢锁)。- 如果
tryAcquire失败,则将当前线程加入等待队列并挂起(通过LockSupport.park())。
- 公平锁
lock()方法:- 直接调用AQS的
acquire(1)。 - 在
tryAcquire方法中,它会先检查等待队列中是否有前驱节点(hasQueuedPredecessors()),如果有,则直接返回false,表示获取失败,乖乖去排队。这就保证了“先来后到”。
- 直接调用AQS的
解锁流程unlock():
- 调用
tryRelease,将state减1。如果state减到0,表示锁完全释放。 - 如果锁完全释放,则唤醒等待队列中的下一个线程(对于公平/非公平,唤醒逻辑一致)。
为什么默认是非公平锁?因为性能通常更好。恢复一个挂起的线程并将其切换到运行状态,涉及操作系统内核调度,开销较大。非公平锁给了新来的线程“插队”的机会,减少了线程切换,提高了吞吐量,但可能导致“饥饿”(某些线程长时间得不到锁)。
7.3 自己动手实现一个简易同步器
理解AQS最好的方式就是尝试用它实现一个简单的同步器。比如,实现一个“同时只允许两个线程进入”的锁(类似二元信号量)。
public class TwinsLock implements Lock { private final Sync sync = new Sync(2); // 初始状态为2 private static final class Sync extends AbstractQueuedSynchronizer { Sync(int count) { setState(count); } // 尝试获取共享锁 @Override protected int tryAcquireShared(int reduceCount) { for (;;) { int current = getState(); int newCount = current - reduceCount; if (newCount < 0 || compareAndSetState(current, newCount)) { return newCount; // 返回剩余资源数,>=0表示成功 } } } // 尝试释放共享锁 @Override protected boolean tryReleaseShared(int returnCount) { for (;;) { int current = getState(); int newCount = current + returnCount; if (compareAndSetState(current, newCount)) { return true; } } } } @Override public void lock() { sync.acquireShared(1); } @Override public void unlock() { sync.releaseShared(1); } // ... 其他Lock接口方法(略) }这个例子展示了如何通过继承AQS并实现tryAcquireShared和tryReleaseShared,轻松构建一个共享式的同步工具。AQS帮你处理了复杂的队列管理和线程阻塞/唤醒,你只需要关注状态变化的逻辑。
8. 实战:从场景出发,构建稳健的高并发程序
理论最终要服务于实践。我们来看几个典型场景,如何综合运用上述JUC组件。
8.1 场景一:实现一个高性能的本地缓存
需求:缓存热点数据,支持并发读写,缓存过期淘汰。
- 数据结构选择:
ConcurrentHashMap作为存储核心。Key为缓存键,Value可以是一个封装了数据和过期时间的对象。 - 过期处理:
- 惰性删除:在
get操作时检查是否过期,如果过期则删除并返回null。简单,但会积累垃圾数据。 - 定期清理:启动一个后台调度线程(可以用
ScheduledThreadPoolExecutor),定期扫描并清理过期条目。需要控制扫描频率和范围,避免影响正常读写。 - 时间轮算法:更高级的方案,将过期任务放入一个环形时间格,精度高、效率高。Netty、Kafka都有实现。
- 惰性删除:在
- 缓存击穿/穿透/雪崩:这是分布式缓存的老生常谈,但在本地缓存同样需要考虑。比如用
ConcurrentHashMap.computeIfAbsent配合数据库查询,或者使用FutureTask包装加载过程,确保一个Key只加载一次。
8.2 场景二:构建一个可靠的异步任务处理器
需求:接收大量任务,异步处理,需要控制并发度,并能获取处理结果。
- 核心组件:
ThreadPoolExecutor+Future/CompletableFuture。 - 线程池配置:根据任务类型(CPU/IO)设置核心和最大线程数,使用有界的
ArrayBlockingQueue,拒绝策略选择CallerRunsPolicy或自定义策略(如将任务持久化到磁盘或MQ)。 - 结果处理:
Future: 提交任务后返回Future对象,可以调用future.get()阻塞获取结果,或future.isDone()轮询。CompletableFuture(JDK8+):功能更强大,支持链式调用、组合多个异步任务、异常处理等。例如:CompletableFuture.supplyAsync(() -> fetchDataFromDB(), executor) .thenApply(data -> processData(data)) .exceptionally(ex -> handleError(ex)) .thenAccept(result -> sendResult(result));
8.3 场景三:多阶段并行计算与结果汇总
需求:计算一个复杂指标,需要并行调用多个外部服务获取数据,然后对所有结果进行聚合。
- 方案:
CountDownLatch或CompletableFuture.allOf()。 - 使用CountDownLatch:
List<Service> services = ...; CountDownLatch latch = new CountDownLatch(services.size()); List<Result> results = Collections.synchronizedList(new ArrayList<>()); // 注意线程安全 for (Service s : services) { executor.submit(() -> { try { Result r = s.call(); results.add(r); } finally { latch.countDown(); } }); } latch.await(); // 主线程等待所有任务完成 // 聚合results - 使用CompletableFuture(更现代):
List<CompletableFuture<Result>> futures = services.stream() .map(s -> CompletableFuture.supplyAsync(s::call, executor)) .collect(Collectors.toList()); CompletableFuture<Void> allFutures = CompletableFuture.allOf( futures.toArray(new CompletableFuture[0])); CompletableFuture<List<Result>> combinedFuture = allFutures.thenApply(v -> futures.stream() .map(CompletableFuture::join) // 此时所有future已完成,join不会阻塞 .collect(Collectors.toList()) ); List<Result> finalResults = combinedFuture.join(); // 获取最终聚合列表CompletableFuture的写法更函数式,组合能力更强,且能更好地处理异常。
高并发编程的世界没有银弹,JUC提供了强大的工具箱,但如何组合使用,取决于你对业务场景、数据特性和性能瓶颈的深刻理解。从理解原理出发,在实战中不断验证和调整,才是从“会用”到“精通”的必经之路。记住,最有效的并发控制,有时恰恰是减少共享数据、降低耦合度的架构设计。