从第11篇走到第12篇,多线程这块的学习终于轮到做小结了。说实话,Java多线程是很多开发者进阶路上的一个分水岭——有人觉得它玄乎,死锁、可见性、线程安全这些问题在单线程环境下根本碰不到,一旦上了并发场景就全冒出来了;也有人觉得它也就那几个关键字和工具类,背一背synchronized、volatile、ThreadPoolExecutor,面试能画个流程图就完事。但这两种态度都容易跑偏。多线程真正难的从来不是API怎么调,而是“多个线程同时跑起来之后,共享资源怎么管、执行效率怎么提、出问题了怎么排查”。这篇小结不打算再逐个知识点罗列一遍,毕竟前11篇已经把Thread的创建方式、生命周期、锁机制、JUC工具类、ThreadLocal、线程池、CompletableFuture这些都拆开讲过了。这篇更重要的事情是:把这些知识点串成一张完整的知识网络,标注出哪些是实际开发里高频踩坑的重灾区,哪些是面试官最爱追问的模糊地带,哪些是你从“会用”走向“会设计”必须跨过的坎。
如果你是刚把多线程API学过一遍的初学者,这篇适合用来做体系化梳理;如果你已经写过一些并发代码但总感觉哪里没打通,这篇适合用来对照检查自己的知识盲区。我会直接从底层原理讲到工程实践,尽量用大白话把那些藏在源码和字节码里的机制说明白。
1. 多线程从入门到进阶,先抓住这三条主线
学多线程容易陷入一个误区:一上来就抱着Thread、Runnable、ExecutorService的API啃,代码写了不少,但脑子里没有框架。我个人的经验是,不管知识细节多碎,先抓住三条主线,后面所有东西都能归位。
1.1 线程本身的生命周期和管理方式
第一条主线就是“线程到底是什么、它从创建到销毁经历了什么”。这里包括最基础的三样东西:
- 线程的创建方式:继承
Thread、实现Runnable、实现Callable结合FutureTask,以及后来更推荐的线程池创建。前三种都绕不开一个本质——“创建一个独立执行的单元”,区别只是返回值、异常处理、任务拆分粒度的不同。 - 线程的生命周期:
NEW(新建)、RUNNABLE(可运行)、BLOCKED(阻塞)、WAITING(等待)、TIMED_WAITING(超时等待)、TERMINATED(终止)。很多初学者画过这张状态图,但真正问到“Thread.sleep()和Object.wait()分别进入哪个状态”“LockSupport.park()又是哪个状态”就含糊了。这就是基础不牢的典型表现。 - 线程的中断与停止:
interrupt()不是强制杀掉线程,而是设置一个中断标志位,由线程自己响应。这个机制很多人理解成“调用interrupt线程就停了”,大错特错。
为什么说这条主线是基础中的基础?因为后面所有关于锁、通信、协作的内容,本质上都是围绕“多个处于不同生命周期的线程”展开的。你连线程状态流转都做不到心中有数,排查死锁、分析线程dump时根本无从下手。
1.2 共享资源的安全防护体系
第二条主线是多线程的核心矛盾:多个线程并发读写同一个资源,怎么保证不出错。这里面有三个层面的问题,一套一套递进的:
- 原子性:一组操作要么全部完成、要么全部不执行,不能拆到一半被别的线程插进来。
- 可见性:一个线程修改了共享变量,其他线程能不能立刻看到这个修改。
- 有序性:编译器、CPU为了优化可能调整指令顺序,这种重排序在多线程下会带来意外结果。
围绕这三个问题的解决方案,就是synchronized、volatile、final、JUC包下的Lock体系、AtomicXXX原子类以及CAS。这条主线的核心不在于记住每个锁的API,而在于理解“为什么不同场景要用不同的手段”。比如volatile只能保证可见性和有序性,不能保证原子性;synchronized在JDK 6之后有了锁升级机制,偏向锁、轻量级锁、重量级锁一套组合拳下来性能已经不算差;ReentrantLock则提供了可中断、可超时、可多条件绑定的能力,更灵活但也要记得unlock()。
1.3 线程间的协作与任务调度
第三条主线是“线程之间怎么沟通、怎么配合干活”。例子包括:
wait() / notify() / notifyAll():传统线程间通信手段,使用前提是必须持有监视器锁(即必须在synchronized块内)。Condition:ReentrantLock下的等待通知机制,支持多个等待队列,比单一的wait/notify更精细。CountDownLatch、CyclicBarrier、Semaphore:它们解决的是“一批线程怎么步调一致”的问题。前者是倒计数门闩,后者是循环栅栏,信号量则控制并发访问的许可证数量。CompletableFuture:异步任务编排,把多个线程的依赖关系、并行执行、结果合并都封装成链式调用。
这三条主线不是割裂的。实际开发中一个功能模块往往同时用到了它们。比如一个多线程数据采集任务,既需要线程池来管理任务线程(主线一),又需要ConcurrentHashMap来安全汇总数据(主线二),还需要CountDownLatch来等待所有采集线程完成后统一入库(主线三)。学的时候逐个击破,用的时候必须串联起来。
2. 核心机制深度解析:你以为的未必是对的
小结的意义在于把那些“学过但没学透”的知识点挖出来重新审视。这一部分我挑几个最高频的误区点,也是多线程知识体系里最容易被一笔带过的地方展开聊。
2.1 可见性、原子性和有序性,一条条掰开说
先拿一个经典场景开刀:两个线程,一个读一个写同一个普通int变量。理论上,如果没有同步机制,读线程可能永远都读不到写线程更新后的值。原因在哪里?
首先是可见性。Java内存模型规定每个线程有自己的工作内存,线程读变量优先从工作内存读,工作内存和主内存之间的同步时机是不确定的。也就是说,线程A改了变量,只是改了自己工作内存里的副本,什么时候刷回主内存、线程B的工作内存什么时候重新拉取,都没有硬性保证。volatile干的事就是强制每次读写都直接走主内存。
其次是重排序。编译器、JIT、CPU都可能为了执行效率调整指令顺序。单线程下重排序不影响最终结果,但多线程下就可能出现“代码先执行了,标志位后生效”的错乱。volatile通过内存屏障禁止了特定类型的重排序。
再来是原子性。i++这行代码看起来是一条操作,实际上在字节码层面是“读取-计算-写回”三步,天然不是原子的。两个线程同时执行i++,哪怕加了volatile,结果依然可能少累加一次。这就是volatile不解决原子性的直观案例。要保证原子性,要么用synchronized/Lock给这段操作加锁,要么用AtomicInteger走CAS自旋。
注意:很多人面试时候说“volatile保证线程安全”,这是不严谨的。准确表达是:volatile保证可见性和有序性,但不保证复合操作的原子性。若共享变量的所有操作都是纯读或纯写、不依赖当前值,才可以用volatile做线程安全控制。
2.2 synchronized的锁升级机制和锁消除
synchronized被叫“重量级锁”是很久以前的事了。JDK 6之后引入了大量锁优化机制,其中最关键的是锁升级。整个过程是:无锁 → 偏向锁 → 轻量级锁(自旋锁)→ 重量级锁。
偏向锁的核心思想:如果一段同步代码只有一个线程反复进入,就不需要真的加锁,只需要在对象头里记录这个线程的ID。有了竞争时偏向锁撤销,升级为轻量级锁。轻量级锁通过CAS抢锁,抢不到就自旋,因为很多锁的持有时间很短,自旋等待比线程阻塞挂起更划算。自旋超过阈值(或者自旋线程数过多)后,才升级为重量级锁,也就是传统的依赖操作系统互斥量(mutex)实现的锁。
这个机制平时写代码感知不到,但理解它有助于解释一个常见的性能现象:为什么synchronized在低竞争场景下性能很好?正是因为偏向锁和轻量级锁全程都在用户态完成,没有内核态切换的开销。还有一个“锁消除”的概念——JIT在运行时如果发现某个锁对象只在当前线程内可见、根本不存在共享,就会直接把这把锁优化掉,连加锁动作都不生成。
2.3 Lock体系的核心:AQS到底在做什么
JUC包下的很多工具类——ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock——都是基于一个叫AbstractQueuedSynchronizer(抽象队列同步器,简称AQS)的框架实现的。
AQS的核心是一个volatile int state变量加一个FIFO双向队列。state的含义由子类自行定义:ReentrantLock里它表示“锁被重入了几次”;Semaphore里它表示“剩余许可证数量”;CountDownLatch里它表示“还需要倒数的计数”。线程获取资源失败时,会被封装成节点挂到队列尾部,然后通过LockSupport.park()把自己阻塞起来;资源释放时会唤醒队列头部的等待线程。
别看AQS很抽象,理解它只需要抓三个点:第一,资源数量记录在state上,通过CAS原子修改;第二,抢不到资源的线程排队,先进先出;第三,阻塞和唤醒通过LockSupport完成。搞懂AQS的骨架,JUC里一半工具类就能拆开看了。
2.4 ThreadLocal的内存泄漏风险,说过多少次了
ThreadLocal是个高频考点也是高频埋坑点。它本身不存数据,而是每个线程内部维护一个ThreadLocalMap,以ThreadLocal对象为key、实际数据为value。问题出在key用的是ThreadLocal的弱引用(WeakReference),而value是强引用。
什么意思呢?如果ThreadLocal对象被GC回收(弱引用不阻止回收),而线程还活着(比如线程池里的线程长期存活),那么ThreadLocalMap里面的value就一直被强引用着无法回收,形成了“key没了但value还在”的内存泄漏。如果这个线程是业务线程池里的,且存的值很大,后果可想而知。
解决方式不复杂:ThreadLocal使用完毕后显式调用remove()。网上讨论“为什么设计成弱引用”很多,我倾向于理解为:弱引用+及时remove()的组合能把泄漏窗口压缩到最小。另外还有一个常见误区——当ThreadLocal被声明为static时,ThreadLocal对象本身不会被GC回收,但value依然可能堆积,所以该删还是要删。
3. 线程池的设计哲学与实操参数调优
线程池是多线程进阶里最实用的工具,没有之一。大多数真实项目的多线程需求都是通过线程池解决的,因为它把“线程的创建和销毁”与“任务的提交和执行”解耦了,同时还能做任务排队、定时执行、拒绝策略等管理。这一部分值得详细展开,因为就算你会用Executors.newFixedThreadPool(),离真正理解线程池还差很远。
3.1 七大参数逐个拆解
ThreadPoolExecutor最完整的构造函数有七个参数,逐个说:
corePoolSize:核心线程数。线程池会一直保持这么多线程,即使它们空闲。除非设置了allowCoreThreadTimeOut为true。maximumPoolSize:最大线程数。线程池里线程数量不允许超过这个值。keepAliveTime:非核心线程的空闲存活时间。超过该时间还没接到新任务的非核心线程会被回收。unit:存活时间的单位。workQueue:任务队列。新任务提交进来,如果当前线程数大于核心线程数,就会先进队列等待,而不是立刻开新线程。这块网上讲的比较多,我起初也时常混淆“先创建新线程,还是先排队”这个顺序,后来明白了核心逻辑是:addWorker前先尝试往队列塞,塞不下时才会尝试创建线程到最大线程数。于是核心数不够、队列也满、线程数还没到上限,才会继续加非核心线程。threadFactory:创建线程的工厂。建议自定义,为了给线程起有业务含义的名字,排查问题时一眼能认出是哪个池子里的线程。handler:拒绝策略。当队列满了,线程数也到上限了,新任务触发的处理策略。JDK提供了四种:AbortPolicy(直接抛异常)、CallerRunsPolicy(调用者线程执行)、DiscardPolicy(丢弃任务)、DiscardOldestPolicy(丢弃最旧任务)。默认是AbortPolicy,所以在生产环境你最好配置一个自己实现的拒绝处理逻辑,比如记录日志并做补偿。
参数之间的联动关系是线程池的精髓。任务提交时,线程池会按以下顺序决策:
- 当前线程数 < 核心线程数 → 创建核心线程执行任务。
- 当前线程数 ≥ 核心线程数 → 尝试放入阻塞队列。
- 队列已满,当前线程数 < 最大线程数 → 创建非核心线程执行任务。
- 队列已满,当前线程数 ≥ 最大线程数 → 执行拒绝策略。
这个流程必须刻在脑子里,因为几乎所有关于线程池的疑难杂症都出在这个流程被误解。
3.2 Executors工厂方法为什么不被看好
Executors提供了几个现成的线程池方法:newFixedThreadPool、newCachedThreadPool、newScheduledThreadPool、newSingleThreadExecutor。用起来确实方便,但阿里Java开发手册明确不推荐,原因是它们使用了无界队列或过大的最大线程数。
其中最典型的就是newFixedThreadPool和newSingleThreadExecutor,它们用的是无界的LinkedBlockingQueue。意味着队列可以无限堆积任务,线程数永远不会超过核心线程数。如果瞬时请求量很大,任务堆积在队列里,消费者线程处理不过来的话,内存占用会飙升,而且任务延迟越来越大,整个系统看起来像是在“慢慢卡死”。newCachedThreadPool的maximumPoolSize是Integer.MAX_VALUE,允许创建海量线程,一旦流量波动,可能直接打垮操作系统。
所以实际开发中我更推荐直接使用new ThreadPoolExecutor(...)或者ThreadPoolTaskExecutor来自定义参数。你不用追求一开始就把参数配到完美,但至少要做到两点:阻塞队列要有界;拒绝策略要明确并且要可观测。
3.3 核心线程数怎么估算:经验公式加压测
这是被问到烂的问题:线程池的核心线程数设置多少合适?网上有各种公式,比如CPU密集型设CPU核数+1,IO密集型设CPU核数 * 2或者按比例算。我的看法是:公式只能作为起点,最终要靠压测调优。
先说理论:线程池大小设计的本质是权衡“CPU计算时间”和“等待时间”。如果任务是纯CPU计算,线程数超出CPU核数并不会带来额外提升,反而增加上下文切换损耗,所以N+1是常见选择。如果是IO密集型,线程大部分时间在等网络或磁盘,CPU利用率低,可以多开线程把等待时间利用起来,所以理论上可以开得多一些,常见经验是CPU核数 / (1 - 阻塞系数)。
但真实业务数据不是恒定的,接口调用耗时会有波动,内存消耗也有约束。我自己的习惯是:
- 先按经验公式算一个初始值。
- 在测试环境用压测工具模拟线上流量,逐步调大线程数,观察吞吐量和CPU利用率。
- 找到“吞吐量不再明显增长”或者“CPU利用率达到瓶颈”的那个阈值,再留出20%~30%的余量作为核心线程数。
提示:线程池参数不是一成不变的,线上服务如果做过容器扩缩容,核心线程数这种参数最好做成可动态调整的。Spring的
ThreadPoolTaskExecutor支持setCorePoolSize动态修改,直接改配置中心的值即可,不需要重启应用。
4. 并发编程的实战编码范式与避坑清单
学完原理最终要落到代码上。这一部分按照我自己做项目时的编码习惯来写,相当于一份可落地的检查清单。
4.1 锁的选择:先synchronized,再考虑Lock
碰到一个线程安全问题,第一反应先别急着炫技上ReentrantLock。绝大多数场景下,synchronized就够了。理由有三点:
- 语法简单,不需要显式加锁解锁,异常时自动释放锁,新手不容易写漏。
- JVM层面持续优化,低竞争场景性能不输Lock。
- 代码可读性好,后续维护的人不容易搞错临界区范围。
那什么时候用ReentrantLock?我的判断标准是:需要尝试获取锁(tryLock)、需要可中断获取锁(lockInterruptibly)、需要多个等待条件(多个Condition),又或者需要公平锁。这些是synchronized做不到或者做起来别扭的。
另外,如果读写比例严重不均,比如读多写少,可以考虑ReentrantReadWriteLock或StampedLock。但要注意:读写锁适合读多写少的场景,写多读少反而因为维护两套锁状态而更慢。而且,即便同步线程在写操作时,读线程也会被阻塞,不适合强一致性的高并发读需求,所以不要太迷信读锁和写锁的组合。
4.2 并发容器的正确打开方式
凡是多线程共享的集合,尽量从下面几个容器里选,不要裸用HashMap、ArrayList:
ConcurrentHashMap:分段锁(实际上JDK 8后改为CAS+synchronized锁桶)实现的高性能并发Map。CopyOnWriteArrayList:读多写少场景下的List,写时复制整个数组,读不加锁。问题在于写操作代价高,不适合频繁写。ConcurrentLinkedQueue:无界非阻塞队列,基于CAS实现,适用于高并发入队出队的场景。BlockingQueue系列:ArrayBlockingQueue(有界、数组实现)、LinkedBlockingQueue(可选有界、链表实现)、SynchronousQueue(不存储元素,直接传递)。ConcurrentSkipListMap:有序并发Map,基于跳表,支持范围查询。
特别提醒:ConcurrentHashMap不等于“完全安全”。它保证单个操作的线程安全,但不保证复合操作的原子性。比如经典的“若不存在则放入”逻辑(putIfAbsent),在JDK 8之前需要外层加锁,JDK 8以后提供了computeIfAbsent等原子方法,用起来更稳。另外,size()在并发环境下只是一个近似值,别拿它做精确业务判断。
4.3 锁的粒度与并发度优化
锁粒度太粗是并发性能的第一杀手。有一种常见的坏味道:在一个很大的方法上直接加synchronized,把无关的操作也锁住了。举个例子,一个汇总方法里面先是查数据库(耗时),然后更新本地缓存,最后累加一个计数器。如果把整个方法锁住,所有线程查数据库都得排队,并发完全废掉了。正确姿势是缩小临界区,只锁“累加计数器”那几行代码;查数据库不需要锁,可以用ThreadLocal或者直接把数据设计成不可变对象。
缩小锁粒度之后,还要注意锁的公共对象问题。很多人锁了一个String常量或者某个自动装箱的Integer,导致不同业务共用了同一把锁。比如用synchronized (userId),如果userId是String类型且存在常量池复用,两个不同用户可能映射到同一个字符串对象,直接互相锁住。比较稳妥的做法是用专门的Object锁对象,或者锁业务对象的hashCode取模分片(但要注意哈希冲突)。还有一种常用套路是分段锁,比如模拟ConcurrentHashMap的设计,把数据分成16个桶,每个桶有自己的锁,线程只锁自己操作的那个桶,并发能力提升非常明显。
4.4 异步编程CompletableFuture:链式编排的高级玩法
CompletableFuture让Java异步编程从“回调地狱”进化为“声明式编排”。多线程小结里不谈它是不完整的。
最常用的几个方法:
supplyAsync(Supplier):异步执行一个有返回值的任务。thenApply(Function):任务完成后,对结果做转换(同步执行)。thenAccept(Consumer):任务完成后消费结果,无返回值。thenCompose(Function):任务完成后继续执行返回CompletableFuture的异步任务,用于串联两个异步任务。thenCombine(CompletionStage):两个异步任务都完成后合并结果。allOf(CompletableFuture...):所有任务全部完成。anyOf(CompletableFuture...):任意一个任务完成即可。
实际开发里的一个典型场景:一个聚合接口需要同时调用用户服务、订单服务、商品服务的接口,然后汇总返回。同步串行调用耗时是三者之和;用CompletableFuture.allOf并行调用,耗时约等于最慢的那个服务。优化效果是肉眼可见的。
我踩过一个坑:没给CompletableFuture指定线程池。它的默认线程池是ForkJoinPool.commonPool(),这个池子的线程数等于CPU核数减1,且是全局共享的。如果所有业务都往这个池子里丢任务,互相干扰不说,遇到阻塞型任务还会把整个commonPool拖垮。所以只要是正式环境,请务必显式传入自己管理的线程池。
5. 搞懂线程的协作机制:wait/notify、LockSupport与工具类
线程不能只管自己跑,更多时候需要“互相等一等”“干完了叫一声”。协作机制这块我单独拎出来讲,因为它不仅是面试常客,日常并发编程也经常用到。
5.1 wait/notify的精髓是“释放锁再等待”
Object.wait()和Object.notify()是所有对象都有的方法,前提是当前线程必须持有该对象的监视器锁。wait()会做什么?它做了三件事:把当前线程放入该对象的等待集合;释放当前持有的锁(注意,是释放锁,不是只挂起);让线程进入WAITING状态,直到被唤醒或中断。
这里有个很多人混淆的知识点:wait()释放的是哪个锁?是它对应对象的锁。如果synchronized (lockA)里又嵌套拿了一个lockB,lockA.wait()只会释放lockA,不会释放lockB。这点写多锁代码时要格外小心,否则可能出现“本希望等待时让出资源,结果别的线程还拿不到锁”的情况。
唤醒的细节也要注意:notify()是随机唤醒一个线程,notifyAll()唤醒所有。因为notify()选择哪个线程不受控制,容易发生“唤醒的线程不是真正需要唤醒的那个”的问题。很多教科书级生产者消费者Demo直接用notifyAll()是有道理的——宁可多唤醒几个,让它们自己重新检查条件,也不要漏唤醒。
5.2 LockSupport:更底层的线程阻塞原语
LockSupport是AbstractQueuedSynchronizer(AQS)的基础构件,核心方法就两个:park()(阻塞当前线程)和unpark(Thread)(唤醒指定线程)。和wait/notify比,它有几个显著特点:
- 不需要持有锁就能调用
park()。 unpark()可以先于park()执行,信号会被记录下来。这相当于一个“许可”机制,持有许可时park()会直接返回,不会阻塞。- 它没有“必须成对使用”的约束。同一个线程可以多次
park(),只要中间有对应次数的unpark(),但要注意许可不会被累积,最多只有一个许可。
日常直接写LockSupport的机会不多,但理解它有助于看懂ReentrantLock内部的lock()阻塞和unlock()唤醒逻辑,也有助于阅读一些JUC工具类的源码。
5.3 CountDownLatch、CyclicBarrier和Semaphore三兄弟
这三个工具类的区别和选型,我见过太多人记混了。用一个实际场景对比着说。
场景:电商大促,需要同时启动10个资源预热任务,全部完成后对外发布“预热完毕”的开关。
CountDownLatch(10):创建时计数10,每个任务完成后countDown()一次,主线程调用await()等待计数归零。它是“一次性”的,计数归零后不可重置。CyclicBarrier(10):也是等10个线程到达,但它是“循环使用”的。每轮10个线程到齐后,栅栏放开,然后自动进入下一轮。它更适合“分批次的并发任务”,比如每轮处理100条数据,多线程处理完了再进行下一轮拆单。Semaphore(5):控制最多5个线程同时访问某资源,类似限流。它不是等待线程到齐,而是许可证分发。
哪个场景用哪一个?简单判断:等结果用CountDownLatch,等齐了继续跑下一轮用CyclicBarrier,限制同时访问量用Semaphore。
6. 多线程问题排查:从死锁到高并发性能瓶颈
写多线程代码不犯错几乎是不可能的。关键是出了问题能不能快速定位。我把排查思路整理成一个常见的诊断路径,这也是最能体现“进阶”水平的地方。
6.1 死锁的成因与排查手段
死锁的四个必要条件想必大家都背得出来:互斥、持有并等待、不可剥夺、循环等待。实际操作里,最常见的死锁形态是“两个线程互相持有对方等待的锁”。
排查死锁最直接的工具是jstack。步骤是:
jps找到目标Java进程的PID。jstack <PID>输出线程快照。- 在输出中查找
deadlock关键字所在的段。
用jstack导出的死锁描述里会明确写出“Found one Java-level deadlock”,并列出等待链。如果并发代码里设计得比较复杂,一次dump看不出来,可以连续dump几次比对,确认哪些线程长时间处于BLOCKED状态且相互等待。
预防死锁的经验就三条:固定加锁顺序;使用tryLock加超时;能缩小锁粒度就绝不扩大。其中加锁顺序这个原则最容易被忽略,尤其是两个方法各自需要两把锁的时候,A线程先拿第一把再拿第二把,B线程反过来拿,死锁概率极高。把所有加锁操作都按同一个全局顺序执行,能从根源上消除循环等待。
6.2 线程dump的阅读技巧
jstack输出的每一段线程信息里,除了线程名和状态,最有用的是栈帧信息。排查问题的时候,我的阅读顺序是:
- 看状态:
RUNNABLE、BLOCKED、WAITING、TIMED_WAITING集中分布的规律。 - 如果大量线程
BLOCKED,直接找它们卡在哪个锁对象上。 - 如果大量线程
WAITING,看它们在哪里park或者wait,通常是LinkedBlockingQueue的take()或Condition.await()。 - 统计不同状态的线程数量,判断是连接池耗尽、线程池全忙、还是锁竞争激烈。
线程池场景还有一个隐蔽问题:核心线程数满且队列堆积,从dump里看到的现象是——线程池线程都RUNNABLE或阻塞在业务调用上,但workQueue.size()非常大。这时候性能瓶颈可能不在锁,而在下游依赖服务响应太慢。所以排查时要结合线程池的状态和队列大小一起看。
6.3 性能排查用到的几个JVM观察手段
除了jstack,还有几个常用的观测手段:
jstat -gcutil <PID>:看GC频率和耗时。频繁Full GC会导致所有线程停顿,表现是接口变慢但并发不高。jmap -dump:format=b,file=heap.bin <PID>:导出堆快照,用MAT分析哪些对象占用了大量内存。多线程场景里常见的问题是线程堆积导致的栈内存高(一个线程默认栈大小1MB,1000个线程就是近1GB),或是ThreadLocal里的数据没清理。top -H -p <PID>:看进程内部哪个线程CPU占用高。配合printf "%x\n" <线程PID>转成十六进制,再到jstack里找对应的线程栈。
性能问题排查和死锁排查最大的不同是:性能问题往往是“积少成多”的,单看一个线程没问题,放大到几百个并发线程才出现瓶颈。所以排查的时候要有放大思维,先通过压测复现,再通过状态统计定位,不要只盯着单次调用。
7. 我对多线程进阶的学习路线总结和几个忠告
前11篇把知识点一个一个讲完了,这一篇做小结。如果前面的内容你还能浮现出大概的知识结构,那么可以进入最后的复盘阶段——不只是“我学过哪些东西”,而是“我能不能解释清楚每个知识点背后的为什么”。
7.1 从会用走向会设计的四个阶段
我复盘自己的多线程学习过程,大致分成了四个阶段:
第一阶段:会用API。知道new Thread().start()、synchronized加锁、Thread.sleep()休眠。这个阶段写出来的并发代码大多是玩具级,甚至不如单线程快。
第二阶段:知道原理。理解了线程状态流转、锁的升级、volatile的语义、wait/notify的配合。这个阶段能看懂常见的并发Demo,也能应付大部分面试题。
第三阶段:会做工程设计。面对一个具体业务场景,知道该用线程池还是CompletableFuture,知道锁应该加在哪一层,知道队列怎么选,知道参数怎么估算。这个阶段写出来的代码才是能上生产的。
第四阶段:会排查和调优。线上出现接口变慢、CPU飙升、死锁、内存泄漏,能通过日志、线程dump、JVM监控快速定位原因并修复。这个阶段已经不是“会写代码”的范畴,而是“有能力掌控系统稳定性”。
7.2 几个容易被忽略的细节提醒
这些都是我实际项目中踩过的坑,或者帮别人review代码时发现的。列出来好了:
- 多线程环境下
synchronized加在String常量上是大忌。字符串常量池的存在会让不同业务代码意外共享同一把锁。 ThreadLocal用完一定记得remove(),尤其在使用了线程池复用线程的场景下。- 线程池里的异常处理要格外注意。
execute()提交任务时,如果任务抛出未捕获异常,线程池会将其吞掉并打印到System.err,业务上根本感知不到。要么在任务内部自行catch,要么用submit()拿Future再处理ExecutionException。 ForkJoinPool的commonPool是全局共享的,别把耗时任务全丢进去。- 锁对象尽量用
private final修饰,防止外部代码拿到引用后人为制造竞争。 volatile和AtomicXXX不能混为一谈。AtomicInteger的incrementAndGet()是原子操作,volatile int的++不是。
7.3 多线程学习的“最后一公里”
最后一公里是什么?我觉得是“把知识还原到业务场景里”。多线程不是为了炫技,而是为了解决真实世界的效率问题。你写一个转账接口,可能需要锁来防止超扣;你写一个报表导出,可能需要线程池并行查询;你写一个实时风控,可能需要CompletableFuture并行判断多条规则。每个知识点的价值,都是在特定场景下才能体现出来的。
如果你现在正准备面试,或者准备把一个并发模块重构得更合理,我的建议是:不要再去背那些罗列式的知识点了,试着用自己的话把下面这些问题讲清楚——
- 一个线程从创建到消亡,中间可能经历哪些状态?哪些操作会触发状态变化?
- 多个线程共享一个变量,你如何保证安全?你能说出两种以上方案的优劣对比吗?
- 线程池提交一个任务后,内部发生了什么?队列、线程数、拒绝策略是怎么协同的?
- 线上出现大量线程阻塞,你打算怎么排查?每一步用什么工具?
这些问题如果能脱口而出,而且能结合实际项目举例,那么多线程这一关就算真正过了。这篇小结写到这里,基本上把前11篇的知识点都串联了一遍——从线程生命周期、同步机制、线程池、并发容器、协作工具,到问题排查和设计思路。也希望读者不要把它当成终点,而是当成自己整理知识的参考框架。