Java 后端八股精讲一问一答,背得下也答得出 速查 追问链路 ⌂ 全部教程

主题 08线程池与并发容器

并发是八股里唯一背不下来的部分——因为它不是一串知识点,而是一堆「如果不这样,会出什么事」。 「核心线程满了放队列,队列满了扩容」这句话几乎人人会背,但面试官接着问「那第 7 个任务为什么被拒」,答得上来的不到三成。 这一章的所有数字都来自本机 JDK 17.0.12 / 16 核上真实跑出来的输出, 包括一份原样的 jstack 死锁日志。

学习目标
能画出线程池扩容顺序、说出第几个任务被拒、现场读懂死锁日志
前置要求
读过第 03 章(栈帧);会写 lambda 和方法引用
本章产出
10 组实测 + 1 份真死锁日志 + 5 张速记卡
建议学时
4–5 小时(代码都要自己敲一遍)

8.1为什么这一章背不下来

前三章(集合、Java 基础、JVM)都能靠理解 + 记忆过关。并发不行,因为并发问题的特点是: 代码看起来完全正确,跑一万次都对,第一万零一次错。所以面试官问并发的目的,是看你有没有「见过它错」。

这一章要解决的问题长这样:

你听到的问题它其实在问说错的代价
「线程池的参数有哪些?」七个参数 + 扩容顺序背得出七个名字,说不出第 7 个任务为什么被拒
「为什么阿里规约禁用 Executors?」无界队列 → OOM答「不安全」,追问一句就空
「线程数开多少合适?」CPU 密集 / IO 密集两套算法答「越多越好」→ 直接出局
「synchronized 和 Lock 区别?」可中断、可超时、公平性只会说「一个关键字一个接口」
「线上 CPU 不高但接口全卡住」线程池打满 / 死锁 / 慢 SQL 占满线程只会说「重启一下」
怎么用这一章:文中所有 ### 段落都是真实运行输出,不是示意。 源码是单个文件 ConcurrencyDemo.java,一个 main 按参数分发: java ConcurrencyDemo grow 只跑实验 1,all 跑全部。 环境:JDK 17.0.12 / Windows x64 / 16 逻辑核。你的核数不同,实验 3 的绝对数字会变,但趋势一致。

8.2七个参数,和那个被背错的口诀

先把口诀钉死。流传最广的版本是:

「核心线程满了就放队列,队列满了就扩容到最大线程数,再满就拒绝。」

这句话顺序是对的,但九成人答完就停在这里,说不出「那到底第几个任务会被拒」。 下面这段代码就是为了把这个顺序变成可数的数字。配置:核心 2、最大 4、队列容量 2。

ArrayBlockingQueue<Runnable> q = new ArrayBlockingQueue<>(2);
ThreadPoolExecutor p = new ThreadPoolExecutor(
        2, 4, 60, TimeUnit.SECONDS, q);
CountDownLatch release = new CountDownLatch(1);   // 卡住所有任务,让它们同时"在跑"

for (int i = 1; i <= 7; i++) {
    final int n = i;
    try {
        p.execute(() -> { release.await(); });    // 任务一进来就阻塞
        Thread.sleep(80);                          // 等它真的被线程接走
        System.out.printf("提交第 %d 个 -> 池大小=%d 活跃=%d 队列=%d%n",
                i, p.getPoolSize(), p.getActiveCount(), p.getQueue().size());
    } catch (RejectedExecutionException e) {
        System.out.printf("提交第 %d 个 -> RejectedExecutionException%n", i);
    }
}
release.countDown();                               // 放行

输出(getPoolSize() 是实际创建了几个线程,getQueue().size() 是队列里堆了几个):

### core=2, max=4, queue=ArrayBlockingQueue(2),连提 7 个任务
提交第 1 个 -> 池大小=1 活跃=1 队列=0
提交第 2 个 -> 池大小=2 活跃=2 队列=0
提交第 3 个 -> 池大小=2 活跃=2 队列=1
提交第 4 个 -> 池大小=2 活跃=2 队列=2
提交第 5 个 -> 池大小=3 活跃=3 队列=2
提交第 6 个 -> 池大小=4 活跃=4 队列=2
提交第 7 个 -> RejectedExecutionException(队列满 + 已到 maximumPoolSize)

这张表要能自己画出来。三个关键转折点:

最容易答错的一句话:「核心满了就先扩容到 max,扩不动再排队」——反了。 真实顺序是「核心 → 队列 → 扩容」。这个顺序直接导致一个后果: 如果队列是无界的,队列永远不满,maximumPoolSize 就永远不会生效。8.3 会用实验证明。

七个参数各自管什么

参数作用调错的后果
corePoolSize常驻线程数,不设 allowCoreThreadTimeOut 就不会回收太小:任务排队;太大:闲时白养线程
maximumPoolSize线程数上限,只有队列满了才会往这里涨配了等于没配(队列无界时)
keepAliveTime超出核心的那部分线程空闲多久被回收设太大 → 峰值过后线程不退还
unit上面时间的单位写成 TimeUnit.SECONDS 却传 60_000 → 等 16 小时
workQueue排队策略,决定扩容能不能发生无界 → OOM;太小 → 频繁拒绝
threadFactory怎么创建线程(起名、设守护、设优先级)线上 dump 出来全是 pool-1-thread-3,定位不了业务
handler满了怎么办默认 AbortPolicy 抛异常,很多人不知道
为什么一定要自定义 threadFactory:线上出问题时你拿到的是 jstack 输出。 线程名叫 order-sync-3,你一眼知道是订单同步;叫 pool-2-thread-3,你得去猜。 一行代码换十分钟定位时间: new Thread(r, "order-sync-" + seq.incrementAndGet())。

8.3四种拒绝策略,和三个被禁用的工厂方法

队列和线程都满了,第四个任务怎么办?JDK 给了四种答案,同一个场景下行为完全不同。 配置:核心 1、最大 1、队列容量 1,连提 3 个任务(必有一个触发拒绝)。

### 同一场景:core=1 max=1 queue=ArrayBlockingQueue(1),连提 3 个
Abort          接受=2 抛异常=1 真正执行=[1, 2]
CallerRuns     接受=3 抛异常=0 真正执行=[3, 1, 2]
Discard        接受=3 抛异常=0 真正执行=[1, 2]
DiscardOldest  接受=3 抛异常=0 真正执行=[1, 3]

看第四列「真正执行」,四种策略的差别一目了然:

策略对第 3 个任务做了什么什么时候用
AbortPolicy
默认
抛 RejectedExecutionException核心业务,宁可失败也不能静默丢
CallerRunsPolicy让提交任务的那个线程自己跑(这里就是 main)能接受降速、但绝不能丢的任务。顺带形成天然背压
DiscardPolicy静默丢掉新的,不抛异常、不打日志日志、埋点等可丢数据。生产事故高发区
DiscardOldestPolicy丢掉队列里最老的,把新的塞进去只关心「最新状态」的场景(如行情推送)
DiscardPolicy 是最危险的默认选项之一。它什么都不做——不抛异常、不打日志、不计数。 你的代码 execute() 返回正常,业务以为发出去了,其实任务凭空消失。 如果非要用,至少包一层自定义 handler 打日志并计数,否则线上「偶发丢单」你会查一周。

三个工厂方法为什么被禁用

《阿里巴巴 Java 开发手册》明确禁止用 Executors 创建线程池。原因不是「不规范」,是两条能直接打挂服务的路。 先看第一条:无界队列让 maximumPoolSize 失效。

### max=4,但队列用 LinkedBlockingQueue(默认容量 Integer.MAX_VALUE)
连提 20 个任务:池大小=1 活跃=1 队列=19(max=4 从未生效)

### 换成 ArrayBlockingQueue(10)
接受=14 拒绝=6 池大小=4 队列=10

同样声明了 maximumPoolSize=4,用无界队列时池大小从头到尾就是 1,19 个任务全堆在队列里。 队列能堆 Integer.MAX_VALUE 个 Runnable —— 这就是 newFixedThreadPool 的真实配置。 任务积压时内存一路涨到 OutOfMemoryError: Java heap space,而不是干脆拒绝。

工厂方法实际的参数隐患
newFixedThreadPool(n)core = max = n,LinkedBlockingQueue(无界)任务无限堆积 → OOM
newSingleThreadExecutor()core = max = 1,LinkedBlockingQueue(无界)同上,而且串行,一个任务卡住全卡
newCachedThreadPool()core=0,max=Integer.MAX_VALUE,SynchronousQueue来多少任务建多少线程 → 线程数爆炸,直接 unable to create new native thread
newScheduledThreadPool(n)max=Integer.MAX_VALUE,DelayedWorkQueue(无界)同上两条各占一半
正确姿势:永远手写 new ThreadPoolExecutor(...),七个参数逐个写清楚。 代码长了三行,但你把「队列多长、线程几个、满了怎么办」三个决定权拿回自己手里—— 这三个数字就是你的系统在压力下的行为。

8.4线程数到底开多少:把公式跑成数字

面试标准答案是两个公式:CPU 密集 N + 1,IO 密集 N × (1 + 等待时间/计算时间)。 但很少有人说清为什么,更少人验证过。本机 16 核,跑 128 个纯计算任务:

### 16 逻辑核,128 个 CPU 密集任务(每个约 300 万次取模累加)
 1 线程 × 128 个任务 ->   315 ms
 8 线程 × 128 个任务 ->   130 ms
16 线程 × 128 个任务 ->    59 ms
32 线程 × 128 个任务 ->    56 ms
64 线程 × 128 个任务 ->    65 ms

三条结论,全部能从这张表读出来:

  1. 1 → 16 线程,耗时降到 1/5 左右(315 → 59),这就是多核的意义。
  2. 16 → 32 几乎不变(59 → 56)。因为只有 16 个核,第 17 个线程只能等别人让出 CPU。
  3. 32 → 64 反而变慢(56 → 65)。多出来的不是效率,是上下文切换的开销。
公式怎么来的:设核数 N,任务是「算 C 毫秒、等 W 毫秒」。 一个线程的 CPU 利用率只有 C/(C+W),要喂满 N 个核就需要 N / (C/(C+W)) = N × (1 + W/C) 个线程。 CPU 密集时 W≈0,退化成 N,加 1 是为了在偶尔缺页/GC 时还有线程能顶上。 IO 密集时 W 很大,比如「算 2ms、等 98ms」,N × 50 才喂得满——但线程本身也吃内存(默认每线程 1MB 栈), 所以工程上一般不会真的按公式开几百个,而是配合异步 IO 或扩大队列。
公式只是起点,不是答案。面试时这样说才完整: 「先按 N+1 / N×(1+W/C) 估一个初始值,上线后看两个指标再调—— 线程池的活跃线程数和队列长度。活跃长期等于 max 且队列在涨,说明线程不够; 活跃远小于 core 且队列为空,说明线程开多了。」

8.5AQS:那个「看不见的等待队列」

面试问「讲讲 AQS」,多数人背「AbstractQueuedSynchronizer,一个 int 状态 + 一个 FIFO 队列」。 问题是这两样东西都看不见,所以背完心里没底。其实 ReentrantLock 把它们暴露成了公开方法:

ReentrantLock l = new ReentrantLock(true);
l.lock();
l.lock();                       // 同一线程重入
System.out.println(l.getHoldCount());        // AQS 的 state
// 5 个线程来抢,然后看:
System.out.println(l.getQueueLength());      // AQS 等待队列的长度
l.unlock();                                  // 释放一层
System.out.println(l.getHoldCount());
l.unlock();                                  // 彻底释放
### 实测输出
同一线程 lock 两次:holdCount=2 当前线程持有=true
5 个线程来抢:AQS 队列长度=5 有排队线程=true
unlock 一次后 holdCount=1,锁仍归我 -> 队列长度=5(别人还在等)
再 unlock 一次:锁被释放,队列长度=0

### ReentrantReadWriteLock
持有读锁时,另一个线程再拿读锁:0.8 ms(共享,不阻塞)
持有读锁时,另一个线程想拿写锁:500ms 内拿到=false(互斥,被卡住)
读锁释放后写锁才拿到,总等待 502 ms

holdCount=2 就是 AQS 的 state,getQueueLength()=5 就是那个 CLH 双向队列的长度。 这两行输出把「背概念」变成了「看得见的数字」。而且注意第三行: 重入两次必须 unlock 两次,少一次锁就永远不放——这是线上「锁泄漏」最常见的原因。

同一个 state,五种含义

AQS 只有一个 volatile int state,不同同步器自己解释这个数字:

同步器state 表示怎么用
ReentrantLock重入次数0 = 空闲;每 lock 一次 +1,unlock 一次 -1,归零才释放
ReentrantReadWriteLock高 16 位读、低 16 位写一个 int 塞两种计数,所以读写锁最多 65535 个读锁
Semaphore剩余许可数acquire 减 1,release 加 1,减到 0 就排队
CountDownLatch剩余计数countDown 减 1,到 0 唤醒所有 await 的线程,不能重置
CyclicBarrier内部用 ReentrantLock + Condition可以重复使用(reset()),和 Latch 的关键区别
一句话串起来:AQS 干的事就是「用 CAS 抢 state,抢不到就进队列排队,前一个 release 时叫醒下一个」。 ReentrantLock、Semaphore、CountDownLatch 的差别,只在于它们怎么解释 state。 面试能说到这一句,就不是背的了。

8.6synchronized 与 ReentrantLock:三个能实测的差别

「一个关键字一个接口」「一个是 JVM 层一个是 API 层」——这些答完,面试官通常接着问「那什么时候必须用 Lock」。 答案藏在三个能跑出来的差别里:

### 实测输出
打断前 waiter 状态 = BLOCKED
打断后 waiter 状态 = BLOCKED(仍然在等,interrupt 对 BLOCKED 无效)
[synchronized] 竟然拿到了锁
[lockInterruptibly] 被中断,立刻抛 InterruptedException 退出等待
lockInterruptibly 响应中断耗时 0.2 ms
tryLock(500ms) 返回 false,等待了 505 ms(不会无限挂住)

### 公平锁 vs 非公平锁:8 线程各 20 万次 lock/unlock
fair=false 47 ms  计数=1600000
fair=true  7705 ms  计数=1600000
能力synchronizedReentrantLock
等待时可被中断不行(interrupt() 后仍是 BLOCKED,实测拿到锁后还正常执行)lockInterruptibly() 立刻抛异常,实测 0.2 ms 退出
等待可设超时不行,只能一直等tryLock(500, ms) 实测 505 ms 返回 false
可指定公平只有非公平new ReentrantLock(true),但代价是 160 倍
条件队列只有一个(wait/notify)可 newCondition() 多个,精确唤醒
释放自动(出同步块就放)必须手写 finally unlock,否则锁泄漏
公平锁 160 倍的代价,是最容易被忽略的一笔账。8 个线程各 20 万次加锁: 非公平 47 ms,公平 7705 ms。 原因不是公平锁「实现得差」,而是它严格遵守先来后到——每次释放都要唤醒队列头那个线程, 被唤醒的线程从内核态切回来需要时间,这期间 CPU 是闲着的。非公平锁允许「刚到的线程直接抢」,把这段时间填满了。 除非业务真的要求严格顺序(如某些排队叫号),否则一律用默认的非公平。

关于「锁升级」,有个已经过时的事实

八股里最经典的一段是「偏向锁 → 轻量级锁 → 重量级锁」。但这段在 JDK 17 上已经不能照背了:

### java -XX:+PrintFlagsFinal -version | grep UseBiasedLocking
bool UseBiasedLocking = false  {product} {default}

### 手动打开试试
$ java -XX:+UseBiasedLocking -version
Java HotSpot(TM) 64-Bit Server VM warning: Option UseBiasedLocking was deprecated
in version 15.0 and will likely be removed in a future release.

JDK 15 起(JEP 374)偏向锁默认关闭,JDK 18 起彻底移除。原因是它的收益在现代应用里已经很小, 却让 synchronized 的实现复杂度翻倍(撤销偏向锁要 STW)。 所以现在正确的说法是:无锁 → 轻量级锁(CAS 自旋)→ 重量级锁(monitor 阻塞), 偏向锁这一步在 JDK 17 默认不存在。面试时补这一句,是明显的加分项——说明你不是背 2018 年的博客。


8.7并发容器:count++ 到底会丢多少

「i++ 不是原子操作」人人会说,但很少有人知道丢的比例有多大。8 线程各 10 万次,三种写法同时跑:

### 8 线程 × 100000 次自增
    int    count++        ->  796383 / 800000  丢 3617 次
    AtomicInteger         ->  800000 / 800000  丢 0 次
    synchronized 包裹 int ->  800000 / 800000  丢 0 次

丢了 3617 次,约 0.45%。这个数字每次运行会浮动(另一次跑丢 5314 次), 但绝不会是 0——这就是「跑一万次都对,第一万零一次错」的量化版本:你压测 1000 次可能一次都不复现,上线跑了三天开始丢单。

原因就一句话:count++ 编译成读 → 加一 → 写回三条指令,两步之间别人可能已经改过了。

方案原理适用
synchronized / Lock互斥,同一时刻只有一个线程进临界区里有多步操作(不只是自增)
AtomicIntegerCAS 自旋重试,无锁单个变量的读改写,竞争不激烈时最快
LongAdder分段累加,最后求和高并发计数(QPS 统计);缺点 sum() 不是实时精确值
CAS 的 ABA 问题:线程 1 读到 A,线程 2 把它改成 B 又改回 A, 线程 1 的 CAS 检查「还是 A」→ 成功。值对了,但中间发生过变化。 链表场景下这会直接改坏结构(比如栈顶被弹出又压入同一个节点)。 解法是 AtomicStampedReference:连版本号一起比。

三个并发容器的选择

容器并发策略什么时候用
ConcurrentHashMap桶级 CAS + synchronized 锁单个桶绝大多数并发 Map 场景。实测 8 线程 40 万次 merge,总数分毫不差
CopyOnWriteArrayList写时复制整个数组读远多于写(监听器列表、白名单)。写很贵,且读到的可能是旧数据
BlockingQueue 家族生产者阻塞 / 消费者阻塞线程池任务队列、生产者消费者解耦

实测那一行是这么来的:8 个线程各做 5 万次 merge("k"+(i%100), 1, Integer::sum), 最后把 100 个桶的值加起来,400000 = 400000,一次不差。同样的代码换成 HashMap, 结果就在 50 上下乱飘、键还可能直接丢——不是 size 少了,是并发写覆盖了别人刚放进去的桶。


8.8ThreadLocal 在线程池里会串数据

这是我认为最值得亲手跑一遍的一个坑,因为它在本地永远不复现(你本地每次 new 一个线程当然没事), 只在线程池里、只在复用线程时出现。

ThreadLocal<String> tl = new ThreadLocal<>();
ThreadPoolExecutor p = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS,
        new LinkedBlockingQueue<>(), r -> new Thread(r, "biz-1"));

for (int i = 1; i <= 3; i++) {
    final int n = i;
    p.execute(() -> {
        if (n == 1) tl.set("用户A的登录态");       // 只有第 1 个任务 set
        System.out.println(n + " -> " + tl.get());
        // 故意不 remove
    });
}
### 实测:core=1,三个任务都跑在 biz-1 上
第 1 个任务(线程 biz-1)读到:用户A的登录态
第 2 个任务(线程 biz-1)读到:用户A的登录态
第 3 个任务(线程 biz-1)读到:用户A的登录态

后两个任务根本没 set,却读到了第 1 个任务的值。因为线程被复用了,ThreadLocalMap 挂在线程对象上,也跟着复用。 在 Web 应用里,这直接等于用户 B 读到了用户 A 的登录态——一个越权漏洞。

为什么还会「内存泄漏」

ThreadLocalMap 的 Entry 继承 WeakReference,key 是弱引用,value 是强引用:

Thread -> ThreadLocalMap -> Entry[] 
                            Entry.key   = WeakReference<ThreadLocal>   ← 弱引用
                            Entry.value = Object                       ← 强引用!

当 ThreadLocal 对象被回收,key 变成 null,但 value 还被 Entry 死死抓着, 而 Entry 又被 Thread 抓着。如果线程是线程池里的常驻线程,这个 value 就永远不释放。 ThreadLocal 内部会在 get/set 时顺手清理 key 为 null 的 Entry,但那是「顺手」——你不调用它就永远不清。

铁律:ThreadLocal 必须配 finally { remove(); }。 set() 之后的所有代码都放进 try,remove() 放进 finally—— 因为抛异常时也需要清理。Spring 的 RequestContextHolder、MyBatis 的 SqlSessionHolder、多数据源切换的 DataSourceContextHolder 全踩过这个坑, 框架层的源码里都是这么写的。

8.9死锁:从代码到 jstack 日志

两个线程、两把锁、加锁顺序相反——四行代码就能造出来:

Thread t1 = new Thread(() -> {
    synchronized (a) { sleep(200); synchronized (b) { /* 干活 */ } }
}, "biz-thread-1");

Thread t2 = new Thread(() -> {
    synchronized (b) { sleep(200); synchronized (a) { /* 干活 */ } }
}, "biz-thread-2");

跑起来之后,两个线程都永久卡住,CPU 占用为 0。用 jstack <pid> 抓到的原文:

Found one Java-level deadlock:
=============================
"biz-thread-1":
  waiting to lock monitor 0x000001efd373f9c0 (object 0x0000000712f45cb8, a java.lang.Object),
  which is held by "biz-thread-2"

"biz-thread-2":
  waiting to lock monitor 0x000001efd3741400 (object 0x0000000712f45ca8, a java.lang.Object),
  which is held by "biz-thread-1"

Java stack information for the threads listed above:
===================================================
"biz-thread-1":
	at ConcurrencyDemo.lambda$deadhold$25(ConcurrencyDemo.java:491)
	- waiting to lock <0x0000000712f45cb8> (a java.lang.Object)
	- locked <0x0000000712f45ca8> (a java.lang.Object)
	at java.lang.Thread.run(java.base@17.0.12/Thread.java:842)
"biz-thread-2":
	at ConcurrencyDemo.lambda$deadhold$26(ConcurrencyDemo.java:494)
	- waiting to lock <0x0000000712f45ca8> (a java.lang.Object)
	- locked <0x0000000712f45cb8> (a java.lang.Object)
	at java.lang.Thread.run(java.base@17.0.12/Thread.java:842)

Found 1 deadlock.

这段日志的读法就三行:

看哪里说明什么
Found one Java-level deadlockJVM 自己判定成环了,不用你推。没有这一行就不是死锁
waiting to lock X, which is held by Y谁在等谁的锁——两条互相指向就是环
- waiting to lock / - locked 成对出现每个线程已经握着哪把、还在等哪把,一眼看清加锁顺序

不想开命令行也能查——ThreadMXBean 一行就能拿到结论:

ThreadMXBean mx = ManagementFactory.getThreadMXBean();
long[] ids = mx.findDeadlockedThreads();     // null 表示没有死锁
// 实测返回 2 个线程:
//   T1 状态=BLOCKED 阻塞在 java.lang.Object@5e9f23b4
//   T2 状态=BLOCKED 阻塞在 java.lang.Object@6f539caf

四个必要条件与三种修法

死锁需要同时满足四条:互斥、持有并等待、不可剥夺、循环等待。破坏任意一条即可,工程上能动的只有第三条和第四条:

修法怎么做
统一加锁顺序(破循环等待)所有地方都按「先 id 小的、再 id 大的」加锁。转账场景的标准解法,首选
用 tryLock 超时(破不可剥夺)拿不到就放手重试。注意要释放已持有的锁,否则只是换个方式卡
缩小锁粒度别在持锁期间调用外部服务 / 发 MQ / 做远程调用
线上「线程全卡住」不一定是死锁。先看 jstack 里有没有 Found one Java-level deadlock: 没有这行就不是死锁。更常见的是三种:①线程池打满,全在 WAITING 等队列; ②全在 RUNNABLE 且堆栈停在 socket read ——下游接口超时;③全在等数据库连接。 死锁的特征是 CPU 接近 0 且线程数不多;打满的特征是队列长度持续上涨。

8.10速记卡与自查

卡要能一口气说完
卡 1 · 扩容顺序 核心 → 队列 → 扩容到 max → 拒绝。core=2/max=4/queue=2 时:第 3 个进队列、第 5 个才扩容、第 7 个被拒。
卡 2 · 三个工厂方法的坑 newFixedThreadPool 无界队列 → 任务堆积 OOM;newCachedThreadPool max 是 Integer.MAX_VALUE → 线程爆炸。一律手写 ThreadPoolExecutor。
卡 3 · 线程数 CPU 密集 N+1;IO 密集 N×(1+W/C)。但公式只是起点,上线看活跃线程数和队列长度再调。
卡 4 · AQS 一个 volatile int state + 一个 CLH 双向队列。Lock 里 state 是重入次数,Semaphore 是许可数,Latch 是剩余计数。
卡 5 · synchronized vs Lock Lock 多的三样:可中断、可超时、可公平。公平锁实测慢 160 倍;JDK 17 偏向锁默认已关。

六个自测(能脱口而出才算过)

  1. 核心 2、最大 4、队列容量 2,连提 7 个任务,第几个会进队列?第几个被拒绝?
  2. newFixedThreadPool(10) 的 maximumPoolSize 是多少?为什么它实际上等于没用?
  3. 16 核机器上跑 IO 密集任务(算 2ms 等 98ms),理论上开多少线程?为什么生产环境不真开这么多?
  4. ReentrantLock 的 getHoldCount() 返回 3 意味着什么?少 unlock 一次会怎样?
  5. 为什么 ThreadLocal 的 key 是弱引用,还是会发生内存泄漏?
  6. jstack 里没有 Found one Java-level deadlock,但接口全卡住了,你会先看哪三个地方?
一句话记忆:线程池的参数不是配置,是系统在压力下的行为合同—— 队列多长决定「能忍多久」,max 多大决定「最多扛多少」,拒绝策略决定「扛不住时撒手还是硬撑」。 三个数字写错,压测时一切正常,真实流量一来就雪崩。

动手清单与自查表

#动手项可观察的判据(做到了会看到什么)
1 跑实验 1,把 core/max/queue 改成 3/6/3 再跑 第 4 个进队列、第 7 个才扩容、第 10 个被拒(3+3+3+1)
2 把队列换成 LinkedBlockingQueue 但声明 max=4 getPoolSize() 始终是 core 的值,队列一路涨到 19
3 用 4 种 handler 各跑一次拒绝场景 Discard 真正执行只有 [1, 2],第 3 个凭空消失却不报错
4 把 lockInterruptibly 换成 lock() 再打断一次 线程一直是 BLOCKED,打断完全无效,最后还拿到了锁
5 造一次死锁,用 jstack 抓日志 看到 Found one Java-level deadlock + 两条 waiting to lock ... held by 互相指向
6 把 ThreadLocal 那段加上 finally remove() 后两个任务变回 null,串数据消失
反例实验(做了会记一辈子):把实验 1 的 ArrayBlockingQueue(2) 换成 LinkedBlockingQueue(),其余不动。你会看到 maximumPoolSize=4 这个参数 从头到尾一次都没生效——池大小永远是 2,队列堆到 5。 同一份代码、只换一个队列实现,系统的容量行为完全变了,而且没有任何报错。
卡住了怎么办去哪查
线程池到底在干什么getPoolSize() / getActiveCount() / getQueue().size() 三个数一起看
任务为什么没执行先确认 handler 是不是 DiscardPolicy(它不报错)
线程卡在哪一行jstack <pid>,先搜 Found one Java-level deadlock
想知道每个线程的忙闲jstack 里数 RUNNABLE / WAITING / BLOCKED 的比例
想留现场jcmd <pid> Thread.print > dump.txt,比重启强一百倍