pooling

09. 池化复用:线程池、连接池与内存池

0. 本章先解决什么问题

创建一个线程、建立一条数据库连接、分配一块堆外内存,这三件事有个共同点:贵。贵的资源用完就扔,等于每次都重新付一遍创建费。池化的思路一句话:

贵的东西造好了别扔,洗干净放回池子,给下一位用。
把”每次创建”的成本,摊薄成”偶尔补充”的成本。

先把”贵”量化,才知道池化各自赚了多少:

  • 创建线程:内核调用 + 默认约 1 MB 栈内存,毫秒级
  • 建立数据库连接:TCP 三次握手 + 认证握手 + 会话初始化,
    同机房几毫秒,跨机房几十毫秒;MySQL 那头还要配一个线程伺候
  • TLS 连接:再加一轮证书交换和密钥协商
  • 分配堆外内存:系统调用进内核,微秒级,高频分配时 GC 和碎片双重压力
  • 对照池化后的领取动作:从队列或缓存里拿现成的,纳秒到微秒级

本章把 Java 后端的三大池(线程池、连接池、内存池)逐个拆到零件,再看池化的静态形态(常量池、Integer 缓存),最后收拢池化统一的反噬边界。

池化的通用结构

这张图怎么读

中间是池的通用生命周期:预建、借出、归还、清洗、补充、淘汰。左右两侧是三大池往这个骨架上的对号入座,底部是所有池共享的三类反噬。

1. 池的通用结构:四件事

任何池都在回答四个问题,后面每个实例都拿这四问去套:

  1. 预建多少: 启动就造好的底仓(核心线程数 / minimumIdle)
  2. 借出怎么给: 有现成的直接给;没有则看策略(等待/新建/拒绝)
  3. 归还怎么收: 状态要洗干净(清 ThreadLocal / 回滚未提交事务),
    脏东西传给下一位是池化事故的头号来源
  4. 边界怎么办: 池到上限后,是排队、扩容还是拒绝,
    队伍排多长,等多久算超时

2. 线程池:ThreadPoolExecutor 完整拆解

2.1 七个参数就是四问的答案

代码块JAVA · 8 行收起展开
new ThreadPoolExecutor(
    corePoolSize,      // 预建多少: 常驻线程数
    maximumPoolSize,   // 边界: 最多扩到几个
    keepAliveTime, unit, // 超出 core 的线程闲多久回收
    workQueue,         // 边界: 排队的地方和队长
    threadFactory,     // 造线程的方式(命名!排查全靠名字)
    rejectedHandler  // 边界的边界: 队满且线程满时怎么办
)  

2.2 执行流程:先队列后扩容

一个任务提交进来的完整判定链:

  1. 活跃线程 < core -> 直接造新线程执行
  2. core 满了 -> 进 workQueue 排队
  3. 队列也满了,线程 < max -> 造”临时工”线程执行
  4. 队满且线程到 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 快的核心是把”借”做成了几乎无锁的三级查找:

  1. 先翻自己的 ThreadLocal 小抄: 本线程之前用过的连接列表,
    有空闲的直接 CAS 标记占用(连共享结构都不碰)
  2. 小抄没有 -> 去共享列表扫,CAS 抢一个空闲的
  3. 都没有 -> 挂到 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] 返回缓存的同一批对象

代码块JAVA · 2 行收起展开
   -> 名场面: 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. 池化的反噬:三类固定成本

  1. 上限变成新的排队点
    连接池打满,业务线程全部阻塞在 getConnection 上,
    线程池又因此被占满,故障从数据库一层层往上传染。
    监控必须盯”等待借出的线程数”和”借出等待时间”,
    它们比池的使用率更早报警
  2. 状态污染
    归还前没洗干净: 线程池里的 ThreadLocal 不清,
    下一个任务读到上一个用户的上下文(登录态串号的经典事故);
    连接归还前没重置 autocommit/隔离级别,下一位莫名其妙
    规矩: 池化资源的使用方要么无状态,要么借出即初始化、归还即清理
  3. 空闲资源变质
    连接被服务端超时断掉、被中间防火墙掐掉,池里躺着一堆尸体。
    对策: 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. 学完本章你能解决什么问题

  1. 三大池各自摊薄的创建成本是什么量级?
  2. 七参数怎么对应池的四问,JUC 和 Tomcat 的判定顺序为什么相反?
  3. FixedThreadPool 和 CachedThreadPool 各在哪个边界上 OOM?
  4. CallerRunsPolicy 和第 3 章的哪个机制共享思想?
  5. ConcurrentBag 三级借出各是什么,线程亲和赚的是什么?
  6. 连接池为什么两头都要保护,多大算大?
  7. maxLifetime 和 wait_timeout 的关系错了会出什么经典故障?
  8. Netty 内存池的三级结构和引用计数各解决什么?
  9. Integer 缓存的名场面怎么解释,静态池为什么没有反噬?
  10. 池的三类反噬是什么,哪两个监控指标比使用率更早报警?

核心一句话:池化把”每次创建”摊成”偶尔补充”,代价是必须管好四件事(预建、借出、清洗归还、边界);上限是新的排队点,状态污染和资源变质是两大事故源;上下游的池要连起来算预算。

延伸阅读