☰
Java多线程核心总结:线程池、锁与并发实战要点
2026/10/1 13:43:49 网站建设 项目流程

这阵子我把 Java 多线程从头到尾又啃了一遍,从最开始的 Thread、Runnable,到 synchronized、Lock,再到线程池和 JUC 下面的工具类,最后又花了几天做了个小结,把脑子和笔记里的东西全部重新整理了一遍。今天这篇“JAVA进阶 THREAD 学习12 多线程小结”,就是把这一阶段的收获浓缩成一个相对完整的总结。它不是什么高深莫测的源码分析,更多是站在“学完一遍之后回头看,哪些是关键、哪些是坑、哪些是面试和项目里真正会被问到的东西”的角度来写。

如果你是刚把 Thread、锁、线程池这些概念学了一遍,但总觉得脑子里是一团乱麻,或者你是准备 Java 面试、想快速把多线程知识串成一条线的同学,那么这篇小结应该比较对胃口。我会从多线程到底在解决什么问题开始讲,然后是线程生命周期、线程安全三要素、锁机制、线程池参数、并发工具类,最后加一部分线上排查经验。这些都是我反复验证过的内容,有些坑也是踩过了才真正明白为什么大家都说“并发编程”是 Java 进阶的分水岭。

1. 多线程小结之前的开胃菜:先看清楚它到底在解决什么问题

1.1 多线程的本质不是“快”,而是“用好资源”

很多人刚学多线程时有个误区,认为开了多线程程序就一定跑得更快。我在入门阶段也这么想过,直到有一次把一张大图片处理的逻辑拆成几十个线程,结果耗时时长反而比单线程还多,才意识到多线程的核心价值根本不是“开更多线程”就更快,而是更合理地使用 CPU、IO 和内存资源。

打个比方:单线程干活就像一个人既要接电话又要写代码,中间不停地在“接完电话切换回写代码”的状态里折腾;多线程则像是你雇了多个人,有人专职接电话,有人专心写代码。但是雇人是有成本、有磨合的,如果你雇了太多人,大部分时间都花在沟通和抢会议室上,整体效率反而下降。多线程要解决的,就是在这中间找到平衡。

从这个角度理解,Java 多线程真正在做的事情有这么几类:

  • 充分利用 CPU 多核能力,把可并行的计算拆开,让核心不闲着。
  • 在 IO 等待(比如网络请求、数据库读写)期间,让 CPU 去做其他任务,减少空转。
  • 让多个任务在“宏观上同时进行”,提升系统的吞吐量和响应速度。

理解了这一点,再看后面的锁、线程池、并发工具类时,你会更清楚它们到底是为什么而存在。像我后来在项目里优化接口性能时,第一件事就是判断瓶颈是 CPU 密集型还是 IO 密集型,因为这两者对应的最优线程策略完全不一样。

1.2 这一阶段需要掌握的核心知识地图

多线程的知识点散落到网上各个博客和源码里,如果没有一张“地图”,很容易陷入一个又一个概念里出不来。我自己整理这份小结时,给知识点分成了六块,分别是线程的创建与生命周期、线程安全问题、锁机制、线程池、JUC 工具类、排查与面试实战。

  • 创建与生命周期:知道 Thread、Runnable、Callable、FutureTask 有什么区别,知道线程有哪些状态,什么操作会导致状态切换。
  • 线程安全:理解原子性、可见性、有序性,以及 volatile、synchronized、Lock 各自的局限和适用场景。
  • 锁机制与原理:从偏向锁、轻量级锁到重量级锁的升级过程,AQS 到底是什么东西,CAS 又是如何工作的。
  • 线程池:核心参数之间的相互影响、拒绝策略的选择、为什么阿里规约强制拒绝 new Thread。
  • 并发工具类:CountDownLatch、CyclicBarrier、Semaphore、ThreadLocal、并发容器的适用场景。
  • 实战排查:当线上出现高 CPU、死锁、队列堆积时,用什么命令和思路快速定位。

我在学完一遍后,最大的感受是:如果你能把第六块“排查与实战”也讲清楚,说明前面的概念你是真的融会贯通了。很多人背得出一堆概念,却不知道线上遇到线程问题该怎么查,这也正是进阶和初学的分水岭。

1.3 并发和并行的区别必须刻进脑子里

讨论多线程前,先把两个很容易混淆的词说清楚:并行和并发。这两个词在面试里也经常被问到,但许多人会用“并发是同一时刻多个任务都在执行”这样模糊的话带过去,其实是不到位的。

并行(Parallel)强调“物理同时执行”。只有多核 CPU 环境中,多个线程真正被多个核心同时执行,才叫并行。并发(Concurrent)强调“多个任务在同一个时间段内交替执行”,单核 CPU 上通过时间片切换也能实现并发,但每个具体时刻只有一个任务在执行。

我习惯用一个生活化例子来说明:你一边喝水一边吃东西,如果是一心二用交替进行,那就是并发;如果有左右手,同时左手喝水、右手吃东西,那就是并行。多线程关心的是并发模型,多核 CPU 则让并发有了真正并行的可能。理解这一点后,你再看线程池参数里的 CPU 密集型和 IO 密集型计算,会更容易理解为什么计算方法不一样。

2. 线程生命周期:从状态流转到代码验证

2.1 Java 六种线程状态总览

在 System 层面,操作系统线程只有创建、就绪、运行、阻塞、结束这么几种。但 Java 的 Thread.State 枚举定义了六种状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。

这六种状态虽然名字看起来很理论,但实际上和我们的编码直接相关。比如 BLOCKED 和 WAITING 都是线程“暂时不能跑”,但它们的本质完全不一样:BLOCKED 是在等一把别人持有的锁,只要锁释放就有机会被唤醒;WAITING 是线程主动调用了 Object.wait()、Thread.join() 等方法,等待别人来“通知”它,如果没有通知,它可能永远等下去。TIMED_WAITING 则是带时间的等待,比如调用 Thread.sleep(1000) 或者 wait(1000),时间到了会自动唤醒。

我整理表格的时候习惯这样记:

  • NEW:线程对象创建了,但还没调用 start()。
  • RUNNABLE:调用了 start(),线程正在 JVM 里执行,或者正在等待操作系统调度。
  • BLOCKED:想进入 synchronized 同步代码块,但锁被别的线程拿走了,只能排队等锁。
  • WAITING:已经拿到了锁,但主动调用 wait() 或 join(),等待另一个线程唤醒。
  • TIMED_WAITING:等待了指定时间,比如 sleep、wait(timeout)、join(timeout)。
  • TERMINATED:run() 执行完毕,或者发生了未捕获异常导致线程退出。

2.2 NEW、RUNNABLE、terminated 之间的实际操作

状态流转不是靠背的,写几行代码观察一下会比看十篇博客都管用。我给大家一个很简单的验证思路,直接复现一下就可以。

public class ThreadStateDemo { public static void main(String[] args) throws Exception { Thread t = new Thread(() -> { // 模拟工作中 System.out.println("线程正在运行: " + Thread.currentThread().getState()); }); System.out.println("调用start前: " + t.getState()); t.start(); Thread.sleep(100); System.out.println("调用start后: " + t.getState()); } }

这里的输出你会看到调用 start 前是 NEW,start 执行后很快就变成了 TERMINATED,因为 run() 里没做什么事情。RUNNABLE 状态在系统负载较高时比较容易观察到,或者在 runnable 里加一个 while(true) 循环,状态会长期保持在 RUNNABLE。

我在实际调试时发现一个有意思的地方:getState() 这个方法本身是一个快照,线程状态随时会变,所以单独看某一次取值没有意义,要观察状态流转,比较好用的方式是配合 jconsole 或 jstack 一起看。

2.3 BLOCKED、WAITING、TIMED_WAITING 怎么区分

这三者是最容易搞混的,面试也特别喜欢问。我曾经踩过一个坑:在项目里用 Object.wait() 实现线程间通信,结果因为忘记加锁,直接抛 IllegalMonitorStateException。后来才理解,wait() 和 notify() 的使用前提是“当前线程必须持有对象的监视器锁”。

为了理解状态区别,我通常用如下代码来模拟。

public class LockStateDemo { private static final Object LOCK = new Object(); public static void main(String[] args) throws Exception { Thread a = new Thread(() -> { synchronized (LOCK) { try { System.out.println("线程a进入wait"); LOCK.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } }); a.start(); Thread.sleep(200); System.out.println("线程a状态: " + a.getState()); // 大概率是 WAITING } }

如果把 LOCK.wait() 换成 Thread.sleep(5000),你会发现线程 a 的状态是 TIMED_WAITING;如果再加上第二个线程去抢同一个 LOCK,没有抢到的那个线程状态就是 BLOCKED。一次简单的实验,三种状态就都见到了。

从实际开发角度讲,WAITING 状态往往和“线程池中的空闲线程”相关。很多线程池的核心线程在没有任务时会调用 workQueue.take(),这个方法会使线程进入 WAITING 状态。所以如果你用 jstack 看运行中的 Java 服务,经常会发现很多线程处于 WAITING 状态,这是正常现象,不用大惊小怪。

2.4 关于中断和 sleep 的小陷阱小结

学到这里,绕不开的一个细节就是线程中断。很多人以为 Thread.interrupt() 会把线程“杀死”,其实它只是设置一个中断标志位,真正让线程停下来还得靠线程自己响应中断。如果线程正处于 sleep、wait 等状态,interrupt 会抛出 InterruptedException,并把中断状态清除。

我自己在实际写业务代码时,发现有一个很容易被忽略的点:InterruptedException 不能随便吞掉。比如用 try-catch 包住 sleep 后直接把异常 catch 掉什么都不做,会导致上层代码完全不知道线程被中断过,这在多线程协作时会产生隐蔽问题。正确做法通常有两种:要么在 catch 里重新设置中断标志(Thread.currentThread().interrupt()),要么在方法签名上抛出异常交给上层处理。

对于需要定时执行的任务,如果一定要用 sleep,建议优先考虑 ScheduledExecutorService 而不是自己写 while 循环 + sleep,因为后者在异常和停止任务时非常难控制。

3. 线程安全问题:从原子性、可见性、有序性说起

3.1 Java 多线程的“三大特性”对应哪些问题

线程安全本质上是解决三个方向的问题,分别是原子性、可见性、有序性。这三个词看着像概念,其实每一块都能对应到真实线上故障。

  • 原子性:一个操作是完整不可分割的。最常见的例子是 i++,看起来是一行代码,但底层其实有读取、加一、写回三步。两个线程同时执行 i++,最后结果可能不是预期值。
  • 可见性:一个线程修改变量后,其他线程能不能立刻看到这个修改。由于 CPU 缓存和指令重排序的原因,可能存在一个线程改了值,另一个线程读到的还是旧值。
  • 有序性:代码在编译和执行时可能发生指令重排,虽然重排后单线程结果一致,但多线程环境下可能会产生意外结果。

我经常用“两个人同时往一张表格里填写同一个单元格”来类比线程安全问题:一个人填到一半,另一个人也来填,最终结果谁写在上面取决于时序,而这个时序我们无法控制。多线程编程要做的,就是通过锁、volatile、并发容器等手段,让这种“谁先写谁后写”的不确定性变得可控。

3.2 volatile 到底保证什么、不保证什么

volatile 是 Java 多线程绕不开的关键字。很多人背结论说“volatile 保证可见性,不保证原子性”,但未必清楚底层为什么。简单解释,volatile 会让被修饰的变量在写入时立刻刷新到主内存,并让其他线程的缓存失效,从而保证一个线程写、其他线程读时能拿到最新值。

但它不能保证复合操作的原子性。比如多个线程同时对 volatile 变量执行 count++,依然会出现数据丢失。面试里有一个非常经典的问题:能不能用 volatile 实现计数器?答案是不能,因为 count++ 不是原子操作。除非你使用 AtomicInteger 或加锁。

我还想提醒一点:volatile 也有限制条件。它适合的场景是“一个线程写,多个线程读”这类发布模式,不适合“多个线程同时写且写操作依赖当前值”的场景。如果只用它来做一个开关标志位,那是完全合适的;如果试图用它解决复杂的状态同步,往往会出问题。

3.3 synchronized 的锁升级过程怎么理解

synchronized 早期被误解为“性能差”,实际上在 JDK 1.6 之后,JVM 对 synchronized 做了大量优化,形成了偏向锁到轻量级锁,再到重量级锁的升级过程。这个优化过程也是面试的高频题。

最开始,如果只有一个线程反复进入同步块,JVM 会借助 CAS 在对象头里记录这个线程的 ID,获得偏向锁,后续这个线程再次进入时不需要再做同步操作。一旦出现竞争,偏向锁会被撤销,升级为轻量级锁,通过自旋来等待锁持有者释放。如果自旋超过一定次数,或者等待线程太多,就膨胀为重量级锁,进入操作系统的内核态阻塞队列。

我自己的体会是,背下这个流程很容易,但在项目中真正要紧的是“避免锁竞争”。既然是重量级锁代价高,就不要把不必要的代码放进 synchronized 块里,也不要长时间持锁。锁粒度尽量缩小,正如俗话说的“锁住了资源才锁,锁得太肥只会拖垮自己”。

3.4 可重入锁 ReentrantLock、AQS 与 CAS 的关系

说完 synchronized,就不得不提 Lock 体系。ReentrantLock 是 java.util.concurrent.locks 包里的可重入锁,比起 synchronized,它多了可中断获取锁、可超时获取锁、支持多个条件队列、可以用 tryLock 实现非阻塞尝试等能力。

ReentrantLock 的核心是 AQS(AbstractQueuedSynchronizer),这是一个维护了“状态位 + 等待队列”的框架。简单而言,AQS 内部用一个 int state 表示资源的占用情况,通过 CAS 修改 state 来尝试获取锁;拿不到锁的线程会被包装成节点放进 CLH 队列里挂起等待。

我没必要在这里复述 AQS 每一行源码,但大家至少要明白一件事:无论是 ReentrantLock、Semaphore 还是 CountDownLatch,它们都建立在 AQS 之上。理解了 AQS,再看 JUC 里的各种工具,会发现它们只是对“共享/独占资源”的抽象不同而已。这也是很多面试官想听到的拔高点。

CAS(Compare and Swap)是并发包底层的重要原语,它是无锁编程的基础。AtomicInteger 的 incrementAndGet 就是用 CAS 实现的。它的问题是 ABA 问题,即一个值从 A 变成 B 又变成 A,另一个线程无法判断它是否被修改过;为此提供了 AtomicStampedReference 等带版本号的类。这些概念虽然冷门,但在“高并发下自旋导致的 CPU 飙高”问题排查时,理解 CAS 的代价就很有用了。

3.5 项目里锁选型的一些实操建议

真实项目里,我不太建议动不动就用 Lock 去替换 synchronized。大多数业务场景,synchronized 其实已经够用;只有当你需要“尝试获取锁、超时获取、公平锁、可中断”等特性时,才倾斜到 ReentrantLock。synchronized 在 JDK 8 以后性能和 ReentrantLock 已经差距不大,而且代码结构更简洁,不容易出现忘记释放锁的问题。

另外一点很重要:无论是 synchronized 还是 ReentrantLock,都应该把锁的范围控制到尽可能小,不要在持锁期间做数据库查询、远程调用、耗时 IO。如果对某个资源无法确定访问会不会阻塞,优先使用 tryLock(timeout) 而不是直接 lock(),这样至少不会让线程无限期等下去。

我还踩过一个比较坑的案例:分布式环境下,单机锁无法解决集群并发问题。这个时候要用 Redis 分布式锁或 ZooKeeper 锁来做跨 JVM 的互斥。这是多线程学习和分布式系统之间最容易脱节的地方。很多新人学了 synchronized 后,以为工程上直接用就万事大吉,结果项目跑着跑着发现数据出错了,原因就是同一份数据被多个服务实例同时处理了。

4. 线程池:面试必问,也是日常开发刚需

4.1 为什么不要用 new Thread 创建线程

在写小结时,我说最直接、最重要的一个进步就是养成使用线程池的习惯。直接在需要异步任务时 new Thread(...).start(),代码写起来很爽,但它有几个致命缺陷:

  • 每次创建和销毁线程都会产生开销,在线程数量大时,这些开销甚至可能比业务任务本身还高。
  • 线程数量不可控,如果并发任务短时间暴涨,系统可能被大量线程耗尽内存。
  • 线程之间没有统一管理,无法限制并发数,也无法复用。

阿里规约里也明确禁止使用 Executors 创建线程池,更不用说 raw new Thread 了。理由在后面会展开。改用线程池后,线程会被复用,任务被放进队列排队执行,系统的资源使用就有了一道“阀门”,不会因为流量突然上涨就立刻被冲垮。

4.2 ThreadPoolExecutor 的七个核心参数

线程池最核心的类自然是 ThreadPoolExecutor,它的构造函数里有七个参数,分别是核心线程数 corePoolSize、最大线程数 maximumPoolSize、空闲线程存活时间 keepAliveTime、存活时间单位 unit、阻塞队列 workQueue、线程工厂 threadFactory、以及拒绝策略 handler。

这七个参数里面最难理解的是它们之间的联动关系。我的记忆方式是把线程池当成一家餐厅:

  • 核心线程数就是正式员工,正常情况下只有这几位在干活。
  • 工作队列就是等位区,任务来了如果正式员工都忙,就让任务排队。
  • 最大线程数指的是临时工上限。当队列也满了,才会开始招临时工来帮忙。
  • keepAliveTime 是临时工空闲多久后会被辞退。
  • 拒绝策略是餐厅实在接不下客人时,如何拒绝新来的任务。

判断线程池何时创建新线程,不是看“当前任务数是否超过核心线程数”,而是先尝试放队列,队列放不下了才考虑创建新线程。这个流程我在初学时总是记反,后来用餐厅类比就再也没错过。很多人聊 final causes 的时候习惯说“核心线程满了就开新线程”,其实严格讲应该先看队列是否已满。

4.3 三种常见线程池各自的使用场景

日常开发中接触最多的线程池有三种,它们都在 Executors 工具类中提供,分别是 FixedThreadPool、SingleThreadExecutor 和 CachedThreadPool。

  • FixedThreadPool:固定线程数,队列是无界 LinkedBlockingQueue,适合执行长期任务,但如果有任务堆积,队列可能会占满内存。
  • SingleThreadExecutor:核心线程和最大线程都是 1,适合保证任务按顺序执行。
  • CachedThreadPool:线程数不固定,空闲线程 60 秒回收,使用 SynchronousQueue,适合大量并且耗时短的任务;但如果任务过多,会频繁创建线程,非常危险。

阿里规约不推荐用 Executors 默认实现新建线程池,主要原因就是 FixedThreadPool 和 SingleThreadExecutor 用了无界队列,任务太多时可能造成内存溢出;而 CachedThreadPool 的线程数不受限制,高并发下可能创建出几百上千个线程。比较好的做法是直接用 ThreadPoolExecutor 构造方法,并明确指定有界队列、线程工厂和拒绝策略,这样一切都在自己掌握中。

4.4 核心线程数怎么算:CPU 密集与 IO 密集的差别

这是一个面试高频题,但也是真正干活时会遇到的问题。核心线程数的估算主要看任务是 CPU 密集型还是 IO 密集型。

  • CPU 密集型任务主要在计算,最优线程数一般建议是 CPU 核数 + 1。这里多出的一个线程是为了应对偶尔发生的内存缺页、系统停顿等情况,不至于让 CPU 完全空转。
  • IO 密集型任务会经常发生网络等待、磁盘读写,阻塞比例高,可设置更多线程,经验公式是 CPU 核数 * 2,或者使用“线程数 = CPU 核数 / (1 - 阻塞系数)”这个公式。阻塞系数在 0.8 到 0.9 之间时,算出来的线程数会明显超过 CPU 核数。

我在优化一个数据导入接口时,就吃过一次亏。那个接口主要是从远程文件读取数据再写库,属于 IO 密集型,但一开始套用了 CPU 密集型公式,只设置了 8 个线程,结果发现 CPU 占用率不高,吞吐却上不去。后来将线程数调到 32,性能明显改善。这背后的原因就是每个线程大量时间在等网络返回,真正占用 CPU 的时间极少。

4.5 拒绝策略怎么选

当线程池已经运行到最大线程数,并且队列也满了,新任务就会被交给 RejectedExecutionHandler 处理。JDK 自带四种拒绝策略:

  • AbortPolicy:直接抛出 RejectedExecutionException,默认策略,适合需要立刻感知任务失败的场景。
  • CallerRunsPolicy:谁提交的任务谁自己执行,也就是调用 execute 的线程来跑。这种策略能天然实现一种简单的限流/背压效果。
  • DiscardPolicy:直接丢弃新任务,不抛异常。
  • DiscardOldestPolicy:丢弃队列里最老的任务,然后重新提交当前任务。

实际项目中,我更多会使用 CallerRunsPolicy,因为它不会让任务被无声无息地丢弃,同时也能降低任务提交速度,给系统一个缓冲过程。如果系统对数据可靠性要求很高,则应该选择 AbortPolicy,并在 catch 里做告警。

还有一个经常被忽略的点:任务本身应该尽量设计为可重试或可落盘的。因为不管选择哪种拒绝策略,都不能完全避免任务在排队过程中丢失或延迟执行。我在做消息推送时,会先把任务持久化到本地表,再由线程池消费,这样即使拒绝也能在下一次补偿任务中继续执行。

5. JUC 并发工具速查:按场景选择比背代码有用得多

5.1 CountDownLatch 和 CyclicBarrier 到底该选谁

JUC 包里提供了非常多的并发工具,如果不懂场景差异,往往会把 CountDownLatch 和 CyclicBarrier 混用。其实它们两个的核心差别很明显:

  • CountDownLatch 是一个计数器,一个或一组线程等待多个其他线程完成任务后才能继续,计数器不可复用。
  • CyclicBarrier 是一个屏障,多个线程互相等待,都到达屏障后一起继续执行,屏障可以循环使用。

我举个例子帮你理解:CountDownLatch 像比赛发令枪,选手们各自跑,观众等待所有选手到达终点后再做下一步;CyclicBarrier 则像一队人约定好达到某个集合点后一起出发,人数到齐之前谁也不能走。实际开发中,如果主线程要等待多个子任务都执行完再聚合结果,用 CountDownLatch 是很自然的;如果一批线程要分阶段同步,每轮到达屏障后推进一轮,则更适合 CyclicBarrier。

5.2 Semaphore:控制同时访问资源的线程数

Semaphore 翻译过来是“信号量”,它维护一组许可证,acquire() 时获取一个许可,没有许可就阻塞等待,release() 时释放许可。它适合在做限流、控制连接数、控制同时访问某资源的并发数时使用。

我在项目里用它控制数据库连接池之外的额外并发查询数量。比如批量对某个第三方接口发起查询,既不能一次全部打过去把对方压垮,也不能完全串行导致效率太低,就设置一个 Semaphore(10),确保同一时间最多只有 10 个请求真正发往第三方。相比固定线程池,Semaphore 更轻量,因为它在不创建更多线程的情况下限制数量,省掉了部分上下文切换。

有一个小注意点:Semaphore 在使用完成后一定要在 finally 里 release,否则许可会被“吞掉”,造成信号量逐渐耗尽,最终所有线程都阻塞在 acquire 上。这和 lock 忘记 unlock 是一个道理。

5.3 ThreadLocal:看似简单,坑却不少

ThreadLocal 提供线程局部变量,每个线程都有自己独立的副本。它最常见的用途是在一个线程内传递上下文信息,比如用户信息、请求 ID、traceId 等。在我做接口日志时,为了把每个请求的 traceId 贯穿到全链路日志里,就会用 ThreadLocal 在拦截器里 set,在任务结束时 remove。

但是 ThreadLocal 有一个经典内存泄漏问题:ThreadLocalMap 里的 Entry 继承了 WeakReference,key 是弱引用,value 是强引用。如果线程长期存活且不再 get/set 这个 ThreadLocal,value 就永远不会被回收。最直接的防线,就是使用完毕后主动调用 remove(),特别是在线程池中复用线程的场景。

如果你在一个请求里设置了 ThreadLocal,却忘了在 finally 中清理,那下一次请求复用同一个线程时,很可能会读到上一次请求留下的脏数据。这个问题出现时排查起来十分隐蔽,因为它不是每次都必然出现,而要取决于线程是否复用以及是否同一个线程处理了两个请求。

5.4 并发容器怎么选:ConcurrentHashMap 不是万能药

在并发场景下,普通的 HashMap 肯定不能直接用,因为扩容时可能出现环形链表或数据错乱。Hashtable 虽然线程安全,但全局锁太重。ConcurrentHashMap 是现在最常用的并发容器,内部通过 CAS + synchronized + 对桶节点加锁的方式提升并发度,读操作基本无锁。

但是 ConcurrentHashMap 保证的是单个操作的线程安全,却不保证复合操作的原子性。比如“先检查某个 key 是否存在再放入值”,如果这两个操作分开执行,依然会有并发问题。在这种场景下,建议使用它的原子方法,比如 computeIfAbsent、putIfAbsent、merge 等,而不是手动 get 后再 put。

同理,CopyOnWriteArrayList 适合读多写少的场景,写入时复制整个数组,读操作不加锁;但如果写操作频繁,会产生大量数组复制,反而性能更差。并发容器选型第一条原则就是看清自己的读写比例和是否接受最终一致。

6. 排查多线程问题的思路与面试实战实录

6.1 线上线程问题排查三板斧:jstack 只是开始

不少同学写多线程代码是一把好手,但线上出了问题就慌了。排查线程相关问题的第一步,是拿到线程快照。最常使用的命令是 jstack ,它会输出 JVM 里所有线程的堆栈信息。使用之前先将线程号转化为十六进制,再去 jstack 输出里匹配 nid。

# 先找到 Java 进程 pid jps -l # 拿到 pid 后导出线程信息 jstack <pid> > thread_dump.txt # 找到 cpu 占用最高的线程号 top -Hp <pid> printf "%x\n" <线程号> grep -A 20 "nid=0x<十六进制线程号>" thread_dump.txt

这一串操作能帮你快速定位哪些线程正在哪一段代码里执行。如果看到大量线程阻塞在同一个锁对象的 monitor 上,十有八九就是锁竞争过度;如果看到大量线程在 WAITING 状态,则要看它们是在等任务队列,还是发生了线程池核心线程空闲正常挂起。

如果配合 JMC、Arthas 或 async-profiler,可以直接分析火焰图,看到热点方法和线程调度情况。不过对于快速响应线上故障来讲,jstack 仍然是最可靠、最通用的一步。

6.2 常见的几个典型问题与对应排查方向

下面这些是我在项目里真实遇到过的线程问题,整理出来供大家排查时参考。

第一个是高 CPU 占用。多数时候不是线程太多,而是某个线程陷入了死循环或过度自旋。排查方向是找到 CPU 占用最高的线程,看它的堆栈是否落在某个 CAS 自旋循环或 while(true) 逻辑里。

第二个是任务堆积。如果队列长度持续增长,说明消费速度跟不上生产速度。这会表现为内存逐渐上涨,或接口响应越来越慢。此时要检查核心线程数和队列大小是否配错,任务处理里是否存在等待外部资源导致吃不满 CPU 的情况。

第三个是死锁。现象是相关接口彻底卡死,jstack 中会出现“Found one Java-level deadlock”字样。死锁发生的条件是互斥、持有并等待、不可剥夺、循环等待四个必要条件。在代码层面,最简单的预防措施是减少锁的嵌套,并尽量按照固定的顺序获取多把锁。

第四个是数据不一致。多个线程同时更新同一记录,最后结果不符合预期,这通常涉及原子性和可见性问题。先检查是否有 shared 可变变量没有被 volatile 或锁保护,再看类似“check-then-act”的逻辑是否使用了并发容器的原子方法。

6.3 面试中的多线程开放题怎么答

面试中比较常考的几道题,与其说是考答案,不如说是在考思路。比如“有一个任务耗时很长,如何优化”,这种题你如果只回答“用线程池异步执行”,多半是不过关的。比较好的回答路径是:

  • 先问清楚任务是 CPU 密集还是 IO 密集,因为优化方向完全不同。
  • 判断哪些部分可以并行,哪些部分存在依赖关系;依赖关系决定了能不能随便拆分。
  • 明确线程池参数如何选取,如何控制并发上限,避免把系统资源打满。
  • 想清楚结果如何聚合,以及超时、失败、重试、取消等异常情况如何处理。
  • 最后补充线程安全问题,比如共享变量是否需要加锁,是否会用 ThreadLocal、ConcurrentHashMap 等工具。

这个思路其实也适用于实际代码设计的回答。面试官要的是“你可不可以把并发问题想完整”,而不是一句“开个线程池”就完事了。当你把上述五层都讲透,并结合自己做过的项目举例,通常能让对方相信你确实有实战经验。

还有一道高频题是“线程池里的线程突然挂了会发生什么”,很多人的第一反应是“那这个任务不就丢了吗”。实际上 ThreadPoolExecutor 内部的工作线程如果因为运行未捕获异常而退出,Worker 会执行 processWorkerExit 逻辑,然后补充新的线程到线程池中,除非线程池状态已经停止。因此核心线程不会因为一次异常就减少,但需要小心的是没有被 try-catch 包裹的任务,它的执行结果和异常信息都可能在补充线程的过程中被静默掩盖。

6.4 高并发场景下的性能陷阱与优化笔记

多线程优化的终点不是“开更多的线程”,而是“减少不必要的竞争和上下文切换”。我在优化一个报表系统时,发现很多线程都在等待同一个 Redis Key 的读操作,即使数据允许本地缓存,也要每次都穿透到远端。后来引入 Caffeine 本地缓存,把热点数据下沉到 JVM 内,平均延迟直接下降一倍。这说明很多时候瓶颈并不是锁,而是外部资源访问太慢导致的长尾等待。

另有几点优化心得顺带记录:

  • 尽量减少同步块粒度,能用局部变量就不要用共享变量。
  • 用不可变对象替代可变对象,从根本上避免可见性和原子性问题。
  • 读多写少时用读写锁或 CopyOnWriteArrayList;写多读少时用 ConcurrentHashMap 之类的并发容器更合适。
  • 如果不得不加锁,保持锁顺序一致,避免死锁;用 tryLock(timeout) 替代无期限 lock()。
  • 对统计类数据,优先考虑 LongAdder 而不是 AtomicLong,因为 LongAdder 在高竞争时会将单点 CAS 分散成多个 cell,减少自旋冲突。
  • 合理设置 JVM 参数和线程池参数,不要把核心线程数拍脑袋决定,最好基于压测数据。

写在最后的小结与个人的一点经验

这篇多线程小结越写越觉得像是一次自我复盘。我在这阶段最大的体会,不是记住了多少 API,而是终于能把“线程生命周期、锁、线程池、并发工具、问题排查”这几块知识连成一张网。以前我会在 wait 和 sleep 的区别上卡住,会在线程池拒绝策略上犹豫,现在再看这些问题,其实都指向同一个本质:多线程编程是在用可控的资源使用换取最大的并发效率。

如果让我给刚准备进阶 Java 并发的人一句实在建议,那就是不要只啃理论,也不要只在 demo 里跑通就停止,而是主动去压测、去线上排查一次真实的线程问题。只有当你亲眼看到 jstack 中几十个线程堆叠在同一个锁等待上时,你对锁竞争的理解才算真正上了台阶。后面如果条件允许,我还会把分布式环境下的多线程问题单独拉出来做一次总结,毕竟单机并发只是第一步,跨进程协作才是更大的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询