简介:面向Java开发者与系统架构师的一份统一轻量级资源管理设计源码,聚焦配置、对象、IoC与AOP资源的透明化容器管理。项目以轻量级理念替代重量级框架束缚,可在复杂业务系统中实现灵活的依赖注入、切面织入与集中配置,适用于需要提升模块独立性和可测试性的中大型应用场景。压缩包共184个文件,涵盖123个HTML说明页、46个Java源文件、2个XML与2个properties配置,以及SVG、JSON、CSS、JS等辅助资源,整体仅1.24MB,结构简洁便于直接阅读与二次扩展。已有260人学习或下载,适合对容器实现和统一资源管理感兴趣的Java开发者参考。通过源码可梳理出容器初始化、拦截器链、类扫描、代理插件等关键模块的设计思路,为自研轻量级框架或重构既有资源管理模块提供可落地的实现模板。
1. 统一轻量级资源管理是什么:一个让服务告别“假死”的设计思路
很多 Java 服务上线头一个月表现正常,跑到第二、第三个月开始频繁“假死”:接口越来越慢,CPU 占用不高,线程却被占满,重启之后恢复正常,过两周又复发。我排查过不少这类问题,根因大多不在业务逻辑,而是文件流没关、缓存越积越多、连接借了不还。
统一轻量级资源管理,就是把散落在代码各处的文件句柄、缓存条目、连接对象收拢到一个管理器里,用一套统一的「登记—借出—归还—回收」契约管理它们的生命周期。它解决的是资源泄漏、回收失控和排查困难这三类问题,同时不引入 Spring 全家桶,不依赖微服务组件,只用 JDK 自带能力就能跑起来。
这套设计适合两类人:一是中小团队的后端开发,想在项目里建立一套可复用的资源管理规范;二是做基础组件交付的工程师,需要一个能嵌入现有项目的轻量级源码骨架。
2. 先把设计边界定清楚:统一什么、轻量在哪、管理到哪一层
2.1 三个关键词的边界:统一不是大而全,轻量不是少功能
先说“统一”。统一管理要统一的是生命周期契约,而不是把所有资源都塞进一个 Map 里让管理器什么都管。文件流、内存缓存、数据库连接,底层用法差异很大,但生命周期行为一致:都有创建、使用、空闲、销毁这些阶段。统一资源管理要做的是把「四态流转」抽成一套模型,让每种资源都遵守同一个接口,而不是把文件读取逻辑和连接池逻辑揉进同一个类。
再说“轻量”。这个设计的约束是:不依赖 Spring 的 Bean 容器,不引入 Netty,不需要注册中心,甚至连外部工具库都可以不引。它是作为一个普通 Java 类嵌入到现有项目中的,代码量控制在几个类的规模。它只解决「底层资源借了要还、闲置了要回收」这件事,不做缓存淘汰策略、不接管连接建立细节。
最后说“管理”。范围画在登记、借出、归还、回收、监控这五件事上。管理器不关心文件通道怎么读写,不关心 SQL 怎么执行,只关心资源在什么时刻被借走、什么时刻归还、空闲多久需要销毁。下面的枚举定义了三种初始资源类型,后续加类型只需要加一个枚举值,不用改管理器主体。
public enum ResourceType { FILE("file", 30_000L), CACHE("cache", 60_000L), CONNECTION("connection", 20_000L); private final String code; private final long defaultIdleTimeoutMillis; ResourceType(String code, long defaultIdleTimeoutMillis) { this.code = code; this.defaultIdleTimeoutMillis = defaultIdleTimeoutMillis; } public long defaultIdleTimeoutMillis() { return defaultIdleTimeoutMillis; } }这段代码把「空闲超时阈值」放进了类型定义:文件句柄最长空闲 30 秒,缓存给 60 秒,连接只给 20 秒。为什么连接最短?因为连接对象通常有底层数据库的空闲限制,管理器回收慢了,底层连接可能已经被服务端断开,业务拿到的是个失效对象。加新类型时,先想清楚这个类型的默认空闲阈值,再决定要不要覆盖它。
下面这张表总结了散养式和统一管理式的差别,方便在团队里对齐预期:
| 维度 | 散养式 | 统一管理式 |
|---|---|---|
| 创建 | 各处 new,无登记 | 管理器登记后借出 |
| 归还 | 靠业务方自觉 close | 成对 acquire/release,可模板化 |
| 回收 | 无感知 | 定时扫描空闲超时资源 |
| 监控 | 无 | 活跃数/空闲数/回收数可查 |
2.2 生命周期四态:从登记到回收的流转模型
资源状态我设计成四态:IDLE、ACQUIRED、EVICTING、CLOSED。一个新资源由 register 创建后先进入 IDLE,意味着已登记、空闲、可借;acquire 成功后进入 ACQUIRED,同时引用计数加一;业务方用完 release,引用计数减到 0 时回到 IDLE;调度线程发现它空闲超时,把状态改成 EVICTING,执行清理后进入 CLOSED,并从登记表移除。
这个模型里最关键的字段是两个:refCount 和 lastAccessAt。refCount 表示当前有多少调用方持有这个资源。同一个资源被多个线程同时借用是常见场景,比如同一份缓存配置被 20 个请求同时读取。如果只看状态不看计数,回收线程很容易把一个还在被用的资源关掉,这种故障非常隐蔽,因为不是每次都复现,属于典型的偶发问题。
lastAccessAt 是最近一次归还时刻,它解决了「最后使用时间」的记录问题。注意这里记录的是归还时刻而不是借出时刻,因为「资源是否空闲」的语义是「已经没有人用」,而不是「曾经被借过」。借出时间由 acquire 记录到另外的字段,用于泄漏诊断,不参与回收判断。
这里要明确状态之间的合法流转:只有 IDLE 才能被 acquire;只有 refCount 减到 0 才能回到 IDLE,所以释放时计数减到 0 之前状态保持 ACQUIRED;只有 IDLE 状态的资源才能被改写成 EVICTING;CLOSED 是终态,不允许再回到任何其他状态。这些规则如果靠散落的 if 判断硬写,很容易漏。建议用一个状态机常量表加原子状态变更来实现,后面 3.1 的代码就是这么做的。
2.3 选型判断:为什么不用现有框架,而是自己写
第一次听到“统一资源管理”,很多人会想:Apache Commons Pool 不是现成的吗?Spring 的 ApplicationContext 不是也在管理生命周期吗?两者都像,但都不完全匹配。Commons Pool 的核心是对象池化,借出、归还、池大小、空闲驱逐都做得很好,但它只解决「同一种池」的问题,跨类型统一登记、统一指标、统一优雅关闭这些需求要自己在外面包一层。
Spring 容器管理的是 Bean 的生命周期,不是资源生命周期。Bean 在容器里常驻,由容器负责依赖注入和销毁回调,但它不关心这个 Bean 内部有没有泄漏一个文件句柄。把 Spring 容器当成资源管理器用的结果,往往是 Bean 销毁时才发现资源早就失控了,而且引入 Spring 本身就破坏了「轻量级」的约束。
我一般这样选型:项目里已经有 HikariCP、Caffeine 这类专业组件时,不推翻它们,而是在上层加一个薄薄的 ResourceManager,把它们的资源实例登记进来,对外统一暴露 acquire/release。项目还在早期、没有历史包袱时,就直接用这套骨架按类型扩展。一个常见误用是试图用 ResourceManager 去管线程池。线程池的任务生命周期和资源池不同,线程是长期跑的任务载体,把它当资源池管,频繁销毁重建反而更贵——这也是我在团队里反复强调的边界。
3. 核心源码实现:一个能在本地跑通的 ResourceManager 骨架
3.1 资源包装类:把“底层对象”和“生命周期状态”绑在一起
先写基础类。下面这段是一个泛型资源包装类,T 是底层资源的真实类型,比如 FileChannel、Connection 或者缓存值。
public final class Resource<T> { private final String id; // 全局唯一标识,比如 "conn:order-db:1" private final ResourceType type; // FILE / CACHE / CONNECTION private final T payload; // 真正被业务使用的底层对象 private final AtomicInteger refCount = new AtomicInteger(0); private volatile long lastAccessAt; // 最近一次归还时间戳 private volatile State state = State.IDLE; private volatile StackTraceElement[] borrowStack; // 借出时记录的调用栈,用于泄漏定位 enum State { IDLE, ACQUIRED, EVICTING, CLOSED } Resource(String id, ResourceType type, T payload) { this.id = id; this.type = type; this.payload = payload; this.lastAccessAt = System.currentTimeMillis(); } boolean tryAcquire() { synchronized (this) { if (state != State.IDLE) return false; // 回收中或已关闭,不可借 if (refCount.getAndIncrement() == 0) { state = State.ACQUIRED; // 从 0 到 1,状态切换到使用中 borrowStack = Thread.currentThread().getStackTrace(); } return true; } } void release() { synchronized (this) { if (refCount.decrementAndGet() == 0) { state = State.IDLE; // 没人持有了,回到空闲态 lastAccessAt = System.currentTimeMillis(); borrowStack = null; } } } boolean tryEvict() { synchronized (this) { if (state == State.IDLE && refCount.get() == 0) { state = State.EVICTING; return true; } return false; } } long idleMillis() { return System.currentTimeMillis() - lastAccessAt; } T payload() { return payload; } StackTraceElement[] borrowStack() { return borrowStack; } int refCount() { return refCount.get(); } }逻辑说明:tryAcquire 用 synchronized 锁住当前资源实例,把「状态检查」和「引用计数增加」放进同一个临界区,避免两个线程同时借出时状态错乱。这里的锁只在单个资源对象上生效,粒度很小,不会变成全局瓶颈。borrowStack 在借出成功时记录当前线程栈,这就是第 6 章泄漏定位的基础——资源没归还时,回收线程能一眼看出是谁借走的。
参数说明:refCount 的语义是「当前正在使用的调用方数量」,不是借出次数。同一个连接被 20 个线程同时引用时,refCount 会涨到 20,只有全部 release 之后才会回到 IDLE。tryEvict 是 sweep 操作的核心,它要求 IDLE 和 refCount==0 同时成立才允许改成 EVICTING,EVICTING 状态下 tryAcquire 会直接返回 false,这样就不会出现「正在回收却被再次借出」的竞态。
3.2 管理器核心:登记、借出、归还、回收一条龙
管理器负责全局注册表、对外入口和定时回收。它用 ConcurrentHashMap 放资源,acquire 路径无锁,只有状态变更走单资源锁,这是吞吐的关键。
public class ResourceManager { private final ConcurrentHashMap<String, Resource<?>> registry = new ConcurrentHashMap<>(); private final ScheduledExecutorService sweeper = Executors.newSingleThreadScheduledExecutor(); private final AtomicLong evictedCount = new AtomicLong(); private volatile boolean acceptingAcquire = true; public <T> void register(String id, ResourceType type, T payload) { registry.put(id, new Resource<>(id, type, payload)); } public <T> void registerIfAbsent(String id, ResourceType type, T payload) { registry.putIfAbsent(id, new Resource<>(id, type, payload)); } public boolean contains(String id) { return registry.containsKey(id); } public void unregister(String id) { Resource<?> removed = registry.remove(id); if (removed != null) closeResource(removed); } @SuppressWarnings("unchecked") public <T> T acquire(String id) { if (!acceptingAcquire) throw new IllegalStateException("资源管理器已关闭"); Resource<?> resource = registry.get(id); if (resource == null) throw new IllegalArgumentException("未登记的资源: " + id); if (!resource.tryAcquire()) { throw new IllegalStateException("资源不可用: " + id); } return (T) resource.payload(); } public void release(String id) { Resource<?> resource = registry.get(id); if (resource != null) resource.release(); } public void startSweeper(long intervalMillis) { sweeper.scheduleWithFixedDelay(this::sweep, intervalMillis, intervalMillis, TimeUnit.MILLISECONDS); } private void sweep() { registry.forEach((id, resource) -> { if (resource.idleMillis() > resource.type().defaultIdleTimeoutMillis() && resource.tryEvict()) { registry.remove(id); evictedCount.incrementAndGet(); closeResource(resource); } }); } private void closeResource(Resource<?> resource) { Object payload = resource.payload(); if (payload instanceof AutoCloseable) { try { ((AutoCloseable) payload).close(); } catch (Exception e) { // 记一条 warn 日志,别让关闭失败影响主流程 System.err.println("close resource failed: " + resource.id() + ", " + e.getMessage()); } } } }逻辑说明:sweep 每轮遍历注册表,先判断空闲时间是否超过该类型的默认阈值,再调用 tryEvict 做一次原子状态切换。tryEvict 失败说明资源刚被借走,跳过不处理,等下一轮再扫。closeResource 用 instanceof AutoCloseable 判断文件通道、连接对象都能天然适配,新接入类型时不需要为每一种资源写专门的 close 逻辑。
参数说明:intervalMillis 建议取 500 到 1000 毫秒。太快的 sweep 会频繁遍历注册表,太慢会让空闲资源多存活好几秒。每轮遍历是 O(n),n 是注册表里的资源总数,几百上千个资源时完全无压力。需要特别注意:注册表的清理必须由 sweep 完成,只靠 evicted 计数而不 remove,会让 map 越来越大,这是后面避坑章要展开的一个问题。
这里补齐了 contains、unregister、registerIfAbsent 三个方法,它们会在第 4 章的文件和缓存接入里用到。unregister 会直接 close 底层资源,所以只应该在确认资源空闲时调用,比如缓存失效时。
3.3 指标采集与热参调整:让回收策略可观测、可调参
需要一个统一样式的指标出口,不然「回收策略合不合理」只能靠猜。
public record ResourceStats(long active, long idle, long evicted, long registrySize) {} public ResourceStats stats() { long active = 0, idle = 0; for (Resource<?> r : registry.values()) { if (r.refCount() > 0) active++; else idle++; } return new ResourceStats(active, idle, evictedCount.get(), registry.size()); }逻辑说明:active 数表示当前真正被使用、还没归还的资源量;idle 数表示已登记但空闲的量;evicted 是累计回收量;registrySize 是当前注册表总大小。四个指标配合才能判断回收策略是否健康:如果 registrySize 只增不减,说明有借无还;如果 evicted 持续上涨而 active 长期不动,说明这种资源类型可能不适合池化,比如文件通道用完就该重建。
参数说明:接入第 4 章的三类资源后,第一件事是把它接到 Prometheus 的 micrometer 出口或者一个最简单的 HTTP 接口上。调参只遵循一个原则:先看 evicted 曲线判断回收是否激进,再决定调 idleTimeout 还是调整资源自身的配置。acquire 失败抛 IllegalStateException 时,调用方要决定是重试还是降级——文件资源可以重建,连接资源应该走重连,缓存资源可以直接回源,这个决策放在接入层做,管理器不替业务方做判断。
4. 把三类常见资源接进来:文件句柄、内存缓存、数据库连接
4.1 文件句柄资源:把“打开—读取—关闭”改成“登记—借出—归还”
文件句柄其实不太适合池化。FileChannel 的 file pointer 是共享的,并发读同一个通道会互相干扰;不同调用方打开同一路径时,各自维护自己的读写位置更安全。所以统一管理文件句柄的核心价值不在复用,而在「强制关闭」:即使业务代码忘了 close,sweep 也会在空闲超时后把它关掉,避免句柄数涨到进程上限。
实践中我用模板方法,业务方不再直接碰管理器接口:
@FunctionalInterface interface FileAction<T> { T run(FileChannel channel) throws IOException; } public <T> T withFile(String path, FileAction<T> action) throws IOException { String id = "file:" + path; if (!manager.contains(id)) { FileChannel channel = FileChannel.open(Paths.get(path), StandardOpenOption.READ); manager.register(id, ResourceType.FILE, channel); } try { FileChannel c = manager.acquire(id); return action.run(c); } finally { manager.release(id); } }逻辑说明:第一次调用时打开通道并登记,之后的调用直接复用已登记的通道。如果中途被 sweep 回收,下一次 contains 返回 false,就会重新打开并登记新通道。模板方法把 acquire/release 成对使用固化下来:return 写在 try 里没问题,finally 永远兜底,release 不可能被异常路径跳过。
参数说明:文件类型的 idleTimeout 建议设 30 秒。句柄本身是操作系统的稀缺资源,空闲超过 30 秒还没人用,基本可以判定业务不会再用,回收掉最稳妥。如果业务确实有低频但长期的句柄需求,可以把阈值调到 60 秒,但要注意句柄数的上限。团队里如果允许业务方直接写 acquire/release,就永远有人会漏 finally,我自己的习惯是把 withFile 这样的模板方法作为唯一入口暴露出去。
有人会问为什么不直接用 try-with-resources。try-with-resources 解决的是「主动关闭」问题,解决不了「资源总量不可观测、无从回收」的问题。统一管理真正补上的是那一层全局登记表,让所有文件句柄的借用和归还都有记录可查。
4.2 内存缓存资源:把散养的 ConcurrentHashMap 收编进统一模型
缓存场景与文件场景相反,核心诉求是复用而不是重建,所以统管的价值在这里体现得最充分。以前缓存是业务代码里一个 static ConcurrentHashMap,按 key 放 value;现在换成 ResourceManager 之后,key 变成资源 id,value 变成资源 payload,过期和驱逐统一由 sweep 完成。
public class CacheResource<K, V> { private final ResourceManager manager; private final Function<K, V> loader; public CacheResource(ResourceManager manager, Function<K, V> loader) { this.manager = manager; this.loader = loader; } public V get(K key) { String id = "cache:" + key; if (!manager.contains(id)) { manager.register(id, ResourceType.CACHE, loader.apply(key)); } try { return manager.acquire(id); } finally { manager.release(id); } } public void invalidate(K key) { manager.unregister("cache:" + key); } }逻辑说明:get 时先查注册表,没有就通过 loader 回源加载并登记,然后 acquire 取出值,用完后在 finally 里 release。这里没有显式的 TTL 判断,超时逻辑已经由管理器按 idleTimeout 统一控制。invalidate 必须调用 unregister 而不是等自动回收,否则旧值会在过期前继续对外可见;unregister 内部会 rm 并 close,下一次 get 触发重新加载。
参数说明:缓存类型的 idleTimeout 建议给 60 秒。有一个细节要提醒:业务方如果 acquire 之后把缓存值存进自己的静态集合,或者长时间持有引用,最后访问时间一直不刷新,管理器会把它当成长期活跃资源,永不回收。所以缓存资源的 acquire 使用面要尽量窄——取出来立即读,读完成尽快 release,不要存进局部集合后再慢慢用。缓存失效的 register 和 invalidate 之间要小心并发:先 invalidate 后 get,会重新加载;如果两个线程同时 invalidate 同一个 key,可能出现短暂的双份加载,这是可以接受的代价。
Caffeine 已经是很成熟的本地缓存库,为什么还要再包一层?因为 Caffeine 解决的是缓存内部的容量控制、淘汰策略和统计,它不会在同一条调用链上,和文件句柄、数据库连接做统一协调。CacheResource 的价值是把缓存也纳入 acquire/release 这套契约,让排查问题时只有一个管理器需要看。
4.3 数据库连接:不重复造轮子,把 HikariCP 收编进来
数据库连接池是成熟领域,自研连接池的性能和稳定性很难超过 HikariCP。统一资源管理在连接场景的正确姿势是:HikariCP 继续负责连接的创建、检验、回收,ResourceManager 负责把连接映射成统一资源并暴露统一入口,让上层业务代码不直接依赖 HikariDataSource。
HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/order"); config.setMaximumPoolSize(10); config.setMaxLifetime(60_000L); HikariDataSource dataSource = new HikariDataSource(config); for (int i = 0; i < 5; i++) { Connection conn = dataSource.getConnection(); manager.register("conn:order:" + i, ResourceType.CONNECTION, conn); }这里连着向管理器登记了 5 条连接,而不是把池子本身当成一个资源。这样做的价值在于:业务代码一律通过 manager.acquire("conn:order:1") 拿连接,通过 manager.release 归还,底层连接池可以用最大池大小继续扩展,但业务侧只有一个出口。排查问题时不必去扒各个业务模块里谁 new 了 DataSource。连接真正归还给 HikariCP 的动作发生在管理器 closeResource 钩子里,业务代码只依赖 ResourceManager 一个入口。
参数上必须卡死的值是:manager 的 idleTimeout 必须小于 HikariCP 的 maxLifetime。比如 Hikari 的 maxLifetime 是 60 秒,管理器 idleTimeout 就设 20 秒,保证管理器在底层连接断开之前先把连接标记为可回收。反过来如果 idleTimeout 大于 maxLifetime,可能出现管理器持有的连接已经被 MySQL 踢掉、业务执行 SQL 时报 connection reset 的诡异现象,这是这个模型里最容易踩的配置错位。
提示:连接资源的 idleTimeout 一定要小于底层连接池的 maxLifetime。两者配置反了,服务会时不时出现「连接被重置」的偶发错误,排查起来非常费劲。
注册 5 条还是 10 条连接,取决于业务并发模型。如果并发请求量大,可以把 Hikari 池里的连接全部登记进来,让业务入口完全走 ResourceManager;如果只是少量管理后台查询,5 条就够。但要注意,被 manager 登记的对象不能再走 HikariCP 自己的 getConnection 获取新连接,否则两边各持有一部分连接,指标对不上。
5. 避坑与常见问题排查:资源管理最容易翻车的五个位置
5.1 现象:连接数缓慢上涨,两周后数据库拒绝连接
数据库连接数每天涨一点,两周后达到 max_connections,数据库直接拒绝新连接。排查代码发现每个获取连接的地方都有 close,但异常分支忘了执行。用 jstack 看一下线程栈,能发现大量线程阻塞在获取连接处,再用 lsof 数一下进程的 TCP 数量,确认连接没有被归还。
jstack <pid> | grep java.sql.Connection -A 5 lsof -p <pid> | grep -c TCP如果第二次执行 lsof 时 TCP 数量在持续上升,基本可以锁定「连接借了没还」。原因很简单:try 块里一旦抛异常,写在 try 尾部的 release 永远不会执行。解决:把 acquire/release 收进模板方法,release 只允许出现在 finally 里。这比代码评审制度可靠得多——评审看的是人的注意力,模板方法看的是代码结构。5.1 是我在生产环境见过频率最高的故障形态,没有之一。
5.2 现象:回收线程把正在使用的资源回收掉
表现是运行时偶发 IOException: Stream Closed 或 Connection closed,重启后消失,过几天又来。原因:sweep 只判断 idleMillis,不判断 refCount;或者 refCount 和状态切换之间存在竞态窗口。早期版本里我写过这种错误代码,别学:
// 错误写法:只判断空闲时间,remove 之后资源可能还在调用方手里 if (resource.idleMillis() > timeout) { registry.remove(id); resource.close(); }这个写法的问题在「判断」和「关闭」不是原子的:if 判断通过后,另一个线程可能刚好 acquire 了这个资源,然后 close 仍然执行,于是正在使用的资源被强制关闭。解决:用 3.1 的 tryEvict(),它要求 state 必须是 IDLE 并且 refCount==0,且这两个条件在同一个同步块内完成判断和变更。只要回收路径和借出路径都走这同一个方法,竞态就不存在。
5.3 现象:加锁后性能骤降
有人给整个 ResourceManager 加了 ReentrantLock,理由是「并发环境下 map 会有问题」,结果压测 20 线程并发借资源,吞吐掉了一半。原因:全局锁让 acquire/release 全部串行化,热点路径互相等待,锁等待时间直接拉高 RT。解决:锁粒度从全局降到单资源。registry 本身是 ConcurrentHashMap,读路径无锁;只有状态变更才需要锁,而且锁只落在当前资源对象上。压测数据表明,这种粒度的锁在千级并发下对吞吐几乎没有影响。
如果已经踩了这个坑,改起来也简单:去掉 manager 上的 lock 字段,把 acquire 和 release 里的 lock.lock() 换成 resource 内部的 synchronized。注意 register 不能放在锁内,否则全局登记表还是会被锁拖慢。
5.4 现象:监控指标看起来正常,但内存持续上涨
active 数量平稳、evicted 曲线也正常,但老年代内存缓慢爬升,Full GC 越来越频繁。原因:evict 时只改了状态、从注册表 remove,没有释放 payload 的强引用。缓存场景尤其典型:value 是大的业务对象,map 里 remove 之后,对象还挂在某个静态集合下,管理器无法控制。监控指标正常不等于不泄漏,这是最像玄学的一种坑。
解决分两步。第一步,在 closeResource 里,对缓存类资源把 payload 字段置空,释放强引用;第二步,在 CacheResource.invalidate 时同步清理业务侧的引用。诊断时用 jmap 导堆,找大对象实例的引用链,大概率能看到某个 static Map 或者 ThreadLocal 持有了缓存值。修复之后,老年代增长曲线会走平,再配合 evicted 曲线才能确认回收真正生效。
5.5 现象:优雅关闭时主线程卡死
应用停机时等待了 30 秒还没退出,Java 进程被迫 kill -9。原因:shutdown 时只停掉了调度线程,但业务线程手里还握着连接资源,release 迟迟不来,管理器一直等待。解决:两阶段关闭。第一步停掉所有入口流量并设置 acceptingAcquire 为 false;第二步等待一个 grace period,让在途请求完成 release;第三步强制回收所有资源并记录告警日志。顺序一旦颠倒,关停流程就会卡在「等一个永远不会来的 release」上。
具体实现上,acceptingAcquire 标志让新的 acquire 直接抛异常,旧的 release 照常走完。grace period 建议给配置化,默认 10 秒,超过这个时间还处于 ACQUIRED 状态的资源,打印 borrowStack 后强制关闭。这一套顺序写进部署脚本的 preStop 钩子里,比在代码里反复调 shutdown 时序可靠得多。
6. 进阶验证与优化收尾:压测、泄漏定位与优雅停机
6.1 五分钟压测:验证回收策略不泄漏
ExecutorService pool = Executors.newFixedThreadPool(20); for (int i = 0; i < 100; i++) { pool.submit(() -> { for (int j = 0; j < 200; j++) { String id = "test:" + (j % 10); try { manager.acquire(id); } finally { manager.release(id); } } }); } pool.shutdown();跑完后看 stats():registrySize 应该稳定在 10,active 应该回到 0。如果 registrySize 涨了,先查业务代码的 release 路径;如果 active 不为 0,说明有借无还。把这个脚本作为每次新增资源类型后的固定动作,比任何代码评审都有效。
6.2 泄漏定位:把未归还资源打印成调用栈
3.1 里的 borrowStack 字段,在 tryAcquire 成功时记录当前线程栈。sweep 发现一个资源空闲超时且 refCount 仍大于 0,说明它被借走后再也没还,此时把 borrowStack 连同资源 id 一起打进日志,能看到是哪一行业务代码借了没还。这条经验救过我很多次:指标只能告诉你泄漏存在,调用栈才能告诉你泄漏在哪一行。
6.3 优雅停机的固定顺序
第一步关闭入口,web 容器不再接收新请求;第二步 manager.setAcceptingAcquire(false),禁止新的 acquire;第三步等待 grace period 让在途请求完成 release;第四步调用 manager.shutdownNow(),强制回收所有空闲资源并打印尚未归还的资源。顺序反了就会出现 5.5 的卡死,建议直接把这套顺序写进部署脚本的 preStop 钩子。
我这几年做通用组件的一个习惯是:每接一种新资源类型,就先把 6.1 的压测脚本和 6.2 的泄漏栈打印一起交付,指标好看不代表不会漏,能把借用栈打出来才是真正的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取