信号量有 2 个许可,两个线程却拿到了同一个连接
用
Semaphore(2)加一个列表做了个简单的对象池,压测时两个线程的数据串了:它们各自拿到了一个许可,手里却是同一个对象。另一个服务跑了几天之后,「最多 20 个并发」的限制变成了 23 个,没有人改过配置。
信号量只维护一个数字:还剩几个许可。「限制同时进行的数量」它做得很好;一旦要「借出一个具体的东西」(连接、缓冲区、客户端实例、许可证席位),就多出四个它不管的问题:借出去的是哪一个、现在归谁、用完还了没有、还回来的还能不能用。
本文在 JDK 25.0.4 上把这些问题逐个排出来,再给出一个带租约的最小实现。场景都是单线程顺序执行,结果是确定的。实验见文末。
一、许可不等于资源
同一个对象放了两次。 2 个许可,池里的两个位置指向同一个对象(初始化时写成了「创建一个、添加 N 次」,或者从配置里读到了重复项)。两个许可都借出去,两个借用者拿到的是同一个对象。信号量没有任何异常,它数的是许可,不检查后面的东西。
没借到也归还。
try {
acquired = permits.tryAcquire(10, MILLISECONDS);
…
} finally {
permits.release(); // 没拿到也执行
}实测上限 2 的信号量,一次超时的 tryAcquire 之后照样 release,等所有人都归还,可用许可是 3。Semaphore 不记录谁持有许可,release() 只是把数字加一,文档也说明了释放许可的线程不要求曾经获取过它。每发生一次这样的超时,并发上限就永久加一,开头那个 20 变 23 就是这么来的。
借了不还。 借出之后抛异常、没有 finally:实测两次之后可用许可为 0,再借 tryAcquire(50ms) 返回 false。池子没有「坏」,只是再也借不出东西,表现为所有请求都在等。
对信号量本身,写法是固定的:只有 acquire 成功之后才进入 try,在 finally 里释放。
if (!permits.tryAcquire(timeout, unit)) throw new RejectedException();
try {
…
} finally {
permits.release();
}二、归还时的三个问题
把信号量换成装着不同对象的阻塞队列(队列空了就等,本身起到限流作用),「同一个对象」的问题没有了,归还又有三个:
| 操作 | 结果 |
|---|---|
同一个资源归还两次(close() 被调了两次) | 池里出现两份 id 1;之后有两个借用者同时持有它 |
| 归还之后继续使用手里的引用 | 原借用者和新借用者指向同一个对象,两方都能调用 |
| 把使用中损坏的资源原样归还 | 下一个借用者拿到一个坏的 |
前两个是同一类问题:池认为资源空闲了,却还有人拿着它。重复关闭在实际代码里很常见,try-with-resources 关一次、异常处理里又手动关一次。第三个会让一次网络抖动的影响扩散开:坏连接回到池里,被一个又一个请求借走、失败、再放回去。
三、租约:把归属做成一个对象
借用时不直接给资源,而是给一个租约对象。它持有资源,实现 AutoCloseable,只能关闭一次:
final class Lease implements AutoCloseable {
private final Conn conn;
private final AtomicBoolean closed = new AtomicBoolean();
Conn conn() {
if (closed.get()) throw new IllegalStateException("lease closed");
return conn;
}
@Override public void close() {
if (!closed.compareAndSet(false, true)) return; // 第二次关闭什么都不做
if (conn.broken) idle.add(factory.get()); // 损坏:丢弃,补一个新的
else idle.add(conn);
}
}实测:
| 场景 | 结果 |
|---|---|
| 池大小 2,借出两个 | id 1 与 id 2,各不相同;第三次借用在超时后抛 pool exhausted |
同一个租约 close 两次 | 空闲资源 1 个,没有多出一份 |
| 关闭后通过租约取资源 | 抛 IllegalStateException(lease closed) |
try-with-resources 里业务抛异常 | 资源照常归还,空闲 2 个 |
| 资源在使用中损坏后归还 | 被替换;之后池里是 id 1 与 id 3,id 2 不再出现 |
几点说明:
- 资源由工厂逐个创建,保证各不相同。池的大小就是资源的个数,不需要另一个信号量。
- 借用带超时。无限期等待会把池耗尽变成线程全部挂住,超时等待与取消见超时了、取消了,任务其实还在跑。
- 损坏与否要有判断方式:使用中抛出的特定异常做标记,或者归还、借出时做一次轻量检查。
- 这个最小实现挡不住借用者把
conn()的返回值另存一份。成熟的连接池借出的是代理对象,所有调用都经过代理,归还后代理拒绝一切调用。java.sql.Connection的文档也规定,对已关闭的连接再调用close是空操作。
四、什么时候不用自己写
数据库连接、HTTP 连接、Redis 连接都有成熟的池,上面这些问题它们都处理过,还处理了本文没有涉及的空闲检测、最大寿命、泄漏检测。要做的是把它们的配置和指标用起来:借用超时、最大寿命、等待借用的线程数。连接池大小与线程数的关系见线程池参数怎么定。
需要自己管理的通常是没有现成池的东西:按许可证限制数量的第三方客户端、预分配的大缓冲区、需要昂贵初始化的计算上下文。这时用「阻塞队列加租约」,不要用「信号量加列表」。
只需要限制并发数、不需要借出具体对象时,信号量是合适的,注意第一节的写法。跨进程的并发限制是另一个问题,见基于 Redis 的分布式信号量,那里的租约还要处理持有者崩溃后的过期。
五、检查清单
- 池里的资源各不相同,由工厂创建。
- 获取成功之后才进入
try,在finally或try-with-resources 里归还。 - 归还是幂等的;归还之后的使用会报错,而不是悄悄成功。
- 损坏的资源不放回。
- 借用有超时;暴露空闲数、等待数和借用耗时。
- 定期核对:空闲数加借出数是否等于池大小。对不上说明有泄漏或重复归还。
配套实验
- codesphere-labs/java/resource-lease-ownership:JDK 25.0.4 上信号量与列表的六种误用,以及带租约的池在重复关闭、关闭后使用、异常与资源损坏时的行为(验证记录)
参考资料