
“不吹不黑”这个开头在Java圈子里见得太多了。但我要说的是并发编程确实是Java后端工程师绕不过去的一道坎不管是日常开发还是面试它都是高频出现的硬骨头。这篇文章不会跟你扯什么高深理论我尽量用最直白的话把线程、锁、内存模型、线程池这些核心概念拆开揉碎讲清楚再配合我在实际项目中踩过的坑帮你看完能直接用、能应付面试、能解决线上问题。不管你是刚学完Java基础想进阶的初学者还是工作了两三年准备跳槽的开发者或者纯粹是想把并发知识体系补齐的老兵这篇文章都适合你。我会把并发编程里那些“知其然不知其所以然”的点一点一点掰开讲比如为什么volatile不能保证原子性、synchronized锁升级到底是怎么回事、线程池为什么不能用Executors创建。这些内容平时散落在各种八股文里今天我把它们串成一条线一次性给你讲透。1. 并发编程到底在解决什么问题1.1 单线程时代的两座大山性能与响应先想一个问题为什么我们需要并发编程最直接的原因是性能。一台服务器CPU有多个核心如果你只用一个线程那其他核心就闲着资源全部浪费。举个我经历过的例子之前做个报表导出功能同步遍历几千条数据逐条查询数据库再组装Excel耗时接近3分钟。后来用线程池把这批数据拆成8个分片并行处理时间直接压到25秒以内。这就是并发提升吞吐量最直观的体现。但引入并发之后问题也跟着来了多个线程同时访问共享数据就会出现数据不一致的情况。这个问题的本质是CPU、内存和线程之间复杂的交互关系。一个线程对变量的修改另一个线程可能看不到两个线程同时执行i结果可能不是期望的2而是1编译器为了提高性能还可能把指令顺序打乱。这些综合起来就是并发编程著名的三大问题原子性、可见性、有序性。原子性一个或多个操作要么全部执行成功要么全部不执行中间不能被打断。经典的例子是i它其实是“读取→计算→写回”三步操作不是原子的。可见性一个线程修改了共享变量其他线程能否立即看到。由于CPU缓存的存在线程A修改了变量但还没写回主内存线程B读到的还是旧值。有序性为了优化性能编译器和CPU可能对指令进行重排序导致代码的执行顺序和书写顺序不一致。1.2 Java的并发武器库全景Java针对这三大问题提供了层层递进的解决方案。从最早的synchronized关键字、volatile关键字到JDK 5引入的java.util.concurrent包简称JUC再到JDK 8的函数式异步编程整个武器库已经非常庞大。我把这些工具按用途分个类你对照着记会清晰很多线程管理类Thread、Runnable、Callable、FutureTask、ThreadPoolExecutor锁机制synchronized、ReentrantLock、ReentrantReadWriteLock、StampedLock同步控制类CountDownLatch、CyclicBarrier、Semaphore、Phaser原子操作类AtomicInteger、AtomicLong、AtomicReference、LongAdder并发容器ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue系列线程本地存储ThreadLocal异步编排CompletableFuture、Future、CompletionService很多初学者看到这一堆东西就懵了不知道怎么学、怎么用。我的建议是先抓住最核心的一条主线——锁与内存模型。只要理解了synchronized的底层原理和Java内存模型JMM后面JUC里的那些工具基本都是围绕这套核心机制衍生出来的语法糖。2. 从线程到锁Java并发的地基2.1 线程的创建方式与生命周期在Java中创建线程有四种方式很多人面试时能背出来但理解不够深。我列个表给你对比一下创建方式优点缺点适用场景继承Thread类简单直接Java单继承扩展性差简单的一次性任务实现Runnable接口解耦任务与线程扩展性好无返回值不能抛异常常规多线程任务实现Callable接口 FutureTask有返回值可抛异常代码稍繁琐需要获取执行结果的场景线程池 ExecutorService线程复用可控性强需合理配置参数绝大多数生产环境这里面关键要理解Callable和Runnable的区别。Runnable就是void run()没有返回值Callable是V call()可以返回一个泛型结果还可以抛异常。FutureTask同时实现了Runnable和Future接口所以它能包装Callable丢给线程池执行执行完再通过get()方法获取结果。但要注意future.get()是个阻塞方法如果你在并发场景里对多个future直接调用get()那可能会让主线程一直卡着等性能反而下降。后面我会讲用CompletableFuture解决这个问题。线程的生命周期是面试高频考点状态一共六种NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。重点理解几个容易混淆的转换sleep()和wait()的区别sleep是Thread的静态方法调用后线程进入TIMED_WAITING状态但不会释放锁wait是Object的方法调用后线程进入WAITING状态并且会释放锁。所以wait方法必须在持有锁的代码块里调用否则抛IllegalMonitorStateException。BLOCKED和WAITING的区别BLOCKED是线程在等待进入synchronized代码块时抢占锁失败进入的状态WAITING是线程主动调用wait/join等方法等别的线程来唤醒它。2.2 synchronized锁的底层原理与锁升级synchronized是Java最基础的同步手段它的原理是每个Java对象都有一个监视器锁Monitor。当一个线程进入synchronized代码块时会尝试获取对象的Monitor获取成功则执行代码执行完释放Monitor获取失败则进入阻塞队列等待锁释放。但在JDK 6之前synchronized是重量级锁性能很差。JVM团队在JDK 6做了大量优化引入了锁升级机制。这个升级路径是无锁 → 偏向锁 → 轻量级锁 → 重量级锁。偏向锁当一个线程第一次访问同步代码块时JVM会在这个对象头的Mark Word里记录这个线程的ID之后这个线程再次进入不需要任何CAS操作直接就能进入。这就像是你办公室的门禁录入了你的指纹你天天刷脸进畅通无阻。轻量级锁当另一个线程也来竞争这个锁时偏向锁就会撤销升级为轻量级锁。它会尝试通过CAS将对象头的Mark Word替换为指向锁记录的指针如果成功就拿到锁失败就自旋等待。重量级锁如果自旋超过一定次数默认10次或者等待线程数过多就升级为重量级锁。此时线程进入操作系统内核态的阻塞队列等待被唤醒这个切换开销很大。这种升级机制的设计初衷是绝大多数场景下同步代码块的锁竞争并不激烈甚至同一线程会反复获取同一个锁所以不一定从一开始就使用最重量级的方案。理解了这个机制你就知道为什么synchronized能保证可见性——因为线程释放锁之前会把工作内存中的共享变量强制刷新到主内存中获取锁时会从主内存重新读入共享变量。2.3 volatile最轻量的同步也不是万能的volatile是比synchronized更轻量的同步手段它的作用有两个保证可见性和禁止指令重排序。它的底层实现是内存屏障。简单说当一个线程写了一个volatile变量时JVM会在写操作后面插入一个写屏障这个屏障会强制将之前的修改刷到主内存读一个volatile变量时会插入读屏障强制从主内存重新加载。同时内存屏障还限制了编译器和CPU的重排序。但volatile最大的坑是它不保证原子性。很多人面试时答到这个点就很容易被追问为什么volatile不能保证i的原子性因为i本身是读-改-写三步操作volatile只保证了每一步操作的可见性和有序性但三步之间还是可能被其他线程插进来。举个例子线程A和线程B同时读取到i0都执行了1操作最后写回主内存时无论是谁先写另一个都会被覆盖最终结果是1而不是2。这就是经典的“丢失更新”问题。那volatile适合什么场景我的经验是适合一个线程写、多个线程读的状态变量场景典型的比如状态标志位public class ServerStatus { private volatile boolean running true; public void stop() { running false; // 写线程 } public void doWork() { while (running) { // 读线程 // 执行任务 } } }这个场景里running变量没有复合操作volatile完全能满足需求而且性能比synchronized好得多。2.4 Java内存模型JMM与happens-before原则理解并发绕不开JMMJava Memory Model。很多人把它形容得非常虚幻其实核心就三个概念主内存、工作内存、内存间交互操作。主内存是所有线程共享的内存区域存储变量每个线程有自己的工作内存对应CPU缓存或寄存器线程对变量的所有操作都必须在工作内存中进行不能直接读写主内存而且线程之间无法直接访问对方的工作内存。这个模型和现实中的CPU架构是对应的主内存对应物理内存工作内存对应CPU的L1/L2缓存。JMM定义了8种内存交互操作比如read从主内存读取、load把读取的值放入工作内存、use工作内存取值执行、assign赋值给工作内存、store工作内存写回主内存、write把值写入主内存等等。这些操作必须满足一定规则比如read和load必须成对出现assign和store必须成对出现。更重要的是happens-before原则它是判断数据是否存在竞争的依据。核心的几条程序次序规则一个线程内书写在前面的操作happens-before书写在后面的操作。volatile变量规则对一个volatile变量的写操作happens-before后续对这个变量的读操作。锁规则解锁操作happens-before后续对同一个锁的加锁操作。传递性如果A happens-before BB happens-before C那么A happens-before C。这里特别强调一下锁规则这就是为什么synchronized能保证可见性的本质原因——线程A释放锁之前的操作对线程B都是可见的因为B获取到锁之后一定能看到A写入的所有共享变量。3. JUC的核心锁、CAS与同步工具3.1 ReentrantLock到底比synchronized强在哪ReentrantLock是JDK 5引入的可重入锁它和synchronized在很多场景下可以互换但功能更丰富。我总结成四个点可中断线程在等待锁的过程中可以响应中断信号通过lockInterruptibly()方法实现。这比synchronized的不可中断特性人性化很多特别是当一个线程因为死锁等原因永远等不到锁时可以强制中断它。可超时tryLock(long timeout, TimeUnit unit)方法让线程在指定时间内获取锁超时后自动放弃避免无限等待。公平锁synchronized是非公平锁ReentrantLock默认也是非公平的但可以构造时传true来启用公平锁。所谓公平锁就是按线程请求锁的顺序来分配先来先得。多个条件变量ReentrantLock可以创建多个Condition对象精确控制唤醒哪一部分线程。synchronized的wait/notify只能唤醒一个或全部无法精准控制。使用ReentrantLock时要特别注意必须在finally块中释放锁否则锁永远不会释放直接导致死锁ReentrantLock lock new ReentrantLock(); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }synchronized是JVM自动释放锁的即使代码抛出异常也会自动释放这是它的一个明显优势。所以我的习惯是能用synchronized解决的优先用synchronized需要用到超时中断、公平锁等高级特性时才上ReentrantLock。3.2 AQSJUC的基石说到ReentrantLock必须提AQSAbstractQueuedSynchronizer。AQS是JUC里几乎所有锁和同步器的基础CountDownLatch、Semaphore、ReentrantReadWriteLock、ThreadPoolExecutor核心逻辑都构建在AQS之上。AQS的设计核心有三部分一个volatile的int类型的state变量表示同步状态。在ReentrantLock里state0表示锁未被占用state0表示锁被占用且大于1表示重入次数。一个FIFO的CLH双向队列用于存放等待获取锁的线程。两种获取模式独占模式exclusive和共享模式shared。ReentrantLock是独占模式Semaphore是共享模式。以ReentrantLock加锁过程为例调用lock()时会调用AQS的acquire方法流程是先尝试通过CAS把state从0改成1成功则持有锁并把当前线程设为独占线程失败则把当前线程封装成Node节点加入CLH队列尾部然后通过LockSupport.park()挂起线程等待被唤醒。当一个线程释放锁时state减1如果变成0就会唤醒队列中下一个等待的线程。为什么ReentrantLock支持重入因为同一个线程再次进入时会判断当前独占线程是不是自己如果是state继续加1所以state代表的是重入次数。理解了AQSJUC里的大部分锁和同步器就都通了一半。3.3 CAS无锁并发的基石CASCompare And Swap是JUC原子类的底层实现方式。它的核心操作是比较并交换即如果内存中的值等于预期值就更新为新值否则不更新整个操作是原子的。Java中CAS通过Unsafe类本地方法实现最终调用CPU的cmpxchg指令这个指令在硬件层面保证了操作的原子性所以即使没有加锁也能实现线程安全。AtomicInteger就是一个典型的CAS应用。它的自增操作public final int incrementAndGet() { return unsafe.getAndAddInt(this, valueOffset, 1) 1; }底层会不断循环执行CAS操作直到成功为止这就是自旋。但CAS有个著名的ABA问题线程A读到值是A执行过程中线程B把值改成了B又改回了A线程A再次CAS时就成功了但它不知道中间发生了波动。解决方式是使用带版本号的原子类比如AtomicStampedReference每次修改值的同时更新版本号。数据库里的乐观锁本质也是这种思想。使用原子类的原则是简单场景用原子类复杂场景用锁。比如全局计数器、ID生成器这种高频原子操作用AtomicLong或者LongAdder非常合适。LongAdder是JDK 8新增的性能比AtomicLong更好它的原理是把一个计数器拆成多个分片不同线程累加到不同的分片上最后sum()时再汇总。并发量特别高的场景LongAdder的吞吐量远超AtomicLong。3.4 三个并发工具类CountDownLatch、Semaphore、CyclicBarrier这三个工具类经常被拿来对比面试中也常被问区别。我直接列个表工具类核心功能典型场景是否可复用CountDownLatch一个线程等N个线程完成主线程等待多个子任务执行完不可重置CyclicBarrierN个线程互相等待都到达屏障后才继续多线程各自执行一部分全部完成后做汇总可重置可循环使用Semaphore控制同时访问的线程数量限流、公共资源池可动态释放CountDownLatch的做法是初始化一个计数器比如5每个线程执行完任务后调用countDown()把计数器减1主线程调用await()等待我一般会在await()方法上设置超时时间防止子任务卡死导致主线程永久阻塞。CyclicBarrier的区别在于所有线程都要互相等待没有主从之分。比如有4个线程分别计算季度业绩都算完之后汇总全年。每个线程计算完会调用await()直到4个线程全都到达这个屏障才一起放行。Semaphore就更好理解了它就是“许可证”。构造函数传入允许同时执行的数量。比较经典的用法是控制数据库连接池的最大连接数比如池子只有10个连接就new Semaphore(10)每次获取连接时acquire()用完了release()超过10个线程同时请求时多余的线程阻塞等待。4. 线程池生产环境里的双刃剑4.1 核心参数逐一说透线程池是并发编程里最容易被误用的工具。很多人一上来就Executors.newFixedThreadPool(10)看着能用但线上出了问题才发现对线程池的认知太浅。我先带你把ThreadPoolExecutor构造函数的7个核心参数逐个吃透corePoolSize核心线程数线程池中常驻的线程数量即使空闲也不会被回收除非设置了allowCoreThreadTimeOut。maximumPoolSize最大线程数线程池允许同时存在的最大线程数量当任务过多时线程数会从核心线程数向最大线程数扩展。keepAliveTime存活时间非核心线程的空闲存活时间超过这个时间如果还没有新任务来就会被回收。unit时间单位keepAliveTime的时间单位。workQueue任务队列当核心线程全忙时新任务会被放入队列等待执行。threadFactory线程工厂用于创建线程的工厂可以自定义线程的命名、是否为守护线程等。handler拒绝策略当任务队列也满了且线程数达到最大时对新任务的处理策略。这里要重点理解线程池的执行流程。提交一个新任务时按下面的顺序走核心线程还没满直接创建核心线程执行任务。核心线程满了队列还没满把任务放到队列里排队。队列也满了线程数还没达到最大值创建非核心线程执行任务。队列满了、线程数也达到最大值了触发拒绝策略。4.2 为什么强烈不建议用Executors很多入门教程喜欢让你用Executors工具类因为它一行代码就能创建线程池够省事。但生产过程环境里这是不推荐的原因如下newFixedThreadPool和newSingleThreadExecutor允许的请求队列长度为Integer.MAX_VALUE相当于一个无界队列。一旦任务提交过多会无限堆积在队列里导致内存溢出OOM。线上出现过这种情况某服务在生产高峰期大量请求打过来任务队列疯狂增长最终直接OOM崩溃。newCachedThreadPool允许创建的最大线程数是Integer.MAX_VALUE极端情况下可能创建海量线程直接耗尽CPU和内存资源。newScheduledThreadPool同样支持的最大线程数是Integer.MAX_VALUE。所以我现在的习惯是永远手动new ThreadPoolExecutor并且根据业务场景定制参数。在阿里Java开发手册里也有明确建议线程池不允许使用Executors去创建而应该通过ThreadPoolExecutor的方式这样的处理方式能让开发者明确线程池的运行规则规避资源耗尽的风险。4.3 线程池参数配置的实战经验线程池参数怎么配不能拍脑袋要根据业务类型来定。如果是CPU密集型任务核心线程数设为CPU核心数1即可。因为CPU密集型任务主要消耗CPU资源线程太多反而会因为频繁的上下文切换降低效率。获取CPU核心数的方式是Runtime.getRuntime().availableProcessors()。如果是IO密集型任务比如大量的数据库操作、远程RPC调用线程大部分时间在等待IOCPU其实很空闲所以可以配置更多的线程。经验公式是核心线程数 CPU核心数 * 2或者更精细地按下面的公式估算线程数 CPU核心数 * (1 平均等待时间 / 平均计算时间)举例假设一个任务的CPU计算时间是10msIO等待时间是90ms那么理论上每个CPU核心可以支撑10个线程1 90/10。另外一个很容易忽略的点是队列大小的选择。有个业界常用的思路如果任务允许一定延迟可以设置一个有界队列比如new ArrayBlockingQueue(100)如果任务不允许排队堆积可以用SynchronousQueue它不会缓存任务提交的任务必须直接交给线程执行否则就创建新线程。拒绝策略的选择也有讲究。默认策略是AbortPolicy直接抛RejectedExecutionExceptionCallerRunsPolicy则是在提交者线程中直接执行任务这样不会丢任务但可能拖慢提交者的处理速度DiscardOldestPolicy会丢弃最老的任务只保留最新的。最保险的方案是自定义拒绝策略在提交者线程里直接执行或者降级处理ThreadPoolExecutor executor new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(100), r - new Thread(r, order-async-pool- r.hashCode()), new RejectedExecutionHandler() { Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { // 降级处理比如写入消息队列稍后重试 } } );5. 并发容器与异步编程利器5.1 ConcurrentHashMap为什么是并发首选HashMap在多线程下肯定不能用这不用多说。Hashtable虽然是线程安全的但它的实现是给put/get等所有方法都加了synchronized并发量高时效率极差。Collections.synchronizedMap也是类似的问题。ConcurrentHashMap性能好是因为它的锁粒度更细。JDK 7的实现是分段锁把一个Map分成16个Segment每个Segment单独加锁不同线程操作不同Segment时可以并行JDK 8改成了CAS synchronized局部锁锁的粒度精确到每个桶的链表头节点并发程度更高。JDK 8的ConcurrentHashMap底层结构是数组链表红黑树。当链表长度超过8且数组长度大于64时链表会转成红黑树这样查询效率从O(n)优化到O(log n)。还有一个细节值得注意ConcurrentHashMap的size()方法在多线程环境下不是实时准确的因为它可能需要临时加锁才能计算总数。JDK 8里的实现是先通过无锁的方式统计各个分片的计数器如果分片数量较少且没有并发修改时误差在可接受范围内。如果你需要精确的实时大小建议使用mappingCount()方法配合业务逻辑判断。5.2 CopyOnWriteArrayList读多写少的场景好帮手CopyOnWriteArrayList是一个很有意思的容器它的核心思想是读操作不加锁写操作加锁并复制整个底层数组。当调用add()或remove()时它会先加锁然后把原数组复制一份在新副本上做修改最后替换掉旧数组的引用。旧数组上的读操作仍然可以继续使用它们读到的还是旧数据不会抛ConcurrentModificationException。这种设计的优点是读操作性能极高没有锁竞争缺点是写操作每次都要复制整个数组如果数组很大或者写操作频繁内存开销和GC压力会非常大。所以它只适合读多写极少的场景典型应用是监听器列表、配置缓存等。5.3 CompletableFuture异步编排的正确姿势前面提到Future的get()是阻塞的多个Future难以组合。CompletableFuture就是为解决这个问题而生的它在JDK 8引入让你可以用函数式编程的方式编排异步任务。举一个真实项目的例子用户下单后需要同时做三件事——发送短信、扣减库存、生成订单日志这三件事互不依赖可以用allOf()并行执行CompletableFutureVoid smsFuture CompletableFuture.runAsync(() - sendSms()); CompletableFutureVoid stockFuture CompletableFuture.runAsync(() - deductStock()); CompletableFutureVoid logFuture CompletableFuture.runAsync(() - writeLog()); CompletableFuture.allOf(smsFuture, stockFuture, logFuture) .thenRun(() - System.out.println(所有操作完成));如果需要处理异常可以加exceptionally()或者handle()方法。exceptionally只处理异常相当于catchhandle不管成功失败都会调用参数里有result和exception两个值相当于finally。这里有个经验默认的ForkJoinPool是公共线程池如果多个业务共用可能出现任务堆积互相影响。所以CompletableFuture建议显式传入自定义线程池比如CompletableFuture.supplyAsync(() - queryData(), executorService)这样能把不同业务的异步任务隔离开不会互相拖累。5.4 ThreadLocal简单但极其容易踩坑ThreadLocal的作用是每个线程持有变量的一份独立副本线程之间互不干扰。非常典型的使用场景是SimpleDateFormat它是线程不安全的如果多个线程共用同一个实例会出现时间解析错乱的问题。我可以定义一个ThreadLocal来存储SimpleDateFormat实例这样每个线程都有自己的格式化对象。但ThreadLocal有个经典的内存泄漏坑。它的底层结构是ThreadLocalMap里面的Entry以ThreadLocal作为key而且这个key是弱引用。如果ThreadLocal对象被业务代码置为null而没有调用remove()那么Entry的key会被GC回收但value仍然被强引用链连接着无法释放。特别是使用线程池时线程是复用的ThreadLocal的生命周期就和线程绑定在一起了。如果线程执行完任务后不清理ThreadLocal下次这个线程再被分配新任务时还能读到上一次任务留下的脏数据这绝对是个巨坑。我的建议是每次使用ThreadLocal都要在finally块中调用remove()ThreadLocalSimpleDateFormat threadLocal ThreadLocal.withInitial(() - new SimpleDateFormat(yyyy-MM-dd)); try { SimpleDateFormat sdf threadLocal.get(); // 业务逻辑 } finally { threadLocal.remove(); }这样既不会内存泄漏也不会出现线程复用带来的脏数据问题。6. 高频题型的标准答法6.1 线程安全的单例模式怎么写单例模式是面试必考而线程安全的单例写法有好几种这里说说最推荐的方式。方式一双重检查锁 volatilepublic class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }这个写法里volatile很关键。因为instance new Singleton()并不是原子操作它实际包含三个步骤分配内存空间、调用构造器初始化对象、把引用指向内存地址。如果不加volatile指令重排序后可能先执行了第三步引用指向地址但对象还没初始化完成。此时另一个线程进来发现instance不为null直接返回一个没初始化好的对象程序直接爆炸。方式二静态内部类public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }这种方式利用类加载机制保证了线程安全因为静态内部类只有在getInstance()被调用时才会被加载和初始化而类的初始化阶段JVM会加锁保证线程安全。代码简单且没有synchronized同步开销是我个人最推荐的方式。6.2 快速排查死锁和线程问题的思路实际开发中遇到线上问题最怕的就是死锁和大范围线程阻塞。别慌按固定的排查流程走就行。第一步用jps找到Java进程PID然后执行jstack PID拿到线程的dump信息。jstack里会明确显示Found one Java-level deadlock并且指明哪些线程持有锁、哪些线程在等待锁。这是最直接的方式。第二步如果线程不是死锁而是大量BLOCKED或WAITING状态就需要看是哪个锁造成的。jstack里会显示线程等待的具体锁对象的hashcode再通过dump信息里Locked ownable synchronizers部分找到持锁线程。这个时候要重点排查持锁线程是否在做耗时操作比如数据库慢查询、远程调用超时等。第三步如果线程全部RUNNABLE但CPU占用极高可以用top -Hp PID查看线程级别的CPU占用然后printf %x\n 线程ID转换成十六进制再回到jstack里搜索十六进制线程ID就能定位到具体是哪个线程的哪段代码在疯狂计算。排查线程问题还有个工具很好用——Arthas阿里的开源诊断工具可以不用重启服务就动态观察方法执行时间、调用栈、Thread状态。遇到线程卡顿的问题直接thread -n 3查看最忙的3个线程再thread -b找出阻塞其他线程的线程排查效率直线提升。6.3 我在并发编程中踩过的三个坑最后分享几个自己真实踩过的坑希望你能跳过。第一个坑锁对象变了。之前写了一段代码用一个Boolean类型的变量当锁后来在过程中把这个变量重新赋值了一下。Boolean在值为true和false时会指向内部缓存的两个不同对象所以线程执行的锁对象和中途换掉之后的锁对象是两个不同的实例锁就失效了。所以记住锁对象要使用final修饰的引用字符串常量、基本类型包装类千万别拿来当锁。第二个坑线程池和ThreadLocal结合使用导致的数据串问题。之前做请求追踪链路把traceId放在ThreadLocal里用了线程池异步打印日志。线程池里的线程复用上一个请求的traceId没有清理导致下一个请求的日志全部串号排查问题的时候对不上号。后来在任务执行完后主动清理并且在提交任务时把traceId传递给异步任务问题才解决。第三个坑异步接口吞吐量看着上去了但数据库连接池被打爆了。这其实是我用线程池优化接口性能时没有考虑到下游资源的承受能力。线程池并发度设置得过高每个线程都去查数据库数据库连接池的最大连接数根本不够用出现大量连接等待超时。后来做了线程池和数据库连接池之间的配比控制给不同接口限制不同的最大并发数才算稳下来。说了这么多其实并发编程和做菜很像。工具就那些能不能把火候控制好、能不能把配料搭配得恰到好处全靠经验和理解。如果你看完了这篇文章建议自己动手把线程池的参数调一调、用jstack排查一下自己项目的线程状态踩过坑才能真正变成自己的东西。最后再补一句我写这篇文章的所有示例码都没有经过任何删改你完全可以放到自己的项目里跑一跑看看到底是怎么回事。祝你在Java并发这条路上越走越稳。