1. 问题背景PageHelper分页插件的内存泄漏现象第一次发现这个问题是在一个用户量激增的凌晨。监控平台突然发出内存告警JVM堆内存曲线呈现典型的阶梯式上升——这是内存泄漏的经典特征。通过Heap Dump分析发现PageHelper的静态ThreadLocal变量持有大量未释放的分页参数对象这些本该在一次请求结束后就被清理的对象却像滚雪球一样不断堆积。PageHelper作为MyBatis最流行的分页插件其核心原理是通过ThreadLocal保存分页参数页码、每页条数等在SQL执行时自动拼接LIMIT语句。这种设计在常规场景下没有问题但当遇到线程池复用的场景如Web容器的异步请求处理ThreadLocal的生命周期就会超出单次请求范围导致内存泄漏。2. 内存泄漏的根因分析2.1 ThreadLocal的工作机制缺陷PageHelper的核心代码如下protected static final ThreadLocalPage LOCAL_PAGE new ThreadLocal(); public static E PageE startPage(int pageNum, int pageSize) { PageE page new Page(pageNum, pageSize); LOCAL_PAGE.set(page); // 分页参数存入ThreadLocal return page; }问题出在Web容器如Tomcat的线程模型上当使用异步Servlet或CompletableFuture等特性时容器线程会被复用。如果开发者没有显式调用PageHelper.clearPage()ThreadLocal中的分页对象就会一直存在直到线程被再次复用——而这个过程可能长达数小时。2.2 典型的内存泄漏场景通过MAT内存分析工具可以看到泄漏对象的引用链Thread → ThreadLocalMap → Entry[] → Page对象在实际业务中以下场景最容易触发此问题异步RPC调用Feign客户端在子线程中调用分页查询定时任务Quartz任务中使用了分页但未清理消息队列消费RabbitMQ监听器处理分页请求3. 解决方案与最佳实践3.1 强制清理ThreadLocal最直接的修复方式是在请求拦截器中添加清理逻辑public class PageHelperCleanInterceptor implements HandlerInterceptor { Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { PageHelper.clearPage(); // 必须执行 } }对于Spring WebFlux等响应式框架需要注册全局过滤器Component public class PageHelperWebFilter implements WebFilter { Override public MonoVoid filter(ServerWebExchange exchange, WebFilterChain chain) { return chain.filter(exchange) .doFinally(s - PageHelper.clearPage()); } }3.2 使用新版PageHelper的安全模式PageHelper 5.3.0版本提供了安全模式配置pagehelper: safe-mode: true # 启用自动清理 support-methods-arguments: true其实现原理是通过AOP在方法执行后自动清理Aspect Component public class PageHelperAspect { AfterReturning(annotation(com.github.pagehelper.PageHelperSafe)) public void afterReturning() { PageHelper.clearPage(); } }3.3 线程池场景的特殊处理当使用线程池时需要重写ThreadPoolExecutorpublic class SafeThreadPool extends ThreadPoolExecutor { Override protected void afterExecute(Runnable r, Throwable t) { PageHelper.clearPage(); MDC.clear(); // 同时清理其他ThreadLocal } }4. 生产环境验证方案4.1 内存泄漏检测脚本编写JMX监控脚本定期检查ThreadLocal内存#!/bin/bash PID$(jps -l | grep your-application | awk {print $1}) jcmd $PID Thread.print | grep -A10 PageHelper.LOCAL_PAGE4.2 Arthas实时诊断使用Arthas监控ThreadLocal状态# 查看PageHelper的ThreadLocalMap watch com.github.pagehelper.PageHelper LOCAL_PAGE {target} # 统计持有分页对象的线程数 thread --state RUNNABLE | grep Page(pageNum | wc -l4.3 压力测试验证用JMeter模拟并发请求观察内存变化ThreadGroup LoopController loops1000/ ThreadGroup.num_threads50/ThreadGroup.num_threads /ThreadGroup健康指标要求内存增长斜率 1MB/minFull GC后内存回落至基线5. 深度防御策略5.1 代码扫描规则在SonarQube中自定义检测规则rule keyPageHelperWithoutClean/key namePageHelper分页缺少清理/name description检测未配对使用PageHelper.startPage()和clearPage()/description code//MethodInvocation[matches(MethodName, startPage)] and not (following-sibling::MethodInvocation[matches(MethodName, clearPage)])/code /rule5.2 单元测试模版编写必须通过的测试用例Test public void testPageHelperMemoryLeak() throws InterruptedException { ExecutorService pool Executors.newFixedThreadPool(5); Future? future pool.submit(() - { PageHelper.startPage(1, 10); // 模拟业务操作 }); future.get(); ThreadLocal? tl (ThreadLocal?) ReflectionTestUtils.getField(PageHelper.class, LOCAL_PAGE); assertNull(tl.get()); // 必须为null }5.3 运行时防护通过Java Agent拦截ThreadLocal操作public class PageHelperAgent { public static void premain(String args, Instrumentation inst) { inst.addTransformer((loader, className, classBeingRedefined, protectionDomain, classfileBuffer) - { if (com/github/pagehelper/PageHelper.equals(className)) { return modifyBytecode(classfileBuffer); } return null; }); } }在解决这个问题的过程中我总结出一个重要经验任何使用ThreadLocal的组件在异步编程盛行的今天都需要重新审视其生命周期管理。对于核心中间件除了提供清晰的文档外更应该在设计层面考虑防呆机制——比如PageHelper的安全模式就是很好的改进方向。