pooling
09. 池化复用:线程池、连接池与内存池
0. 本章先解决什么问题
创建一个线程、建立一条数据库连接、分配一块堆外内存,这三件事有个共同点:贵。贵的资源用完就扔,等于每次都重新付一遍创建费。池化的思路一句话:
贵的东西造好了别扔,洗干净放回池子,给下一位用。
把”每次创建”的成本,摊薄成”偶尔补充”的成本。
先把”贵”量化,才知道池化各自赚了多少:
- 创建线程:内核调用 + 默认约 1 MB 栈内存,毫秒级
- 建立数据库连接:TCP 三次握手 + 认证握手 + 会话初始化,
同机房几毫秒,跨机房几十毫秒;MySQL 那头还要配一个线程伺候 - TLS 连接:再加一轮证书交换和密钥协商
- 分配堆外内存:系统调用进内核,微秒级,高频分配时 GC 和碎片双重压力
- 对照池化后的领取动作:从队列或缓存里拿现成的,纳秒到微秒级
本章把 Java 后端的三大池(线程池、连接池、内存池)逐个拆到零件,再看池化的静态形态(常量池、Integer 缓存),最后收拢池化统一的反噬边界。
这张图怎么读
中间是池的通用生命周期:预建、借出、归还、清洗、补充、淘汰。左右两侧是三大池往这个骨架上的对号入座,底部是所有池共享的三类反噬。
1. 池的通用结构:四件事
任何池都在回答四个问题,后面每个实例都拿这四问去套:
- 预建多少: 启动就造好的底仓(核心线程数 / minimumIdle)
- 借出怎么给: 有现成的直接给;没有则看策略(等待/新建/拒绝)
- 归还怎么收: 状态要洗干净(清 ThreadLocal / 回滚未提交事务),
脏东西传给下一位是池化事故的头号来源 - 边界怎么办: 池到上限后,是排队、扩容还是拒绝,
队伍排多长,等多久算超时
2. 线程池:ThreadPoolExecutor 完整拆解
2.1 七个参数就是四问的答案
代码块收起展开
new ThreadPoolExecutor(
corePoolSize, // 预建多少: 常驻线程数
maximumPoolSize, // 边界: 最多扩到几个
keepAliveTime, unit, // 超出 core 的线程闲多久回收
workQueue, // 边界: 排队的地方和队长
threadFactory, // 造线程的方式(命名!排查全靠名字)
rejectedHandler // 边界的边界: 队满且线程满时怎么办
) 2.2 执行流程:先队列后扩容
一个任务提交进来的完整判定链:
活跃线程 < core-> 直接造新线程执行- core 满了 -> 进 workQueue 排队
- 队列也满了,线程 < max -> 造”临时工”线程执行
- 队满且线程到 max -> 拒绝策略
注意顺序:JUC 的默认设计是先排队、后扩容。这个选择偏向 CPU 密集场景(排队比造线程便宜,反正 CPU 就那几个核)。
而 Tomcat 面对的是 IO 密集的 web 请求,等在队列里纯属浪费,所以它定制了任务队列把顺序反过来:线程不到 max 先扩线程,到了 max 才排队。
同一个池骨架,按业务形态改判定顺序,改造细节在 tomcat线程池。
2.3 四种拒绝策略和两个内置池陷阱
AbortPolicy(默认): 抛异常,快速失败
CallerRunsPolicy: 提交者自己跑。天然的背压: 提交线程被拖住,
上游速度自动降下来(对照第 3 章 TCP 的 rwnd,同一个思想)
DiscardPolicy / DiscardOldestPolicy: 悄悄丢新的 / 丢最老的,
接受丢任务的场景才可用,日志都不留是它最坑的地方
Executors 工厂方法的两个经典陷阱,病根都是”边界没设”:
newFixedThreadPool: 队列是无界的 LinkedBlockingQueue
-> 下游一慢,任务无限堆积,OOM 在队列里
newCachedThreadPool: max 是 Integer.MAX_VALUE
-> 洪峰一来,线程无限扩,OOM 在线程栈上
所以规范要求手写 ThreadPoolExecutor,逼你亲手给四问填答案
2.4 该开多少线程:公式和它的前提
- CPU 密集:N 核 + 1(多的那 1 个补缺页等偶发停顿)
- IO 密集:经验值 2N,理论式 N x (1 + 等待时间/计算时间)
一个请求 10 ms 里 9 ms 在等库,单核就能喂 10 个这种线程 - 前提:公式假设任务同质。混合负载的正解是拆池,
快慢任务各一个池,互不拖累(船舱隔离的思想)
线程池全套细节在 ThreadPoolExecutor。
3. 连接池:HikariCP 拆解
3.1 为什么连接必须池化
第 0 节算过单价:一次建连几十毫秒,而一次简单查询本身才几毫秒,不池化等于每次查询付十倍开销费。另一头同样受益:MySQL 每条连接要吃一个线程和一份会话内存,连接数暴涨会把数据库先拖死。连接池同时保护了两端。
3.2 ConcurrentBag:借出路径为什么快
HikariCP 快的核心是把”借”做成了几乎无锁的三级查找:
- 先翻自己的 ThreadLocal 小抄: 本线程之前用过的连接列表,
有空闲的直接 CAS 标记占用(连共享结构都不碰) - 小抄没有 -> 去共享列表扫,CAS 抢一个空闲的
- 都没有 -> 挂到 handoff 队列等别人归还,
等满 connectionTimeout(默认 30 秒)抛超时异常
归还: 状态复位后优先塞给正在等的线程(handoff 直递),
没人等就标回空闲
线程亲和(第 1 级)赚的还是缓存局部性:同一个连接反复被同一个线程用,相关内存都是热的。机制源码在 连接池核心 - ConcurrentBag。
3.3 池该多大:越大越好是最常见的误解
连接在数据库那头是要吃 CPU 和内存的实体,
太多连接 = 数据库端上下文切换和锁竞争加剧,吞吐反降。
HikariCP 作者给的起点公式: 核数 x 2 + 磁盘数,
一个 8 核库配十几二十个连接通常就是最优区间
配套参数:
maximumPoolSize: 上限(连 minimumIdle 一起设成固定大小是官方推荐)
connectionTimeout: 借不到等多久(这是业务线程真实的阻塞时长)
maxLifetime: 连接最长寿命,要比 MySQL 的 wait_timeout 短,
否则池里躺着的是已被服务端单方面断掉的死连接,
借出去第一条 SQL 就报错(经典的”隔夜第一个请求必失败”)
leakDetectionThreshold: 借出超过阈值没归还就打日志,
抓”忘了 close”的泄漏(try-with-resources 是根治)
4. 内存池:jemalloc 和 Netty 的 PooledByteBuf
第 2 章讲过 jemalloc 按固定档位切内存,那就是分配器层的池:每个档位一堆现成的槽,申请按档位领,释放回档位,向 OS 的批发只在池子整体不够时发生。
Netty 把同一套思想搬进了 JVM:
- 动机:网络程序高频申请释放缓冲区。堆内分配触发 GC 压力,
堆外分配(DirectByteBuffer)每次都是系统调用,都贵 - 结构:预先向 OS 要下大块内存(chunk,默认 16 MB),
内部按层级切页、切小块(8 KB 页,更小的按档位),
加线程本地缓存让高频小额分配不碰共享结构
(三级结构和 HikariCP 的三级借出神似: 线程小抄 -> 共享池 -> 新建) - 代价:池化的 ByteBuf 必须手动 release,引用计数归零才回池,
忘了 release 就是堆外内存泄漏,Netty 专门内置了泄漏探测器
5. 静态池:常量池和包装类缓存
池化还有一个静态形态:不可变对象造一次,全局永远复用。
字符串常量池: 字面量 “abc” 进池,相同字面量全程序共享一份,
String.intern() 手动入池。不可变是前提(可变就不敢共享)
Integer 缓存: Integer.valueOf 对 [-128, 127] 返回缓存的同一批对象
代码块收起展开
-> 名场面: Integer a = 127, b = 127; a == b 为 true
Integer a = 128, b = 128; a == b 为 false教训: 包装类比较永远 equals,== 撞上缓存边界就是灵异 bug
Long/Short/Byte/Character 有同款缓存,Boolean 干脆只有两个实例
享元模式(Flyweight)是这个形态的设计模式名字。它和动态池的区别:静态池的对象不可变、不用归还、没有借出边界,所以没有下一节的反噬,是最省心的池。
6. 池化的反噬:三类固定成本
- 上限变成新的排队点
连接池打满,业务线程全部阻塞在 getConnection 上,
线程池又因此被占满,故障从数据库一层层往上传染。
监控必须盯”等待借出的线程数”和”借出等待时间”,
它们比池的使用率更早报警 - 状态污染
归还前没洗干净: 线程池里的 ThreadLocal 不清,
下一个任务读到上一个用户的上下文(登录态串号的经典事故);
连接归还前没重置 autocommit/隔离级别,下一位莫名其妙
规矩: 池化资源的使用方要么无状态,要么借出即初始化、归还即清理 - 空闲资源变质
连接被服务端超时断掉、被中间防火墙掐掉,池里躺着一堆尸体。
对策: maxLifetime 主动换血、keepalive/testWhileIdle 定期探活
附赠一条容量规划: 池是预算,不是弹性。
上下游的池大小要连起来算(Tomcat 200 线程配 20 连接,
意味着高峰时 180 个线程在等连接,这可能正是设计,也可能是事故,
取决于你有没有算过)
7. 联系实际:池视角的排查清单
-
现象:隔夜后第一个请求必报连接失败
-
方向:maxLifetime 大于服务端 wait_timeout,池里是死连接
-
现象:接口偶发 30 秒整的超时
-
方向:connectionTimeout 的默认值,说明借连接等满了 30 秒,
去查是池太小还是有慢 SQL 长期霸占连接 -
现象:用户 A 看到了用户 B 的数据
-
方向:线程池 + ThreadLocal 没清理,状态污染
-
现象:堆外内存持续上涨,GC 无感
-
方向:Netty 池化 ByteBuf 忘 release,开泄漏探测器定位
-
现象:128 == 128 判定失败
-
方向:Integer 缓存边界,换 equals
-
现象:调大连接池后数据库反而更慢
-
方向:连接数超过数据库最优区间,那头切换和竞争加剧
8. 学完本章你能解决什么问题
- 三大池各自摊薄的创建成本是什么量级?
- 七参数怎么对应池的四问,JUC 和 Tomcat 的判定顺序为什么相反?
- FixedThreadPool 和 CachedThreadPool 各在哪个边界上 OOM?
- CallerRunsPolicy 和第 3 章的哪个机制共享思想?
- ConcurrentBag 三级借出各是什么,线程亲和赚的是什么?
- 连接池为什么两头都要保护,多大算大?
- maxLifetime 和 wait_timeout 的关系错了会出什么经典故障?
- Netty 内存池的三级结构和引用计数各解决什么?
- Integer 缓存的名场面怎么解释,静态池为什么没有反噬?
- 池的三类反噬是什么,哪两个监控指标比使用率更早报警?
核心一句话:池化把”每次创建”摊成”偶尔补充”,代价是必须管好四件事(预建、借出、清洗归还、边界);上限是新的排队点,状态污染和资源变质是两大事故源;上下游的池要连起来算预算。