8、线程与协程内存管理:线程池复用策略、ThreadLocal内存泄漏、Kotlin协程的挂起与恢复、协程上下文与内存泄漏

说到线程和协程的内存管理,这其实是个老生常谈但又特别容易踩坑的话题。我这些年做性能优化,光是ThreadLocal泄漏就帮人擦过好几次屁股。今天咱们就把这块掰开揉碎了聊。

8.1 线程池复用策略:别让线程成为内存黑洞

线程池这东西,说白了就是个线程的「共享单车」。你用完我接着用,省得每次创建销毁都折腾系统资源。但问题来了——复用策略没选对,内存泄漏就找上门了。

我见过不少项目,线程池核心线程数设得特别大,结果空闲线程占着栈内存不释放。一个线程默认栈大小通常是1MB,你开100个核心线程,光栈就吃掉100MB。这还没算线程局部变量呢。

核心原则:线程池的复用策略要跟业务场景匹配。CPU密集型用N+1,IO密集型用2N,这是基本公式。

我个人习惯用ThreadPoolExecutor自定义参数,而不是直接用Executors的快捷方法。为什么?因为Executors.newFixedThreadPool用的是无界队列,任务积压时内存会爆。

// 我推荐的做法
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    4,                      // 核心线程数
    8,                      // 最大线程数
    60L, TimeUnit.SECONDS,  // 空闲线程存活时间
    new LinkedBlockingQueue<Runnable>(128), // 有界队列
    new ThreadFactoryBuilder().setNameFormat("my-pool-%d").build(),
    new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);

这里有个细节:CallerRunsPolicy会让提交任务的线程自己跑,虽然会阻塞调用方,但至少不会丢任务。我在项目中遇到过用DiscardPolicy导致关键日志丢失的惨案,从那以后我再也不敢随便丢弃任务了。

8.2 ThreadLocal内存泄漏:一个隐形的内存杀手

ThreadLocal这玩意儿,用好了是线程隔离的利器,用不好就是内存泄漏的元凶。你想想看,ThreadLocalMap的key是弱引用,但value是强引用。线程池里的线程长期存活,value就永远释放不了。

避坑指南:我曾经在一个后台服务里用ThreadLocal存储用户请求上下文,结果线程池复用导致旧数据污染新请求。更可怕的是,那个上下文对象里有个Bitmap引用,内存直接飙到OOM。

解决方案其实很简单:

  • 用完必须remove:在finally块里调用ThreadLocal.remove()
  • 用try-finally包裹:确保异常情况下也能清理
  • 考虑用弱引用包装:如果实在不能remove,至少让value能被回收
// 正确的使用姿势
private static final ThreadLocal<UserContext> userContext = new ThreadLocal<>();

public void processRequest(Request request) {
    try {
        userContext.set(parseContext(request));
        // 业务逻辑...
    } finally {
        userContext.remove(); // 必须清理!
    }
}

嗯,这里要注意:remove方法会同时清理key和value,而set(null)只会把value置空,key还在。所以一定要用remove

8.3 Kotlin协程的挂起与恢复:轻量级线程的秘密

协程为什么比线程轻量?说白了就是挂起时不占栈内存。线程挂起要保存整个调用栈,协程挂起只保存一个Continuation对象。这个对象大概几十字节,跟线程的1MB栈比,简直是九牛一毛。

我刚开始学协程时,总觉得挂起恢复很神秘。其实原理不复杂:

  1. 挂起点:编译器在suspend函数调用处插入状态机代码
  2. 状态保存:局部变量被保存到Continuation对象的字段里
  3. 恢复执行:根据状态机标签跳转到对应位置继续执行
小技巧:kotlinx.coroutines.debug库可以打印协程的调用栈,调试挂起恢复逻辑特别有用。
// 协程挂起恢复的简化示意
suspend fun fetchData(): String = suspendCoroutine { continuation ->
    // 模拟异步操作
    thread {
        Thread.sleep(1000)
        continuation.resume("数据加载完成")
    }
}

你看,suspendCoroutine把挂起逻辑暴露出来了。实际开发中我们很少直接用它,但理解这个原理对排查问题很有帮助。

8.4 协程上下文与内存泄漏:Scope管理是关键

协程上下文里藏着Job、Dispatcher、CoroutineName等元素。其中最危险的就是Job——如果协程被取消,但Job还持有外部引用,内存泄漏就发生了。

我在项目中遇到过这样一个场景:Activity里用GlobalScope.launch启动协程,Activity销毁了协程还在跑。结果协程持有Activity的引用,导致整个Activity无法被GC回收。

解决方案:viewModelScopelifecycleScope替代GlobalScope。这些Scope会跟随生命周期自动取消。
// 错误示范:GlobalScope导致泄漏
class MyActivity : AppCompatActivity() {
    fun loadData() {
        GlobalScope.launch { // 危险!Activity销毁后协程还在跑
            val data = fetchData()
            updateUI(data) // 持有Activity引用
        }
    }
}

// 正确做法:使用lifecycleScope
class MyActivity : AppCompatActivity() {
    fun loadData() {
        lifecycleScope.launch {
            val data = fetchData()
            updateUI(data) // 生命周期感知,自动取消
        }
    }
}

还有一个容易忽略的点:协程的withContext切换线程时,如果传入了错误的Dispatcher,也可能导致上下文泄漏。我建议统一用Dispatchers.IO做网络请求,Dispatchers.Main更新UI,别混用。

8.5 知识体系总览

下面这张图把线程和协程的内存管理要点串起来了,你可以对照着梳理自己的项目:

线程与协程内存管理知识体系 线程池复用策略 • 核心线程数:CPU密集型N+1 • 最大线程数:IO密集型2N • 有界队列防OOM • CallerRunsPolicy保底 ThreadLocal内存泄漏 • Key弱引用,Value强引用 • 线程池复用导致泄漏 • 必须finally中remove • 避免存储大对象 协程挂起与恢复 • 状态机实现挂起 • Continuation保存上下文 • 不占线程栈内存 • 轻量级线程替代方案 协程上下文泄漏 • Job持有外部引用 • GlobalScope危险 • 使用lifecycleScope • Dispatcher选择要谨慎 最佳实践总结 • 线程池参数按需配置 • ThreadLocal用完即删 • 协程Scope绑定生命周期 • 定期做内存快照分析

说实话,线程和协程的内存管理没有银弹。关键是要理解底层原理,然后根据业务场景做取舍。我个人的经验是:能用协程就别用线程,协程的挂起恢复机制天然比线程省内存。但如果是CPU密集型的计算任务,线程池还是更合适。

最后提醒一句:不管用线程还是协程,写完代码一定要用Profiler跑一遍内存快照。我见过太多「我觉得没问题」然后线上OOM的案例了。

公众号:蓝海资料掘金营,微信deep3321