Java多线程并发编程核心:锁机制、线程池与实战避坑指南

Java多线程并发编程核心:锁机制、线程池与实战避坑指南 1. 为什么每个 Java 开发者都绕不开多线程先说说我自己的经历。刚工作那会儿我接手过一个数据同步模块单线程跑一批任务要四十多分钟每次等得都想摔键盘。后来领导提了一句“用多线程试试”我吭哧吭哧写了个new Thread扔循环里结果数据乱成了一锅粥查了一天一夜才发现是共享变量被并发修改了。那次之后我才明白多线程不是“会用 API”就行而是要真正理解并发背后的原理。多线程之所以成为 Java 面试的“钉子户”是因为它几乎能考察一个开发者所有的底层素养——操作系统知识、JVM 内存模型、锁的机制、性能调优意识甚至代码设计的权衡能力。你会发现很多公司招人时问的第一轮技术题就有“聊聊你对多线程的理解”这不是没有道理的。无论你用 Java 做后端服务、大数据处理还是写中间件多线程都是绕不开的核心能力。这篇文章我打算彻底讲透 Java 多线程这条线从最基础的概念讲起逐步深入到线程创建、并发问题、锁机制、JUC 工具类、线程池最后再整理一些高频面试题和我在实战中踩过的坑。内容偏实战导向每一部分都会解释“为什么”而不是只会“怎么用”。适合刚接触多线程的初学者也适合准备面试、想系统梳理知识体系的进阶开发者。我会用尽量通俗的方式表达复杂的地方配类比和例子保证你能看懂、能记住、能用在项目里。2. 线程与进程先搞清楚这两个最底层的概念2.1 进程是“车间”线程是“工人”很多人一上来就去背“进程是资源分配的最小单位线程是 CPU 调度的最小单位”背完了还是不懂。我换个说法。可以把运行中的程序想象成一个车间。车间里有独立的地盘、设备、原材料仓库这些东西是车间独占的其他车间不能随便进来拿——这就是进程拥有独立的资源空间。你启动一个 Java 程序JVM 就会创建一个进程给它分配独立的内存区域。车间里会有多个工人干活这些工人共享车间里的设备、工具和原材料——这就是线程共享进程的资源。Java 程序运行时main方法所在的线程就是第一批进车间的工人你还可以再招更多人进来一起干活。关键区别在几个地方资源隔离程度进程之间相互独立一个进程崩溃通常不会直接搞挂另一个进程线程之间共享内存一个线程乱改数据可能直接带崩整个进程。切换开销进程切换需要保存和恢复大量的上下文信息开销大线程切换轻量得多所以多线程在并发场景下性价比远高于多进程。通信方式进程间通信要用管道、消息队列、共享内存等 IPC 机制比较麻烦线程直接读写共享变量就能通信但方便的同时也带来了并发安全问题。2.2 并发和并行一字之差含义完全不同这两个词经常被混用但在多线程里它们是两个维度的东西。你一定见过那种“一个人边吃泡面边看剧”的形容——这其实是在描述“并发”。并发是同一时间段内多个任务交替执行。单核 CPU 上也能实现并发因为 CPU 在多个线程之间快速切换看起来像同时进行其实是“分时复用”。并行是同一时刻多个任务真的同时执行。这需要多核 CPU 支持每个核心各自执行一个线程才是真正的“同时”。写代码的时候你并不需要显式区分这两个概念但理解它们能帮你正确评估性能期望。比如你有一个 8 核的服务器理论上并行执行的线程数上限大约是 8还要算上超线程超过这个数量并不是坏事但你要明白再多的线程对 CPU 密集型任务来说不会带来线性性能提升反而可能因为上下文切换而变慢。2.3 为什么说线程是轻量级的“轻量级”这三个字网上很多文章都只是带过。我直接说底层逻辑。操作系统创建一个进程要做的事情包括分配独立的内存地址空间、初始化页表、建立文件描述符表、分配代码段和数据段等等这是一整套“重装备”。而创建一个线程只需要在已有进程的基础上分配一个独立的栈空间和一组寄存器状态线程共享进程的代码段、数据段和堆内存。正因如此线程的创建速度远快于进程线程之间的切换也不需要切换地址空间这大大减少了切换开销。Java 里线程和操作系统线程是一一对应的Thread对象的创建底层会调用操作系统的线程创建接口。这一点在 JDK 21 之前是常态后面出了虚拟线程才有所改变——不过这是后话基础阶段你先把原生线程模型吃透。3. 线程的创建方式与生命周期从 new 到 TERMINATED3.1 三种创建方式哪种最好用第一种继承 Thread 类public class MyThread extends Thread { Override public void run() { System.out.println(线程运行中: Thread.currentThread().getName()); } public static void main(String[] args) { MyThread t new MyThread(); t.start(); } }这是最古老的写法缺点很明显Java 是单继承你继承了Thread就不能继承其他类了扩展性差。而且Thread类本身代表的就是“线程”这个概念你把业务逻辑塞进线程类里职责就不清晰了。不推荐但面试可能会问你要知道它的缺点。第二种实现 Runnable 接口public class Task implements Runnable { Override public void run() { System.out.println(任务执行中: Thread.currentThread().getName()); } } public class Main { public static void main(String[] args) { Thread t new Thread(new Task()); t.start(); } }这是经典写法比继承Thread好因为把“任务”和“执行任务的线程”解耦了。你的任务类只关心业务逻辑至于它跑在哪个线程上、生命周期怎么管理交给Thread对象去管。这是初学者最推荐掌握的写法。第三种实现 Callable 接口结合 FutureTaskpublic class CallableTask implements CallableString { Override public String call() throws Exception { Thread.sleep(2000); return 任务执行结果; } } public class Main { public static void main(String[] args) throws Exception { FutureTaskString futureTask new FutureTask(new CallableTask()); Thread t new Thread(futureTask); t.start(); // 在这里可以做其他事... System.out.println(主线程继续干别的活); // 需要结果时再获取此时如果任务没完成会阻塞等待 String result futureTask.get(); System.out.println(获取到结果: result); } }Callable解决了Runnable的痛点Runnable的run()方法没有返回值也不能抛出受检异常。Callable的call()方法既能返回泛型结果也能声明抛出异常。FutureTask是Runnable和Future的合体既可以丢给Thread跑也可以用来获取结果、取消任务。这三种方式不是替代关系而是递进关系。前面两种适合“执行任务”第三种适合“需要任务执行结果”的场景。但是注意直接new Thread这种方式在现代生产代码中几乎已经绝迹了因为手动创建线程的代价高、难以管理后面讲线程池的时候你会明白为什么。3.2 生命周期六种状态一张表看懂Java 线程有六种状态定义在Thread.State枚举中。很多面试题让你“说说线程的状态有哪些”这块必须背得滚瓜烂熟。状态进入条件说明NEWnew Thread()之后start()之前线程刚创建还没启动RUNNABLE调用start()之后就绪状态等待 CPU 调度执行包含了操作系统层面的 running 和 ready 两种状态BLOCKED等待获取监视器锁synchronized 锁想进入同步代码块但锁被别的线程持有WAITING调用wait()、join()、LockSupport.park()无限期等待需要其他线程唤醒TIMED_WAITING调用sleep(ms)、wait(timeout)、join(timeout)、LockSupport.parkNanos()有超时时间到点自动醒来TERMINATEDrun()方法执行完毕或抛异常线程生命周期结束有个细节需要注意BLOCKED 和 WAITING 是两种不同的“等”。BLOCKED 是等着拿synchronized锁拿不到就进不了同步块WAITING 是你拿到了锁、主动调用wait()释放锁去等一个条件。前者是被动等待后者是主动让出。很多人答的时候混在一起一下就露馅了。再看一个容易混淆的Thread.sleep()不会释放锁。Object.wait()会释放锁。这两个问题的组合是面试高频我在后面的问题汇总里还会重点讲。3.3 start() 和 run() 的区别面试必背你可能会觉得这问题太基础了但真问起来很多人会答歪。直接一点说直接调用run()只是普通方法调用执行run()的还是当前线程通常是主线程不会新建线程。调用start()才会创建一个新线程由新线程去执行run()方法里的逻辑。看一下start()到底做了什么它会让线程进入 RUNNABLE 状态等待 CPU 调度一旦被调度就会执行run()方法。而run()方法本身是从Runnable接口里继承下来的它只是一个回调方法。如果你没有通过start()启动线程而直接调run()那就等于在主线程里写了个普通的循环调用完全没用到多线程。我遇到过不止一个初学者在这个地方踩坑写完t.run()后说“为什么我的多线程没生效”检查半天才发现不是start()。这个点虽小但特别典型。4. 并发编程的三大特性原子性、可见性、有序性4.1 先说清楚问题从哪来JVM 内存模型里有一个主内存每个线程还有自己的工作内存可以理解为 CPU 缓存。线程对变量的所有操作都是在工作内存里进行的操作完再同步回主内存。这就引出两个问题线程 A 改了变量线程 B 不知道读到的还是旧值——可见性问题。多个线程同时对同一个变量做“读-改-写”操作互相覆盖——原子性问题。再加上编译器和 CPU 为了优化性能会调整指令顺序还有有序性问题。这三个问题把所有并发 bug 的根源都概括了。你想要写对多线程代码本质上就是在和这三个问题作斗争。4.2 原子性要么全做要么全不做所谓原子性是指一个操作是不可分割的中间不能插入其他线程的操作。比如count这行代码看起来是一条语句但底层对应了三条指令读取 count 的值、count 加 1、把新值写回。三个线程同时执行count可能导致最终结果远小于预期。为什么因为线程 A 读到了 count1还没写回线程 B 也读到了 count1两个都加 1 后写回结果只加了 1 而不是 2。这是最经典的“丢失更新”问题。要保证原子性最直接的手段是加锁也就是让“读-改-写”这段代码在同一时刻只允许一个线程进入。synchronized、Lock都是干这个事的。后面会详细展开。4.3 可见性一个线程改了另一个线程看不见说个我自己实际遇到过的事。我写过一段生产者-消费者的演示代码生产者线程给一个boolean flag赋值true消费者线程在循环里等这个 flag 变成true才终止。写了while (!flag) {}结果跑了半天主线程也不退出——消费者线程读到的 flag 始终是false。原因就是生产者在自己的工作内存里改了 flag消费者的工作内存看不到这个变化两边各看各的。用volatile修饰 flag 就解决了。volatile的底层原理是对 volatile 变量的写操作会立即刷新到主内存对 volatile 变量的读操作会直接从主内存读取并且会通过内存屏障禁止指令重排序。这里必须说清一点volatile能保证可见性和有序性但保证不了原子性。它适合那种“一个线程写、多个线程读”的场景不适合count这种复合操作。前期最容易犯的错就是把volatile当成“万能并发神器”。4.4 有序性指令重排导致的隐蔽 bug编译器和 CPU 为了提升执行效率可能会对没有数据依赖的指令进行重排序。单线程下重排无所谓因为结果是等价的但多线程下重排序可能让你的代码逻辑“看起来不太对”。最经典的例子是双重检查锁单例DCL。public 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; } }instance new Singleton()这行代码底层分三步分配内存、调用构造方法初始化对象、把引用指向这块内存。如果第三步被重排到第二步之前另一个线程就可能拿到一个“已经分配内存但还没初始化完成”的对象然后去用它就会出现问题。解决办法就是在instance字段上加volatile。volatile会插入内存屏障禁止对 volatile 变量的读写操作重排序。这个例子也是面试里特别高频的“为什么 DCL 要加 volatile”。5. synchronized 与锁多线程的核心机制5.1 用洗手间类比锁机制想象一个公共洗手间只有一个坑位。一个人进去了把门锁上其他人只能在门口排队等着。那个人完事了开门出来下一个人才能进去。这就是synchronized的基本逻辑——同一时间只有一个线程能进入临界区同步代码块其他线程必须阻塞等待。Java 中synchronized有三种用法修饰实例方法锁的是当前实例对象。public synchronized void add() { count; }修饰静态方法锁的是当前类的 Class 对象。public static synchronized void add() { count; }修饰代码块可以指定任意对象作为锁。public void add() { synchronized (this) { count; } }这里要注意一个常见的误区如果两个线程访问的是不同实例的同步方法它们是互不阻塞的因为锁的对象不同。只有锁同一个对象才会产生竞争关系。5.2 锁升级从偏向锁到重量级锁这是 Java 多线程面试里的重头戏也是区分“背过”和“真懂”的分水岭。早期 Java 的synchronized是重量级锁每次加锁都要通过操作系统的互斥量实现线程阻塞和唤醒都需要用户态和内核态之间切换开销极大。后来 HotSpot 做了大量优化引入了锁升级机制。现在的锁状态有四种无锁、偏向锁、轻量级锁、重量级锁。偏向锁同一个线程第一次拿到锁后会在对象头里记录这个线程的 ID。之后这个线程再来无需任何 CAS 操作直接进入同步块。适用于“锁被同一个线程反复获取”的场景。轻量级锁当有第二个线程来竞争时偏向锁会升级为轻量级锁。这个阶段线程通过自旋CAS 循环尝试获取锁去抢锁而不是直接阻塞。适用于“线程交替执行同步块、竞争不激烈”的场景。重量级锁如果自旋超过一定次数或者等待的线程太多轻量级锁会升级为重量级锁。这时未获取到锁的线程会被真正挂起进入 BLOCKED 状态等待操作系统唤醒。这个机制的演进核心思想是尽量用便宜的方案解决并发问题。绝大多数同步代码块根本不存在激烈竞争用偏向锁打个标就行少数情况竞争加剧再用自旋顶一顶实在顶不住了才动用重量级锁。5.3 wait/notify线程间的协作机制synchronized解决了互斥问题但线程之间的“协作”还需要另一套机制。经典的场景是生产者-消费者缓冲区满了生产者要等待缓冲区空了消费者要等待。这时候就需要wait()和notify()。wait()的作用是让当前线程释放锁并进入 WAITING 状态等待被唤醒。notify()的作用是随机唤醒一个在该锁上等待的线程。notifyAll()则是唤醒所有在等待的线程。这里有几个硬性要求缺一个都会报IllegalMonitorStateExceptionwait()、notify()、notifyAll()都必须在synchronized代码块或方法里使用。调用的对象必须是当前持有的锁对象。wait()会释放锁但notify()不会立即释放锁要等 synchronized 代码块执行完才释放。实际写等待条件的时候强烈建议用while而不是if来检查条件。原因是线程被唤醒后可能条件又被其他线程改了while会重新检查条件避免“虚假唤醒”问题。synchronized (lock) { while (queue.size() MAX_SIZE) { lock.wait(); // 缓冲区满了等 } queue.add(item); lock.notifyAll(); }6. JUC 并发工具从 Lock 到并发容器6.1 Lock 接口与 synchronized 的对比JDK 5 引入了java.util.concurrent包简称 JUC它的核心之一就是Lock接口和ReentrantLock实现类。相比synchronizedReentrantLock的优势在于支持中断响应lock.lockInterruptibly()允许线程在等待锁的过程中响应中断。支持超时获取锁tryLock(timeout, unit)到点还没拿到锁就放弃避免无限阻塞。支持公平锁通过构造函数传入true实现公平锁让等待最久的线程先拿锁。支持多个条件变量newCondition()可以创建多个等待队列更精细地控制线程唤醒。基本用法ReentrantLock lock new ReentrantLock(true); // true 表示公平锁 lock.lock(); try { // 临界区代码 } finally { lock.unlock(); // 一定要在 finally 里释放 }这里我特别强调finally解锁是因为一旦临界区发生异常unlock()不执行会直接导致锁无法释放其他线程就全部卡死。这种事故我见过不只一次。synchronized则是 JVM 层面的隐式锁不需要手动释放但功能相对固定。选择建议简单场景、不追求高级特性优先用synchronized需要超时、中断、公平性、多条件等高级特性用ReentrantLock。6.2 CAS 与原子类无锁并发的利器AtomicInteger、AtomicLong等原子类为什么能保证线程安全却不用加锁答案是 CASCompare And Swap。CAS 是一种硬件级别的原子指令它的逻辑是比较当前值是否等于预期值如果相等就更新为新值否则就说明有其他线程改了值操作失败。整个过程是原子的不存在并发问题。注意CAS 有个经典问题——ABA 问题。假设线程 A 读到值是 1线程 B 把值改成 2 又改回 1线程 A 再执行 CAS 时发现还是 1就以为没人动过。实际上中间变了两次。AtomicStampedReference通过引入版本号来解决这个问题每次修改版本号加一就能检测出 ABA。不过对于大多数业务场景ABA 问题影响不大面试能说出来就是加分项。6.3 常用并发工具类盘点JUC 包里工具特别多我挑几个高频的说说。CountDownLatch一个计数器初始化一个值多个线程执行完某个操作后调用countDown()减一主线程调用await()等待计数器归零。适合“等待 N 个线程都完成后主线程再继续”的场景。CountDownLatch latch new CountDownLatch(3); for (int i 0; i 3; i) { new Thread(() - { System.out.println(子任务完成); latch.countDown(); }).start(); } latch.await(); // 主线程等待 3 个子任务完成 System.out.println(全部完成);Semaphore信号量控制同时访问某一资源的线程数量。比如限制某个接口最大并发数为 10就初始化new Semaphore(10)线程进入前acquire()获取许可用完release()释放许可。这在实际限流场景中特别实用。CyclicBarrier让一组线程互相等待等所有线程都到达一个屏障点之后再一起继续。和 CountDownLatch 的区别是栅栏可以循环使用Latcher 是一次性的。如果面试官问“CountDownLatch 和 CyclicBarrier 有什么区别”你就答前者一个或多个线程等待其他线程完成不可复用后者是一组线程互相等待可复用。6.4 并发容器别再手动加锁了写多线程代码时很多人第一反应是给HashMap、ArrayList的操作加锁。这种思路没错但太原始。JUC 提供了大量现成的并发容器性能和正确性都经过充分验证。ConcurrentHashMap这是并发场景下最常用的容器。JDK 8 以后它放弃了分段锁改用CAS synchronized实现。读操作不加锁写操作锁住桶的首节点并发度大幅提升。简单场景直接把HashMap换掉就行。CopyOnWriteArrayList写时复制。写操作会复制一份新的数组在新数组上修改然后替换引用读操作在旧数组上执行无需加锁。适合“读多写极少”的场景比如缓存黑白名单。BlockingQueue阻塞队列。ArrayBlockingQueue、LinkedBlockingQueue都实现了阻塞队列接口put()在队列满时阻塞take()在队列空时阻塞天然适配生产者-消费者模型。使用这些容器的最大价值在于你不需要自己控制复杂的同步逻辑降低 bug 概率而且它们的内部实现经过了大量场景验证性能远比你自己写个加锁容器可靠。7. 线程池生产环境用线程的正确姿势7.1 为什么不用 new Thread每次new Thread都要经历创建、执行、销毁的完整过程在高并发场景下会带来几个问题线程创建和销毁本身有开销大量短生命周期任务会导致频繁创建销毁线程浪费系统资源。线程数量没有上限如果任务积压可能创建出成千上万个线程直接压垮系统。线程缺乏统一管理难以控制并发度、难以监控状态。线程池解决的核心问题就是线程的复用和数量约束。预先创建一批线程任务提交进去后复用这些线程执行空闲的线程不会销毁等待下一个任务。7.2 ThreadPoolExecutor 的七大参数面试必问我一个个讲清楚。public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueueRunnable workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)corePoolSize核心线程数即使空闲也不会销毁的线程数。maximumPoolSize最大线程数线程池允许的最大线程数包含核心线程和非核心线程。keepAliveTime空闲存活时间非核心线程空闲多久后被回收。unit时间单位配合 keepAliveTime 使用。workQueue任务队列核心线程都忙时新任务先进队列排队等待。threadFactory线程工厂创建线程的工厂通常用来自定义线程名方便排查问题。handler拒绝策略线程池饱和、任务无法执行时的处理策略。7.3 任务执行的完整流程当一个任务提交给ThreadPoolExecutor时执行顺序是这样的如果当前线程数小于corePoolSize创建核心线程处理任务。如果当前线程数达到corePoolSize任务进入workQueue排队。如果队列满了且线程数小于maximumPoolSize创建非核心线程处理任务。如果队列满了且线程数达到maximumPoolSize触发拒绝策略。这里有个常见的坑很多初学者以为“池子创建好了任务进来线程数会先达到 maximum 再排队”实际上这个顺序是反的是先排队、队列满了才创建非核心线程。这个顺序记不住的话画图理解特别容易。7.4 四种拒绝策略别选错策略行为适用场景AbortPolicy直接抛RejectedExecutionException默认策略能直接发现异常CallerRunsPolicy调用者线程自己执行任务不想丢任务也起到天然限流作用DiscardPolicy静默丢弃无任何提示任务允许丢失不推荐DiscardOldestPolicy丢弃队列头部最旧任务重新尝试提交允许丢弃旧任务我最常用的是CallerRunsPolicy因为它不会丢任务只是让提交任务的线程自己跑这个任务相当于把“拒绝”变成“降级”。核心思想是任务不能丢哪怕慢一点。7.5 手动创建还是用 Executors阿里巴巴开发规范明确禁止使用Executors提供的快捷方法创建线程池原因是它们是“有隐患的快捷方式”。Executors.newFixedThreadPool()队列是LinkedBlockingQueue默认容量是Integer.MAX_VALUE任务堆积时可能产生大量队列积压占满内存。Executors.newCachedThreadPool()最大线程数是Integer.MAX_VALUE任务过多时可能创建出海量线程耗尽 CPU 和内存。Executors.newScheduledThreadPool()同样是无限队列。生产环境建议用ThreadPoolExecutor显式传参创建或者交给 Spring 的线程池封装。至少你能知道队列有多大、线程上限是多少、拒绝策略是什么这比“看起来简单”的快捷方法可靠得多。7.6 核心线程数到底设置多少这是实践中绕不开的问题给出一个经验公式。CPU 密集型任务核心线程数设置为CPU 核数 1。IO 密集型任务核心线程数设置为CPU 核数 * 2。为什么 IO 密集型可以设置更多因为 IO 密集任务大量时间在等待网络、磁盘响应这段时间 CPU 是空闲的可以让更多线程去占满 CPU。而 CPU 密集任务线程数超过核心数CPU 调度成本反而上升。不过经验公式只是起点真实场景还要考虑内存、GC、依赖服务的吞吐量需要通过压测调整。我见过不少团队用公式算了个值就一直不动后来性能问题一查才发现线程数设得太高导致 CPU 频繁上下文切换这属于“纸上谈兵”。8. 实战经验我踩过的那些多线程的坑8.1 坑一ThreadLocal 用完不清理导致内存泄漏ThreadLocal是一个线程局部变量工具每个线程有自己的独立副本常用于传递上下文信息比如用户信息、请求 ID。它的内部实现很有意思每个Thread里有一个ThreadLocalMapkey 是ThreadLocal的弱引用value 是强引用。如果ThreadLocal外部引用被置空key 会被 GC 回收但 value 还会被线程的ThreadLocalMap强引用导致 value 无法被回收产生内存泄漏。尤其在线程池场景下线程是复用的如果不在使用完以后调用remove()上次请求的数据就可能被下一次请求读到这是非常隐蔽的 bug可能导致串号事故。正确做法使用完ThreadLocal后在finally块中调用remove()。ThreadLocalString context new ThreadLocal(); try { context.set(some value); // 业务逻辑 } finally { context.remove(); }8.2 坑二线程池的异常被静默吞掉这是最让人头疼的问题之一。ThreadPoolExecutor执行任务时如果任务的run()方法抛了异常异常会被线程池捕获不会直接打印到你控制的日志里。你完全不知道任务失败了只是结果少了排查起来特别痛苦。解决思路有三个提交Callable任务通过Future.get()获取异常信息。重写ThreadFactory包装Runnable在run()方法里 try-catch 记录日志。重写afterExecute()方法对任务执行后的异常做统一处理。我自己最常用的是第二种因为Callable的阻塞获取结果会拖慢主流程而afterExecute在很多封装里行为不太一致。8.3 坑三读写共享变量时盲目用 volatile我以前犯过一个错用volatile修饰一个计数器多个线程做累加结果每次运行结果都不对。后来才彻底理解volatile保证的是“读到的永远是最新值”但“读-改-写”这个复合步骤本身不是原子的。线程 A 读了 count1线程 B 也读了 count1两个都 1 写回最终结果是 2 而不是 3。volatile救不了这种问题必须用AtomicInteger或者加锁。现在的经验是先问这个变量是否会被多个线程同时写会写且有复合操作就不要指望volatile了。8.4 坑四异步任务异常和主线程完全隔离这个坑常出现在使用CompletableFuture的时候。很多人以为给thenApply或者thenAccept里写了代码异常会正常抛给调用方实际上异步线程的异常是“隔离的”主线程根本感知不到需要显式调用exceptionally或handle处理。CompletableFuture.supplyAsync(() - { if (true) { throw new RuntimeException(异步任务出错了); } return ok; }).exceptionally(ex - { System.err.println(异步异常: ex.getMessage()); return fallback; });这其实是多线程编程和单线程编程的重大思维差异单线程里异常会顺着调用栈往上抛多线程里每个线程的调用栈是独立的异常默认只属于那个线程。理解这个差异能帮你避免一大半“为什么失败了我不知道”的问题。8.5 坑五死锁死锁是指两个或多个线程在互相等待对方释放锁结果谁也执行不下去。最简单的例子线程 A 持有锁 1等待锁 2线程 B 持有锁 2等待锁 1两边都不放手。排查死锁的办法是先jps找到进程号再用jstack看线程转储信息里面会明确标出“Found one Java-level deadlock”以及互相等待的锁信息。预防死锁有几条经验尽量缩小锁的范围不要在锁内调用外部接口、执行长时间操作。保持锁的获取顺序一致避免“你锁 1 再锁 2我锁 2 再锁 1”这种交叉。优先使用替代方案能用ConcurrentHashMap、原子类解决的就别用多层锁。考虑加锁超时用tryLock而不是死等lock()。9. 高频面试题自查清单这一节把多线程最常见的面试题整理出来方便你对照自查。每一道题背后的“考点”我也标注出来你对着问题自己讲一遍讲得通就 OK。Q1: 线程和进程的区别是什么考点基础概念、资源隔离、切换开销。从“车间和工人”的角度展开顺便说并发和并行的区别。Q2: 创建线程有哪几种方式考点Thread、Runnable、Callable 的对比。重点强调run()和start()的区别以及每种方式的优缺点。Q3:sleep()和wait()有什么区别考点方法归属Thread 静态方法 vs Object 实例方法、锁行为sleep 不释放锁wait 释放锁、使用场景sleep 是暂停执行wait 是等待条件满足。Q4:synchronized和Lock有什么区别考点语法层面自动释放 vs 手动释放、功能层面可中断、可超时、公平性、多条件、性能层面现代 JDK 中两者性能差距不大选择取决于需求。Q5:volatile能保证原子性吗考点volatile只保证可见性和有序性不保证原子性。举例说明count为什么不能用 volatile 解决。Q6: 什么是死锁如何避免考点死锁的四个必要条件常见避免策略。最好能现场画出资源占用和等待的关系图用文字描述就行。Q7: 线程池有哪些参数拒绝策略有哪些考点七个参数各自含义任务执行的完整流程四种拒绝策略。这是线程池部分的核心必须能一口气说出来。Q8:ThreadLocal的原理是什么为什么会有内存泄漏考点ThreadLocalMap的 key 是弱引用、value 是强引用理解清理的必要性。Q9:CompletableFuture是什么和Future有什么区别考点Future需要手动get()阻塞获取结果CompletableFuture支持回调式编程、组合多个异步任务。Q10: 如何排查 CPU 占用率过高的问题考点top -Hp找到高 CPU 的线程jstack看线程转储中的线程名和调用栈。这个考的是实战能力不是背诵能力。我把这套清单整理下来是因为面试前快速过一遍特别好用。你可以对照问题自己打字完整回答能写出来基本就是真会了。10. 最后再分享一点点我的体会多线程学到最后你会发现最难的不是 API 怎么用而是思维方式的转变。单线程编程里代码是你一个接着一个执行的多线程编程里代码的执行顺序变得不可预测你需要站在“多个执行流同时跑”的角度去推演每一步可能出现的情况。这种思维不是看文章能看会的一定要自己写代码验证、踩坑、崩溃才能真正建立起来。我的建议是从小实验开始写一个多线程累加器看看不用锁、加锁、用原子类三种方式的差异写一个生产者-消费者分别用wait/notify和BlockingQueue实现模拟一次死锁再用jstack把它抓出来。这些实验都不复杂但做完一遍你对多线程的理解会有一个质的飞跃。Java 并发这块内容确实多但主线很清晰搞懂线程的生命周期理解并发三大特性掌握 synchronized 和 Lock熟悉 JUC 工具类最后把线程池用明白。把这条主线吃透基础已经足够扎实了。剩下的就是在实战中不断遇到问题、解决问题积累自己的“排坑手册”。文章写到这里其实也是我对自己这些年多线程经验的一次复盘。有些坑我踩过、有些 bug 我调了几天几夜这些教训如果能帮你少走一段弯路那这篇文章就没白写。