8、线程与协程内存管理:线程池复用策略、ThreadLocal内存泄漏、Kotlin协程的挂起与恢复、协程上下文与内存泄漏
说到线程和协程的内存管理,这其实是个老生常谈但又特别容易踩坑的话题。我这些年做性能优化,光是ThreadLocal泄漏就帮人擦过好几次屁股。今天咱们就把这块掰开揉碎了聊。
8.1 线程池复用策略:别让线程成为内存黑洞
线程池这东西,说白了就是个线程的「共享单车」。你用完我接着用,省得每次创建销毁都折腾系统资源。但问题来了——复用策略没选对,内存泄漏就找上门了。
我见过不少项目,线程池核心线程数设得特别大,结果空闲线程占着栈内存不释放。一个线程默认栈大小通常是1MB,你开100个核心线程,光栈就吃掉100MB。这还没算线程局部变量呢。
我个人习惯用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就永远释放不了。
解决方案其实很简单:
- 用完必须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栈比,简直是九牛一毛。
我刚开始学协程时,总觉得挂起恢复很神秘。其实原理不复杂:
- 挂起点:编译器在suspend函数调用处插入状态机代码
- 状态保存:局部变量被保存到Continuation对象的字段里
- 恢复执行:根据状态机标签跳转到对应位置继续执行
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回收。
viewModelScope或lifecycleScope替代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密集型的计算任务,线程池还是更合适。