先说说我为什么要写这篇东西。做Java这么些年,从当年校招面试被问“你讲讲synchronized和ReentrantLock的区别”开始,到后来作为面试官去问别人同样的问题,再到在线上环境被并发问题折磨得焦头烂额,我越来越觉得:并发编程这块内容,是Java知识体系里少有的、既能考倒新手又能难住老手的领域。很多八股文背得滚瓜烂熟的人,写出来的并发代码照样出事故;反过来,真正理解了并发底层逻辑的人,哪怕不背八股也能把思路讲得明明白白。这篇文章我不想写成一个面面俱到的Java并发教程,而是想从一个过来人的角度,把并发编程里那些面试必问、实战必踩的点和大家一起捋一遍。如果你是准备面试的候选人,这篇文章能帮你把零散的知识点串成体系;如果你已经在写业务代码,希望它能帮你在排查线上并发问题的时候多几个排查方向。
1. 并发编程到底在解决什么问题
先说一个最基本的判断:并发编程不是Java独有的概念,但Java在语言层面、JVM层面和类库层面都提供了极其丰富的并发支持,这也是Java能长期统治服务端领域的重要原因之一。很多人在学习并发的时候一上来就背各种锁、各种同步工具,结果越学越乱,根本原因是没有先想清楚一个问题——并发编程到底在解决什么?
我们可以把并发问题归结为三个核心特性:原子性、可见性、有序性。这六个字基本可以贯穿所有并发知识点。
原子性说的是一个操作或者多个操作要么全部执行且在执行过程中不被任何因素打断,要么全都不执行。经典的i++问题就是原子性被破坏的例子,i++看起来是一个操作,但在字节码层面其实是先读取、再修改、再写回三个步骤,两个线程同时执行i++的时候,就可能出现两个线程都读到同一个旧值、然后各自加一写回的情况,结果i只增加了一次。这就是典型的“丢失更新”。
可见性指的是当一个线程修改了共享变量的值,其他线程是否能立即看到这个修改。在CPU多核架构下,每个核心有自己的一级二级缓存,一个线程修改了变量可能只是写在了自己的工作缓存里,没有刷回主内存,另一个线程读到的还是旧值,这就是可见性问题。
有序性则是指编译器和CPU为了优化指令执行效率,可能会对指令进行重排序。单线程下重排序不会影响执行结果,但多线程下重排序可能导致一些看似不可思议的问题,比如经典的“指令重排序导致双重检查锁失效”问题。
明白了这三个特性,再回头看并发编程的各种工具,思路就会清晰很多:synchronized和Lock主要解决原子性和可见性,volatile主要解决可见性和有序性,CAS通过硬件指令保证原子性,ThreadLocal干脆就不共享变量,从根本上避开并发问题。
1.1 线程的生命周期没有你想的那么简单
线程是并发编程的载体,但很多人对线程的状态流转其实是一知半解的。Java的Thread.State枚举定义了六种状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。这里有个经常被误解的点:Java里的RUNNABLE实际上是包含了操作系统线程状态中的“运行中”和“就绪”两种状态的,也就是说一个线程只要没在等待锁、没在主动等待,就处于RUNNABLE状态,它可能正在被CPU执行,也可能在等待CPU调度。
从实战角度来说,我见过很多人在定位线上问题的时候,光看线程栈就慌了,因为几乎全是RUNNABLE。其实RUNNABLE不等于“线程在干活”,它只是表示线程没有在等待锁和等待唤醒,有可能是在疯狂执行代码,也有可能是因为IO而阻塞(在Java里IO阻塞仍然算RUNNABLE)。真正需要警惕的是三种状态:BLOCKED、WAITING和TIMED_WAITING。大量线程阻塞在BLOCKED状态通常意味着锁竞争严重,大量线程处于WAITING状态则要检查是不是线程被永久挂起了。
另外有个细节值得提一下,线程在调用start()方法之后才进入RUNNABLE,调用stop()虽然已经被标记为废弃方法但仍然可以强制终止线程,不过它会释放掉该线程持有的所有锁,导致数据不一致,所以坚决不要用。正确的中断方式是配合interrupt标志位做协作式中断。
1.2 JMM:并发编程的“内存模型地基”
JMM(Java内存模型)是理解并发问题的地基,它的核心规则可以用一句话概括:所有变量都存在主内存中,每个线程有自己独立的工作内存,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存,线程之间无法直接访问对方的工作内存,只能通过主内存来传递变量值。
这个模型的本质是为了屏蔽不同硬件和操作系统的内存访问差异,让Java程序在各种平台上都能有一致的并发表现。JMM还定义了一套happens-before规则,这是判断数据是否存在竞争、线程是否安全的关键依据。程序顺序规则、监视器锁规则、volatile变量规则、传递性规则、线程启动规则、线程终止规则、线程中断规则、对象终结规则,这八条规则是面试中很容易被追问的细节。
我举个实际例子,很多人以为在方法里赋值了一个共享变量、然后另一个线程去读,就能立刻读到新值,其实不一定。如果没有happens-before关系的保证,编译器完全可能将赋值操作重排序到其他操作之后,导致读线程执行时看到的还是旧值。所以写并发代码的时候,不要靠“运气”去猜执行顺序,而是要按照happens-before规则去推导,凡是跨线程的共享变量访问,必须找到一条happens-before链路来确保可见性。
2. 从volatile到synchronized:同步原语里的门道
接下来展开讲并发编程最核心的几块内容。这块内容在面试里几乎必问,而且面试官特别喜欢从一个点深挖下去,比如从volatile问到JMM,再从JMM问到synchronized的锁升级,一路问到你答不上来为止。
2.1 volatile到底保证了什么,不保证什么
volatile可能是被误解最多的关键字。很多人只知道“volatile能保证可见性”,但说不清楚它为什么能保证可见性,更说不清楚它的局限性。
volatile保证两件事:一是可见性,对一个volatile变量的写操作,会强制把当前线程工作内存中的值刷回主内存,同时让其他线程中该变量的缓存失效;二是有序性,JMM会通过在volatile变量读写操作前后插入内存屏障来禁止指令重排序。
但volatile不保证原子性。最经典的例子是volatile修饰的int变量做i++,依然是线程不安全的。因为i++是读改写三步操作,volatile只能保证读的时候看到最新值、写的时候刷新到主内存,但无法阻止两个线程同时读到同一个值然后各自加一再写回。
那什么时候该用volatile?我举几个实际场景。比如一个boolean类型的开关变量,由一个线程设置,其他线程读取,用来控制循环退出,这种情况volatile就够用了。再比如单例模式中的双重检查锁,单例实例用volatile修饰,是为了防止指令重排序导致其他线程拿到未初始化完成的对象。还有状态标志位、状态计数器(配合原子类使用)等场景都很适合。
在实战中用volatile有个很容易忽略的坑:如果你在循环里频繁读volatile变量,性能损耗比读普通变量要大,因为volatile的读操作本质上是在告诉CPU“你别用缓存了,去主内存取”。但如果这个变量本来就经常需要在多线程间同步,这种损耗是值得的。
2.2 synchronized的锁升级机制
早期Java的synchronized因为性能问题被诟病了很久,直到JDK 6引入了锁升级机制,性能才有了质的飞跃。所谓锁升级,就是说synchronized并不是一开始就是重量级锁,而是按照“偏向锁 -> 轻量级锁 -> 重量级锁”的路径逐步升级的。
偏向锁的核心思想是:如果一个线程获取了锁,那么在接下来的执行过程中,只要没有其他线程竞争,持有偏向锁的线程就无需再进行任何同步操作。这是基于一个统计事实:大多数锁在同一个线程内被反复获取。偏向锁在JDK 15开始默认被禁用,主要是因为如今的应用大量使用线程池,锁竞争频率和数据结构的复杂度都发生了变化,偏向锁的收益已经不明显甚至带来了额外的维护成本。
轻量级锁适用于“线程交替执行同步块”的场景,线程通过CAS去尝试获取锁,如果获取失败说明有竞争,就会膨胀为重量级锁。重量级锁依赖于操作系统的互斥量(Mutex)实现,涉及到用户态和内核态的切换,开销最大。
关于锁升级有个经典的误区:很多人以为“偏向锁可以撤销并重新偏向”,这在旧版本JVM中确实是这样的,但随着JVM版本迭代,偏向锁的撤销逻辑和性能损耗也在变化,不要过于依赖偏向锁的特性来优化代码,大多数情况下让JVM默认策略去处理就好。
在实际编码中,synchronized的使用有几点经验值得分享。第一,尽量缩小同步块的范围,不要把无关代码放在锁内执行;第二,尽量降低锁粒度,能锁方法就不要锁整个对象,能锁代码块就不要锁整个方法;第三,避免在同步块中调用耗时操作(如IO、网络请求),否则会极大降低并发吞吐。
2.3 原子类与CAS:无锁编程的精髓
CAS(Compare And Swap,比较并交换)是Java并发包里原子类的核心实现机制。它的操作逻辑是:内存地址V的当前值如果是期望值A,就把它更新成新值B,否则不做任何操作,整个过程是原子的,由CPU指令直接保证。
Java中java.util.concurrent.atomic包下的AtomicInteger、AtomicLong、AtomicReference等类都是基于CAS实现的。CAS最大的优势是避免了线程上下文切换和锁带来的开销,在竞争不激烈的时候性能远优于锁。但它也有三个经典问题:ABA问题、自旋开销大、只能保证单个共享变量的原子操作。
ABA问题指的是:一个线程把值从A改成B又改回A,另一个线程执行CAS时发现值还是A,就认为它没有被修改过,但事实上是修改过的。解决方法是使用带有版本号的AtomicStampedReference,每次修改时同时更新版本号。这个在面试里经常被问到,尤其在描述“CAS存在什么缺点”的时候要能答上来。
自旋开销大则是指当多个线程同时竞争一个变量时,CAS失败后如果没有立即放弃,而是不断重试(自旋),在高并发下会导致CPU占用飙升。所以JDK 8之后引入了LongAdder,它通过分段思想将竞争分散到多个Cell上,最后汇总,在高并发计数场景下性能比AtomicLong高出不少。
3. AQS:Java并发类的“总开关”
如果只看Java并发包里的一个类,那我一定会选AbstractQueuedSynchronizer,也就是AQS。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock等核心同步工具,底层都用到了AQS。理解了AQS,等于拿到了打开并发工具类大门的钥匙。
3.1 AQS的原理:state + CLH队列
AQS的设计思路概括起来就是两个核心元素:一个volatile修饰的int类型state变量,一个基于双向链表实现的CLH等待队列(FIFO)。state的含义由具体的子类来定义,比如ReentrantLock中state表示锁的重入次数,Semaphore中state表示剩余的许可数量,CountDownLatch中state表示计数的剩余值。
线程获取资源时,会通过CAS去尝试修改state;修改成功说明获取到资源,可以继续执行;修改失败则把当前线程封装成一个Node节点,加入到CLH队列尾部,并通过LockSupport.park()挂起自己。当持有资源的线程释放资源时,会修改state并唤醒队列中的第一个等待线程。
这里有个很关键的细节:AQS使用CLH队列来管理等待线程,而不是直接让线程阻塞,这种方式可以减少线程上下文切换的开销。而且AQS提供了独占模式和共享模式两种获取资源的方式,支持可中断、超时获取等扩展能力,所以它的扩展性极强。
3.2 ReentrantLock和synchronized的对比
说完了AQS的原理,肯定要聊聊ReentrantLock。面试中“synchronized和ReentrantLock的区别”是一道高频题,光列点可以列出七八条,但要真正答好,你需要把它们放在一个体系里去理解。
首先,两者都是可重入锁,也就是说同一个线程可以多次获取同一把锁而不会死锁。但ReentrantLock是基于AQS实现的,synchronized是基于JVM内部监视器实现的。从使用灵活性上看,ReentrantLock支持公平锁和非公平锁的切换,而synchronized只有非公平锁;ReentrantLock支持尝试非阻塞获取锁(tryLock)、支持超时获取锁、支持中断响应,而synchronized在获取锁失败时会一直阻塞,无法响应中断。
从JDK版本演进的角度看,早期的synchronized性能远不如ReentrantLock,但随着JVM对synchronized的不断优化(尤其是锁升级机制),两者在性能上的差距已经微乎其微。所以在实际项目中,我个人的选择习惯是:除非需要使用ReentrantLock特有的功能(如可中断、超时、公平锁),否则优先使用synchronized,因为它的代码更简洁,也不容易犯忘记释放锁的错误。ReentrantLock必须手动释放锁,而且必须在finally块中释放,否则一旦抛出异常,锁就永远不会被释放。
关于ReentrantLock的公平锁和非公平锁,还有个容易被忽视的细节:非公平锁的性能通常优于公平锁,因为非公平锁允许线程在获取锁时“插队”,减少了线程挂起和唤醒的开销。但非公平锁可能导致某些线程长期得不到锁而“饿死”,所以对公平性有严格要求的场景(如超时任务调度)才建议使用公平锁。
3.3 读写锁和StampedLock的取舍
ReentrantReadWriteLock是一种读写分离的锁,它维护了读锁和写锁两把锁:多个线程可以同时持有读锁,但写锁是排他的,而且写锁持有期间其他线程既不能读也不能写。这种设计非常适用于读多写少的场景,比如缓存系统。
使用ReentrantReadWriteLock时有个经典陷阱:锁降级。也就是说,持有写锁的线程可以再获取读锁,然后释放写锁,此时该线程持有的锁就从写锁降级为了读锁,不会出现数据不一致的问题。但反过来,持有读锁去获取写锁是行不通的,会导致死锁。
不过ReentrantReadWriteLock在极端高并发下可能产生“写锁饥饿”的问题,因为读锁可以持续被获取,写锁一直等不到机会。JDK 8引入的StampedLock提供了一种乐观读的模式(tryOptimisticRead),它不实际加锁,只是记录一个版本号,读操作结束后再验证版本号是否变化,如果没变说明读操作期间没有写操作,数据是安全的。这种乐观读在读多写少的场景下吞吐量要明显优于ReentrantReadWriteLock。
但StampedLock有几个限制要提前了解:它不支持重入,且不支持条件变量,所以在使用时要格外小心,防止死锁。从工程角度来说,我不建议在核心链路中过度追求极致的锁性能,先把锁的语义搞对,再考虑优化吞吐量,顺序不能反。
4. 并发容器与工具类:JDK帮我们封装好的并发套路
除了锁和同步原语,Java并发包里还提供了一大批并发容器和并发工具类。这些工具类设计精巧,几乎涵盖了日常开发中90%的并发协作场景,但它们各自有适用边界,用错了场景反而适得其反。
4.1 ConcurrentHashMap的演进和精髓
ConcurrentHashMap可以算得上Java并发容器中的“明星产品”,也是面试中的常客。JDK 7和JDK 8的ConcurrentHashMap实现差异很大。JDK 7采用分段锁(Segment)的机制,把整个Map分成多个Segment,每个Segment是一把独立的锁,不同的线程可以同时操作不同Segment中的数据,从而提升并发度。JDK 8则抛弃了分段锁,改用CAS + synchronized的精巧组合,锁的粒度细化到了数组中的每个桶(bin),并发度更高,而且避免了分段锁带来的内存开销。
JDK 8的ConcurrentHashMap在put操作时,如果目标桶为空,就通过CAS直接插入,不需要加锁;如果桶不为空,则对桶的头节点加synchronized锁,然后执行插入操作。与此同时,当某个桶的链表长度超过阈值(8)且数组长度大于等于64时,会转为红黑树以提高查找效率。这些优化的核心思想就是尽可能减少加锁的范围和频率。
在实际使用中,ConcurrentHashMap的size()方法在并发情况下无法返回一个绝对精确的值,它只是做了一个近似统计(基于baseCount和CounterCell的分段累加),这在绝大多数业务场景下是完全可以接受的。如果你需要一个精确的全局计数,应该在业务层面额外保证,而不是依赖ConcurrentHashMap的size()。
4.2 CopyOnWriteArrayList和阻塞队列
CopyOnWriteArrayList采用了“写时复制”的思想:读操作不加锁,直接在底层数组上读取;写操作会先复制一份新数组,在新数组上进行修改,然后用新数组替换旧数组。这种方式保证了读线程永远读到的是某一时刻的完整快照,非常适合“读多写极少”的场景,比如配置列表、白名单等。
但它的代价也很明显:每次写操作都要复制整个底层数组,如果数组很大而且写操作频繁,内存开销和GC压力都会很大。所以千万不要在“写多读少”的场景用它。
阻塞队列是另一个重要的并发容器,ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、PriorityBlockingQueue等各有特点。它们底层依赖锁和条件变量(Condition)来实现“队列满时生产者阻塞、队列空时消费者阻塞”的效果。ArrayBlockingQueue是基于数组的有界队列,LinkedBlockingQueue是基于链表的有界队列(默认大小为Integer.MAX_VALUE),SynchronousQueue则没有缓冲容量,每个插入操作必须等待另一个线程的移除操作,适合一对一传递任务。
4.3 CountDownLatch、CyclicBarrier、Semaphore的适用场景
这三个工具类的名字很容易混淆,面试中也经常被拿来一起问。
CountDownLatch是“倒计时门闩”,一个线程(或一组线程)在CountDownLatch的计数器归零之前会一直等待,而其他线程可以调用countDown()将计数器减一。典型场景是主线程等待多个子线程都完成某个任务后再继续执行。CountDownLatch的计数器只能使用一次,不能重置。
CyclicBarrier是“循环屏障”,多个线程互相等待,当所有线程都到达屏障点之后,屏障才会打开,所有线程同时继续执行。它的计数器可以循环使用(用reset()重置),适合“多线程分阶段并行计算”的场景。CyclicBarrier还有一个重载构造器可以传入一个barrierAction,在所有线程到达屏障后、释放线程之前执行。
Semaphore是“信号量”,本质上是共享锁,用来控制同时访问某个资源的线程数量。比如数据库连接池,最多只允许10个线程同时获取连接,就可以用Semaphore来限制。Semaphore还支持公平和非公平两种模式,默认是非公平模式。
从场景划分的角度看,可以粗暴地记为:CountDownLatch是“等别人完成”,CyclicBarrier是“等互相集合”,Semaphore是“限制人数”。
5. 线程池:面试必考,线上更容易出事
线程池这块内容,在面试中的重要性怎么强调都不过分。几乎可以说,并发编程八股文里最核心的实操内容就是线程池。而且线程池的许多细节问题,恰恰是实际生产环境中掉坑最多的。
5.1 线程池的核心参数和执行流程
ThreadPoolExecutor是Java线程池的核心实现类,它有七个关键参数:核心线程数(corePoolSize)、最大线程数(maximumPoolSize)、空闲线程存活时间(keepAliveTime)、存活时间单位(unit)、任务队列(workQueue)、线程工厂(threadFactory)、拒绝策略(handler)。
线程池的执行流程可以这样理解:当提交一个任务时,如果当前运行的线程数小于核心线程数,就创建新线程来处理任务;如果当前运行的线程数达到了核心线程数,新的任务会被放入任务队列中排队;如果任务队列也满了,而且当前运行的线程数小于最大线程数,就创建临时线程(非核心线程)来处理任务;如果线程数已经达到最大线程数且队列也满了,就会触发拒绝策略。
这里有个和直觉可能不一致的地方:核心线程并不是“先创建的线程一直干活”,非核心线程也不是必须在核心线程用完才创建。线程池在处理新任务时,会先尝试让核心线程处理,但核心线程如果都忙,任务会先进入队列排队,而不是直接创建非核心线程。只有当队列也满了,才会创建非核心线程。
5.2 四种拒绝策略和execute/submit的区别
当线程池无法处理新提交的任务时,会触发拒绝策略。ThreadPoolExecutor提供了四种内置策略:AbortPolicy(默认,直接抛出RejectedExecutionException)、CallerRunsPolicy(由提交任务的线程自己执行该任务)、DiscardPolicy(静默丢弃任务)、DiscardOldestPolicy(丢弃队列中最旧的任务,然后重新提交当前任务)。
在实际项目中,AbortPolicy是默认策略,但直接抛出异常可能导致任务丢失或系统崩溃,所以需要业务方自己决定如何处理。CallerRunsPolicy通常被认为是一种比较安全的策略,因为由调用者执行任务,可以降低任务的提交速度,起到“让提交线程帮忙干活”的作用,但它会阻塞调用线程,如果调用线程本身很关键,也需要谨慎使用。
关于execute和submit的区别,也是一个高频考点。execute方法没有返回值,无法感知任务执行过程中抛出的异常,如果任务抛出运行时异常,异常会直接抛出到线程池的UncaughtExceptionHandler中,线程本身会被销毁并重建,这在某些场景下会导致任务悄悄丢失。submit方法返回一个Future对象,通过Future.get()可以获取任务执行结果或者在任务抛出异常时捕获异常。但要注意,调用Future.get()会阻塞当前线程直到任务完成,如果不需要获取结果,直接用execute更合适。
如果任务既需要提交到线程池执行,又需要感知异常,推荐的做法是在任务内部用try-catch包裹业务逻辑,把异常信息记录到日志中,同时调用线程池的setUncaughtExceptionHandler来兜底。
5.3 线程池参数到底怎么设置
这是面试中容易被深挖的问题:你负责的系统,线程池的核心线程数和最大线程数怎么定?网络上有很多“公式”,比如CPU密集型就设置N+1,IO密集型就设置2N。但实际项目中,这些公式只能作为起点,不能生搬硬套,因为线程数的多少不仅取决于CPU核数和任务类型,还取决于任务的平均耗时、响应时间要求、连接池大小、下游依赖的吞吐等因素。
我的经验是先分为几类任务来估算。对于CPU密集型任务,线程数建议设置为CPU核数+1,因为CPU密集型的性能瓶颈在CPU计算上,设置太多线程反而会因为频繁切换上下文而降低吞吐。对于IO密集型任务,线程数可以适当增加,核心思路是“等待时间越多,需要的线程数越多”。有一个经验公式是:线程数 = CPU核数 * (1 + 平均等待时间 / 平均计算时间)。但这同样只是估算,最终还是要通过压测来验证。
另一个容易忽略的参数是workQueue的容量。有些系统把workQueue设置得非常大(比如Integer.MAX_VALUE),这样虽然任务不会因为队列满而拒绝,但会导致线程数永远只停留在核心线程数,无法扩容到最大线程数,而且如果任务积压过多,内存占用会急剧上升,甚至OOM。所以“队列不能无限长,要有上限”是线程池配置的一条红线。
线程池还有一个细节,就是核心线程默认是不会被回收的。如果希望核心线程在空闲一段时间后也被回收,需要调用allowCoreThreadTimeOut(true)来开启。这个配置对于请求量波动很大的系统比较有用,可以及时回收空闲线程,降低资源占用。
5.4 ThreadLocal为什么会内存泄漏
聊线程池不能不提ThreadLocal,因为ThreadLocal和线程池结合使用时,非常容易引发内存泄漏。ThreadLocal的原理是:每个Thread内部有一个ThreadLocalMap,Map的key是ThreadLocal实例的弱引用,value是线程中保存的变量值。当ThreadLocal实例没有强引用时,key会被GC回收,但value仍然被ThreadLocalMap强引用持有,如果这个线程长期存活(比如线程池中的核心线程),value就永远不会被回收,从而造成内存泄漏。
解决方法是:在使用完ThreadLocal之后,显式调用remove()方法清理。在线程池中使用ThreadLocal的代码,尤其要在finally块中调用remove(),确保执行完毕后把线程上下文清理干净。
这个问题的背后其实是对“线程池的线程是复用”这件事的深刻理解。如果你的业务代码中某个Runnable任务往ThreadLocal里塞入了用户信息,但任务结束时没有清理,下一个任务复用了同一个线程,就可能会读到上一个任务遗留的数据,轻则数据串模,重则权限越权。
6. 异步编程:从Future到CompletableFuture
并发编程不仅仅是多线程同步,异步编程也是并发体系的重要分支。JDK 8引入的CompletableFuture,把异步编程的可读性和组合能力提升了一个档次,如今在微服务、异步编排等场景下使用非常广泛。
6.1 Future和FutureTask的局限
Future表示一个异步计算的结果。通过ExecutorService.submit()可以提交一个Callable任务并得到一个Future对象,然后通过future.get()获取执行结果。这里有几个很容易踩的坑。
第一,future.get()是阻塞方法,它会一直等待任务完成,如果任务执行时间很长,调用线程会一直挂起。如果你在for循环里提交了10个任务,然后逐个调用get(),那么即使第2个任务早就完成了,只要第1个任务没完成,第2个任务的get()也得不到执行,白白浪费了并行能力。
第二,Future没有提供“任务完成时回调”的能力,也就是说你没有办法在任务执行完成的那一刻立刻得到通知,只能靠轮询isDone()或者阻塞等待get()。这在很多业务场景下不够灵活。
FutureTask是Future的一个实现类,它本身既是一个任务又是一个Future,可以结合Callable或Runnable使用,也可以通过get()获取结果。由于FutureTask实现了Runnable,它可以提交给线程池执行,也可以直接由Thread执行,这给异步任务带来了更多灵活性。
6.2 CompletableFuture的核心用法
CompletableFuture的优势在于它提供了声明式的异步编排能力。你可以通过supplyAsync()提交一个异步任务,用thenApply()对结果做转换,用thenAccept()消费结果,用thenCombine()组合两个异步任务的结果,用exceptionally()处理异常,用allOf()等待所有任务完成,用anyOf()等待任一个任务完成。
这里有个非常重要的经验:CompletableFuture的异步任务默认使用ForkJoinPool.commonPool(),这是一个全局共享的线程池,如果所有业务代码都往里面丢任务,非常容易出现线程池排队和相互阻塞的情况。正确的做法是给CompletableFuture提供一个专用的线程池,例如通过supplyAsync(supplier, executor)指定。
CompletableFuture在异常处理上也比Future要好用得多。你可以用exceptionally()来处理异常并返回一个默认值,可以用handle()同时处理结果和异常,也可以用whenComplete()做后置处理。它把这些异常处理逻辑嵌入到了异步编排的流程中,而不是像Future一样只能在get()的时候抛出ExecutionException。
另一个常见的坑是调用join()或get()时机不当。CompletableFuture的设计本意是异步非阻塞,但你调用了get()或join()之后,当前线程就会被阻塞住,如果你在事务方法里用这种写法,实际上还是同步执行的。所以需要根据场景合理选择:如果下游任务没有强依赖关系,尽量采用回调的方式(whenComplete、thenCompose),把异步链路串起来,而不要阻塞等待结果。
7. 从八股到实战:并发问题的排查思路
聊了这么多理论,最后必须落到实践上。很多开发者在面试中关于并发编程的知识点背得头头是道,但真正遇到线上并发问题时,往往不知道怎么入手排查。我这里整理了一些常见的并发问题排查思路,希望对你有帮助。
7.1 死锁的排查和预防
死锁是并发编程中最经典的问题。四个必要条件缺一不可:互斥、持有并等待、不可剥夺、循环等待。只要打破其中一个条件,就可以避免死锁。
排查死锁的第一个策略是:尽量使用synchronized或Lock时,保持加锁顺序一致。第二个策略是:使用ReentrantLock的tryLock()方法,设置超时时间,获取锁失败就重试或者降级处理,而不是无限阻塞。第三个策略是:在代码上线前做多线程并发测试,模拟高并发场景下的锁竞争。
一旦线上确实发生了死锁,现象通常表现为:某个接口响应时间急剧拉长,线程池队列堆积,CPU使用率可能不高但是请求全部卡住。此时可以通过JStack命令(jstack )导出线程快照,然后查找“Found one Java-level deadlock”的字样,就能看到死锁的线程对和对应的锁对象、代码行号。
定位到死锁代码之后,除了调整加锁顺序,还可以考虑使用定时锁(tryLock带超时时间)、使用并发工具类替代手工锁(比如用ConcurrentHashMap的computeIfAbsent代替双重检查锁)、或者减少共享锁的持有时间。
7.2 线程池耗尽和OOM
线程池耗尽和OOM是线上并发问题中最常见的两类。线程池耗尽的典型现象是:请求超时、线程池的活跃线程数长时间接近最大线程数、任务队列堆积严重,甚至出现RejectedExecutionException。导致线程池耗尽的原因有很多,可能是任务执行耗时过长(比如下游服务响应慢、IO阻塞),可能是线程池配置过小,也可能是代码存在死循环或未捕获的异常导致线程异常退出后重建频繁。
排查线程池问题时,不要只盯着线程数,要结合任务队列的积压情况、任务平均执行时长、线程和池的监控指标一起看。如果你的监控系统没有采集线程池指标,强烈建议在项目启动时注册一组线程池的JMX指标,至少包括:活跃线程数、核心线程数、最大线程数、队列积压任务数、拒绝任务数。这些指标可以帮助你在问题发生前做出预判。
OOM是另一个常见灾难。并发场景下OOM通常有两类原因:一是线程数过多导致线程栈内存耗尽(OutOfMemoryError: unable to create new native thread),二是因为任务队列或缓存无限制增长导致堆内存不足。前者要关注线程池最大线程数的设置,以及是否有外部系统直接创建大量线程;后者要关注有界队列的使用、缓存淘汰策略、以及是否需要做限流。
注意:如果服务器上出现“unable to create new native thread”的OOM,光靠调大JVM堆内存是没用的,因为线程栈使用的是操作系统本机内存,不是JVM堆内存。此时要优先排查系统级的线程数限制(ulimit -u)和进程总线程数。
7.3 常见的并发错误写法与纠正
我在Code Review中见过太多并发相关的低级错误,这里列几个最高频的:
一是懒加载单例没有加volatile。双重检查锁的单例如果不用volatile修饰实例字段,其他线程可能拿到一个“半初始化”的对象,因为JVM在new对象的时候,分配内存、初始化实例、指向内存地址这三个步骤可能被重排序。
二是对共享可变对象直接暴露getter/setter。比如一个HashMap作为类属性,外部直接getMap().put()修改,没有任何同步保护,这种问题在并发环境下极难排查,因为可能很长时间不出错,一出错就是线上事故。解决办法是返回Collections.unmodifiableMap()或者使用ConcurrentHashMap。
三是用ArrayList/HashMap等非线程安全容器在多线程共享。这类问题可以用“先查Javadoc是否标注为线程安全”来预防。JDK中标注了线程安全的容器包括Vector、Hashtable、ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue等,此外Collections工具类也提供了synchronizedXxx()方法把非线程安全容器包装成线程安全版本,但锁粒度较大,性能不如并发容器。
四是同步块内调用外部接口或RPC。这会长时间持有锁,导致其他线程全部阻塞,整个系统被拖垮。必须要做的操作是:先取出需要的数据,在锁外完成耗时调用,或者将耗时调用改造成异步任务。
8. 并发编程的学习路径和面试准备建议
我知道很多读者问完技术细节之后,最关心的还是一个问题:面对并发编程的八股文,到底怎么背、怎么准备面试。我把我的经验拆成几个层面讲。
关于学习路径,我的建议是先建立“问题驱动”的思维。不要一个一个知识点孤立地背,而是先问自己:并发编程会遇到哪些问题(原子性、可见性、有序性),然后每个问题有哪些解决方案(锁、volatile、CAS、原子类、并发容器),各种方案的原理是什么、优缺点是什么、适用场景是什么。按照这个思维导图去学习,知识就是成体系的。
关于面试回答技巧,也有一个比较实用的方法:面试官问你一个并发问题的时候,先给出结论,也就是“是什么”,然后展开原理,最后补充一个实际的业务场景或者代码层面怎么做。比如面试官问synchronized锁升级,你可以先回答锁的四个状态,再说说每种状态对应什么竞争情况,最后提一下在实际项目中如何通过锁升级的知识来优化性能。
关于编码实操,强烈建议自己动手写几个并发小Demo去验证自己的理解。比如写一个多线程累加计数器,分别用synchronized、ReentrantLock、AtomicInteger、LongAdder去实现,对比它们的性能和正确性;写一个生产者消费者模型,用ArrayBlockingQueue或者Condition实现;写一个自定义的AQS同步工具,比如实现一个一次性闸门(类似CountDownLatch)。这些代码量都不大,但写一遍和看十遍的效果完全不同。
最后补充一点,把并发编程放到Java整个知识体系的大背景下看,它和JVM、操作系统、数据结构、设计模式都有交叉。很多并发问题的根源其实在操作系统层面(比如线程调度、内存模型),而很多并发设计的思路又和数据结构密切相关(比如ConcurrentHashMap如何利用哈希表的结构特点去降低锁粒度)。如果你能把并发编程的知识和其他模块融会贯通,那么不管是面试八股还是线上实战,你都会有一种“游刃有余”的感觉。
我在实际带团队的过程中,面试了不少候选人,也在线上排查过各种诡异的并发问题。最大的感受是:并发编程的知识点看起来千头万绪,但只要抓住了“三大特性(原子性、可见性、有序性)+ AQS + 并发容器 + 线程池”这条主线,八股文背诵和实际编码都能形成一个自洽的体系。真心建议每个Java开发者,都能亲手写一遍那些经典并发Demo,而不是只停留在“看过就会”的状态。有些底层的设计思想,比如对共享资源的访问控制、对性能与一致性之间平衡的把握,是写在书本之外的,只有亲手踩过坑,才能真正长在你自己身上。