ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java并发编程核心体系精讲:锁、AQS与线程池实战

Java并发编程核心体系精讲:锁、AQS与线程池实战 先说说我为什么要写这篇东西。做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并发编程的“内存模型地基”JMMJava内存模型是理解并发问题的地基它的核心规则可以用一句话概括所有变量都存在主内存中每个线程有自己独立的工作内存线程对变量的所有操作都必须在工作内存中进行不能直接读写主内存线程之间无法直接访问对方的工作内存只能通过主内存来传递变量值。这个模型的本质是为了屏蔽不同硬件和操作系统的内存访问差异让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无锁编程的精髓CASCompare 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. AQSJava并发类的“总开关”如果只看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_VALUESynchronousQueue则没有缓冲容量每个插入操作必须等待另一个线程的移除操作适合一对一传递任务。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密集型就设置N1IO密集型就设置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内部有一个ThreadLocalMapMap的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而不是只停留在“看过就会”的状态。有些底层的设计思想比如对共享资源的访问控制、对性能与一致性之间平衡的把握是写在书本之外的只有亲手踩过坑才能真正长在你自己身上。
返回列表