线程池的介绍、原理、监控运维、框架使用场景案例
1. 线程池的概念和作用
线程池是一种线程复用的技术,它可以有效地控制线程的数量,处理过程中将任务添加到队列,然后在线程创建后启动这些任务。主要作用有:
- 重用线程,减少线程创建和销毁带来的开销。
- 可以有效控制线程的数量,方便线程管理。
- 提高系统响应速度,当有新的任务到来时,无需等待创建新线程就可以立即执行。
2. 线程池的工作原理
主要涉及到如下原理:
- 任务的添加:将要执行的任务添加到任务队列中,等待线程池分配线程执行。
- 线程的创建:初始化线程池时会创建一定数量的工作线程,默认情况下会等到有任务添加才创建线程。
- 任务的调度:从任务队列中取出任务,调度给工作线程执行。
- 线程的监控:线程池会根据活动线程数自动新增或删除工作线程。
- 任务的执行:工作线程不断从任务队列取出任务执行,直到队列为空。
- 线程的回收:工作线程空闲超时后会被删除,线程池的线程数量不会无限增长。
3. JDK 线程池的使用
JDK 中提供的 ThreadPoolExecutor 实现了线程池,可以灵活地设置各项参数。usage 如下:
ThreadPoolExecutor executor = new ThreadPoolExecutor( 2, //核心线程数 5, //最大线程数 60L, //空闲线程存活时间 TimeUnit.SECONDS, new LinkedBlockingQueue<>(3), //任务队列 Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy() //拒绝策略 );
executor.execute(() -> {
//任务逻辑
});
小结
使用线程池可以有效地管理大量短生命周期的线程,节省频繁创建和销毁线程的开销。
4. 开源框架 Excutors 的使用
JDK 提供的线程池功能较基础,开源框架 Executors 在此基础上做了许多封装,更加易用。主要有:
- newCachedThreadPool:创建一个可缓存的线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
- newFixedThreadPool: 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
- newScheduledThreadPool: 创建一个定长线程池,支持定时及周期性任务执行。
- newSingleThreadExecutor: 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。
Usage 如下:
//创建一个可缓存线程池
ExecutorService cachedPool = Executors.newCachedThreadPool();
//创建一个定长线程池
ExecutorService fixedPool = Executors.newFixedThreadPool(5);
//创建一个周期性执行的线程池
ExecutorService scheduledPool = Executors.newScheduledThreadPool(5);
//创建一个单线程化的线程池
ExecutorService singlePool = Executors.newSingleThreadExecutor();
这些线程池都实现了ExecutorService接口,使用方法与JDK ThreadPollExecutor类似,可以使用submit()/execute()提交任务,使用shutdown()关闭线程池。
5. SpringBoot 中的线程池
在SpringBoot中,可以通过下面配置自定义线程池:
spring:
task:
execution:
pool:
core-size: 10 #核心线程数
max-size: 20 #最大线程数
queue-capacity: 200 #队列容量
thread-name-prefix: task- #线程名前缀
然后在程序中可以通过注入TaskExecutor使用线程池:
@Autowired
private TaskExecutor taskExecutor;
public void doSomething(){
taskExecutor.execute(() -> {
//任务逻辑
});
}
SpringBoot使用的线程池实现也是JDK中的ThreadPoolExecutor,只是进行了封装和配置化。我们可以按需灵活配置线程池各参数,满足不同的业务需求。
6. 线程池的合理配置
线程池的配置参数较多,如何选取合理的配置是一个值得探讨的问题。主要的配置参数有:
- corePoolSize:核心线程数,线程池中最少存在的线程数量。
- maximumPoolSize:最大线程数,线程池中最多存在的线程数量。
- keepAliveTime:空闲线程存活时间,线程池中超过corePoolSize数目的空闲线程最大存活时间。
- queueCapacity:任务队列容量,当活动线程数达到maximumPoolSize时,新任务会被加入队列等待执行。
这里给出一些合理配置的思路:
- CPU密集型任务:
- corePoolSize = CPU核心数 + 1
- maximumPoolSize = CPU核心数 * 2 + 1
- maximumPoolSize = CPU核心数 * 2 + 1
- queueCapacity设置较大,如200-1000
- IO密集型任务:
- corePoolSize可以设为CPU核心数,甚至更大
- maximumPoolSize不宜太大,否则容易OOM
- keepAliveTime可以大一些,如300s
- keepAliveTime可以大一些,如300s
- 混合型任务:
- corePoolSize可以为CPU核心数左右
- maximumPoolSize不宜过大,否则容易OOM
- keepAliveTime适中,比如120s
- queueCapacity不宜太大,否则混合型任务的响应速度会下降
除此之外,对于任务执行时间较长的场景,不建议使用过大的线程池,否则可能导致线程积压,反而带来效率损失。线程池的配置还是需要根据具体业务场景进行针对性调优。
7. 线程池的其他拒绝策略
JDK 线程池的拒绝策略只有 AbortPolicy(默认)、CallerRunsPolicy 和 DiscardPolicy 三种。还有其他的拒绝策略,可以根据需要进行选择:
- AbortPolicy:直接抛出 RejectedExecutionException 异常阻止系统正常运行。
- CallerRunsPolicy:用调用者所在线程来执行任务,不会抛出异常。
- DiscardPolicy:不处理该任务,直接丢弃掉。
- DiscardOldestPolicy:丢弃最老的未处理的任务请求。
- RejectExecutionHandler:自定义拒绝策略,实现RejectedExecutionHandler接口。
一般来说,对于非关键任务可以选择DiscardPolicy、DiscardOldestPolicy等丢任务策略;对于关键任务则需要选择CallerRunsPolicy、RejectExecutionHandler等不会丢失任务的策略。AbortPolicy只适用于适当配置线程池,不会出现拒绝的情况下。
8. 线程池的监控与维护
为了维护线程池的稳定性,我们需要对其进行监控。主要监控内容有:
- 核心线程数与最大线程数:这两个参数直接控制着线程池的吞吐量和系统稳定性,需要根据任务场景进行动态调整。
- 任务队列大小:如果任务队列经常性充满,说明线程池吞吐量跟不上,需要适当增大线程池规模或减小任务量。
- 活动线程数:如果活动线程数经常接近或达到最大线程数,说明线程池忙碌,任务等待时间会增加,服务响应速度下降。
- 完成任务总数与异常任务总数:这两个参数可以衡量线程池处理能力和系统稳定性。异常比例过高时需要查找根因。
- 线程池中线程运行时间分布:如果大部分线程运行时间较短 but CPU使用率较高,说明CPU资源可能出现瓶颈。这需要进一步分析和优化。
除了监控之外,我们还需要对线程池进行定期维护,主要方面有:
- 线程池线程和任务缓存的清理:需要清理那些长时间空闲的线程和过期未执行的任务。
- 对阻塞队列和线程池参数的调优:根据任务变化和系统负载动态调整以达到最佳吞吐量。
- 对异常任务的重新提交机制:对那些可重试的异常任务,需要建立重试机制,而不是直接丢弃。
- 线程池的关闭机制:在系统关闭时,要给线程池发送关闭信号,并等待其正确关闭所有线程。
综上,维护一个高性能且稳定的线程池还是需要全面考虑的。只有同时兼顾到线程池的监控、参数调优和日常维护,才能保证其长期高效运行。
9. 线程池的常见问题
使用线程池中一些常见的问题,我们需要在开发和运维中重点关注。
- 线程泄漏:线程完成任务后没有正确关闭,导致线程池中积累越来越多的线程,占用系统资源。解决方案是及时关闭线程池或设置keepAliveTime。
- 线程阻塞:线程在执行过程中因IO或其他原因被长时间阻塞,导致线程池中实际可用线程数量减少,甚至全部阻塞。此时任务无法得到执行,服务可用性下降。解决方案是避免线程长时间阻塞,可设置超时时间或增大线程池容量。
- 线程饥饿:任务提交速度过快,线程池中线程来不及执行新任务就被下一个任务覆盖,导致部分任务得不到执行。此时需要增大线程池容量及任务队列容量。
- 资源耗尽:当系统资源耗尽(CPU 100%、内存耗尽)时,线程池会相应减慢或停止运行,导致任务堆积且系统不可用。此时需要减小任务量或扩充系统资源。
- 队列阻塞:当任务队列容量满后,继续提交的任务会被阻塞,甚至抛出 RejectedExecutionException。当任务较为密集时容易出现此问题,需要增大队列容量和线程池容量。
除此之外,也要考虑到线程池在高并发场景下的稳定性问题,需要对其进行压力测试,观察触发OOME等异常的并发阈值,并作出相应优化。总之,webp应用中使用线程池还是需要全面考虑,这也是一个值得深入研究的内容。
10. 实例:一个简单的线程池实现
我们来看一个简单的线程池实现。它包含:
- 任务队列:使用 BlockingQueue 实现,可以选择 ArrayBlockingQueue、LinkedBlockingQueue 等。
- 工作线程:Worker 线程从队列取任务并执行。
- 线程 Factory:用来创建 Worker 线程。
- 任务提交:通过 execute() 方法提交任务到队列。
- 线程池监控:记录任务总数、完成数、活动线程数等信息。
- 线程池关闭:通过 shutdown() 平滑关闭线程池。
public class SimpleThreadPool {
//任务队列
private BlockingQueue<Runnable> taskQueue;
//线程列表
private List<Worker> workers = new ArrayList<>();
//线程池监控
private Monitor monitor;public SimpleThreadPool(int coreSize, int maxSize, BlockingQueue<Runnable> queue) { taskQueue = queue; monitor = new Monitor(); for (int i = 0; i < coreSize; i++) { Worker worker = new Worker(); workers.add(worker); worker.start(); } } //提交任务 public void execute(Runnable task) { if (task != null) { taskQueue.add(task); monitor.incrementSubmitCount(); } } //关闭线程池 public void shutdown() { for (Worker worker : workers) { worker.shutdown(); } } class Worker extends Thread { @Override public void run() { while (!isInterrupted()) { Runnable task = null; try { task = taskQueue.take(); task.run(); monitor.incrementCompleteCount(); } catch (InterruptedException e) { break; } } } public void shutdown() { interrupt(); } }
}
这个简单的线程池实现了基本的提交任务、关闭线程池和监控线程池运行状态的功能。虽然还不及JDK线程池功能完备,但已包含了线程池的基本构成与运作过程,希望这可以帮助大家进一步理解线程池的原理。
如果要将这个简单线程池应用在实际项目中,还需要考虑到线程池容量动态调整、异常处理、清理工作线程等更丰富的功能,不过 starters 可以从简单的实现开始,逐步丰富与增强。
11. 一个可运维的线程池方案
在实际工程中,我们需要提供一个可运维的线程池方案,方便进行监控、参数调优和问题排查。这里给出一个方案思路:
- 线程池基础参数:允许外部动态设置 corePoolSize、maximumPoolSize、keepAliveTime、queueCapacity 等基础参数。
- 线程池监控:开放线程池运行中的监控数据,如活动线程数、任务数(提交总数、完成总数、队列中等)、任务执行时间等。这些数据需要实时收集并暴露给外部系统。
- 动态参数调整:提供外部接口动态调整线程池各参数,包括线程数、队列容量和空闲线程存活时间等。这需要线程池能够感知外部变化并动态调整内部配置。
- 自定义拒绝策略:允许外部设置自定义的拒绝策略,比如重试机制、积压到本地等。这需要开放接口给拒绝策略的开发者进行设置和调优。
- 定制化线程工厂:允许设置定制化的线程工厂来生产工作线程。使线程池能够灵活使用不同类型的工作线程。
- 任务提交与结果反馈:任务提交时可以传递一定上下文,任务执行完成后可以将结果反馈给提交方。这需要对任务和结果进行封装与标识。
- 线程池问题排查:提供线程池运行过程中产生的各类Log与报警信息,方便问题排查与回溯分析。比如线程阻塞时间过长等。
- 流量控制与过载保护:提供外部接口进行线程池流量控制,避免线程池超载运行。可以在任务提交时进行流量判断和限流。
- 优雅关闭机制:提供关闭线程池的接口,在关闭时可以选择立即关闭还是处理完现有任务后再关闭。
以上方案还需要具体实现与配套的运维系统才能发挥最大效果。但提供一个开放、可监控、可运维的线程池框架已是实际工程中比较理想的状态。这至少可以大大减轻工程师在使用线程池及其优化时的难度,提高系统的稳定性。
12. 线程池的使用场景和案例分析
理解了线程池的原理与实现后,我们还需要结合实际场景去灵活运用它。这里分析几个较典型的使用场景和案例:
- 高并发场景:这是线程池的一个典型应用场景。在高并发场景下,频繁创建和销毁线程会极大消耗系统资源,使用线程池可以有效地进行线程复用和控制线程总数,提高系统吞吐量和响应速度。
- 任务异步执行:将一些非关键性任务交给线程池异步执行,而主线程继续做其它高优先级任务。这可以充分利用CPU资源,不会因为异步任务而阻塞主线程。
- 定时任务执行:线程池可以结合定时器,实现定时任务的执行。通过调度线程池的工作线程执行定时任务,不会因单个定时任务延迟或异常而影响其他定时任务的执行。
- 事件驱动异步IO:IO密集型应用中,可以采用Reactor模式,通过IO事件驱动线程池来异步执行任务。这可以有效地避免由于IO阻塞导致的线程池资源耗尽。Netty中的NIO线程池采用的就是这种模式。
案例1:网站高并发场景下的使用。可以使用线程池预先创建一定数量的工作线程,将用户请求作为任务提交给线程池执行,以此来达到异步处理用户请求的效果,从而提高系统吞吐量。
案例2:批量数据处理场景下的使用。可以使用线程池实现异步批量数据处理,主线程将数据列表拆分为多个批次,提交给线程池执行,而自己则继续前行其它操作。这可以最大限度地利用CPU资源,不会因为批量数据处理而长时间阻塞主线程。
案例3:定时任务的使用。我们可以 periodically 提交定时任务给线程池,由其中一个工作线程负责执行,然后等待下次调度。通过这种方式,实现定时任务的线程池实现,避免单个定时任务执行时间过长影响其他定时任务。
13. Netty 中的线程模型与线程池
Netty 是一个异步事件驱动的网络应用框架,其线程模型和线程池的实现值得我们借鉴和学习。
Netty 的线程模型主要包含以下几个部分:
- Boss线程:主要负责接收客户端的连接,负责关联Selector和注册连接到Selector。
- Worker线程:主要负责网络读写事件,数据的编解码及业务逻辑处理等事情。
- Selector:选择器,主要用于监听多路连接请求事件,并将IO事件通知给Worker线程。
其基本工作流程是:
Boss线程接收连接请求并注册到Selector,Selector监听IO事件并将事件通知给Worker线程,Worker线程即刻处理IO事件,对连接进行业务处理。
在这个线程模型下,Netty 为不同的事件和任务划分出不同的线程来处理,避免单一类型任务对其它任务产生影响。并通过线程池的手段实现线程复用,合理控制系统资源。
Netty 中的线程池主要分为两种:
- NioEventLoopGroup:处理I/O和事件的线程池,包含多个NioEventLoop。这里的线程具有定长线程池的性质,可以有效避免线程数量膨胀导致的问题。
- DefaultEventExecutorGroup:处理业务逻辑和后台任务的线程池。这里的线程数量是不定长度的,Netty 会根据系统属性动态调整线程池大小,以满足业务需求。
NioEventLoopGroup 通常作为 BossGroup 和 WorkerGroup 使用,来处理 I/O 事件和网络读写。DefaultEventExecutorGroup 常作为 Server 的后台业务线程池使用。
总之,Netty 通过线程模型和不同类型的线程池配合,实现了事件驱动、任务分类和线程复用,这为其能够高效并且可靠地运行于高并发环境奠定了基础。这也是我们在设计一个高性能服务器程序时值得学习的地方。
Netty 的线程模型和线程池的设计思想,对其他网络应用框架同样具有很好的借鉴意义。理解它们的工作机制有助于我们开发出一个高效、稳定的网络服务程序。
14. 如何判断线程池是否已满
在使用线程池过程中,我们常需要判断其是否已经达到最大容量,以决定是否继续提交新任务。这里介绍几种判断线程池是否已满的方法:
- 直接判断线程池中的活动线程数是否达到 maximumPoolSize。如:
if (executor.getActiveCount() == executor.getMaximumPoolSize()) {
// 线程池已满
} else {
// 线程池未满,可以提交新任务
}
- 提交一个Callable任务,如果无法立即执行(入队失败),则认为线程池已满。如:
try {
executor.submit(callableTask);
} catch (RejectedExecutionException e) {
// 线程池已满
}
- 使用executor的submit方法提交一个Runnable任务,在任务运行前判断当前线程数量。如果达到最大线程数,则线程池已满。如:
executor.submit(new Runnable() {
public void run() {
if (executor.getPoolSize() == executor.getMaximumPoolSize()) {
// 线程池已满
} else {
// 运行任务逻辑
}
}
});
- 为线程池设置一个拒绝策略,当线程池满时会执行此策略。在拒绝策略的实现方法中判断线程池已满。如:
executor.setRejectedExecutionHandler(new RejectedExecutionHandler() {
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
if (executor.isTerminated() || executor.isShutdown()) {
// 关闭状态,不再接收新任务
} else {
// 线程池已满,执行拒绝策略
}
}
});
- 为线程池的最大线程数设置一个队列容量(比如0),如果添加新任务时无法立即执行,则会抛出异常,从而判断为已满。如:
ThreadPoolExecutor executor = new ThreadPoolExecutor(coreSize, maxSize, 60,
TimeUnit.SECONDS, new LinkedBlockingQueue<>(0));
try {
executor.execute(task);
} catch (RejectedExecutionException e) {
// 线程池已满
}
以上就是几种判断线程池是否已满的方法,可以根据实际场景选择一种或多种组合使用。理解线程池的运行机制有助于我们作出正确判断,选择最佳的判断方式。
15. 线程池的容量规划计算
合理地配置线程池的大小是使用线程池的一个关键点。如果线程池过小,无法满足任务需求,导致任务积压和系统吞吐量下降;如果线程池过大,会造成资源浪费和系统稳定性降低。那么如何计算一个合适的线程池大小呢?
这里介绍几点规划线程池容量的思考:
- 任务特性:了解任务的类型、执行时间长短及异常比例等特性。长时间任务更适合较小的线程池,短时间任务则需要较大线程池。异常比例高的任务也需要保留更多空闲线程。
- CPU 核数:一般来说,线程池大小不应超过机器CPU核数的1-2倍。过大会导致CPU频繁切换线程,降低效率。
- 等待队列:等待队列容量也影响线程池大小。等待队列越大,线程池大小可以越小。反之,等待队列小时则需要更大的线程池以防任务积压。
- 系统内存:线程池大小与系统内存相关。线程越多,占用内存也越大。需要根据系统内存容量决定线程池规模。
- 峰值流量:还需要考虑到任务的峰值流量。线程池大小必须满足峰值流量下的任务需求,才能保证系统稳定性。
- 监控及弹性:最后,线程池大小也需要根据运行监控数据动态调整。当任务增长时及时增加线程,任务减少时及时回收空闲线程。
一般来说,
线程池coreSize可 initially 设置为:CPU 核数 + 1。
maximumSize 可设置为:coreSize * 2 或 coreSize * 3。
阻塞队列大小设置为:maximumSize / 2 或 maximumSize。
在此基础上,需要根据系统运行情况动态监控,进行参数调优:
- 如果队列经常出现满的情况,则需要增大 maximumSize 和 queueSize。
- 如果活动线程数量 stabilize 在 maximumSize 附近,则需要增大 maximumSize。
- 如果大部分线程都处于空闲,则需要减小 coreSize 和 maximumSize,回收资源。
- 如果任务提交速度过大导致大量任务积压,则需urgently 增大线程池规模,以防系统崩溃。
综上,合理计算线程池大小是一个递归的过程,需要不断根据任务特性、系统资源以及监控信息进行评估和优化。但初始设置和动态调优又不能太极端,需要选取一个平衡值,这也是使用线程池并发设计的精髓与难点所在。
16. 线程池之异步回调模式
在 muitl-threading 编程中,回调机制是比较常用的一种机制。它可以在一个线程中启动某个任务,然后在该任务完成后在线程中得到通知,然后进行后续的一些处理。
使用线程池实现异步回调的一般步骤如下:
- 定义一个回调接口,包含任务完成后的回调方法。如:
public interface Callback {
void onComplete(Result result);
}
- 在执行任务时,将回调接口的实现类作为参数传递给线程池的提交方法。如:
executor.submit(new Task(), new Callback() {
public void onComplete(Result result) {
// ... 处理结果
}
});
- 在任务类(Task)的任务完成方法中,调用回调接口的回调方法。如:
public class Task implements Runnable {
private Callback callback;
// ...@Override public void run() { Result result = doSomething(); callback.onComplete(result); }
}
- 回调方法会在一个线程池的工作线程中执行,从而实现在主线程启动一个任务,在完成后由工作线程调用回调通知主线程。
这个模式的应用场景是:主线程需要启动一些耗时任务,但又不能被这些任务的执行时间绑死,通过异步回调可以在任务完成后得到通知并进行必要的后续操作。
如服务器程序启动一段数据加载任务,加载完成后通知服务器程序数据加载完毕,然后开启服务器监听端口。如果没有异步回调,服务器线程会一直等待数据加载完成,造成线程阻塞,延迟启动服务。
异步回调模式将任务的执行过程和回调方法解耦,使两者可以在不同的线程中执行,这适用于多线程环境中的异步任务通知场景。它也体现了线程池实现异步任务及其回调的便利性,这是thread pool 编程中比较实用的模式和机制。
将任务提交给线程池执行,然后在回调方法中得到任务执行结果,这是一种比较简洁高效的异步任务通信方式,相比传统join()和future方式更加灵活和解耦。这也是我们学习使用线程池的原因之一。
在Netty和Spring等框架中都广泛应用了这种异步回调的模型,理解线程池的基本原理和机制有助于我们灵活使用各种并发框架。
17. 线程池的应用实例 - 批量数据操作
这里给出线程池应用的一个实例场景:批量数据操作。比如批量插入数据库或调用第三方接口等。
如果不使用线程池,我们的代码可能如下:
List<Data> dataList = ... // 待操作的数据列表
for (Data data : dataList) {
insertToDB(data); // 插入数据库操作
}
这种方式会有两个问题:
- 如果数据量很大,循环插入会耗费很长时间,阻塞主线程;
- 如果某次插入失败或超时,会影响后续数据的插入,不利于重试机制的实现。
使用线程池后,代码可以改进为:
List<Data> dataList = ...
ExecutorService executor = Executors.newFixedThreadPool(10); // 创建10个工作线程的线程池for (Data data : dataList) {
executor.submit(() -> insertToDB(data)); // 提交插入任务到线程池
}
executor.shutdown(); // 关闭线程池
这种方式使用线程池实现了数据批量插入的异步执行,有以下好处:
- 主线程可以继续执行其他任务,不会被数据插入操作所阻塞;
- 如果某次插入失败,其它线程继续工作,利于实现重试机制,提高数据插入的成功率;
- 可以设置线程池容量,防止批量插入对数据库造成过大压力;
- 简化代码逻辑,通过提交任务到线程池实现异步,不需要显式管理多个线程。
这就是使用线程池改进批量数据操作的示例,避免了同步操作的阻塞和失败扩散问题,提高了系统吞吐量和数据操作的稳定性。
类似的场景还有:
- 批量调用第三方接口服务
- 批量处理文件或日志数据
- 批量发送邮件等
总之,任何需要批量定量操作的数据或任务,如果操作时间较长,都适合采用线程池实现异步批处理。这既可以避免操作对主线程的影响,也可以设置并发量控制批处理速度,使系统资源不被洪水般的批处理任务淹没。
18. 线程池的应用实例 - 网站爬虫
这里给出线程池另一个应用实例场景:网站爬虫。网站爬虫需要爬取大量网页数据,如果不使用线程池,代码可能如下:
public void crawl() {
for (String url : urls) {
Document doc = httpGet(url);
parse(doc);
extractLinks(doc);
}
}
这种单线程爬虫有几个问题:
- 爬取速度慢,无法实现高效爬取大规模网页;
- 如果某个网页请求或解析失败,会直接影响后续网页的爬取;
- 无法控制爬虫对网站服务器的压力,容易被封禁。
使用线程池可以改进为:
ExecutorService executor = Executors.newFixedThreadPool(50); // 创建50个工作线程的线程池
for (String url : urls) {
executor.submit(() -> {
Document doc = httpGet(url);
parse(doc);
extractLinks(doc);
});
}
executor.shutdown();
这种多线程爬虫有明显优势:
- 可以充分利用系统资源,实现高效爬取大量网页数据;
- 如果某个网页请求失败,其它线程继续工作,利于重试机制的实现;
- 可以控制工作线程数量,避免对网站服务器产生过大压力,降低被封禁的风险;
- 简化爬虫逻辑,通过提交任务给线程池实现异步操作,不需要管理多个线程;
- 可以根据网页数据解析和链接提取的耗时动态增大或减小线程池大小,优化爬虫效率。
这就是使用线程池改进网站爬虫的示例,实现了高效率、可控制和可靠的多线程爬虫程序。相比单线程爬虫具有明显优势,这也是大部分工业级爬虫软件选用的方案。
类似的场景还有:
- 大规模网络数据抓取
- 高并发的网络客户端等
19. 总结
OK,到这里我们的线程池学习就告一段落了。回顾一下,我们学习了以下主要内容:
- 什么是线程池及其作用:线程池能够有效地管理线程资源,提高系统CPU利用率,解决资源过度分配和调度问题。
- 线程池状态及其工作原理:线程池有RUNNING,SHUTDOWN,STOP,TIDYING 和TERMINATED五种状态。当执行execute()方法提交任务时,线程池会创建或重用线程处理任务,同时维持运行的线程数在规定范围内。
- 线程池的主要参数:核心线程数、最大线程数、队列容量、线程空闲存活时间等重要参数介绍。
- 线程池的主要拒绝策略:AbortPolicy,CallerRunsPolicy,DiscardPolicy和DiscardOldestPolicy。当阻塞队列满时,采取不同的拒绝策略处理添加的任务。
- 线程池的使用示例:主要包含创建线程池,提交任务,关闭线程池等步骤和代码示例。
- 四种线程池的对比:newFixedThreadPool,newCachedThreadPool,newSingleThreadExecutor和newScheduledThreadPool的特点介绍和适用场景对比。
- 异步回调模式:任务提交给线程池执行,使用回调接口在任务完成后得到反馈,这是一种简洁灵活的异步通信机制。
- 线程池关键应用实例:网站爬虫,批量数据操作,高并发服务器等应用案例分析。
线程池关键应用实例:网站爬虫,批量数据操作,高并发服务器等应用案例分析。