主题 08线程池与并发容器
并发是八股里唯一背不下来的部分——因为它不是一串知识点,而是一堆「如果不这样,会出什么事」。 「核心线程满了放队列,队列满了扩容」这句话几乎人人会背,但面试官接着问「那第 7 个任务为什么被拒」,答得上来的不到三成。 这一章的所有数字都来自本机 JDK 17.0.12 / 16 核上真实跑出来的输出, 包括一份原样的 jstack 死锁日志。
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)
这张表要能自己画出来。三个关键转折点:
- 第 3 个任务:核心线程已满,先进队列(池大小没变)。
- 第 5 个任务:队列满了(2 个),这才扩容到 3。
- 第 7 个任务:池到 4、队列 2,全满 → 拒绝。
七个参数各自管什么
| 参数 | 作用 | 调错的后果 |
|---|---|---|
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 → 16 线程,耗时降到 1/5 左右(315 → 59),这就是多核的意义。
- 16 → 32 几乎不变(59 → 56)。因为只有 16 个核,第 17 个线程只能等别人让出 CPU。
- 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 的关键区别 |
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
| 能力 | synchronized | ReentrantLock |
|---|---|---|
| 等待时可被中断 | 不行(interrupt() 后仍是 BLOCKED,实测拿到锁后还正常执行) | lockInterruptibly() 立刻抛异常,实测 0.2 ms 退出 |
| 等待可设超时 | 不行,只能一直等 | tryLock(500, ms) 实测 505 ms 返回 false |
| 可指定公平 | 只有非公平 | new ReentrantLock(true),但代价是 160 倍 |
| 条件队列 | 只有一个(wait/notify) | 可 newCondition() 多个,精确唤醒 |
| 释放 | 自动(出同步块就放) | 必须手写 finally unlock,否则锁泄漏 |
关于「锁升级」,有个已经过时的事实
八股里最经典的一段是「偏向锁 → 轻量级锁 → 重量级锁」。但这段在 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 | 互斥,同一时刻只有一个线程进 | 临界区里有多步操作(不只是自增) |
AtomicInteger | CAS 自旋重试,无锁 | 单个变量的读改写,竞争不激烈时最快 |
LongAdder | 分段累加,最后求和 | 高并发计数(QPS 统计);缺点 sum() 不是实时精确值 |
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 deadlock | JVM 自己判定成环了,不用你推。没有这一行就不是死锁 |
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 偏向锁默认已关。 |
六个自测(能脱口而出才算过)
- 核心 2、最大 4、队列容量 2,连提 7 个任务,第几个会进队列?第几个被拒绝?
newFixedThreadPool(10)的maximumPoolSize是多少?为什么它实际上等于没用?- 16 核机器上跑 IO 密集任务(算 2ms 等 98ms),理论上开多少线程?为什么生产环境不真开这么多?
ReentrantLock的getHoldCount()返回 3 意味着什么?少 unlock 一次会怎样?- 为什么
ThreadLocal的 key 是弱引用,还是会发生内存泄漏? jstack里没有Found one Java-level deadlock,但接口全卡住了,你会先看哪三个地方?
动手清单与自查表
| # | 动手项 | 可观察的判据(做到了会看到什么) |
|---|---|---|
| 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,串数据消失 |
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,比重启强一百倍 |