
做技术复盘尤其是做多线程这种学的时候觉得懂了一写代码就崩的知识点最忌讳的就是把笔记写成Java doc的复读机。刷完一轮Thread相关的教程回头把整个知识体系像拆机器一样重新装一遍你会发现之前很多好像懂了的地方其实全是漏洞。这篇文章就是给自己这份学习笔记做的一次完整小结也顺便给正在跟多线程死磕的朋友一份能照着自己推演一遍的框架。先说清楚这份小结覆盖的范围线程的创建方式、生命周期、核心机制synchronized、volatile、wait/notify、线程池、并发工具类以及我在学习过程中反复踩过的理解误区。适用对象是已经看完一轮Java多线程基础、准备面试或者在写并发代码前想系统梳理一遍的人。如果你是刚接触线程和进程区别的新手这篇文章也能帮你建立骨架但建议先跑通几个最简单的Thread Demo再回来看。Java多线程这块内容有一个很坑的特点知识点密度极高而且每个知识点之间是互相纠缠的。你单看synchronized觉得挺好理解一碰wait/notify就开始绕再撞上ReentrantLock和Condition就彻底晕了。所以我这篇小结不打算平铺直叙地把API念一遍而是按底层机制 → 核心手段 → 工具封装 → 实战排查这条链路来组织毕竟只有知道JVM里线程到底怎么跑的你才能理解为什么有时候换个写法性能差十几倍也才能看懂那些面试题到底在考什么。1. 从进程到线程底层模型决定了你后面所有的理解先说一个我在学习初期就踩过的坑大部分人背概念的时候都知道进程是资源分配的最小单位线程是CPU调度的最小单位但这句话到底意味着什么直到我看了HotSpot的线程实现和Linux的pthread模型才真正理解。1.1 JVM线程和操作系统线程的映射关系JVM里的java.lang.Thread实例并不等于一个操作系统线程。HotSpot虚拟机的实现方式是每一个Java线程被创建后会通过JNI调用底层操作系统的线程创建接口在Linux上走的是pthread_create在Windows上走的是CreateThread。所以Java线程本质上是一比一映射到内核线程的线程的创建、销毁、调度全部由操作系统负责JVM只是包了一层壳。这个模型带来两个很直接的后果。第一个后果是线程创建的成本很高。因为每一次new Thread()都要触发一次系统调用涉及内核资源的分配和上下文切换的准备工作。这也解释了为什么生产环境里几乎没人直接new Thread()而是用线程池复用线程——线程创建的开销比你想象中大得多尤其是在高并发场景下频繁建线程销毁线程的损耗会被急剧放大。第二个后果是线程数量不是越多越好。因为每个线程都要占用独立的栈空间JVM默认的线程栈大小是1MB可通过-Xss参数调整所以创建1000个线程理论上就要预留1GB的虚拟内存。更重要的是当线程数量超过CPU核心数后OS调度器要做线程上下文切换每次切换都要保存寄存器和程序计数器等状态。如果你开了几百个线程抢一个CPU大部分时间都花在切换而不是干活上。1.2 线程的生命周期与状态流转别再背反了我见过不少八股文把Java线程的几个状态背得滚瓜烂熟结果问调用了start()之后线程处于什么状态就答错了。Java线程生命周期有六个状态为了好记我习惯把它分成两条线一条是操作系统的传统五状态模型新建、就绪、运行、阻塞、终止一条是JVM在Thread.State枚举里定义的六状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。关键点在于JVM的RUNNABLE其实把就绪和运行合并了。在Java层面你没法区分线程是正在被CPU执行还是排队等着被调度反正都是RUNNABLE。这一点在排查CPU飙高问题时特别容易困惑你看到线程dump里很多RUNNABLE状态以为是并行执行的其实它们可能只是躺在调度队列里。状态流转里最容易搞混的是BLOCKED和WAITING。BLOCKED线程在等一把被别的线程持有的锁synchronized的monitor锁进不去同步代码块。WAITING线程主动调用了wait()、join()或者LockSupport.park()在等被唤醒。TIMED_WAITING有超时时间的等待比如sleep(1000)、wait(1000)。开发里最常见的错误是把sleep当成释放锁的操作。Thread.sleep()确实会让线程进入TIMED_WAITING但它不会释放任何持有的锁。真正会释放锁的是wait()这也是为什么wait被设计成必须在synchronized代码块里调用——它要先把monitor锁还回去让别的线程有机会进入临界区。我在学习的时候给自己做过一张状态流转图来理清关系这里不画图了用文字描述NEW调start()进RUNNABLERUNNABLE抢锁失败进BLOCKED拿到锁或被唤醒回RUNNABLERUNNABLE调wait/join/park进WAITING有超时的进TIMED_WAITINGRUNNABLE跑完run方法进TERMINATED。这张流转图加上谁持有锁谁唤醒我两个问题基本能覆盖所有状态判断题。2. 三大核心机制synchronized、volatile、wait/notify的配合逻辑多线程编程的本质是解决三个问题原子性、可见性、有序性。而synchronized、volatile和wait/notify这套组合拳就是为了解决这三个问题搭起来的最原始骨架。2.1 synchronized锁的是对象不是代码这是我认为最需要反复强调的一句话synchronized锁的是对象不是某段代码。写public synchronized void method()锁的是this写public static synchronized void method()锁的是该类的Class对象写synchronized(lockObj) {}锁的就是lockObj这个实例。理解了锁对象之后很多诡异现象就说得通了。比如两个线程一个调synchronized实例方法一个调synchronized静态方法它们是不会互斥的因为一个锁的是对象、一个锁的是Class是两个完全不同的monitor。再比如synchronized锁的是对象所以同一把锁的竞争才互斥不同锁对象之间互不干扰。用字符串常量做锁对象是极端危险的操作因为JVM常量池会复用字符串字面量两个毫无关系的代码块可能莫名其妙锁到同一个对象上。synchronized底层靠的是monitor监视器锁JVM会有偏向锁、轻量级锁、重量级锁的升级过程这也是面试高频点。学习的时候不用死记升级条件但要理解它的设计思路无竞争时用偏向锁减少开销竞争变多时升级为轻量级锁通过CAS自旋来等待自旋失败再膨胀为重量级锁让线程进入阻塞。这套机制的本质是尽量让锁的开销去适配当前的竞争强度。2.2 volatile它解决可见性但不解决原子性volatile大概是Java里被误解得最严重的关键字。很多人把它当成让变量线程安全的银弹实际上它的作用范围窄得很保证变量在不同线程间的可见性禁止编译器和CPU对它进行指令重排序但它完全不保证复合操作的原子性。举个典型场景volatile int count两个线程同时执行count照样会丢数据。因为count在字节码层面是读-改-写三个步骤volatile只能保证每次读的时候看到的是最新值但没法把读值、加一、写回合并成一个原子操作。这个坑我建议每个人都去写Demo跑一遍两个线程各加一万次最终结果大概率不是两万。那volatile到底用在哪儿最经典的就是作为状态标志位。一个线程里写running false另一个线程在主循环里检查while (running)。如果不加volatileJIT编译时可能把running缓存到寄存器里主循环永远看不到其他线程的修改程序就停不下来。加了volatile每次读都强制走内存实际上走的是缓存一致性协议比如缓存失效写线程一改读线程立刻能看到。还有一个容易忽略的点volatile的可见性保证了写之前的普通变量赋值对读线程也是可见的。这就形成了所谓的happens-before规则也叫volatile变量规则。所以volatile适合用来发布一些不可变快照状态比如通过volatile引用发布一个不可变对象读线程拿到引用后看到的是完全构建好的状态。2.3 wait/notify重在协作细节魔鬼很多synchronized解决的是互斥wait/notify解决的是线程间的协作。经典的生产者消费者模型里生产者往队列里放数据队列满了就wait消费者从队列取数据队列空了也wait彼此通过notify唤醒。这里有几个我在实践里踩过跟头、也建议你优先留意的细节第一wait()会释放monitor锁notify()不会释放锁。notify只是通知真正释放锁要等当前线程退出synchronized代码块。所以notify()应当放在临界区靠后的位置尽快释放锁让被唤醒线程有机会竞争。如果你在一个循环里notify半天还不退出同步块被唤醒的线程也只能干瞪眼。第二wait被唤醒后是重新进入就绪队列需要重新抢锁抢到锁后从wait的下一条指令继续执行不是从头执行。所以wait必须放在循环里检查条件而不能用if。为什么因为被唤醒不代表条件一定满足了——可能被其他线程虚假唤醒或者被多个消费者同时唤醒后队列又空了。标准写法是synchronized (lock) { while (queue.isEmpty()) { lock.wait(); } // 消费 }写成if在单生产单消费模型里可能没问题但一旦多生产多消费很容易出现唤醒后条件不成立却继续向下执行的bug。第三notifyAll()和notify()的选择。notify()只随机唤醒一个等待线程如果被唤醒的线程不满足条件它会继续wait但没被唤醒的那个线程就永远等不到通知了。这就是经典的信号丢失问题。保守起见条件不唯一时用notifyAll()。3. 线程池与Future为什么说不要用Executors默认工厂是有道理的线程池是生产环境使用频率最高、也最容易写错并发配置的地方。JDK自带的ThreadPoolExecutor是核心Executors只是提供了一组预设参数的工厂方法。3.1 线程池核心参数功能对应什么业务场景ThreadPoolExecutor有七个构造参数核心的是前五个参数含义生产中我的参考建议corePoolSize核心线程数即使空闲也保留要结合任务类型算下面详说maximumPoolSize最大线程数一般由压测结果回推keepAliveTime非核心线程空闲存活时间默认60s比较合理workQueue任务等待队列四种队列各有适用场景threadFactory线程工厂一定自定义命名和daemon属性handler拒绝策略生产慎用DiscardPolicy很多人问核心线程数到底配多少我的粗糙经验是分两类计算CPU密集型任务核心线程数取CPU核心数 1因为线程多了除了增加切换开销没别的好处IO密集型任务核心线程数可以放大到CPU核心数 * 2甚至更多因为线程阻塞在IO上时不占CPU多开线程能提高吞吐。但这两个公式只是起点最终一定要靠压测校正。任务队列的选择也很关键SynchronousQueue不存储任务直接交给线程执行。适合任务短小且希望严格串行的场景。Executors的newCachedThreadPool用的就是它因为队列不缓存任务来一个就开一个线程空闲线程存活60秒又会被回收所以它的最大线程数实际上是Integer.MAX_VALUE。LinkedBlockingQueue无界或有界队列。Executors的newFixedThreadPool默认用无界队列这有一个隐患如果任务生产速度长期大于消费速度队列会无限堆积造成内存膨胀最终OOM。ArrayBlockingQueue有界队列最值得推荐的生产配置。配一个合理容量配合拒绝策略才能让线程池在过载时有反馈而不是默默堆积。3.2 自定义线程池的最小完整配置我自己现在写线程池基本不用Executors的静态方法而是手动newThreadPoolExecutor并配齐参数ThreadPoolExecutor executor new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(100), r - { Thread t new Thread(r, biz-worker- ThreadLocalRandom.current().nextInt(1000)); t.setDaemon(false); t.setUncaughtExceptionHandler((thread, e) - log.error(thread {} died, thread.getName(), e)); return t; }, new ThreadPoolExecutor.CallerRunsPolicy() );这里有两个细节值得解释。线程工厂里设置UncaughtExceptionHandler是必须的因为线程池里的线程如果抛出未捕获异常该线程会结束而任务在execute()时丢失异常栈如果你没全局兜底线上出了bug只能看到一堆问号。拒绝策略用CallerRunsPolicy而不是AbortPolicy是因为在微服务内部宁可让提交任务的那个线程自己去执行也不想悄悄丢弃任务。当然数据一致性要求高的场景可能选择DiscardOldestPolicy或自定义排队策略这要结合业务取舍。另外要记住线程池里的线程执行任务抛出运行时异常时线程池会重新创建新线程顶上所以看起来线程池没炸但任务其实失败了。排查这类问题要靠Future.get()拿异常或者配置线程池的afterExecute钩子。3.3 Future和CompletableFuture的使用定位submit()提交一个有返回值的任务返回Future通过future.get()能拿到结果或者异常。get()是阻塞的这个阻塞如果想加超时一定要传时间参数否则遇到卡死的任务你这一等就是永远。CompletableFuture是Java 8加入的异步编排工具适合处理有依赖关系的多个异步任务。比如等两个查询都完成后合并结果这种场景用CompletableFuture.allOf要比你手动维护两个Future再用get轮询优雅得多。不过学习的时候建议先把Future用熟再上CompletableFuture否则你连它内部线程池的坑都看不懂。CompletableFuture默认使用的ForkJoinPool.commonPool()是全局共享的CPU密集环境下很容易成为瓶颈生产环境记得传入自定义Executor。4. 并发工具类AQS、锁和各种并发容器到底解决什么问题多线程学习到后半段就是包罗万象的并发包java.util.concurrent。这块内容多但可以归纳成一条主线绝大多数显式锁和同步工具底层都依赖AQSAbstractQueuedSynchronizer。4.1 AQS的设计哲学AQS核心是一个volatile int state加一个CLH变体的FIFO等待队列。子类通过重写tryAcquire和tryRelease两个方法来决定state的获取和释放逻辑。ReentrantLock就是通过它实现的state表示持有锁的次数每次重入state加一每释放一次减一。理解AQS的关键是它把获取资源的失败者怎么排队等待这件事抽象出来了。无论搞互斥锁、读写锁、信号量还是闸门排队唤醒的机制都一样变的只是state的语义。这也是为什么CountDownLatch、Semaphore、ReentrantReadWriteLock都是AQS家族成员。学习AQS还有一个收获你会明白自旋和阻塞是怎么结合的。AQS在尝试获取锁失败后会先通过LockSupport.park()挂起线程被唤醒后再抢一次。这种设计避免了独占CPU的忙等同时通过CAS无锁修改state保证了高效。很多人面试时被问AQS怎么实现公平锁答案就是hasQueuedPredecessors()方法判断队列里是否已有等待者如果公平锁模式队列非空新来的线程就不抢了老老实实排队。4.2 ReentrantLock和synchronized怎么选ReentrantLock和synchronized在生产环境都能用我的选择标准很简单需要超时等待、可中断、或者尝试非阻塞获取锁时用ReentrantLock只要一个简单的互斥临界区直接synchronized因为它写法简洁、锁会自动释放、异常栈更直观。ReentrantLock的几个API值得逐一掌握Lock lock new ReentrantLock(true); // true表示公平锁 if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 临界区 } finally { lock.unlock(); } }tryLock(timeout)是加锁超时的最佳实践避免了死锁风险——拿不到锁就放弃线程不至于卡死。lock()必须放在try块之外unlock()必须放在finally里这是铁律。配套的Condition其实就是更细粒度的wait/notifycondition.await()对应wait()condition.signal()对应notify()。但Condition伟大之处在于它不绑死一把锁同一把ReentrantLock可以有多个Condition队列。典型例子是BoundedBuffer需要队列满和队列空两拨等待者分别用两个Condition唤醒时精确到某一拨而不是像synchronized那样要么随机唤醒一个、要么全唤醒再自己循环等。4.3 并发容器和变量类的正确打开方式ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue这些工具类面试几乎必考。学习时有一个判断标准你知不知道它们各自用在什么场景ConcurrentHashMap是默认的高并发Map实现Java 8之后是数组链表红黑树通过CASsynchronized对桶加锁读操作基本无锁。要注意它的size()并不精确迭代也不保证强一致。CopyOnWriteArrayList读多写少极好用写的时候复制底层数组读的时候无锁。但写操作代价很高频繁写别用它。ConcurrentLinkedQueue是无界非阻塞队列CAS搞定入队出队适合单生产者单消费者或高吞吐低延迟场景但size()无意义。ThreadLocal严格来说不是解决并发共享问题的它是每个线程一份独立副本。它的坑在于线程池中线程复用导致ThreadLocal值串数据以及内存泄漏ThreadLocalMap的Entry强引用了ThreadLocal就行弱引用才对用完之后记得remove()。并发原子类AtomicInteger等靠的是CAS底层走Unsafe的compareAndSwapInt适合做计数器等简单原子操作。LongAdder在极高竞争下比AtomicLong性能好因为它分段做了累加最后调用sum()才汇总缺点是精确性会损失一点适合统计流量等非强一致场景。5. 从理论到实战排查线上多线程问题的一套基本方法学了再多API线上一出问题还是得靠排查能力。我在实际里总结了一套从定位到确认的相对固定流程分享给大家参考。5.1 线程dump怎么分析当一台机器CPU飙高或者服务卡住不动第一件事就是抓线程dump。Linux命令jps # 找到java进程PID jstack pid threaddump.txt线上如果不好直接执行jstack也可以用jcmd Thread.print。拿到dump后先搜java.lang.Thread.State重点看线程的状态分布大量线程WAITING或TIMED_WAITING且集中在同一个锁对象附近基本就是死等。搜索waiting on 0x...全局搜相同的十六进制对象地址如果发现两组线程互相持有对方想要的锁那就是经典死锁。大量线程RUNNABLE但业务线程CPU占用率异常高多半是死循环或频繁GC。配合top -Hp pid看线程级CPU再对照dump里的nid0xff...换算成十进制就能找出是哪个线程在空转。大量线程BLOCKED说明锁竞争激烈需要查是不是有线程长期占着monitor。线上我一般会连续抓三次dump间隔几秒对比线程堆栈是不是稳定在同一处。如果每次都一样基本可以确定是卡在某段逻辑里如果每次都变可能是高频任务。5.2 死锁模拟与检测经验死锁是面试最爱问、也是真实世界最容易写出来的一种bug。四个人手锁一把又同时去要别人的锁四个全部卡死线下Demo很容易复现。但线上环境复杂死锁往往不是那么对称所以JVM自带的检测工具很重要jstack -l pid在dump末尾会有Found one Java-level deadlock段落直接列出循环等待的线程和锁对象。此外JDK自带jconsole的线程页面也能自动检测死锁。但要注意工具只能检测到已经确定的死锁如果每次碰运气才卡住还得靠日志和压测去复现。死锁产生的四个必要条件互斥、持有并等待、不可剥夺、循环等待背诵没有意义关键是写代码时养成固定加锁顺序的习惯。比如两个资源必须先锁A再锁B那所有线程都遵守同一顺序就不会形成循环。如果锁顺序实在无法统一考虑用tryLock加超时拿不到锁就回滚重试让程序自己打破僵局。5.3 从线程安全到数据一致性的全局视野学多线程学到后期我发现很多人包括我自己前期有个误区认为线程安全就是给共享变量加锁。其实线程安全只是并发正确性的一个子集。真正复杂的是数据一致性比如先写缓存再落库先扣库存再发消息跨了多个资源、多个服务之后本地锁完全无能为力需要引入分布式锁基于Redis或ZooKeeper、数据库乐观锁、消息队列的事务消息这些上层方案。作为Java线程进阶的收尾我建议脑海中至少要有这个分级底层靠synchronized/volatile/CAS保证单JVM内线程安全中层靠ThreadPoolExecutor/CompletableFuture控制并发任务编排上层靠数据库约束、锁、分布式事务解决跨进程一致性。学习多线程如果只陷在API里忽略了这一层遇到真正的高并发业务就会被加锁就好的错觉坑得很惨。分布式锁这块本身能写一篇长文我不展开细说只说设计要点拿锁要带请求标识释放锁要校验是自己的锁防止误删别人的锁锁一定要设过期时间防死锁锁续期要有独立机制比如Redisson的红锁或者简单的看门狗思路。这些和Java线程池的设计哲学其实是同一个道理任何一套并发机制都必须考虑占用资源的线程挂了对别人有没有影响。6. 多线程笔记的后悔药三个我早该知道的认知学完一整轮多线程回头看印象最深的不是某个细节API而是三个整体性认知。它们不属于任何一章教科书但我希望早点有人告诉我。第一先研究数据要不要共享再做线程优化很多并发问题的根源不是锁没用好而是数据架构里本不该共享的东西硬被共享了。我见过同事用线程池并行处理一批独立订单结果每线程都要改一个共享的统计Map于是各种ConcurrentHashMap、AtomicLong、锁互相叠加性能还不如串行快。后来把统计改成每条任务返回结果最后再做一次reduce代码简单了、性能也上去了。写多线程前先花十分钟想清楚哪些是真正必须跨线程共享的状态哪些根本不需要共享。大多数场景用不可变对象、用局部变量、用任务计算结果汇合的方式能从源头上消灭线程安全问题。第二压测才是并发参数的唯一裁判线程池核心线程数配多少、队列长度多大这个问题没有标准答案只有针对你的接口RT和吞吐量压测之后才有答案。公式给的是起点不是终点。我经历过的教训是按网上公式配了CPU核心数1结果一次大流量进来队列堆积RT飙到十几秒因为这台机器上还跑着别的CPU密集型任务。后来在真实环境压测调整后线程数反而比公式建议值小了不少。并发场景下任何参数调整都要用监控数据说话拍脑袋配数等于埋雷。第三ThreadLocal用完必须remove这个坑我确信绝大多数人都踩过。线程池线程复用ThreadLocal里的值在下一次任务执行时还在如果你不清理下一条任务就会读到上一条任务的残留值而且这种bug极其隐蔽不崩不报错只在特定业务场景下产生脏数据。Java官方在设计ThreadLocalMap时把Entry的key设成弱引用是为了避免ThreadLocal对象泄漏但value仍然有强引用链路所以规范做法永远是try { // 业务代码 } finally { threadLocal.remove(); }这也引出一个更本质的道理——多线程代码的每一个资源线程、锁、ThreadLocal都要有明确的生命周期管理意识。谁创建的谁负责回收这比背任何API都重要。写在最后这份多线程小结写到这里其实也只是把这座大山的登山杖整理了一遍。真正有价值的不是知道ReentrantLock有公平锁参数不是会背AQS的CLH队列结构而是遇到一个线上问题你能顺着线程状态 → 锁竞争 → 数据可见性 → 生命周期这条线索快速锁定最可疑的那一层。我自己的下一个学习方向是Java内存模型JMM因为无论是synchronized的锁升级还是volatile的可见性保证还是AQS里state的读取最终都要落到happens-before规则上。这部分不啃透前面那些学过的东西始终是空中楼阁。给同样在进阶路上的朋友一个建议学多线程不要贪多每学一个机制都写一个能复现问题的小Demo比如故意用if写wait条件看会不会触发脏读、故意用无界队列看内存涨多快。踩过坑的知识才是自己的背过的细节早晚还给文档。