前言
在程序开发过程中,我们常常运用各种池化技术来缓存创建成本高昂的对象,像线程池、连接池和内存池等。通常的做法是预先创建一批对象存入池中,在需要使用时直接从池中取出,使用完毕后归还,以便后续复用。同时,还会借助一些策略动态调整池中对象的数量,以此来提升系统性能和资源利用率。
由于线程的创建过程开销较大,随意创建大量线程会引发严重的性能问题。因此,深入了解使用线程池时容易出现的错误就显得尤为重要。
手动创建线程池
在 Java 的 Executors 类中,提供了一些便捷的工具方法,能帮助我们快速创建线程池。然而,我们应当避免直接使用这些方法,而是选择手动通过new ThreadPoolExecutor来创建线程池。
若不手动创建线程池,可能会引发诸多问题。以newFixedThreadPool和newCachedThreadPool这两个方法为例,它们在使用过程中隐藏了大量参数,极有可能因资源耗尽而导致 OOM 问题。
OOM 即 Out Of Memory,OOM 问题是指在计算机程序运行过程中,由于申请的内存空间超
过了系统所能提供的可用内存资源,导致程序无法继续正常运行的一种错误状态
下面就这两个方法来说明一下,使用时为什么可能导致OOM问题。
newFixedThreadPool
查看newFixedThreadPool的源码可以发现,线程池的工作队列直接创建了一个LinkedBlockingQueue,而其默认构造方法创建的是一个长度为Integer.MAX_VALUE的队列,几乎可以看作是无界的。
这个无界队列就是问题所在。
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
public LinkedBlockingQueue() {
this(Integer.MAX_VALUE);
}
尽管newFixedThreadPool能够将工作线程数量控制在固定值,但由于任务队列无界,如果任务数量众多且执行速度较慢,队列就会迅速积压大量任务,最终撑爆内存,引发 OOM 异常。
newCachedThreadPool
从newCachedThreadPool的源码中可以看到这种线程池的最大线程数也是integer.MAX_Value,可以认为i是没有上限的,而工作队列是SynchronousQueue,它是一个没有存储空间的阻塞队列,这意味这只要有请求进入,就必须找到一个线程来处理,如果当前没有空闲的,则创建一个新的。
这样在处理耗时长,任务多的情况下,就会无限的创建线程,导致OOM问题
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(),
threadFactory);
}
鉴于上述风险,强烈不建议使用快捷创建线程池的方法。我们必须根据具体的业务场景和并发量,仔细评估并设置线程池的相关参数。并且,在任何情况下,都应为自定义的线程池指定具有明确意义的名称,以便在后续排查问题时能够快速定位。
确认线程池本身是不是复用的
在业务中使用线程池时,务必确认线程池是被复用的,而不是每次都创建新的线程池。否则,在运行时可能会创建大量线程池,不仅浪费系统资源,还可能导致性能急剧下降。线程池的优势就在于其能够通过复用已有的线程来减少线程创建和销毁的开销,若无法做到复用,线程池的使用便失去了原本的意义。
例如,我们可以使用一个静态字段来存放线程池的引用。在需要获取并使用线程池时,直接返回这个静态字段所指向的线程池实例,而不是重新创建新的线程池。这样一来,整个应用程序中始终使用同一个线程池,确保了线程池的复用性。同时,在多线程环境下,为了保证线程安全,还需要考虑使用合适的同步机制来管理对线程池引用的访问。例如,可以使用Double-Checked Locking模式来确保线程池在首次创建时的线程安全性,代码示例如下:
public class ThreadPoolUtil {
private static volatile ExecutorService threadPool;
public static ExecutorService getThreadPool() {
if (threadPool == null) {
synchronized (ThreadPoolUtil.class) {
if (threadPool == null) {
// 根据实际业务场景设置合适的线程池参数
threadPool = new ThreadPoolExecutor(
5,
10,
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>()
);
}
}
}
return threadPool;
}
}
慎用线程池的混用策略
我们应极力避免多个不同业务混用同一个线程池的情况。这是因为线程池的参数设置必须依据具体的业务场景来确定,不同业务的任务特性和资源需求差异很大。盲目地将不同业务的任务放入同一个线程池,很可能导致某个业务在使用该线程池时,线程池的内部参数并不适配,进而引发一系列问题,严重影响业务的正常运行。
特别是对于 IO 密集型任务和 CPU 密集型任务,一定要使用不同的线程池,并分别设定合适的参数。IO 密集型任务通常会花费大量时间在等待 I/O 操作完成上,此时线程大部分时间处于空闲状态,因此可以配置较多的线程数,以充分利用 CPU 资源,提高系统的整体吞吐量。例如,可以将线程数设置为 CPU 核心数的 2 倍甚至更多。而 CPU 密集型任务则需要持续占用 CPU 资源进行计算,线程数过多反而会因为线程上下文切换带来额外的开销,降低系统性能。对于 CPU 密集型任务,一般将线程数设置为 CPU 核心数加 1 较为合适,这样既能充分利用 CPU 资源,又能避免过多的上下文切换。
例如,在一个电商系统中,商品查询操作可能是 IO 密集型任务,因为需要频繁地从数据库中读取数据;而订单计算操作可能是 CPU 密集型任务,需要进行复杂的价格计算和库存扣减逻辑。如果将这两个业务的任务放在同一个线程池中,可能会导致商品查询操作因为线程数不足而响应缓慢,订单计算操作因为线程数过多而性能下降。因此,为了保证系统的高效稳定运行,应分别为这两种类型的任务创建独立的线程池,并根据其特性进行合理的参数配置。
// 创建IO密集型线程池
ExecutorService ioThreadPool = new ThreadPoolExecutor(
10,
20,
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>()
);
// 创建CPU密集型线程池
ExecutorService cpuThreadPool = new ThreadPoolExecutor(
Runtime.getRuntime().availableProcessors() + 1,
Runtime.getRuntime().availableProcessors() + 1,
0L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>()
);