JUC-源码分析

JUC-源码分析
mengnankkzhou线程
1.从 JVM 的角度来说一下线程和进程之间的关系
一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)\资源,但是每个线程有自己的*程序计数器、虚拟机栈 和 本地方法栈*。
线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。
那么?为什么程序计数器、虚拟机栈和本地方法栈是线程私有的呢?为什么堆和方法区是线程共享的呢?
- 程序计数器:
主要作用:字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。
so,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
- 虚拟机栈和本地方法栈
虚拟机栈: 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
那么我们怎么创建线程呢?
一般来说,创建线程有很多种方式,例如继承Thread类、实现Runnable接口、实现Callable接口、使用线程池、使用CompletableFuture类等等。
严格来说,Java 就只有一种方式可以创建线程,那就是通过new Thread().start()创建。不管是哪种方式,最终还是依赖于new Thread().start()。
然后我们的run方法是直接启动线程,start方法是新建一个线程然后使用run方法来启动线程
2.说一下线程的生命周期
Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:
- NEW: 初始状态,线程被创建出来但没有被调用
start()。 - RUNNABLE: 运行状态,线程被调用了
start()等待运行的状态。 - BLOCKED:阻塞状态,需要等待锁释放。
- WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
- TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
- TERMINATED:终止状态,表示该线程已经运行完毕
线程创建之后它将处于 NEW(新建) 状态,调用 start() 方法后开始运行,线程这时候处于 READY(可运行) 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态。
当线程执行 wait()方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态。
TIMED_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis)方法或 wait(long millis)方法可以将线程置于 TIMED_WAITING 状态。当超时时间结束后,线程将会返回到 RUNNABLE 状态。
当线程进入 synchronized 方法/块或者调用 wait 后(被 notify)重新进入 synchronized 方法/块,但是锁被其它线程占有,这个时候线程就会进入 BLOCKED(阻塞) 状态。
线程在执行完了 run()方法之后将会进入到 TERMINATED(终止) 状态。
3.多线程
为什么我们要使用多线程?
总体上呢:
从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
底层来看:
单核时代:在单核时代多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率。 假设只运行了一个 Java 进程的情况,当我们请求 IO 的时候,如果 Java 进程中只有一个线程,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么可以简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率。
多核时代: 多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 核心上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。
单核CPU实现多线程:
单核 CPU 是支持 Java 多线程的。操作系统通过时间片轮转的方式,将 CPU 的时间分配给不同的线程。尽管单核 CPU 一次只能执行一个任务,但通过快速在多个线程之间切换,可以让用户感觉多个任务是同时进行的
操作系统主要通过两种线程调度方式来管理多线程的执行:
- 抢占式调度(Preemptive Scheduling):操作系统决定何时暂停当前正在运行的线程,并切换到另一个线程执行。这种切换通常是由系统时钟中断(时间片轮转)或其他高优先级事件(如 I/O 操作完成)触发的。这种方式存在上下文切换开销,但公平性和 CPU 资源利用率较好,不易阻塞。
- 协同式调度(Cooperative Scheduling):线程执行完毕后,主动通知系统切换到另一个线程。这种方式可以减少上下文切换带来的性能开销,但公平性较差,容易阻塞。
4.死锁
线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
四个条件:
互斥条件:该资源任意一个时刻只由一个线程占用。
请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
如何判断死锁呢?
使用jmap、jstack等命令查看 JVM 线程栈和堆内存的情况。如果有死锁,jstack 的输出中通常会有 Found one Java-level deadlock:的字样,后面会跟着死锁相关的线程信息。另外,实际项目中还可以搭配使用top、df、free等命令查看操作系统的基本情况,出现死锁可能会导致 CPU、内存等资源消耗过高
1 | # 获取Java进程PID |
然后我们可以在程序中去检查死锁:
1 | public class DeadlockDetector { |
Mysql的话,我们可以去查看日志,找deadlock字段记录的,然后看他选择的哪一个事务进行了回滚
1 | -- 查看最近的死锁信息 |
如何避免死锁呢?
破坏请求与保持条件:一次性申请所有的资源。
破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
- 按照顺序获取锁
1 | public class OrderedLockDemo { |
- 超时锁
1 | public class TimeoutLockDemo { |
去给这个锁设置一个超时的时间
- 使用并发工具类
1 | // 使用Semaphore避免死锁 |
锁&关键字
1.Synchonized
synchronized是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。在 Java 早期版本中,synchronized属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。在 JDK18 中,偏向锁已经被彻底废弃
synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。
当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。对象锁的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。
- monitor和对象头
在HotSpot JVM中,每个Java对象都有对象头,主要包含:Markword这里记录的是锁信息的状态,GC标记,hashcode等,然后是一个Class Pointer指向Class对象的指针
Markword是根据锁的状态是变化的,根据锁的升级是进行变化的
锁的升级:
- 无锁状态:Mark Word存储hashCode、GC年龄等,头是unused
- 首次进入:尝试偏向锁,Mark Word存储: 线程ID + epoch + age + 01,头是threadID:54
- 重入:recursions++,仍是偏向锁
- 如果有其他线程竞争,升级为轻量级锁,Mark Word存储: Lock Record指针 + 00,JVM在当前线程栈中创建Lock Record,markOop就是原始的markword,oop被指向的锁对象。头是ptr_to_lock_record
- 竞争激烈时,升级为重量级锁 ,Mark Word存储: Monitor对象指针 + 10,当升级到重量级锁时,创建Monitor对象,Mark Word指向它。头是ptr_to_monitor
然后我们的线程是要去竞争锁的,首先我们会使用快速路径:尝试CAS获取锁,如果失败了就自旋等待,然后超过重试的次数的话,加入EntryList,阻塞等待。
然后我们如果阻塞了的话,这个时候需要去notify,需要从WaitSet移到EntryList,然后等待的话,需要先释放锁,然后加入等待Set,然后阻塞等待notify
1 | -XX:+UseBiasedLocking // 开启偏向锁(默认开启) |
实际应用问题:
hashCode调用**会导致偏向锁失效
System.identityHashCode()会强制升级锁
大量短期锁竞争时,禁用偏向锁可能更好
Monitor对象**在锁释放后不会立即回收,可能影响GC
- 偏向锁的撤销(好难哭),JDK18已经废弃
条件:
比如说线程1获取了偏向锁,然后线程2去尝试获取偏向锁,然后这个时候就会触发偏向撤销,升级为轻量级锁
调用Object.hashCode()或System.identityHashCode(),此时obj处于偏向锁状态,Mark Word存储线程ID等信息
调用hashcode就会立刻撤销偏向锁,因为偏向锁的Mark Word无法存储hashCode
然后调用wait方法,让obj等待了,立即撤销偏向锁,升级为重量级锁,因为wait需要Monitor对象支持
流程:
检测撤销条件:检查是否为偏向锁模式,在markword获取偏向的线程ID,判断撤销类型,如果是匿名偏向直接撤销,有具体偏向线程,需要更复杂的处理
安全点操作:在安全点执行撤销操作,
2.volatile
Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
它最原始的意义就是禁用 CPU 缓存。如果我们将一个变量使用 volatile 修饰,这就指示 编译器,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
他能保证可见性,禁止重排,但是不能保证原子性,比如i++
指令重排是编译器和 CPU 的优化手段,通过改变代码执行顺序来提升性能。然而,重排序可能会破坏多线程中的正确性。
通过插入 内存屏障(Memory Barrier),volatile 保证了对该变量的操作的顺序不会被重排,确保了对共享变量的写操作在前,读操作在后。这种内存屏障的插入通常是 StoreLoad 指令,它确保写操作的数据先写入主内存,后续的读操作必须从主内存中获取最新值
插入的是StoreLoad指令,先将cpu缓存刷新到主内存之前,不会将主内存的缓存,加载到cpu中
使用场景:
停止标志位:多线程间通过共享的 volatile 变量来通知其他线程是否停止工作。
DCL的单例模式
AQS中的state变量,用来计数
3.乐悲观锁
悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
像 Java 中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现
高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。
乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)
在 Java 中java.util.concurrent.atomic包下面的原子变量类(比如AtomicInteger、LongAdder)就是使用了乐观锁的一种实现方式 CAS 实现的。
高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。
悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。
乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考java.util.concurrent.atomic包下面的原子变
量类)。
4.ReentranLock
ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。
ReentrantLock 里面有一个内部类 Sync,Sync 继承 AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。Sync 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。ReentrantLock 默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁
相比synchronized,ReentrantLock增加了一些高级功能。主要来说主要有三点:
- 等待可中断 :
ReentrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。也就是说当前线程在等待获取锁的过程中,如果其他线程中断当前线程「interrupt()」,当前线程就会抛出InterruptedException异常,可以捕捉该异常进行相应处理。 - 可实现公平锁 :
ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过ReentrantLock类的ReentrantLock(boolean fair)构造方法来指定是否是公平的。 - 可实现选择性通知(锁可以绑定多个条件):
synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。 - 支持超时 :
ReentrantLock提供了tryLock(timeout)的方法,可以指定等待获取锁的最长等待时间,如果超过了等待时间,就会获取锁失败,不会一直等待。
总结:
ReentrantLock是Java中的显式可重入互斥锁,提供灵活的线程同步功能。它支持公平和非公平模式,允许同一线程多次获取锁而不会发生死锁。其核心特性包括:可中断的锁获取(lockInterruptibly())、超时尝试锁(tryLock())、以及支持多个Condition条件变量。与synchronized相比,ReentrantLock需要手动管理锁的获取和释放,适用于复杂的多线程场景。
voliate修饰变量的实现
- 当同一个线程再次请求锁时,如果它已经持有该锁,计数器会增加,以允许多次获取而不会阻塞。
- ReentrantLock维护一个等待队列,当线程无法获取锁时,会被放入该队列。每当持有锁的线程释放锁时,计数器会减少,直到归零,其他线程才能获取锁。
5.Semaphore
Semaphore(信号量)可以用来控制同时访问特定资源的线程数量。
1 | // 初始共享资源数量 |
当初始的资源个数为 1 的时候,Semaphore 退化为排他锁。
Semaphore 有两种模式:。
- 公平模式: 调用
acquire()方法的顺序就是获取许可证的顺序,遵循 FIFO; - 非公平模式: 抢占式的,默认是非公平的
可以使用Redis+Lua来做限流的处理
它默认构造 AQS 的 state 值为 permits,你可以将 permits 的值理解为许可证的数量,只有拿到许可证的线程才能执行。
调用semaphore.acquire() ,线程尝试获取许可证,如果 state >= 0 的话,则表示可以获取成功。如果获取成功的话,使用 CAS 操作去修改 state 的值 state=state-1。如果 state<0 的话,则表示许可证数量不足。此时会创建一个 Node 节点加入阻塞队列,挂起当前线程。
调用semaphore.release(); ,线程尝试释放许可证,并使用 CAS 操作去修改 state 的值 state=state+1。释放许可证成功之后,同时会唤醒同步队列中的一个线程。被唤醒的线程会重新尝试去修改 state 的值 state=state-1 ,如果 state>=0 则获取令牌成功,否则重新进入阻塞队列,挂起线程。
6.CountDownLatch
CountDownLatch 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。
CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。
CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。当线程使用 countDown() 方法时,其实使用了tryReleaseShared方法以 CAS 的操作来减少 state,直至 state 为 0 。当调用 await() 方法的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await() 方法就会一直阻塞,也就是说 await() 方法之后的语句不会被执行。直到count 个线程调用了countDown()使 state 值被减为 0,或者调用await()的线程被中断,该线程才会从阻塞中被唤醒,await() 方法之后的语句得到执行
比如多次处理六个没有顺序关系的文件,全都处理完之后,返回给用户
7.CyclicBarrier 有什么用?
它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。
CountDownLatch 的实现是基于 AQS 的,而 CyclicBarrier 是基于 ReentrantLock(ReentrantLock 也属于 AQS 同步器)和 Condition 的。
CyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。
比如我们去好几个任务完成之后,给他们综合起来
8.Java中的CAS
CAS(Compare-And-Swap)是一种无锁的原子操作机制,广泛应用于Java的并发编程中。它通过比较内存中的实际值与预期值,决定是否将该值更新为新值。CAS的操作依赖于三个参数:当前值、预期值和新值。如果当前值等于预期值,则更新为新值;否则不做任何操作。CAS的优势是能够避免传统锁带来的性能开销,但也存在ABA问题,可以通过引入版本号或时间戳来解决。典型应用包括AtomicInteger等原子类的实现。
底层是Unsafe的CAS操作,是调用的操作系统的
在unsafe类里面静态方法尝试去先获取表示 value 字段在 AtomicInteger 对象中的内存偏移地址。
然后调用compareAndSwapInt方法去执行调用底层的 cmpxchg 指令
9.ReentrantReadWriteLock
ReentrantReadWriteLock 适用于读多写少的高并发场景,特别是在需要频繁读取但较少修改的数据同步需求。它的读锁允许多个线程并行访问,而写锁则保证写操作的独占性和数据一致性。常见的应用场景包括缓存系统、配置管理和统计数据管理等。
线程池
1.线程池的处理任务的流程
如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。
如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,拒绝策略会调用RejectedExecutionHandler.rejectedExecution()方法。
答案是可以的!ThreadPoolExecutor 提供了两个方法帮助我们在提交任务之前,完成核心线程的创建,从而实现线程池预热的效果:
prestartCoreThread():启动一个线程,等待任务,如果已达到核心线程数,这个方法返回 false,否则返回 true;prestartAllCoreThreads():启动所有的核心线程,并返回启动成功的核心线程数。
2.设计一个能根据任务优先级执行的线程池
不同的线程池会选用不同的阻塞队列作为任务队列,比如FixedThreadPool 使用的是LinkedBlockingQueue(有界队列),默认构造器初始的队列长度为 Integer.MAX_VALUE ,由于队列永远不会被放满,因此FixedThreadPool最多只能创建核心线程数的线程。
PriorityBlockingQueue (优先级阻塞队列)作为任务队列(ThreadPoolExecutor 的构造函数有一个 workQueue 参数可以传入任务队列)。
要想让 PriorityBlockingQueue 实现对任务的排序,传入其中的任务必须是具备排序能力的,方式有两种:
- 提交到线程池的任务实现
Comparable接口,并重写compareTo方法来指定任务之间的优先级比较规则。 - 创建
PriorityBlockingQueue时传入一个Comparator对象来指定任务之间的排序规则(推荐)。
不过会遇见以下的问题
PriorityBlockingQueue 是无界的,可能堆积大量的请求,从而导致 OOM。
可能会导致饥饿问题,即低优先级的任务长时间得不到执行。
由于需要对队列中的元素进行排序操作以及保证线程安全(并发控制采用的是可重入锁 ReentrantLock),因此会降低性能。
对于 OOM 这个问题的解决比较简单粗暴,就是继承PriorityBlockingQueue 并重写一下 offer 方法(入队)的逻辑,当插入的元素数量超过指定值就返回 false 。
饥饿问题这个可以通过优化设计来解决(比较麻烦),比如等待时间过长的任务会被移除并重新添加到队列中,但是优先级会被提升。
Future
1.一个任务需要依赖另外两个任务执行完之后再执行,怎么设计
1 | // T1 |
通过 CompletableFuture 的 allOf() 这个静态方法来并行运行 T1 和 T2,当 T1 和 T2 都完成后,再执行 T3。
2.使用 CompletableFuture,有一个任务失败,如何处理异常?
使用 CompletableFuture的时候一定要以正确的方式进行异常处理,避免异常丢失或者出现不可控问题。
下面是一些建议:
- 使用
whenComplete方法可以在任务完成时触发回调函数,并正确地处理异常,而不是让异常被吞噬或丢失。 - 使用
exceptionally方法可以处理异常并重新抛出,以便异常能够传播到后续阶段,而不是让异常被忽略或终止。 - 使用
handle方法可以处理正常的返回结果和异常,并返回一个新的结果,而不是让异常影响正常的业务逻辑。 - 使用
CompletableFuture.allOf方法可以组合多个CompletableFuture,并统一处理所有任务的异常,而不是让异常处理过于冗长或重复
3.在使用 CompletableFuture 的时候为什么要自定义线程池?
CompletableFuture 默认使用全局共享的 ForkJoinPool.commonPool() 作为执行器,所有未指定执行器的异步任务都会使用该线程池。这意味着应用程序、多个库或框架(如 Spring、第三方库)若都依赖 CompletableFuture,默认情况下它们都会共享同一个线程池。
虽然 ForkJoinPool 效率很高,但当同时提交大量任务时,可能会导致资源竞争和线程饥饿,进而影响系统性能。
为避免这些问题,建议为 CompletableFuture 提供自定义线程池,带来以下优势:
- 隔离性:为不同任务分配独立的线程池,避免全局线程池资源争夺。
- 资源控制:根据任务特性调整线程池大小和队列类型,优化性能表现。
- 异常处理:通过自定义
ThreadFactory更好地处理线程中的异常情况。












