什么是线程泄露?

线程泄露是指:应用程序中创建了线程,但在其任务完成后却无法被垃圾回收器(GC)回收,导致线程对象及其相关的资源(如内存、CPU 时间片)持续占用,且无法被重新使用的情况。

简单来说,就是只生不死的线程。它与内存泄露的概念非常相似,只不过泄露的核心对象是 Thread

每个 Java 线程都对应一个操作系统级别的线程,其开销是相当大的(默认栈大小通常为 1MB)。因此,即使只泄露少量线程,也会快速消耗掉系统的可用内存和 CPU 调度能力。


线程泄露的常见原因及示例

导致线程泄露的根本原因是:线程未能正常结束其生命周期(即未从 RUNNABLE 状态进入 TERMINATED 状态)。以下是几个典型场景:

1. 线程池使用不当(最常见的原因)

如果你使用了 ExecutorService必须记得在不再需要时将其关闭。如果没有关闭,池中的核心线程会一直等待新的任务,从而阻止 JVM 正常退出,线程也无法被回收。

错误示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ThreadPoolLeak {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(5);

// 提交一些任务
for (int i = 0; i < 10; i++) {
executor.submit(() -> {
System.out.println("Executing task: " + Thread.currentThread().getName());
});
}

// 忘记调用 shutdown() 或 shutdownNow()
// executor.shutdown(); // 这行被注释掉了,导致了泄露!
}
}
// 即使 main 方法执行完毕,JVM 也不会退出,因为线程池中的 5 个核心线程还在活跃地等待任务。

修复方法:
使用 shutdown()(优雅关闭,等待已提交任务完成)或 shutdownNow()(立即尝试停止所有任务)。
通常配合 awaitTermination 使用:

1
2
3
4
5
6
7
8
9
executor.shutdown();
try {
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow(); // 强制关闭
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}

2. 线程被无限期阻塞或死循环

如果线程中的 run() 方法逻辑包含一个无限循环,或者线程在等待一个永远不会到来的资源(如死锁、等待一个永远不会被触发的条件、错误的 wait()/notify()、网络 I/O 阻塞没有超时等),那么该线程将永远无法结束。

错误示例(死循环):

1
2
3
4
5
6
7
8
9
10
11
public class InfiniteThread extends Thread {
@Override
public void run() {
// 错误:没有退出条件的循环
while (true) {
// 做一些工作...
// 如果这里没有 break、return 或者检查中断状态的逻辑,
// 这个线程将永远运行下去。
}
}
}

错误示例(阻塞没有超时):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class BlockingThreadLeak {
public void leak() {
Thread thread = new Thread(() -> {
try {
// 从 Socket 读取数据,但没有设置超时 (SO_TIMEOUT)
Socket socket = new Socket("somehost", 8080);
socket.getInputStream().read(); // 这里可能会永远阻塞
System.out.println("Data received!"); // 可能永远执行不到
} catch (IOException e) {
e.printStackTrace();
}
});
thread.start();
// 即使想中断它,如果 read() 不响应中断,也无济于事
}
}

修复方法:

  • 为阻塞操作(网络 I/O、等待锁)总是设置合理的超时时间
  • 使用中断(Interruption) 作为协作机制来取消任务。确保你的任务代码能够正确响应中断请求。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    @Override
    public void run() {
    while (!Thread.currentThread().isInterrupted()) { // 检查中断状态
    try {
    // 做一些可中断的工作,例如 Thread.sleep, BlockingQueue.take 等
    Thread.sleep(1000);
    } catch (InterruptedException e) {
    // 收到中断信号,优雅退出循环
    Thread.currentThread().interrupt(); // 重新设置中断状态
    break;
    }
    }
    System.out.println("Thread exiting gracefully.");
    }

3. 持有对线程对象的强引用

即使线程已经执行完毕(进入 TERMINATED 状态),如果你在某个地方(如一个静态 Map)一直持有它的引用,那么这个 Thread 对象本身就无法被 GC 回收。虽然底层的 OS 线程已经销毁,但这仍然是一种对象泄露。

错误示例:

1
2
3
4
5
6
7
8
9
10
public class ThreadReferenceLeak {
private static final Map<Long, Thread> THREAD_CACHE = new HashMap<>();

public void startThread() {
Thread thread = new Thread(() -> {...});
THREAD_CACHE.put(thread.getId(), thread); // 将线程对象存入缓存
thread.start();
}
// 即使线程执行完毕,由于 Map 中一直有它的引用,Thread 对象无法被 GC 回收。
}

修复方法:

  • 使用 WeakHashMap 等弱引用的数据结构。
  • 在线程结束后,主动从集合中移除其引用(可以使用 ThreadLocal 或在线程的 finally 块中执行清理操作)。

如何检测和诊断线程泄露?

  1. 监控工具(首选方法):

    • jstack: JDK 自带命令行工具。使用 jstack -l <pid> 可以打印出指定 Java 进程的所有线程栈信息。分析 jstack 的输出,查看是否有大量同名且状态相同的线程(如 WAITING, RUNNABLE),这通常是泄露的迹象。
    • JConsole / VisualVM: JDK 自带的可视化监控工具。在线程选项卡中,你可以实时看到线程数量的变化曲线。如果线程数随着时间的推移只增不减,那基本可以确定存在线程泄露。你还可以查看每个线程的详细状态和栈跟踪。
    • Java Mission Control (JMC): 更高级的商业级(对于开发/测试免费)监控工具,提供更详细的分析。
  2. 编程式监控:

    • 使用 Thread.getAllStackTraces() 来定期获取所有活跃线程的快照。
    • 使用 ThreadPoolExecutor 的 API(如 getPoolSize(), getActiveCount())来监控线程池的状态。
    • 集成像 MicrometerDropwizard Metrics 这样的库,将线程池指标导出到监控系统(如 Prometheus/Grafana),并设置线程数增长的告警。

总结与最佳实践

  1. 优先使用线程池:避免手动创建线程,使用 ExecutorService 来管理线程的生命周期。
  2. 务必关闭线程池:在应用程序或服务关闭时,确保正确地关闭所有线程池。
  3. 编写可响应的任务代码:任务代码应该能够检查中断状态并对中断请求做出响应,以便能够被取消。
  4. 总是设置超时:为所有可能会阻塞的操作(网络调用、锁获取等)设置合理的超时时间。
  5. 谨慎管理线程引用:避免在全局缓存或长期存在的对象中持有线程对象的强引用。
  6. 实施监控和告警:在生产环境中,对应用程序的线程数量进行持续监控,并设置阈值告警。