☰
Java并发编程进阶:从线程基础到线程池实战与死锁排查
2026/10/7 17:33:34 网站建设 项目流程

线程这东西,刚入门的时候觉得不就是new Thread然后start()嘛,认真学起来才发现水有多深。从基础的状态流转到并发工具类,再从线程池参数调到死锁排查,每一层都有大量细节;而且一旦项目上了规模、流量上来,线程用得不好,线上问题能让你排查到怀疑人生。这篇内容我打算从线程的本体讲起,逐步带你理清创建线程的正确姿势、线程安全的底层逻辑、线程池的实战调参、线程协作工具以及死锁排查的完整思路。我尽量不写教科书式的理论,而是把实际项目里用得上的经验和踩过的坑一并整理出来,希望对正在进阶 Java 并发的同学有实实在在的帮助。

1. 先理清线程的本体:从进程、状态到上下文切换

1.1 进程和线程的关系,为什么说线程才是 CPU 调度的基本单位

很多人背过“进程是资源分配的基本单位,线程是 CPU 调度的基本单位”,但这句话到底意味着什么,未必细想过。进程就像一个工厂,它拥有独立的厂房、设备、仓库——对应着独立的内存空间、文件句柄、环境变量;线程则是工厂里的工人,大家共享同一个厂房的资源和设备,只不过每个人负责不同的工序。开一个进程的成本很高,因为要重新申请内存空间、初始化资源;但在同一进程里开线程就轻量得多,线程之间还能直接共享进程内的数据,不需要像进程之间那样走复杂的进程间通信(IPC)。

所以在高并发的服务端应用里,我们几乎不会为了处理一个请求就去开一个进程,而是在进程内用线程池去承接任务。这里有个值得注意的点:线程是 CPU 调度的基本单位,意味着操作系统真正分配 CPU 时间片时,看的是线程而不是进程。哪怕一个进程里开了一万个线程,CPU 眼里这一万个线程都是独立调度对象,谁的时间片到了谁就上 CPU 跑。这个特性决定了我们在设计并发程序时,线程的数量并非越多越好,因为 CPU 核心数是有限的,线程太多反而会让大量时间花在排队和上下文切换上。

1.2 线程生命周期与状态流转:背八股没用,要能看懂现场

我在面试候选人时,最常问的一个问题是:wait()和sleep()都会让线程停下来,区别是什么?很多人的回答是“一个释放锁一个不释放锁”,没错,但更深层的区别在于线程状态。sleep()属于TIMED_WAITING,它到期后会自动回到RUNNABLE;wait()则要依赖notify()或notifyAll()去唤醒,它进入的是WAITING状态。

Java 线程状态其实是操作系统线程状态的一层封装,经典的有六种:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。RUNNABLE其实包含了操作系统里的“就绪”和“运行中”两种状态,因为 JVM 觉得把两者区分开对 Java 层面没有直接价值;BLOCKED通常是线程在竞争synchronized锁时进入的;而WAITING和TIMED_WAITING则对应wait、join、park这一类操作。

在排查线上问题时,jstack打出来的线程栈会直接标出每个线程处于什么状态,如果你能熟练读懂这些状态,排查问题会顺利得多。比如大量线程卡在BLOCKED,基本可以判定是锁竞争激烈;大量线程处于WAITING且有规律地在某个wait()方法上停住,那多半是业务层的等待队列设计出了问题,而不是 CPU 不够。

这里我建议进阶开发者养成一个习惯:遇到性能问题不要急着怀疑硬件,先用jps找到进程号,再用jstack看一眼线程状态分布,很多结论会立刻浮出水面。

1.3 线程切换到底会不会“泄漏”,以及多大开销算大

“线程切换时会泄漏吗”这个问题比较复杂。线程切换本身不会造成内存泄漏,因为线程栈和程序计数器都是线程私有的,切出去再切回来,现场都还保存着。真正容易出问题的是:切换前后的锁状态、阻塞 IO 的上下文、以及数据库连接这类昂贵资源的持有。

如果线程拿到锁之后去做了耗时很长的 IO 操作,其他线程只能干等,你看到的现象就是“线程切换频繁但任务就是不推进”,本质是锁粒度太粗,而不是切换本身泄漏了资源。线程切换的开销主要在于:内核态与用户态的切换、寄存器和程序计数器的保存恢复、以及 CPU 缓存失效。一次切换大概需要几百纳秒到几微秒不等,如果系统中的线程数量是 CPU 核心数的几十倍,光切换开销就可能吃掉大量 CPU。

所以工程上有个接受度比较高的经验:线程池的线程数不是越大越好,CPU 密集型任务一般设为核心数 + 1 到 2,IO 密集型任务可以适当调大,但也要结合线程阻塞时长和任务量来算,我后面在讲线程池参数时会具体演示怎么算。

2. 创建线程的几种姿势,以及我踩过的那些坑

2.1 继承 Thread 还是实现 Runnable,我劝你优先用 Runnable

Java 创建线程最常见的两种方式就是继承Thread类和实现Runnable接口。很多教程会从语法角度对比,说 Java 是单继承所以实现接口更灵活。这当然没错,但从工程经验看,更核心的原因是:Thread类本质上是“线程这种系统资源”的抽象,把业务任务写在一个继承Thread的类里,等于把资源和业务强耦合了。

实际项目里,任务通常是一个会被反复执行的动作,比如“拉取订单数据”“发送通知消息”。如果你每次都new一个继承Thread的类去跑,线程就变成了一次性用品,用完即弃,资源开销很大;而把任务实现为Runnable,你可以把它丢给线程池反复执行,线程本身是复用的。

还有一个容易忽略的点:实现Runnable的类可以很方便地配合线程池、定时任务框架使用;而继承Thread的类在 Spring 这类框架里做集成时,总有一种“格格不入”的感觉。所以我的原则是:除非你要重写Thread本身的某些行为,否则永远优先用Runnable。

2.2 需要返回结果时,用 Callable 和 FutureTask

Runnable的run()方法返回类型是void,如果任务执行完需要拿到结果,就得另外想办法。常见做法是定义成员变量接结果,但多线程环境下这种写法很容易出并发问题。更优雅的方式是直接用Callable。

Callable<Integer> task = () -> { TimeUnit.SECONDS.sleep(2); return 1 + 1; }; ExecutorService executor = Executors.newFixedThreadPool(2); Future<Integer> future = executor.submit(task); // 做别的事... Integer result = future.get(); // 这里会阻塞直到任务完成

Future.get()有一个重要的细节:它是个阻塞方法,任务没完成时当前线程会一直等在那里。如果在get()之前没有设置超时时间,一旦任务内部出现死循环或者阻塞,调用方线程就永远挂住了。所以实际项目里我几乎都会带上超时参数:

Integer result = future.get(3, TimeUnit.SECONDS);

超时后抛出的TimeoutException需要捕获,并且要正确处理——通常是取消任务、记录告警。很多人只记得get()要捕获InterruptedException和ExecutionException,却漏掉TimeoutException,这是实战中很典型的疏漏。

2.3 为什么不推荐直接 new Thread,以及“线程复用”到底省了什么

直接new Thread的代码简单直观,本地写个 demo 完全没有问题;但一到生产环境,这种方式就成了灾难的起点。每new一个线程,JVM 就要向操作系统申请一块线程栈内存,默认大小通常在 512KB 到 1MB 之间(不同系统、不同参数设置差异不小),频繁创建销毁线程,内存和 CPU 的消耗都很客观。

更麻烦的是,大量线程同时存在会让系统频繁发生上下文切换,CPU 忙于切换线程而不是执行任务。如果并发量高,服务就会表现为响应变慢、负载升高,但你看线程 dump 又会发现大量线程处于WAITING或者TIMED_WAITING,活儿都堆着没干。

线程池解决的核心问题就是“复用”:核心线程常驻,任务来了直接分配,任务执行完线程并不销毁,而是继续等待下一个任务。减少线程创建销毁的开销,同时限制并发线程的数量,避免资源被无限创建的线程耗尽。这也是为什么现在你用Executors.newFixedThreadPool(10)这种工厂方法,本质上还是落到ThreadPoolExecutor这个核心类上。

3. 线程安全的底层逻辑:从可见性到原子性

3.1 一段“看似正确”的多线程累加代码,为什么会得到错误结果

先看一个经典场景:多个线程同时对一个int变量做自增操作。

private static int count = 0; public static void main(String[] args) throws InterruptedException { int threadCount = 10; int loopCount = 1000; Thread[] threads = new Thread[threadCount]; for (int i = 0; i < threadCount; i++) { threads[i] = new Thread(() -> { for (int j = 0; j < loopCount; j++) { count++; } }); threads[i].start(); } for (Thread t : threads) { t.join(); } System.out.println(count); }

你运行一次,结果大概率不是 10000,可能是 6000、8000,且每次都不一样。原因在于count++在 JVM 层面并不是一个原子操作,它至少包含三步:读取count的值到寄存器、把值加 1、把新值写回内存。当两个线程同时读到同一个旧值,各自加 1 后写回,就丢了一次自增,最终结果比预期小。

要解决这个问题,最直接的办法是加锁或者使用原子类。但要真正理解为什么要这样做,就要知道 Java 内存模型(JMM)的三要素:原子性、可见性、有序性。count++缺的是原子性;两个线程同时改共享变量且没有一个线程能看到另一个线程的最新修改,缺的是可见性;指令重排可能导致代码执行顺序和源码不一致,缺的是有序性。

3.2 synchronized 的三种用法,以及锁升级是怎么回事

synchronized三种用法分别是:修饰实例方法(锁当前实例)、修饰静态方法(锁当前类的 Class 对象)、修饰代码块(锁指定的对象)。它的核心是互斥,同一时间只有一个线程能持有锁进入临界区,其他线程只能在阻塞队列里等。

不过现代 JVM 对synchronized做了很大的优化,锁并不是一上来就是重量级的。早期 JDK 里synchronized依赖操作系统底层 mutex,线程一旦竞争不到锁就会从用户态切到内核态挂起,开销很大;后来引入了偏向锁、轻量级锁、重量级锁的升级路径:无锁竞争时不加锁(偏向),出现轻微竞争时用 CAS(轻量级),竞争激烈才升级为重量级锁。

对进阶开发者来说,知道锁升级的机制有助于理解“为什么 synchronized 在低竞争场景下性能并不差”。但更重要的还是锁粒度的设计:如果你在一个方法上直接用synchronized修饰整个方法,方法里的无关操作也会被串行化,拖慢并发能力。我一般建议先把临界区尽量缩小,只锁真正需要保护的那几行代码。

3.3 volatile 只保证可见性,不保证原子性

volatile是很多面试题里绕不开的点。它到底有什么用?简单说:被volatile修饰的变量,线程读取时一定会从主内存去拿最新值,写入时也会立刻刷回主内存,从而保证“一个线程修改后,其他线程可见”。但它不能保证多个线程同时写同一个变量的“复合操作”是安全的。

比如用volatile修饰计数器,两个线程同时对它做volatileCount++,依然会丢数据,因为自增不是一个原子操作。volatile更适合“一个线程写,多个线程读”的场景。最经典的案例是状态标志位:

private volatile boolean running = true; public void stop() { running = false; } public void work() { while (running) { // 执行业务逻辑 } }

如果这里不加volatile,工作线程可能长时间看不到running的最新值,循环就无法及时退出。因为 JIT 编译器可能把running优化到寄存器里,导致读不到主内存的更新。加volatile之后,每次读取都要从主内存拿,代价是牺牲了一点性能,但保证了可见性。

3.4 AtomicInteger 为什么线程安全?CAS 与 ABA 问题

AtomicInteger之类的原子类,底层依赖 CAS(Compare And Swap)指令。CAS 做的是“先比较再交换”:只有当内存中的当前值和预期值一致时,才把新值写入,否则重试。因为 CAS 是 CPU 指令级操作,不需要进入内核态,所以在低竞争场景下性能非常好。

从热搜词里能看到atomicinteger线程安全吗这类问题,答案是:AtomicInteger本身是线程安全的,它的自增操作能保证原子性。但它只能保证自己的操作原子,并不能保证“多个原子类一起做复合操作”的原子性。比如在一个事务里你先检查atomicA再更新atomicB,两个操作之间被另一个线程打断,整体逻辑就可能出问题。

CAS 还有一个著名的问题是 ABA:一个值从 A 变成 B,又变回 A,CAS 检查时发现值是 A,认为没有变化就继续操作,但实际发生过改变。大部分场景 ABA 不影响正确性,但如果你写的是无锁数据结构,ABA 可能导致错误的覆盖。解决办法是使用带版本号的AtomicStampedReference。

4. 线程池:生产环境真正的线程管理方案

4.1 核心参数逐个拆解:corePoolSize、maximumPoolSize、keepAliveTime

ThreadPoolExecutor的构造方法里有几个核心参数,理解透这组参数,线程池才算是真正入门了。

corePoolSize是核心线程数,线程池会尽量保证这么多线程是活着的,即使没有任务也不销毁。maximumPoolSize是最大线程数,当任务队列排满后,线程池才会创建更多线程,但上限不能超过这个值。keepAliveTime是多余线程的存活时间——超过核心线程数的那些线程,如果空闲时间超过该值就会被回收。

这里有个经常被搞混的执行顺序:新任务进来后,如果当前线程数小于corePoolSize,直接创建新线程执行任务;如果当前线程数超过corePoolSize,任务会先放入阻塞队列排队;只有当队列也满了,才会尝试创建新线程直到maximumPoolSize;如果线程数已经到最大且队列也满,就触发拒绝策略。很多人在看网上文章时容易把流程记反,以为队列满之后就不会在执行中的线程里兜底了,其实队列是最先承接多余任务的地方。

4.2 阻塞队列怎么选:LinkedBlockingQueue、ArrayBlockingQueue 还是 SynchronousQueue

线程池的阻塞队列选择直接影响任务的排队行为。LinkedBlockingQueue是链表实现,默认容量是无界的,用Executors.newFixedThreadPool创建线程池时用的就是它,风险在于任务堆积太多会占满内存。ArrayBlockingQueue是数组实现,有界,需要指定容量,适合明确知道队列积压上限的场景。SynchronousQueue比较特殊,它不存任务,每个插入操作必须等待另一个线程执行移除操作,否则会一直阻塞,用它时线程池只有看到有消费者才交接任务。

从热搜词线程池的阻塞队列选择来看,这个细节是很多人困惑的点。我的建议是:生产环境优先使用有界队列(如ArrayBlockingQueue),并给线程池加上拒绝策略和告警,避免无界队列把内存吃满。无界队列看似省心,实际上把风险延后到了一瞬间内存溢出的程度,排查起来更痛苦。

4.3 四种拒绝策略,实际项目中怎么选

当线程池已经达到最大线程数,且队列也满了,再有新任务进来就要执行拒绝策略。默认策略是AbortPolicy,直接抛RejectedExecutionException,让调用方感知到任务失败了。DiscardPolicy是静默丢弃,任务直接消失,没有任何提示;DiscardOldestPolicy是丢弃队列里最老的任务,然后重新尝试提交当前任务;CallerRunsPolicy则是让提交任务的线程亲自执行这个任务,以此起到节流作用。

工程上我较为常用的是CallerRunsPolicy,因为它不会丢任务,还能通过“让调用方自己跑”来反向压住生产者方的提交速度。不过要注意,CallerRunsPolicy执行任务的线程不是线程池里的线程,可能会影响调用方主流程的响应时间,所以具体选哪种策略要看业务诉求。

4.4 一个实战调参的完整例子

假设业务是处理订单同步接口,平均耗时约 50ms,其中 40ms 在等待远程接口返回(IO 密集型),10ms 是本地计算。服务器是 8 核 CPU。

IO 密集型的经验公式是:线程数 ≈ CPU 核心数 * (1 + 等待时间 / 计算时间)。这里等待时间占比 80%,计算时间占比 20%,所以合理的线程数大约是8 * (1 + 4)= 40 个左右。但配置时还要考虑远程调用的下游是否扛得住这么高的并发,所以我会先从 20 个线程起步,压测后逐步上调。

队列容量我会根据请求峰值来算:假设高峰期每秒 200 个请求,单个任务平均处理时间 50ms,那么队列里同时积压的任务大约为200 * 0.05= 10 个,可以再加上一定的缓冲。设置容量为 100 的ArrayBlockingQueue比较稳妥,既能吸收短期毛刺,又不会让内存无限制增长。

keepAliveTime我一般设置 60 秒,超时回收多余线程后,核心线程数保留 20 个即可。核心线程是否可以超时回收由allowCoreThreadTimeOut控制,默认是 false,也就是说核心线程一旦创建就不会销毁,除非显式关闭线程池。

5. 线程间的协作:锁只是基础,通信才是进阶分水岭

5.1 wait 和 notify 的经典配合,以及为什么更推荐 Condition

synchronized配合wait()/notify()是 Java 最经典的线程协作方式。wait()会让当前线程释放锁并进入WAITING,notify()会唤醒一个正在等待的线程,notifyAll()唤醒全部。这里有个非常重要的细节:wait()必须在持有锁的代码块中被调用,否则会抛IllegalMonitorStateException。

但wait()/notify()粒度比较粗:你只能唤醒一个或全部等待线程,无法精确指定唤醒哪一类等待者。作为替代,Lock配合Condition能拆分多个等待集合。比如生产者消费者模型里,你可以用两个Condition:一个表示“队列不满”,一个表示“队列不空”,生产者等待notFull,消费者等待notEmpty,唤醒时各唤醒各的,效率高也清晰。

现实中多数项目已经不会直接用最底层的wait()/notify()去写复杂协作逻辑了,而是用BlockingQueue这类现成组件。它们内部封装好了条件等待与唤醒,代码更加安全。不过底层机制还是要理解,因为排查问题时你很可能直接在线程栈里看到wait()调用。

5.2 CountDownLatch 与 CyclicBarrier:等待多个线程完成的两种思路

热搜词里有java线程等待都完成,这个需求非常典型:主线程要等一批子任务全部完成后,再继续往下走。于是CountDownLatch出场了。它的机制是设定一个初始计数,每个任务完成后调用countDown()减一,主线程调用await()阻塞等待,计数归零后放行。

CountDownLatch的计数只能减少,不能重置,是一次性工具。如果业务场景是“跑活动期间每天定时等一批任务完成”,用CountDownLatch就不太合适,需要每次重新创建实例。

CyclicBarrier的思路则是“线程之间互相等待”:多个线程到达同一个屏障点后,都等齐了才继续一起往下走。它天然支持重置,可以循环使用。区别在于CountDownLatch偏向“一个线程等一批线程”,而CyclicBarrier偏向“一批线程互相等”。如果你只是主线程统一等子线程结果,用CountDownLatch即可;如果是一批线程分阶段并行处理,每阶段都要等齐,用CyclicBarrier更贴合。

5.3 Semaphore 限流,以及信号量适合什么场景

Semaphore维护一组许可证,线程在执行前必须acquire()拿到许可证,执行完release()归还。它在限流场景下非常好用:比如某个下游接口最多可以同时接受 10 个请求,多打一个就可能超时,那么Semaphore(10)就能从本地线程层面限制并发的接入数。

控制并发数不只有Semaphore一种办法,限流器RateLimiter也能做,不过两者角度不同:Semaphore限制的是并发数量,不管单位时间流量;RateLimiter限制的是速率,比如每秒最多放行多少个请求。实际项目中可能要同时使用:先用Semaphore控制下游连接数,再用限流器控制请求频率,避免突发流量挤爆下游。

6. 死锁,以及如何用工具把它揪出来

6.1 死锁的四个必要条件,用一个生活化例子讲清

死锁是线程进阶里必须讲透的主题。它的发生需要同时满足四个条件:互斥(资源不能被多个线程同时共享)、持有并等待(线程持有资源的同时还在等别的资源)、不可剥夺(资源只能由持有者主动释放)、循环等待(线程之间形成互相等待的环路)。

生活化的例子就是:两个人吃饭,桌上只有一把筷子和一个勺子,甲抢到了筷子需要勺子,乙抢到了勺子需要筷子,谁都不肯先放下手里的餐具,两个人就这么僵住了。对应到代码里,最常见的就是两个线程各自持有一把锁,然后互相等对方手里的另一把锁。

6.2 手把手排查一次死锁:jps 和 jstack

死锁不像报错那样会直接弹异常,它是一种“程序卡住但进程还活着”的症状。排查步骤一般是这样:

  1. 用jps找到目标 Java 进程的 PID。
  2. 用jstack PID导出线程栈。
  3. 在输出里搜索 “deadlock” 或者 “Found one Java-level deadlock”。
  4. 查看相关线程的栈信息,找到它们各自持有什么锁、等待什么锁。

jstack会明确标出死锁的线程ID和锁对象,以及形成环路的两个节点。有一次我排查一个支付对账服务卡死的问题,jstack出来后立刻看到两个线程各自占用一个数据库连接池锁,又在等对方的另一个资源,几个不到就定位到了问题代码。学会用这些 JVM 自带的工具,比到处问别人“程序为什么不走”要高效得多。

6.3 避免死锁的工程手段

死锁防胜于治。最实用的手段包括:按固定顺序获取多个锁,让所有线程都先拿锁A再拿锁B,这样就不会形成循环等待;尝试用超时锁获取机制(比如ReentrantLock.tryLock(timeout))而不是无限期等待;尽量缩小锁范围,减少持锁时间;将大的共享资源拆分成多个小资源,并用并发容器替代手工加锁。

另外就是善用“锁排序”的思路:如果锁资源有天然编号(比如订单号、用户ID),就统一按编号从小到大加锁。我见过太多因为“先更新A表再更新B表”和“先更新B表再更新A表”两种顺序混用导致的死锁,最后统一加锁顺序后问题直接消失。

7. 守护线程与中断机制:进阶路线里绕不过的细节

7.1 守护线程到底有什么用,什么时候用

JVM 里线程分两种:守护线程和用户线程。守护线程的特点是:当进程中所有用户线程都结束时,守护线程会自动停止,JVM 随之退出。所以守护线程适合作为“后台辅助型”角色,比如垃圾回收线程就是典型守护线程。

从热搜词java+编写守护线程来看,很多初学者对守护线程的认知还停留在概念上。有应用场景要注意:如果业务里的心跳检测线程、监控上报线程被设为守护线程,那么当主线程结束时它们会立刻消失,可能造成“心跳突然停止”的问题。我一般建议:只有确定线程生命周期跟随主线程时,才设置setDaemon(true),千万不要在业务关键路径上用守护线程跑重要任务。

也可以用一段小代码验证守护线程的特性:主线程sleep3 秒后结束,守护线程里的循环也会随之终止,而普通用户线程不会。用的时候一定要结合业务判断。

7.2 线程中断机制:不是 stop,是 interrupt

早期 JDK 的Thread.stop()由于安全问题已经被废弃。现在的中断机制是一个“协作式”的过程:调用thread.interrupt()只是给目标线程打一个中断标志,目标线程需要自己去检查标志并响应。如果线程正阻塞在sleep()或wait()上,interrupt()会让这些方法抛出InterruptedException。

处理InterruptedException时,常见的错误是直接catch之后什么都不做,导致中断标志被吞掉。正确的做法通常是:恢复中断标志,即Thread.currentThread().interrupt(),或者直接向上抛出异常。在线程池任务里尤其要注意:池里的线程是复用的,如果任务吞掉了中断信号,线程池就无法感知任务被取消的意图,后续任务也可能受影响。

7.3 ThreadLocal 的内存泄漏问题,以及 FastThreadLocal 的思路

ThreadLocal是线程封闭的利器,每个线程持有自己的一份变量副本。它的底层是ThreadLocalMap,key 是弱引用形式的ThreadLocal,value 是强引用。key 会被 GC 回收,但 value 不会自动清除,于是出现了“key 为 null 但 value 仍然存在”的脏条目。

在线程池场景下,线程长期存活,脏条目会一直占着内存,最终导致 OOM。这也是为什么很多文章强调:使用ThreadLocal之后一定要在 finally 里调用remove()。

try { threadLocal.set(value); // 业务逻辑 } finally { threadLocal.remove(); }

Netty 里则提供了FastThreadLocal,用数组代替ThreadLocalMap,减少哈希查找开销并优化了清理逻辑,在高性能网络框架里收益明显。如果你在写网络层相关代码,可以留意这种优化思路。

8. 高频面试题和实战收尾:这些才是拉开差距的地方

8.1 为什么很多公司禁用 Executors 直接创建线程池

Executors.newFixedThreadPool虽然用起来方便,但企业规范里经常禁止直接用。原因是它默认使用无界队列LinkedBlockingQueue,任务无限堆积时内存可能被撑爆。Executors.newCachedThreadPool更夸张,它的最大线程数是Integer.MAX_VALUE,几乎没有上限,高并发下会创建海量线程,直接把系统资源耗尽。

所以更稳妥的做法是手动new ThreadPoolExecutor(...),显式指定核心线程数、最大线程数、队列容量和拒绝策略。虽然代码看起来繁琐一些,但每一个参数你都心里有数,线上问题出现时也能根据配置快速判断瓶颈在哪里。

8.2 如何优雅地关闭线程池:shutdown 和 shutdownNow 的区别

线程池关闭有两个方法:shutdown()会拒绝新任务,但等已经在队列里的任务全部执行完再关闭;shutdownNow()会立刻中断正在执行的任务,并返回队列里还没执行的任务列表。到底用哪个,取决于业务能否容忍任务被中断。

我一般是先shutdown(),然后等待一段时间,看是否全部结束;如果超时还没结束就再补一个shutdownNow()。这样既给任务一个“优雅收尾”的机会,又避免了服务下线时被任务强拖。等待期间可以用awaitTermination阻塞一段时间。

8.3 线程池线程数到底怎么算,一个公式和一次压测

关于线程池该设置多少线程,网上流传一个经验公式:CPU 密集型任务,线程数设为 CPU 核心数 + 1;IO 密集型任务,线程数设为 CPU 核心数 * 2。这个公式只能作为初步起点,真正靠谱的方式还是压测。

比如一个任务 90% 时间在等待远程接口,只有 10% 时间在本地计算,那么理论线程数是核心数 * (1 + 0.9 / 0.1)= 核心数 * 10。但最终设多少,要靠压测看吞吐量曲线。线程数从 8 开始,逐步增加到 16、32、64,观察吞吐量增速和延迟拐点,找到吞吐量不再明显增长、延迟开始恶化的那个点,就是合理的线程数。压测时要关注的是后端依赖的承受能力,而不是盲目拉高线程数。

线程池的空闲线程回收参数keepAliveTime也值得关注:如果你能确定高峰期结束后任务量会大幅下降,适当地把多余线程快速回收可以节省资源;如果任务波动频繁,回收又重建会造成额外开销,此时可以把keepAliveTime设长一些。

9. 踩坑清单与实战建议

最后把这些年在并发编程上踩过的坑集中整理一下,每一条都是真金白银换来的经验。

第一,尽量不要在synchronized代码块里做耗时操作。锁住一个方法然后去远程调用,等于让所有线程排队等一个网络响应,吞吐量直接崩。正确做法是把锁的范围缩小到修改共享数据的几行代码,远程调用放到锁外执行。

第二,HashMap在多线程环境下可能造成 CPU 100% 的问题。JDK 7 里并发 put 时链表可能成环,线程 get 时陷入死循环,现在 JDK 8 改成了尾插法,不再有这个问题,但依然不建议多线程直接使用HashMap,ConcurrentHashMap才是正解。

第三,线程池里的任务要设置合理的超时时间。任务写得不严谨,比如内部因为某个下游接口一直没有返回而无限等待,线程池里的线程会被全部占满,新任务全部进入队列排队,系统响应越来越慢直到不可用。给任务加一个超时保护,是成本最低的防灾手段。

第四,使用CompletableFuture做异步编排时,要注意自定义线程池。CompletableFuture默认使用ForkJoinPool.commonPool(),这个池子属于全局共享,如果任务里有阻塞操作,很容易把公共池的线程占满,影响其他业务。线上使用最好显式传入自定义线程池。

第五,善用超时配置。Future.get(timeout)、CountDownLatch.await(timeout)、Semaphore.tryAcquire(timeout)这些带超时的版本,能避免线程无限期等待。如果你发现某个线程长期停留在WAITING,大概率就是没有加超时控制。

第六,多读线程 dump。每遇到线上问题,把jstack输出保存下来,不需要所有内容都看得懂,重点关注BLOCKED、WAITING的线程数量、线程栈里出现的锁对象特征,时间久了你会对并发问题形成天然直觉。

线程编程最难的往往不是那些 API,而是对共享状态的理解。我也是在这件事上栽过多次跟头之后,才慢慢养成先画共享资源图、再写并发代码的习惯。多写多测多调,配合 JVM 工具耐心分析,才是通往 Java 并发的正路。

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

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

立即咨询