【Java面试经典】Executors创建线程池常见用法及优劣势

208 阅读7分钟

今天咱们来聊聊 Executors 创建线程池这事儿。简单总结一下Executors 提供了几种便捷的方式来创建线程池,它用起来挺方便,但也有一些潜在的问题。

一、常见用法

  1. newFixedThreadPool

    • 原理:创建一个固定大小的线程池。它会预先创建指定数量的线程,这些线程会一直存活,等待任务到来。当所有线程都在执行任务时,如果有新任务提交,任务会在一个无界阻塞队列(通常是 LinkedBlockingQueue)中等待,直到有线程空闲。

    • 实际项目中的用法示例

      • 假设我们在开发一个小型的 Web 服务器,用于处理 HTTP 请求。我们知道服务器的硬件资源有限,并且对于并发请求的处理能力有一个大概的预估。比如,服务器的 CPU 核心数是 4,我们可以使用 newFixedThreadPool 创建一个大小为 4 的线程池来处理请求。
        import java.util.concurrent.ExecutorService;
        import java.util.concurrent.Executors;
        public class WebServer {
            private ExecutorService executorService;
            public WebServer() {
                // 创建一个固定大小为4的线程池
                executorService = Executors.newFixedThreadPool(4);
            }
            public void handleRequest(Request request) {
                executorService.execute(() -> {
                    // 模拟处理请求的过程,比如解析请求、查询数据库、生成响应等
                    System.out.println("正在处理请求: " + request.getUrl());
                });
            }
            public void shutdown() {
                executorService.shutdown();
            }
        }
  • 在这个例子中,线程池大小固定为 4,就像是有 4 个服务员在餐厅里等待顾客(请求)。当有 4 个请求同时到来时,4 个线程就会同时处理。如果还有更多请求,它们会排队等待,直到有线程空闲。

  1. newCachedThreadPool

    • 原理:这个线程池的线程数量是动态变化的。如果有新任务提交,并且当前没有空闲线程,就会创建一个新线程来处理任务。如果线程空闲了一段时间(默认 60 秒),就会被回收。它使用的是 SynchronousQueue 作为任务队列,这种队列不存储任务,只是直接将任务交给线程。

    • 实际项目中的用法示例

      • 考虑一个爬虫项目,我们需要从大量的网页中获取数据。每个网页的获取速度可能不同,而且网页的数量非常多。我们可以使用 newCachedThreadPool 来创建线程池。
        import java.util.concurrent.ExecutorService;
        import java.util.concurrent.Executors;
        public class WebCrawler {
            private ExecutorService executorService;
            public WebCrawler() {
                // 创建一个可缓存的线程池
                executorService = Executors.newCachedThreadPool();
            }
            public void crawlPage(String url) {
                executorService.execute(() -> {
                    // 模拟爬取网页的过程,比如发送HTTP请求、解析HTML等
                    System.out.println("正在爬取网页: " + url);
                });
            }
            public void shutdown() {
                executorService.shutdown();
            }
        }
  • 在这个例子中,因为每个网页的爬取任务时间不确定,可能有的网页很快就能爬取完成,有的可能会因为网络等原因耗时较长。newCachedThreadPool 会根据任务的到来动态地创建线程,当一个网页爬取完成后,线程可以很快地被用于下一个任务,或者如果一段时间没有任务,线程就会被回收,这样就能够高效地利用资源。

  1. newSingleThreadExecutor

    • 原理:创建一个只有一个线程的线程池。所有提交的任务会按照提交的顺序在这个线程中依次执行。它内部使用了一个无界队列(LinkedBlockingQueue)来存储任务。

    • 实际项目中的用法示例

      • 想象我们有一个日志记录系统,需要将各种不同模块产生的日志信息按顺序写入文件。我们就可以使用 newSingleThreadExecutor。
        import java.util.concurrent.ExecutorService;
        import java.util.concurrent.Executors;
        public class LogWriter {
            private ExecutorService executorService;
            public LogWriter() {
                // 创建一个单线程的线程池
                executorService = Executors.newSingleThreadExecutor();
            }
            public void writeLog(String log) {
                executorService.execute(() -> {
                    // 模拟将日志写入文件的过程
                    System.out.println("正在写入日志: " + log);
                });
            }
            public void shutdown() {
                executorService.shutdown();
            }
        }
  • 在这个例子中,由于日志的写入顺序很重要,我们不能让多个线程同时写入文件,否则可能会导致日志混乱。newSingleThreadExecutor 就保证了所有的日志按照提交的顺序依次写入文件。

  1. newScheduledThreadPool

    • 原理:这个线程池主要用于定时或周期性地执行任务。它内部有一个延迟队列(DelayedWorkQueue),用于存储定时任务。可以通过一些方法(如 schedule、scheduleAtFixedRate 等)来设置任务的执行时间和周期。

    • 实际项目中的用法示例

      • 假设我们在开发一个任务调度系统,需要每天定时备份数据库,并且每隔一段时间清理一些临时文件。我们可以使用 newScheduledThreadPool。
        import java.util.concurrent.ExecutorService;
        import java.util.concurrent.Executors;
        import java.util.concurrent.TimeUnit;
        public class TaskScheduler {
            private ExecutorService executorService;
            public TaskScheduler() {
                // 创建一个可定时执行任务的线程池,大小为2(可以根据任务数量调整)
                executorService = Executors.newScheduledThreadPool(2);
            }
            public void scheduleDatabaseBackup() {
                // 每天凌晨2点备份数据库
                executorService.scheduleAtFixedRate(() -> {
                    System.out.println("正在备份数据库");
                }, getTomorrowAt2AM(), 24, TimeUnit.HOURS);
            }
            public void scheduleTempFileCleanup() {
                // 每3小时清理一次临时文件
                executorService.scheduleAtFixedRate(() -> {
                    System.out.println("正在清理临时文件");
                }, 0, 3, TimeUnit.HOURS);
            }
            private long getTomorrowAt2AM() {
                // 计算明天凌晨2点的时间戳(这里省略具体计算过程)
                return 0L;
            }
            public void shutdown() {
                executorService.shutdown();
            }
        }
  • 在这个例子中,我们通过 newScheduledThreadPool 创建了一个线程池,并且使用它来安排数据库备份和临时文件清理这两个任务。线程池会根据我们设置的时间和周期,自动执行这些任务。

二、优势

  1. 简单方便

    • 在实际项目开发中,时间就是金钱。Executors 提供了简单的工厂方法来创建不同类型的线程池,这使得开发者不需要深入了解线程池的复杂配置,就可以快速地在项目中应用线程池。比如在一些小型项目或者原型开发阶段,快速地使用 newFixedThreadPool 或者 newCachedThreadPool 来实现简单的并发功能,能够节省大量的时间和精力。
  2. 代码可读性高

    • 当其他开发者查看代码时,通过使用 Executors 创建的线程池,能够很容易地理解代码的意图。例如,看到 newSingleThreadExecutor,就能够马上知道这里的任务是按照顺序执行的。这种清晰的代码结构有助于团队协作和代码维护。
  3. 满足常见场景需求

    • 对于很多常见的并发场景,Executors 提供的这几种线程池类型基本能够满足需求。比如,对于固定数量的并发任务可以使用 newFixedThreadPool,对于任务数量不确定但希望高效利用资源的情况可以使用 newCachedThreadPool,对于需要顺序执行任务的场景可以使用 newSingleThreadExecutor,对于定时任务可以使用 newScheduledThreadPool。

三、劣势

  1. 资源管理问题

    • 潜在的线程过多问题(以 newCachedThreadPool 为例) :在高负载的情况下,newCachedThreadPool 可能会创建大量的线程。比如在一个处理海量网络请求的系统中,如果请求量突然暴增,newCachedThreadPool 会不断地创建新线程来处理请求。每个线程都需要占用一定的系统资源,包括内存(用于线程栈等)和 CPU 时间(用于线程切换等)。如果线程数量过多,可能会导致系统资源耗尽,甚至出现内存溢出(OutOfMemoryError)的情况。
    • 资源浪费问题(以 newFixedThreadPool 和 newSingleThreadExecutor 为例) :在 newFixedThreadPool 和 newSingleThreadExecutor 中,线程数量是固定的。如果在某些时段,任务量很少,这些线程就会处于空闲状态,白白占用系统资源。例如,在一个业务有高峰期和低谷期的系统中,低谷期时固定数量的线程池中的线程大部分时间都在等待任务,这就造成了资源的浪费。
  2. 队列策略不够灵活

    • Executors 创建的线程池默认的队列策略可能不适合所有的场景。比如 newFixedThreadPool 和 newSingleThreadExecutor 使用的是无界队列(LinkedBlockingQueue)。在一些情况下,如果任务产生的速度远远大于线程处理的速度,无界队列会不断地存储任务,可能会导致内存占用过多。而在某些对任务队列有特殊要求的场景,如需要使用有界队列来限制任务数量,Executors 创建的线程池就不能很好地满足需求。
  3. 定制化程度有限

    • 在一些复杂的项目中,可能需要对线程池进行更精细的定制。例如,需要设置线程的优先级、使用自定义的线程工厂来创建具有特定属性的线程,或者定义特殊的拒绝策略来处理当线程池无法接受新任务的情况。Executors 创建的线程池在这些方面的定制化程度相对较低,可能无法满足这些特殊的需求,这时候可能需要直接使用 ThreadPoolExecutor 来进行更细致的配置。