1. 从CPU调度视角看线程:它到底是快在哪,又贵在哪
很多人学线程,上来就背"线程是轻量级进程""线程之间共享堆内存"这类定义,结果一到生产环境就懵:为什么我开了200个线程,程序反而比20个线程还慢?为什么并发上去了,CPU使用率却不见涨?这些问题,光靠背概念是解决不了的。想真正进阶,得先从CPU的视角理解线程到底是怎么跑起来的。
1.1 线程切换的真实成本:不是"轻量"就不花钱
"轻量级进程"这个说法本身没有错,但它容易让人产生一个错误联想——既然轻量,那就便宜,所以可以随便开。"线程切换"这个动作,实际开销比你想象中要大得多。
线程切换时,操作系统内核要做的第一件事是保存当前线程的上下文。上下文包含什么?程序计数器(PC)、寄存器组、线程栈指针、浮点寄存器状态,在某些平台上还涉及SIMD寄存器、页表缓存状态等。这些数据量虽然只有几KB到几十KB,但关键在于:切换动作发生在内核态,这意味着每次切换都要经历一次用户态到内核态的模式切换,这个动作本身就是一次昂贵的系统调用。
开销更大的还在后面。CPU的乱序执行流水线、分支预测器、TLB(页表缓存)、L1/L2 Cache,这些硬件状态都是跟着线程走的。切换线程之后,新的线程要访问的指令和数据大概率不在高速缓存里,CPU缓存会被迫"冷启动",等缓存填满才算进入稳定状态。极端情况下,一次线程切换命中不到任何缓存,后续几百个周期都在等待内存数据。所以线程数一旦超过CPU物理核数太多,大量的时间不是在跑业务代码,而是在"换人"。
我见过一个典型的案例:某团队用Java写了一个爬虫采集服务,为了"提高并发",每来一个URL就new一个Thread,结果线上CPU使用率才30%左右,但接口平均RT(响应时间)却从80ms涨到了2秒以上。系统一直处于线程频繁切换的震荡状态,所有线程都在排队等CPU时间片,业务却几乎没有推进。后来把线程模型改成固定线程池,线程数收敛到CPU核数的两倍,RT直接降回了100ms以内。这就是"线程不是越多越好"最直观的证明。
顺带提一个概念:上下文切换本身是可以被观测的。Linux下用vmstat看cs(context switch)列,如果这个数值持续在几十万甚至上百万级别,说明你的线程调度已经吃满了系统资源;想进一步定位是哪些线程在折腾,可以用perf配合pidstat -w来做线程级别的上下文切换统计。这些工具在分析线程性能问题的时候比看代码管用得多。
1.2 核数、线程数与吞吐量的三角关系
要给一个系统定线程数,首先得搞清楚它的瓶颈类型。Java领域有一个流传很广的经验公式:CPU密集型任务,线程数设为CPU核心数 + 1;IO密集型的任务,线程数设为CPU核心数 * 2。这个公式方向是对的,但精度远远不够。
更严谨的做法是引入"等待占比"这个概念。如果一个任务的执行过程可以拆分为计算时间和等待时间(等IO、等网络、等锁),那么理论上最合适的线程数大约是:
线程数 ≈ CPU核心数 * (1 + 等待时间 / 计算时间)假设一个任务平均每执行10ms就需要等待90ms的IO结果,那么等待/计算 = 9,如果你有4核CPU,理论线程数就是4 * (1 + 9) = 40。这个公式的本质是:线程在等待的时候,CPU不应该闲着,而应该是另一个线程在跑计算——你要的是让CPU始终有活干,而不是让线程数量去堆积排队。
但公式永远只是起点。生产环境中还要考虑内存占用、GC压力、锁竞争、连接池上限这些实际约束。我的经验是:先用公式估算一个量级,再通过压测逐步上调,观察"吞吐量不再随线程数增长"的那个拐点,那个拐点附近再略微回调,就是当前环境下的最优值。线程池不是一次配好就永远不变的,它要跟着业务量、部署规格、依赖服务的响应速度动态调整。
1.3 创建线程的三种姿势,以及它们分别在什么场景下该被淘汰
谈到创建线程,Java里无非三种方式:继承Thread、实现Runnable、实现Callable配合FutureTask。很多博客把这三种方式列出来对比一下就算完了,但实际开发中,这三种方式能直接用的场景已经越来越少了,它们更像是"基本功",而不是"工具"。
- 继承Thread:本质是把线程要执行的逻辑和线程本身耦合在一起,Java是单继承,类一旦继承Thread就无法再继承其他类,而且启动一次之后无法复用。现在几乎找不到必须用它的理由,唯一的价值可能是教学时演示线程是怎么启动的。
- 实现Runnable:把任务逻辑和线程解耦开了,这是最大的进步。但它有一个明显的缺陷:run方法没有返回值,任务执行过程中发生的受检异常也没有办法直接抛出去,只能在方法内部自己处理。如果需要任务执行的结果,就得自己写共享变量再用wait/notify去等,非常别扭。
- Callable + FutureTask:解决了返回值的问题,FutureTask实现了Runnable接口,既能被Thread直接执行,也能放到线程池里。它内部维护了一个任务状态,调用get()时会阻塞等待任务完成,或者抛出ExecutionException把任务内部的异常包装着抛出来。
但是请记住:生产环境的代码里,这三种方式都不应该直接出现。直接new Thread意味着线程生命周期完全由你管理,线程无法复用,频繁创建销毁本身就消耗资源;而裸用FutureTask做异步,你也绕不开线程调度的问题。
真正该做的是把异步任务的执行能力下沉到线程池,代码里只关心"提交任务",至于任务由哪个线程执行、什么时候执行,那是线程池的事。这也是为什么Java并发包里的Executor框架才是实际开发中的主角。后面我会专门写线程池的选择和使用,这里先把这个认知建立起来:线程不是被"创建"出来用的,而是被"池化"出来复用的。
2. 线程安全问题的根源:可见性、原子性与有序性到底在防什么
线程进阶绕不开线程安全,而线程安全的所有问题都可以归结到三个底层概念:可见性、原子性、有序性。这六个字背后的原理,才是理解synchronized、volatile、Lock这一堆工具的核心。
2.1 从硬件架构说起:为什么主内存里的变量会"看不见"?
先做一个最基础的实验:定义一个boolean类型的running标志,一个线程启动后循环检查这个标志,另一个线程在一定延时后将标志置为false。很多刚入门的人会以为第二个线程一旦置位,第一个线程马上就退出循环——但真实运行结果往往令人诧异:第一个线程可能压根感知不到running被修改了,程序永远停不下来。
原因在于CPU和内存之间存在多层缓存。现代CPU的计算速度远快于内存的访问速度,为了抹平这个差距,芯片里塞了L1、L2、L3多级高速缓存。线程A和线程B即使运行在同一颗CPU的不同核心上,它们各自看到的数据也可能来自不同的缓存副本。线程A把running改成false,写到了自己的缓存行里,还没有刷新到主内存;线程B读running时,从自己的缓存里读到的仍然是旧值true。这就是"可见性"问题——一个线程对共享变量的修改,另一个线程未必能立即看到。
为了解决这个矛盾,硬件和操作系统层面提供了各种缓存一致性协议,比如MESI(Modified-Exclusive-Shared-Invalid)协议。但协议只能保证缓存一致性的最终收敛,不能保证"每次读取都拿到最新值"——后者还需要语言层面提供额外的同步机制。
2.2 volatile的意义与边界:它解决了可见性,但管不了原子性
volatile是Java里最轻量的同步手段,它的作用可以概括为三点:保证变量在不同线程之间的可见性;禁止指令重排序;但并不保证复合操作的原子性。
可见性这一点,volatile的做法是:写入volatile变量时,JVM会强制把当前线程工作内存中的值刷新到主内存;读取volatile变量时,JVM会让当前线程从主内存中重新读取。同时,volatile的写操作在硬件层面往往伴随着内存屏障,屏障之前的所有普通写操作也会一并刷新出去;读操作则会使该处理器缓存中对应的缓存行失效,必须重新从主内存加载。所以volatile不仅管自己,还顺带把操作前后的指令划了一道"栅栏"。
但原子性是另一回事。像count++这样的操作,在字节码层面是"读-改-写"三步,即使count被声明为volatile,两个线程同时执行count++,仍然可能各自读到的都是同一个旧值,然后各自加1写回去,最终只加了1而不是2。这就是经典的"非原子复合操作"问题。如果你要保证i++这类操作的原子性,必须用synchronized、Lock,或者更直接的java.util.concurrent.atomic包下的原子类(比如AtomicInteger),它们在内部通过CAS(Compare And Swap)来保证更新的原子性。
这里有个容易被忽视的陷阱:CAS也不是万能的。在高竞争场景下,CAS会频繁失败、频繁重试,白白消耗CPU;而且CAS只能保证"单个变量"的原子性。如果你要同时更新两个变量,CAS还需要借助AtomicReference把多个字段封装成一个对象来处理。
2.3 Java内存模型:JMM如何约束线程与主内存的关系
Java跨平台特性的背后,靠的是Java内存模型(JMM)的统一抽象。JMM规定所有变量都存储在主内存中,每个线程拥有自己的工作内存,工作内存保存了该线程用到的变量的主内存副本拷贝。线程对变量的所有读写操作都必须在工作内存中进行,不能直接读写主内存;不同线程之间也无法直接访问对方的工作内存,线程间变量值的传递只依赖于主内存。
JMM定义了一套Happens-Before规则,用来判断两个操作之间是否具备"先行发生"的关系。如果操作A先于操作B发生(Happens-Before),那么A的结果对B是可见的。常见的几条规则包括:
- 程序次序规则:在一个线程内,书写在前面的代码先行发生于书写在后面的代码;
- 锁规则:对一个锁的解锁,先行发生于后面对这个锁的加锁;
- volatile规则:对一个volatile变量的写操作,先行发生于后面对这个变量的读操作;
- 传递性:如果A先行发生于B,B先行发生于C,那么A也先行发生于C。
理解Happens-Before的意义在于:它能帮你判断"我的代码能不能安全地不加锁"。如果你能证明两条规则之间存在Happens-Before关系,那共享数据就没有安全问题;如果证明不了,哪怕从直觉上觉得"应该没问题",程序也极可能在某个CPU架构上翻车。这个判断方式是并发编程中最底层也最实用的思维工具。
3. synchronized和Lock怎么选:锁升级、AQS与真实场景的取舍
Java里最常用的两个互斥方案就是synchronized关键字和java.util.concurrent.locks包下的Lock接口。它们的底层原理不同,适用的场景也不同。很多人只知道"synchronized是悲观锁、Lock功能更丰富",但真正做技术选型时,还需要看锁升级机制和AQS的设计。
3.1 从无锁到重量级锁:synchronized的升级路径
JDK 1.6之前,synchronized是用操作系统提供的互斥量(Mutex)实现的,任何一次加锁都需要从用户态切换到内核态,性能非常差。后来在JDK 1.6引入了锁升级机制,把锁的代价尽量留在用户态。整个升级路径是:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
偏向锁的逻辑是:如果一个锁被同一个线程多次获取,那就没有竞争的痕迹,JVM可以在对象头里记录这个线程的ID,后续这个线程再次进入同步块时,只要比对ID一致,就直接放行,不做真正的加锁操作。轻量级锁则是应对"少量线程交替获取锁"的场景:线程在栈帧中创建锁记录(Lock Record),通过CAS尝试把对象头里的Mark Word替换为指向锁记录的指针。CAS成功就持有锁,失败则说明存在竞争,锁会膨胀为重量级锁。重量级锁就是传统的内核态互斥量,线程获取不到锁时会被挂起,进入等待队列。
到了实际调优的层面,有一点很重要:偏向锁在高竞争场景下反而是一种负担,因为撤销偏向锁的过程需要等待全局安全点(SafePoint),偶尔会造成STW的停顿。所以JDK 15之后偏向锁被默认禁用,JDK后续版本已经在逐步废弃它。写业务代码时你不需要刻意去配置偏向锁,但要理解:锁竞争越激烈,synchronized的成本会越高,因为它做了很多"尝试",各种尝试失败之后才最终落到内核态挂起。
3.2 对ReentrantLock的追问:它比synchronized强在哪里
Lock的实现里,最常用的就是ReentrantLock。它的底层基于AQS(AbstractQueuedSynchronizer),这是整个java.util.concurrent包的灵魂。
ReentrantLock能做的事情,在synchronized时代要么做不到,要么做得很笨重:
- 可中断地获取锁:
lock.lockInterruptibly(),线程在等待锁的过程中可以响应中断,避免"卡死"在锁上无法退出; - 支持超时:
tryLock(long timeout, TimeUnit unit),等待锁的时间可控,超时后直接放弃; - 公平锁:synchronized是非公平的(抢占式),ReentrantLock默认非公平,但构造函数传true可以实现公平排队;
- 多个条件队列:synchronized的wait/notify只有一个等待队列,ReentrantLock的Condition可以new多个,让不同状态的等待线程分池等待。
不过,这些功能都是有代价的。ReentrantLock的加锁和解锁必须显式调用,而且必须在finally块里unlock,稍不注意就会留下锁没释放的Bug。相比之下,synchronized的锁由JVM自动释放,代码简洁很多,也天然支持异常路径下的锁释放。
所以我的选择标准很简单:如果只是简单互斥,没有超时和中断需求,优先synchronized;如果需要在等待锁时支持超时、公平策略、多条件队列,或者性能压测发现synchronized已经成为瓶颈,再考虑换ReentrantLock。不要为了炫技去用Lock,代码的可维护性永远高于"用了高级API"的满足感。
3.3 读写锁和AQS框架:为什么读多写少要单独处理
除了ReentrantLock,Lock家族里还有ReadWriteLock。它把锁分成了读锁和写锁:读锁是共享的,多个线程可以同时持有;写锁是排他的,只有一把。这个设计的理论基础是:多个线程同时读同一个变量不存在数据竞争,而读写并发时才是危险的。因此读多写少的场景用读写锁,可以把并发度拉满;相反,如果写操作占比很高,读写锁的额外判断反而引入更多开销,性能可能还不如普通互斥锁。
AQS定义了这套"共享/排他"机制的骨架。AQS内部维护了一个volatile int类型的state字段,以及一个CLH队列的变体。state的含义由具体的锁实现去定义:在ReentrantLock里state表示重入次数;在ReentrantReadWriteLock里,state的高16位表示读锁持有数量,低16位表示写锁重入次数;在Semaphore和CountDownLatch里,state又分别表示信号量许可数和待倒计数。
理解AQS有个很好用的类比:把state当成一个"公共车位剩余数量",获取锁就是申请车位,成功就把数字减一(CAS操作);释放锁就是把数字加一。车位满了就排队,等前面的线程释放后再唤醒队列里的线程。AQS内部用LockSupport的park/unpark来挂起和唤醒线程,而不是让线程在等待时疯狂自旋——这也是它比纯自旋锁高效的核心原因。
对面试来说,AQS是必考的重点;对实际开发来说,知道AQS的存在能让你在阅读并发工具源码时轻松很多。不需要把每一行源码都背下来,但至少要知道:你用的CountDownLatch、Semaphore、ThreadPoolExecutor的Worker线程,底层都是AQS在调度。
4. 线程池参数不是拍脑袋:核心线程数、队列与拒绝策略的权衡
线程池是Java并发编程中最常用的工具,也是最容易被用错的工具。很多人会写newFixedThreadPool(10),但问到核心线程数怎么定、队列用有界还是无界、拒绝策略选哪个,就答不上来了。线程池的每一个参数背后都是资源与吞吐量的权衡,没有一套"一劳永逸"的配置。
4.1 参数之间的联动关系:不只是读一遍定义
ThreadPoolExecutor一共有七个参数:corePoolSize(核心线程数)、maximumPoolSize(最大线程数)、keepAliveTime(空闲线程存活时间)、unit(时间单位)、workQueue(任务队列)、threadFactory(线程工厂)、handler(拒绝策略)。
要理解它们的关系,关键是记住线程池的扩容顺序:先让核心线程处理任务;核心线程忙不过来时,新任务进入任务队列等待;队列满之后,才创建非核心线程(总数不超过maximumPoolSize);如果线程数已经达到最大值且队列也满了,触发拒绝策略。
这个顺序决定了两个非常重要的推论。第一,队列的容量决定了线程池的"缓冲能力":队列越大,任务越不容易被拒绝,但任务的排队时间会变长,响应延迟会变大。第二,非核心线程的触发条件是"队列满"而不是"核心线程忙":如果你把队列设成Integer.MAX_VALUE(new LinkedBlockingQueue()默认就是),那么最大线程数这个参数等于没有意义,因为队列永远装不满,非核心线程永远不会被创建。
这也是为什么很多公司内部禁用Executors.newFixedThreadPool和newSingleThreadExecutor的原因:它们底层使用的都是无界队列,一旦任务积压,线程数不会扩展,队列却无限膨胀,最终可能出现内存溢出的风险。
4.2 拒绝策略和线程工厂:最容易被忽略却最能保命的两个参数
拒绝策略,看名字就知道它是在"线程池已经承受不住"的时候触发的。JDK自带了四种实现:
- AbortPolicy:直接抛出RejectedExecutionException,这是默认策略;
- CallerRunsPolicy:不抛异常,而是让提交任务的线程自己执行该任务,起到天然限流的作用;
- DiscardPolicy:静默丢弃任务;
- DiscardOldestPolicy:丢弃队列里最旧的任务,再尝试提交当前任务。
选择哪种策略,取决于你的业务形态。如果是核心链路,建议AbortPolicy配合统一的异常处理,宁可快速失败也不让任务莫名消失;如果是削峰填谷的异步处理场景,CallerRunsPolicy能把多余的压力反向传导给上游,让系统缓慢而稳定地消化积压。Discard系列策略在多数业务场景下风险太大,任务被丢了你都不知道,除非你明确知道丢数据没关系。
线程工厂同样值得自定义。默认的DefaultThreadFactory创建的线程名称是"pool-1-thread-1"这种,出了问题在日志里很难追溯到是哪个业务线的线程池。我给线上线程池定义线程工厂时,通常会写入业务标识和线程组名称,比如"order-async-worker-1"。这样当CPU飚高、线程数异常时,用jstack一抓就能立刻看出是哪个模块的线程池在作妖。
4.3 一次线上事故复盘:无界队列造成的OOM是怎么发生的
说一个我曾经遇到的真实事故,你可以对照检查自己的线上代码。某系统的推送服务,核心流程是把用户行为事件写入Kafka,为了方便批量发送,团队用Executors.newFixedThreadPool(5)创建了一个线程池,任务丢进去就算完事,从不关心队列状态。上线初期流量小,一切正常。
后来业务做了一次大促活动,瞬时流量涨了几十倍。由于写入Kafka的IO不稳定,任务处理速度跟不上提交速度,newFixedThreadPool的无界队列开始积压任务。每个任务里都带着完整的事件对象,内存占用持续增长,最后堆内存被无界队列撑爆,发生OOM,整个服务宕机,而且因为OOM导致进程退出前没来得及做优雅停机,一部分积压任务直接丢失。
复盘的时候,根因不在于"线程池没配好",而在于把有界还是无界的选择权交给了默认实现。后来修正时,我们把线程池显式改为:核心线程8、最大线程16、队列容量2000、拒绝策略用CallerRunsPolicy、线程工厂带了业务标识。大促期间即使任务偶尔积压到队列上限,多余的请求也会在提交任务的线程里被慢速消化,系统虽然变慢了,但不会崩,数据也不会丢。
这套配置不是最优解,但它是在"吞吐优先"和"稳定性优先"之间做出的明确取舍。理解了线程池参数的联动逻辑,你也能为自己的业务场景做出同样的取舍。
4.4 动态调优:不重启服务也能调整线程池参数
线程池参数不是运行期间就固定死了的。ThreadPoolExecutor自带setCorePoolSize、setMaximumPoolSize和setKeepAliveTime方法,可以在运行时动态调整。这在应对"平时流量低、大促流量高"这类场景时非常关键。
我习惯把线程池的参数暴露成配置中心的动态配置项,例如核心线程数、最大线程数、队列容量都支持动态修改。调整时有一个顺序原则:如果要调大吞吐,先调大maximumPoolSize再调大corePoolSize;如果要缩小资源占用,先调小corePoolSize再调小maximumPoolSize。另外特别注意,动态改队列容量并不可靠,LinkedBlockingQueue的capacity是final的,没法运行时更改,如果要动态调整队列长度,只能替换整个工作队列或者换用支持动态容量的队列实现,比如自定义一个基于数组的阻塞队列。
如果不想自己实现,也可以借助第三方微内核框架的dynamic threadpool方案,目前已经有相对成熟的开源实现,社区里叫"动态线程池"的项目不少,核心原理也仍然是运行时调整corePoolSize和maximumPoolSize,只是在监控制和告警上做得更完善。先用好ThreadPoolExecutor自带的动态能力,再看是否需要引入额外组件,这个顺序比较稳。
5. ThreadLocal的便利与代价:内存泄漏和跨线程传递的那些坑
ThreadLocal在Java面试中几乎必问,使用频率也相当高。它最典型的应用是保存当前线程的上下文信息,比如用户会话、请求链路ID、数据库连接、事务状态等。它的核心机制很好理解:每个Thread内部维护了一个ThreadLocalMap,以ThreadLocal对象为key,以你存入的值为value。这样每个线程读到的都是自己那份数据,天然线程隔离。
5.1 为什么ThreadLocal会内存泄漏:弱引用不是免死金牌
ThreadLocal的内存泄漏问题几乎人人都听过,但不一定人人都能讲清楚。ThreadLocalMap的key是ThreadLocal对象的弱引用(WeakReference),value是强引用。当外部不再持有某个ThreadLocal实例的强引用时,这个ThreadLocal可以被GC回收,但此时ThreadLocalMap里的value还在,并且被当前线程的ThreadLocalMap强引用着。如果线程存活时间很长,而value对象又比较大,这一堆"key为null但value还活着"的条目就会一直驻留在内存里。
更麻烦的是,线程池中的线程通常不会被销毁,它们是复用的。一个线程执行完任务后回到池子里,它的ThreadLocalMap里仍然存着上次任务设置的key为null的条目。下次任务如果没有调用remove,积少成多,最终就会导致内存泄漏。
有一个经常被引用的观点是:ThreadLocalMap在get和set的时候会清理key为null的条目,所以泄漏没那么严重。这话没错,但它依赖一个前提——你后续还会访问这个ThreadLocal。如果一条任务设置完就再也不动这个ThreadLocal,那清理逻辑永远不会触发,泄漏就实实在在存在。
因此,规矩一定要立住:每次使用完ThreadLocal之后必须显式调用remove()。尤其在使用线程池的场景下,把remove放在finally块里是最稳妥的做法。不要依赖"反正线程要销毁"这种侥幸心理,因为线程池里的线程根本不会按你设想的时间销毁。
5.2 线程池中的ThreadLocal串数据问题:比泄漏更隐蔽
线程池加ThreadLocal还有一个更隐蔽的坑——数据串扰。假设一个线程池有10个线程,第一个任务在ThreadLocal里存了用户A的信息,第二个任务提交到同一个线程时,如果没有先清理ThreadLocal,那么第二个任务读到的可能是用户A的残留数据。
这个问题的恐怖之处在于,它不是必然会发生的,它取决于第二个任务恰好被分配到了哪个线程。当线程池有空闲线程时,任务会被分配给空闲线程执行,如果会话上下文的设置逻辑写在任务提交方而非任务执行方,就很容易出现"偶发性数据错乱"。
正确的做法是:在线程池执行任务的入口处统一清理ThreadLocal,或者干脆在任务对象内部显式设置和清理。具体来说,可以让工作线程在处理任务开始时先调用ThreadLocal的set方法初始化本次任务的上下文,在finally里调用remove清理。把这两个动作固化到任务执行模板里,而不是散落在业务代码里,可以最大程度避免串数据问题。
5.3 跨线程传递的解决方案:InheritableThreadLocal和TransmittableThreadLocal
ThreadLocal本身不能跨线程传递,这在异步化改造时特别麻烦。你希望在主线程初始化一个traceId,提交到线程池后子线程能继续带着这个traceId打日志,但原生ThreadLocal做不到。
Java提供了InheritableThreadLocal来解决"父线程创建子线程时传递值"的问题。它会在new Thread时把父线程的InheritableThreadLocal值复制一份给子线程。但线程池场景下它没法用:线程池里的线程不是每次任务都新建的,而是复用的,InheritableThreadLocal只在线程创建那一刻拷贝一次,后续父线程再改值,子线程不会跟着更新。第二个坑是,线程池的核心线程在执行完任务后会一直存活,它的ThreadLocal里的旧值也不会自动消失。
目前业界用得比较多的方案是阿里开源的TransmittableThreadLocal(TTL)。它的核心思路是:在任务提交时,捕获当前线程的所有TTL值快照;在任务真正执行前,把这些快照重新设置到执行线程上;任务执行结束后,恢复执行线程原有的TTL值。这样就可以在复用线程池的情况下,实现上下文的精准传递和还原。
如果你的系统做全链路追踪(traceId传递),或者在做异步化改造时需要传递用户鉴权信息,TTL基本是标准答案。但也要注意,引入TTL意味着每一个异步提交点都要被包装一层,对性能和代码侵入都有一定影响。是否要全量引入,还是只针对关键链路引入,需要根据实际情况权衡。我的习惯是:如果项目里只有一两个异步入口,手动传递参数就够了;如果异步化很普遍,再引入TTL统一治理。
6. 异步化的演进方向:从Future到CompletableFuture再到虚拟线程
线程的终极价值之一就是异步化,但异步化的实现方式一直在演进。从最原始的Future,到CompletableFuture的声明式编排,再到JDK 21正式推出的虚拟线程,Java的异步编程模型正在经历一轮又一轮的革新。了解这条演进路线,能帮你判断当前项目的异步代码该用哪种模型,也避免踩到古老API的坑。
6.1 Future的局限性:阻塞等待和无法编排
Future接口作为Java 5时代异步化的代表,最大的问题就是获取结果时必须调用get(),而get()是一个阻塞方法。这等于把"异步"活生生打回了"同步":提交任务后,如果你需要基于任务结果继续做下一步,就不得不阻塞等待。
更要命的是,Future无法编排多个异步任务之间的依赖关系。假设你有三个任务,任务B必须在任务A完成之后执行,任务C又依赖A和B的结果,用原生Future来实现会变成层层嵌套的阻塞等待,代码又丑又难维护。这个阶段,很多团队会引入回调机制,但回调嵌套多了又会出现"回调地狱",代码可读性直线下降。
对于这种场景,我的经验是:如果只是单个异步任务的"提交->等待结果",Future完全够用;一旦出现两个以上任务的组合编排,就应该考虑CompletableFuture或者更成熟的异步框架。
6.2 CompletableFuture的编排能力:thenApply和thenCompose该怎么用
CompletableFuture在Java 8引入,它真正的优势在于提供了丰富的编排方法,让你用"声明式"的方式描述任务之间的依赖关系,而不是手动阻塞等待。
实际使用中最关键的区分是thenApply和thenCompose这对容易混淆的方法。thenApply的作用是对结果做同步转换:前一个CompletableFuture完成后,回调函数直接在当前线程执行,返回一个新的CompletableFuture。thenCompose的作用是把两个异步任务串联起来:回调函数返回的必须是一个CompletableFuture,新生成的CompletableFuture会等待这个内部Future完成后才会完成,相当于"异步任务之间进行链式组合"。
用一个简单的类比:thenApply是在拿到了快递之后当场拆开看看,操作是立即的,不产生新的快递;thenCompose是在拿到快递后把它转交给另一辆配送车继续派送,操作本身又启动了一个新的配送流程。
还有一个常见问题:默认情况下,CompletableFuture的异步编排使用的是ForkJoinPool.commonPool,它的线程数等于CPU核数减一。如果你的任务里有阻塞IO,commonPool的线程会被占满,其他所有依赖CompletableFuture的异步任务都会遭殃。所以涉及IO操作时,一定要用带Executor参数的重载方法,把任务提交到你的业务线程池里,而不是让它默默使用commonPool。
6.3 虚拟线程:当线程不再昂贵,原来的编程模型是否该被抛弃
JDK 21的虚拟线程(Virtual Thread)是Java并发模型近年来最大的变化。它的核心思路是把线程调度从操作系统挪到了JVM内部:虚拟线程是运行在操作系统线程(称为平台线程,Platform Thread)之上的,一个平台线程可以承载成千上万个虚拟线程。虚拟线程的创建和切换成本极其低廉,因为切换不涉及内核态,几乎就是一次普通的栈切换。
这意味着很多之前需要用异步框架才能解决的问题,现在可以用最简单的方式写:一个请求进来,同步调用下游服务,需要等待IO的时候,虚拟线程会被调走,平台线程继续执行其他虚拟线程。没有人去做复杂的异步编排,代码看起来就像同步代码,但并发能力却可以支撑起很大的吞吐量。
但虚拟线程也不是银弹。它在以下场景中需要特别小心:synchronized块中使用虚拟线程时,如果发生阻塞,JVM会将该虚拟线程的载体线程也一并阻塞,这被称作"固定"(pinning)现象,可能会削弱虚拟线程的优势;虚拟线程的创建虽然便宜,但也不能无限制地创建,每个虚拟线程依然有栈内存;此外,线程局部变量在虚拟线程场景下的内存开销会被放大,因为虚拟线程数量通常远大于平台线程数量。
我的建议是:新项目可以直接采用虚拟线程作为默认的并发模型,尤其在IO密集型应用中收益明显;但老项目不要着急全面改造,可以先在一两个服务链路中做试点,把pinning问题和线程局部变量的使用方式踩清楚,再逐步铺开。
6.4 异步模型的选型思路:不是越新越好,而是合适才好
综合来看,现在的Java异步选型其实有三个清晰的层次。
如果只是简单并行执行几个独立任务,Future配合线程池完全够了,不要复杂化。如果任务是IO密集型的,且编排关系复杂,CompletableFuture是相对成熟、生态完备的选择,但要为它单独配置线程池并做好异常兜底。如果运行环境是JDK 21+,且应用本身是典型的IO密集型服务,虚拟线程带来的代码简洁性和并发能力非常有吸引力,值得重点试验。
另外还有一点容易被忽略:异步化不是免费的。无论用哪种模型,都要考虑线程池的背景线程生命周期、任务取消的语义、异常如何在异步链路上传递和观测、容器关闭时如何优雅停机。这些才是异步编程真正让人头疼的地方,也是从"会用API"走向"能hold住生产环境"的分水岭。我的经验是,先把一个链路的异步化做到完备——异常能打日志、任务能取消、流量能控制——再去扩展下一个链路,否则异步代码会变成新的故障源。
回到最开头的问题:JAVA进阶之线程,进阶的不是API数量,而是对底层机制的掌握和对技术选型的判断力。线程池、锁、ThreadLocal、异步模型,这些都是"术",而理解它们背后的CPU调度、内存模型、资源权衡,才是真正的"道"。这套认知建立起来了,后面无论是排查线上问题还是设计新的并发模块,你都会有方向感。