线程是Java并发编程的基石也是从初级走向高级必须跨过的一道坎。很多同学写了好几年业务代码线程也用了不少但一遇到线上问题就抓瞎线程池参数怎么配CPU飙高怎么排查死锁怎么定位这些背后其实都是对线程模型、生命周期、同步机制的理解不够扎实。这篇文章我打算从线程的本质讲起沿着创建方式、生命周期、同步机制、线程池、并发工具类到线上排查这一条主线往下走。全文不搞虚的每一段都是这些年实打实踩过的坑和验证过的方案适合想系统啃下Java并发这块硬骨头的同学也适合已经在写多线程代码但总觉得哪里没透亮的工程师对照自查。1. 线程的本质为什么并发编程绕不开它1.1 从进程到线程到底省了什么我们得先把线程到底解决了什么问题这件事想清楚。早期操作系统里一个进程就是一条执行流进程之间内存相互隔离切换进程的开销非常大——要保存和恢复完整的CPU上下文、页表、文件描述符等一大堆资源。后来人们发现很多任务并不需要完全隔离它们更希望共享同一块内存、同时并发执行这时候再开进程就太重了。线程的出现就是在进程内部再切出几条独立的执行流。线程是CPU调度的基本单位同一个进程下的线程共享堆内存和方法区只保留自己独占的程序计数器、虚拟机栈和本地方法栈。这意味着线程之间的通信成本极低——直接读写同一块内存就行但代价也来了数据同步、可见性、死锁这些问题全都跟着冒出来了。在JVM层面Java线程和操作系统线程是一一对应的。也就是说你每new一个Thread底层就会通过pthread_create之类的方式创建一个原生线程由操作系统负责调度。这个设计在JDK的很多版本里都是这么干的后来引入的虚拟线程Project Loom又是另一套玩法但在传统并发模型里理解原生线程的映射关系是做一切分析的基础。1.2 你写的多线程代码底层发生了什么很多初学者会有一个疑惑我明明就是调了thread.start()为什么代码就突然同时跑了背后的关键是CPU的时间片轮转。操作系统把CPU时间切成极小的片断分配给不同的线程由于切换极快人眼感知上就像在同时执行。实际上在单核CPU上多线程本质是伪并行真正并行需要多核CPU并且在同一个时刻有多个线程被分配到不同核心上执行。这里有个特别容易踩的坑不要以为写了几行线程代码就一定更快。如果任务是纯CPU密集型的线程数超过核心数反而会因为上下文切换导致性能下降。上下文切换不是免费的每次切换要保存寄存器、程序计数器等信息线程越多这个开销就越大。我在给一些团队做代码评审时经常看到有人为了并发硬把一个简单循环拆成几十个线程跑实测下来比单线程还慢好几个数量级这就是没搞懂底层模型。1.3 并发三要素原子性、可见性、有序性聊线程必然绕不开并发编程的三要素这是理解后面所有同步工具的前提。原子性说的是一个操作或者多个操作在CPU执行过程中不被中断的特性。比如count这个操作看起来是一行代码但底层要经历读取变量、计算加一、写回变量三个步骤任何一个线程在这中间插一脚结果就乱了。可见性是指一个线程修改了共享变量之后其他线程能不能立刻看到这个修改。由于CPU缓存的存在每个线程操作的有可能是副本改完不写回主内存别的线程就感知不到。有序性则是编译器和CPU对指令进行重排优化后代码执行顺序可能和书写顺序不一致这在单线程里没问题多线程共享变量时就会产生意外。我之前带过一个小伙伴他写了一个用boolean变量控制线程停止的demo。主线程改了标志位子线程却停不下来他百思不得其解。我让他给标志位加上volatile问题瞬间解决他当时就很惊讶。其实这就是典型的可见性问题——子线程从CPU缓存里一直读旧值根本没注意到主内存已经变了。2. 创建线程的三种姿势以及源码层面发生了什么2.1 三种创建方式的对比与选择创建线程的常规姿势有三种继承Thread类、实现Runnable接口、实现Callable接口配合FutureTask。先看继承Thread的方式public class MyThread extends Thread { Override public void run() { System.out.println(继承Thread方式); } } new MyThread().start();这种方式最直观但有几个硬伤一来Java是单继承你继承了Thread就不能继承别的类了二来线程的任务逻辑和线程本身耦合在一起扩展性差三来拿不到执行结果。实现Runnable接口是目前最推荐的基础方式public class MyTask implements Runnable { Override public void run() { System.out.println(实现Runnable方式); } } new Thread(new MyTask()).start();把任务和线程解耦同一个任务可以丢给不同线程跑线程池底层也是基于这套抽象。如果需要返回结果就得用CallableCallableInteger task () - 42; FutureTaskInteger futureTask new FutureTask(task); new Thread(futureTask).start(); Integer result futureTask.get();Callable的call()方法有返回值还能抛出受检异常。拿FutureTask包装后交给线程执行通过futureTask.get()阻塞等待结果。工程上我个人的习惯是能不用Thread子类就不用Runnable和Callable按需选择。而且从Java 8开始lambda表达式让这三种写法都可以变得非常简洁Runnable直接写成new Thread(() - System.out.println(hello), 线程名).start()就行。2.2 start()与run()这俩天差地别这是面试里几乎必问的一个问题但很多人实际写代码时也犯糊涂。调用run()只是普通的方法调用会在当前线程里同步执行run方法体的代码根本没有新线程。只有调用start()才会真正开启一个新线程。start()的底层逻辑值得看一眼它会调用一个native方法start0()这个方法会向JVM申请创建一个新的原生线程然后由操作系统调度执行最终由新线程去执行run()。也就是说start()是创建并启动线程的入口run()是新线程要执行的逻辑。一个特别容易踩的坑线程只能start一次。如果对一个已经start过的线程再次调用start()会抛出IllegalThreadStateException。原因也很好理解——线程一旦进入运行状态它的状态已经被JVM记录在案了重复启动从语义上就不允许。2.3 线程命名的艺术我想单独拎出来说这个因为太重要又太容易被忽略。线上排查问题时线程名就是你的门牌号。我见过太多生产事故里线程池跑出来的线程全都叫pool-1-thread-1dump出来的日志根本分不清是哪个业务在跑。正确做法是创建线程时显式给它一个有意义的名字Thread t new Thread(runnable, order-sync-worker);如果是线程池用ThreadFactory来统一命名ThreadFactory factory new ThreadFactory() { private final AtomicInteger cnt new AtomicInteger(1); Override public Thread newThread(Runnable r) { return new Thread(r, payment-callback- cnt.getAndIncrement()); } };用ThreadFactory还有一个隐藏好处可以顺便设置守护线程、优先级、异常处理器。我在项目里一般会在这里统一加上UncaughtExceptionHandler保证线程挂了至少能留下日志不至于静默消失。3. 线程生命周期六种状态与关键方法详解3.1 线程状态的完整流转JDK里Thread.State枚举定义了六种线程状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。需要注意的是Java层面的RUNNABLE状态其实包含了操作系统层面的运行中和就绪中两个状态因为JVM认为只要线程可以被调度执行就算RUNNABLE。整个流转过程是这样的new Thread()创建后处于NEW状态还没启动调用start()进入RUNNABLE线程进入synchronized同步块或者同步方法时如果没拿到锁就会进入BLOCKED阻塞状态拿到锁之后重新回到RUNNABLE当线程执行了Object.wait()、Thread.join()等操作时进入WAITING状态等待被显式唤醒如果是带超时时间的wait(1000)、sleep(1000)、join(1000)则进入TIMED_WAITINGrun方法执行完毕或者抛出未捕获异常后线程就进入TERMINATED状态。我经常用一张比喻帮人记RUNNABLE是在候机厅等飞机随时可能被调度上跑道BLOCKED是进了安检但被卡住排队等着别人把锁让出来WAITING是跑到咖啡厅休息没有闹钟必须别人来叫TIMED_WAITING是定了个闹钟睡一觉时间到自动醒或者提前被叫醒。3.2 wait、sleep、join、yield的行为差异这几个方法初学者特别容易混。先说sleep和waitsleep是Thread的静态方法调用后线程进入TIMED_WAITING但不会释放任何对象锁wait是Object的实例方法调用后线程进入WAITING或TIMED_WAITING并且会释放持有的对象锁。这个释放不释放锁的差异直接决定了你在写同步代码时该用哪个。举个实际例子// 错误示范sleep不释放锁会导致其他线程一直拿不到锁 synchronized (lock) { Thread.sleep(5000); } // 正确用法wait会释放锁让其他线程有机会进入 synchronized (lock) { lock.wait(5000); }join的作用是当前线程等待调用join的线程终止。比如在main线程里调thread.join()main会进入WAITING状态直到thread跑完。join的本质其实是基于wait/notify机制实现的JVM在Thread线程终止时会自动调用notifyAll来唤醒等待在join上的线程。yield是让出当前CPU时间片让同优先级的其他线程有机会执行但线程状态还是RUNNABLE。这个用得很少因为调度完全由操作系统决定yield顶多算一个建议。3.3 中断机制优雅地停止线程这是Java并发里最容易出问题的一个主题。JDK里Thread.stop()被标记为废弃了因为它会直接终止线程的执行导致数据不一致甚至资源不释放。Stop一个线程就等于在执行现场直接拔电源任何同步块、锁清理逻辑全部失效所以严禁使用。正确的停止方式是通过interrupt机制。线程实例上有一个中断标志位调用interrupt()会把这个标志位置为true。对于sleep、wait、join这种阻塞中的线程会直接抛出InterruptedException。这里有个非常关键的细节catch到InterruptedException之后中断标志位会被清除也就是重新变为false。所以正确的处理方式是先恢复中断标志try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 恢复中断状态 // 根据业务决定是否退出 return; }很多人不恢复中断标志导致外层代码虽然收到了异常信号但线程的中断状态还是false后续逻辑可能继续运行线程根本停不下来。这是我排查线上问题时见过的高频bug之一。对于一般任务线程我习惯在循环体里判断中断标志while (!Thread.currentThread().isInterrupted() 还有任务) { doWork(); }这样外部调用interrupt后线程会在下一个循环条件判断时体面退出把正在处理的这一件事办完。4. 线程安全的核心synchronized、volatile、CAS与锁升级4.1 synchronized的三种写法与锁的持有者锁本质上就是给共享资源划定一条同一时间只允许一个线程通过的红线。Java中最基础的锁是synchronized它有三种用法// 用法一修饰实例方法锁的是当前实例对象 public synchronized void methodA() {} // 用法二修饰静态方法锁的是当前类的Class对象 public static synchronized void methodB() {} // 用法三同步代码块锁的是括号里指定的对象 public void methodC() { synchronized (this) { // 临界区 } }注意实例方法和静态方法用的是两把不同的锁。同一个类的两个实例方法被两个线程各自访问时因为锁对象是各自实例互不干扰。静态方法锁的是Class对象和所有实例对象都不同。synchronized的底层原理在JVM层面是monitor enter和monitor exit指令。每个Java对象都有一个监视器锁monitor线程进入同步代码块前要获取锁退出时要释放锁。整个过程是JVM帮我们自动完成的不需要手动管理这也是它相比Lock接口更基础、更安全的原因。4.2 锁升级从偏向锁到重量级锁很多人以为synchronized锁一上来就是重的其实在HotSpot虚拟机里锁有四种状态无锁、偏向锁、轻量级锁、重量级锁。这是个逐渐升级的过程而且只升不降。偏向锁是针对只有一个线程访问同步块的场景设计的。第一个拿到锁的线程会在对象头里记录自己的线程ID之后再次进入同步块时不需要CAS去抢锁直接判断线程ID匹配就行。如果出现第二个线程竞争偏向锁会撤销并升级为轻量级锁。轻量级锁通过CAS自旋来尝试获取锁适合锁持有时间很短的场景。自旋有一定次数上限超过之后或者锁竞争非常激烈时就膨胀为重量级锁由操作系统内核的互斥量来实现这时候线程拿不到锁就会进入BLOCKED状态涉及到用户态和内核态的切换开销最大。从工程优化的角度看理解锁升级最大的意义在于不要在小范围内无脑加锁也不要把持锁时间拖得太长。锁持有时间长自旋的线程就多等待的线程也多压力全堆在锁上。4.3 volatile可见性问题的一剂良药volatile解决了可见性和有序性问题但不解决原子性。它有两个核心语义第一每次读取 volatile 变量会直接从主内存读每次写入 volatile 变量会立即写回主内存。这样其他线程就能读到最新值。第二通过内存屏障禁止指令重排序保证volatile变量前后的代码顺序不被打破。经典的双重检查锁单例模式就是靠volatile修好的。静态实例字段用volatile修饰防止其他线程读到半初始化的对象public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }不加volatile为什么不行因为new Singleton()这一步底层有多个步骤分配内存、初始化对象、把引用指向内存。这三个步骤可能被重排序如果先把引用指向了还没完成初始化的内存另一个线程读到这个引用就会返回一个半成品对象。加上volatile之后重排序被禁止初始化一定发生在引用赋值之前。4.4 CAS与原子类无锁并发的思路CASCompare And Swap是一种无锁的原子操作。它的核心逻辑是先比较内存中的值是否等于我预期的旧值如果相等就把新值写入否则说明别人改过了操作失败。整个过程在硬件层面是原子指令不需要加锁。java.util.concurrent.atomic包下所有原子类都基于CAS实现。比如AtomicInteger的incrementAndGetAtomicInteger counter new AtomicInteger(); counter.incrementAndGet();它的底层会在一个循环里做CAS尝试直到成功为止。这叫做自旋。CAS也不是没有毛病最著名的就是ABA问题变量值从A改成B又被改回ACAS检查时发现还是A就认为没被修改过但中间其实经历了一次变更。解决ABA问题可以用带版本号的AtomicStampedReference。从一个过来人的角度看CAS和锁的选择不是谁优谁劣而是看场景竞争激烈时CAS会疯狂自旋浪费CPU不如直接上锁竞争缓和时CAS没有线程切换开销性能更好。5. 死锁的产生、排查与工程化规避5.1 死锁的四个必要条件死锁虽然听起来很高级但原理极其简单一组线程互相持有对方需要的锁谁也不让谁都进行不下去。教科书上讲死锁需要满足四个条件互斥、持有并等待、不可剥夺、循环等待。实际开发中我遇到最多的死锁场景是线程A拿到锁1等待锁2线程B拿到锁2等待锁1。这种交叉持锁用大白话说就是你踩了我的脚我踩了你的脚谁先疼谁先求饶但没人求饶。5.2 用jstack定位死锁的完整步骤有一年我们在压测一个交易系统时接口突然大面积超时服务还在运行但请求基本不返回。我第一时间打了线程dump用jstack命令抓下现场jstack -l 进程PID thread_dump.txt打开dump文件最显眼的位置就是Found one Java-level deadlock这几个字后面会列出参与死锁的线程、各自持有的锁、正在等待的锁。配合代码行号几乎可以立刻锁死问题代码。那次的问题出在两个不同服务之间同步调用了对方的接口各自线程池里的线程都被对方等着典型的分布式环境下的等待链死锁。这也是一个很深刻的教训死锁不只是synchronized之间的事任何等待资源释放的场景都可能演化成死锁包括等待另一个线程池的线程归还。5.3 工程化规避死锁的手段规避死锁最有效的办法是破坏循环等待条件。规则很简单多个锁必须按照固定的全局顺序去获取。比如我们要同时操作订单和库存两张表就定死规矩——先锁订单再锁库存。人人都遵守就不会出现交叉持锁。另一种办法是使用带超时时间的锁获取比如Lock接口的tryLock(timeout)Lock lock1 new ReentrantLock(); if (lock1.tryLock(2, TimeUnit.SECONDS)) { try { // 拿不到锁就放弃不让线程无限等下去 } finally { lock1.unlock(); } }tryLock拿不到锁时可以选择释放之前的锁、回滚、重试或者降级给系统留一条活路。我在设计对账任务时就用过这个方案实测下来能避免相当一部分隐性死锁。6. 线程池实战核心参数、工作流程与配置经验6.1 七个核心参数到底在说什么线程池是生产环境中使用频次最高的并发组件。ThreadPoolExecutor的构造方法有七个参数每个都别有深意。核心线程数corePoolSize是线程池的基本盘即使空闲也倾向于保留的数量。最大线程数maximumPoolSize是线程池能容纳的线程上限。keepAliveTime和unit配合使用指的是非核心线程空闲多久后被回收。阻塞队列workQueue是核心线程都在忙时新任务排队等待的区域。线程工厂threadFactory控制线程的创建方式拒绝策略handler则决定队列也满、线程也达到上限时新任务怎么处理。这几个参数之间的关系可以用一个简单的流程记忆任务来了先看核心线程有没有空闲有就干没有就丢队列排队队列满了再看能否加非核心线程加到最大值还不行就触发拒绝策略。6.2 一个任务进来后经历了什么我们用一个具体例子把流程走一遍核心线程数2最大线程数4队列容量3。提交第1个任务核心线程1执行提交第2个任务核心线程2执行。这时再提交第3个任务核心线程都忙丢进队列。第4、5个任务同样进队列。当第6个任务提交时队列已经满了容量3于是尝试创建非核心线程最大线程数4还有余量创建线程3来执行。第7个任务来时队列仍然满创建线程4来执行。第8个任务再来核心线程2非核心线程2都忙、队列也满触发拒绝策略。这里有一个特别容易踩的坑很多人以为队列满了才会创建非核心线程这是对的但要注意队列满不满取决于你传入的是有界还是无界队列。如果用无界队列比如LinkedBlockingQueue默认的Integer.MAX_VALUE容量那队列永远不会满意味着maximumPoolSize完全没意义非核心线程永远不会创建。6.3 为什么我建议你远离Executors的快捷方法Executors提供了一批现成方法比如newFixedThreadPool、newCachedThreadPool、newSingleThreadExecutor。我不建议在正式项目里直接使用因为它们的默认参数全是坑。newFixedThreadPool用的是无界队列积压任务可能挤爆内存。newCachedThreadPool的最大线程数是Integer.MAX_VALUE任务一多疯狂建线程导致文件描述符耗尽。newScheduledThreadPool也是无界队列。用《Java并发编程实战》里那句经典总结这些快捷方法没有一个适合生产环境的。正确的做法是用ThreadPoolExecutor显式指定所有参数尤其是有界队列和拒绝策略。我在项目里的一个典型配置是ExecutorService executor new ThreadPoolExecutor( 8, 16, 30L, TimeUnit.SECONDS, new ArrayBlockingQueue(200), new ThreadPoolExecutor.CallerRunsPolicy() );6.4 核心线程数怎么算公式与经验值线程数的计算是个老生常谈但又谁也绕不开的问题。通用经验法则是CPU密集型任务线程数设为CPU核数1让每个线程都被充分压榨CPUIO密集型任务线程数设为CPU核数×2因为IO等待期间线程在阻塞需要更多线程来顶上。如果要做得更精细可以用下面的公式估算线程数 CPU核数 × (1 等待时间/计算时间)比如一个任务平均计算耗时20毫秒IO等待耗时80毫秒等待/计算48核CPU的合理线程数就是8×(14)40。我在实际调优时通常不是一上来就算死而是先给一个保险值压测看CPU利用率和RT响应时间再逐步调整。线程数不是越多越好线程多了上下文切换开销就大线程少了压不满CPU。6.5 拒绝策略怎么选JDK提供了四种拒绝策略。AbortPolicy是默认的直接抛RejectedExecutionException适合能接受任务丢弃的场景或者你想显式感知系统过载。DiscardPolicy和DiscardOldestPolicy都是静默丢弃风险很大一般不建议。CallerRunsPolicy特别有意思它不让线程池执行任务而是让提交任务的那个线程自己来跑相当于一个天然的节流阀——提交线程都在忙着执行任务了哪里还有精力继续提交任务。我在关键链路里比较喜欢用CallerRunsPolicy宁可拖慢提交方也不丢任务。7. JUC并发工具类并发协作的正确打开方式7.1 CountDownLatch一个线程等N个线程CountDownLatch的经典场景是主线程等待多个子线程都完成任务后再继续。比如并发查询多个维度数据每个查询一个线程主线程用await()等它们全部完成再汇总。关键在于countDown()和await()的配合。初始计数器为N每个子线程任务完成后调用一次countDown()把计数器减一主线程await()会阻塞直到计数器归零。计数器不能重置所以它适合一次性的并发协作。实际开发中要注意countDown()最好放在finally里执行不然子线程中途抛异常会导致计数器永远减不到零主线程就永远等下去。7.2 CyclicBarrierN个线程互相等待CyclicBarrier和CountDownLatch方向相反。它是让N个线程都到达某个屏障点之后再一起继续往下走。典型的场景是批量任务中的分阶段同步多线程各自处理数据处理完一批后在屏障处集合等大家都到了再开始下一批。CyclicBarrier的Cyclic就是可循环的意思计数器到达归零后会自动重置所以可以重复使用。它和CountDownLatch最本质的区别CountDownLatch是别的线程等我去减数CyclicBarrier是所有线程等我到了再一起走。7.3 Semaphore信号量控制并发上限Semaphore就是许可的管理员。初始化一个数字N表示最多允许N个线程同时访问某个资源。线程执行前acquire()拿许可执行完release()还许可。没有许可以就得阻塞等待。它的典型应用是限流和连接池管理。比如某个下游接口只允许同时5个请求超出只能等待信号量就是最佳工具。有一点必须注意release()必须在finally里调用否则一旦线程执行中途异常许可就烂在手里其他线程越积越多等在这里系统很快瘫痪。7.4 ConcurrentHashMap:从分段锁到CAS锁这是并发容器里最值得钻研的一个类。Java 7时代的ConcurrentHashMap用分段锁把整个Map分成若干段每段独立加锁多个线程只要操作不同段就能并发执行。Java 8之后改成了CASsynchronized的组合数组每个元素节点独立加锁锁粒度更细并发度更高。这里也解释了为什么并发场景下优先用它而不是Hashtable或者Collections.synchronizedMap。后两者是全局锁所有操作串行化性能差得不是一星半点。而ConcurrentHashMap的get操作连锁都不用直接读写操作也只在对应桶上锁高并发下吞吐量优势非常明显。我个人实战里还有个小提醒ConcurrentHashMap的size()方法在高并发下不一定返回精确值它为了性能允许一定的近似性。如果需要绝对精确的统计值考虑额外计数器或者加锁保证。8. 线上线程问题排查与性能调优实录8.1 线程dump的正确抓取姿势线上出了问题线程dump是第一手现场证据。最常用的命令是jstack但要抓得准是有讲究的。如果进程刚好在线直接jstack PID可以如果问题是偶发性的建议连续抓多份dump文件间隔几秒对比看线程状态的变化趋势。单份dump可能什么都看不出来多份dump才能反映线程到底卡在哪了。还有一种特殊情况进程假死JStack命令本身都卡住了这时候要用jstack -F强制模式。JVM参数层面我习惯在启动脚本里加上输出线程dump到本地文件的能力出问题时快速把现场保留下来。8.2 CPU飙高三步定位到线程代码行线上CPU飙高是并发问题里最醒目也最急的故障。整套排查流程我已经固化下来了基本三步走。第一步top命令找到CPU占用最高的进程PID。第二步top -Hp PID找到该进程内CPU占用最高的线程ID把它转成十六进制printf %x\n PID。第三步执行jstack PID打出线程dump在dump文件里搜索十六进制线程ID那一段堆栈几乎直接就是问题代码的位置。这个问题如果出现在我们自己的代码里最常见的几个原因无限循环、空转自旋、无阻塞的while循环、或者某些资源竞争导致线程疯狂重试。曾经我排查过一个CPU打满的问题最后定位到是某个日志框架在极高并发下内部有自旋锁竞争线程全在自旋空转把CPU耗干了。找到之后换成异步日志问题当场解决。8.3 高频坑位被反复踩的并发错误第一个坑是在循环里new线程。每循环一次就创建一个线程任务稍多线程数量就失控。正确做法是先用线程池缓存任务把创建线程的开销和数量管理统一收口。第二个坑是忘记设置线程名。出问题时dump打出来一堆默认名字排查效率极低。线程名在排查工具里就是一线情报务必从创建之初就好好起名。第三个坑是ThreadLocal内存泄漏。ThreadLocal在线程池环境下等于一个隐藏的脏数据仓库线程池里的线程复用不会销毁ThreadLocal里的值会一直残留。用完之后务必调用remove()清理。第四个坑是任务中吞掉了InterruptedException。日志没打、标志位没恢复导致线程无法被正确中断。这在前面中断机制小节里已经详细讲过是最容易忽略但又影响很大的一个细节。写在最后的小心得抓线程这块我觉得最大的坎不在API而在思维方式。写并发代码时脑子里必须始终绷着一根弦共享变量是否安全、锁的顺序是否一致、线程是否会被一直阻塞、任务堆积时系统怎么兜底。这些年我处理过的并发线上事故十次里有八次不是输在方案设计而是输在对细节的忽视上——忘了finally里释放锁、忘了捕获中断异常后恢复标志、忘了限流、忘了给线程起名字。如果你刚入门先把synchronized、volatile、线程池这几个基础吃透再去碰各种并发工具类。如果你已经在写并发代码我强烈建议你从今天开始给每个线程起好名字亲手抓一次线程dump分析一下。这个习惯养成了后面排查任何并发问题都会省力很多。