0
点赞
收藏
分享

微信扫一扫

Java线程池原理与实战详解

攻城狮Chova 2021-09-30 阅读 116
技术分享

前言

在面向对象编程中,创建和销毁对象是很费时间的,因为创建一个对象要获取内存资源或者其它更多资源。在Java中更是如此,虚拟机将试图跟踪每一个对象,以便能够在对象销毁后进行垃圾回收。所以提高服务程序效率的一个手段就是尽可能减少创建和销毁对象的次数,特别是一些很耗资源的对象创建和销毁。如何利用已有对象来服务就是一个需要解决的关键问题,其实这就是一些"池化资源"技术产生的原因。比如大家所熟悉的数据库连接池正是遵循这一思想而产生的!

线程池是什么?

简单来说,线程池是指提前创建若干个线程,当有任务需要处理时,线程池里的线程就会处理任务,处理完成后的线程并不会被销毁,而是继续等待下一个任务。由于创建和销毁线程都是消耗系统资源的,所以,当某个业务需要频繁进行线程的创建和销毁时,就可以考虑使用线程池来提高系统的性能啦。

线程池可以做什么?

借由《Java并发编程的艺术》这本书,使用线程池能够帮助我们 :

降低资源消耗。通过重复利用已经创建的线程,能够降低线程创建和销毁造成的消耗。

提高响应速度。当任务到达时,任务可以不需要等待线程的创建就能立即执行。

提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

如何创建一个线程池

首先创建一个 Runnable 接口实现类。

这里让我们使用 ThreadPoolExecutor 来创建一个线程池进行测试:

最后让我们来看一下运行结果 :

可以看到,当核心线程数为 5 时,即使总共要运行的线程有 15 个,每次也只会同时执行 5 个任务,剩下的任务则会被放入等待队列,等待核心线程空闲后执行。总的来说步骤如下 :

Executor框架

Executor 框架是 Java5 之后引进的。在 Java5 之后,通过 Executor 来启动线程比使用 Thread 的 start 方法更好。除了更易管理,效率更好(用线程池实现,节约开销)外,还有关键的一点 :有助于避免 this 逃逸问题。

this 逃逸

this 逃逸是指在构造函数返回之前其他线程就持有该对象的引用,调用尚未构造完全的对象的方法时可能引发奇怪的错误。

引发 this 逃逸通常需要满足两个条件 :一个是在构造函数中创建内部类,另一个就是在构造函数中将这个内部类发布了出去。

由于发布出去的内部类对象自带对外部类 this 的访问权限,这就导致在通过内部类对象访问外部类 this 时,外部类可能并未构造完成,从而导致一些意想不到的问题。

典型的 this 逃逸情景如下 :

通过使用线程池进行统一的线程调度,省去了在程序中手动启动线程的步骤,从而避免了在构造器中启动一个线程的情况,因此能够有效规避 this 逃逸。

ThreadPoolExecutor常用参数

1. corePoolSize :核心线程线程数

定义了最小可以同时运行的线程数量。

2. maximumPoolSize :最大线程数

当队列中存放的任务达到队列容量时,当前可以同时运行的线程数量会扩大到最大线程数。

3. keepAliveTime :等待时间

当线程数大于核心线程数时,多余的空闲线程存活的最长时间。

4. unit :时间单位。

keepAliveTime 参数的时间单位,包括 TimeUnit.SECONDS、TimeUnit.MINUTES、TimeUnit.HOURS、TimeUnit.DAYS 等等。

5. workQueue :任务队列

任务队列,用来储存等待执行任务的队列。

6. threadFactory :线程工厂

线程工厂,用来创建线程,一般默认即可。

7. handler :拒绝策略

也称饱和策略;当提交的任务过多而不能及时处理时,可以通过定制策略来处理任务。

ThreadPoolExecutor 饱和策略 : 指当前同时运行的线程数量达到最大线程数量并且队列也已经被放满时,ThreadPoolTaskExecutor 所执行的策略。

常用的拒绝策略包括 :

ThreadPoolExecutor.AbortPolicy: 抛出 RejectedExecutionException 来拒绝新任务的处理,是 Spring 中使用的默认拒绝策略。

ThreadPoolExecutor.CallerRunsPolicy: 线程调用运行该任务的 execute 本身,也就是直接在调用 execute 方法的线程中运行 (run) 被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。此策略提供简单的反馈控制机制,能够减缓新任务的提交速度,但可能造成延迟。若应用程序可以承受此延迟且不能丢弃任何一个任务请求,可以选择这个策略。

ThreadPoolExecutor.DiscardPolicy: 不处理新任务,直接丢弃掉。

ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。

为什么推荐使用 ThreadPoolExecutor 来创建线程?

规约一 :线程资源必须通过线程池提供,不允许在应用中自行显示创建线程。

使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源开销,解决资源不足的问题。如果不使用线程池,有可能会造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。

规约二 :强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

Executors 返回线程池对象的弊端如下:

FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列长度为 Integer.MAX_VALUE,可能会堆积大量请求,从而导致 OOM。

CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE,可能会创建大量线程,从而导致 OOM。

几种常见的线程池

FixThreadPool 固定线程池

FixThreadPool :可重用固定线程数的线程池。

执行机制 :

若当前运行的线程数小于 corePoolSize,来新任务时,就创建新的线程来执行任务;

当前运行的线程数等于 corePoolSize 后,如果再来新任务的话,会将任务加到 LinkedBlockingQueue;

线程池中的线程执行完手头的工作后,会在循环中反复从 LinkedBlockingQueue 中获取任务来执行。

FixThreadPool 使用的是无界队列 LinkedBlockingQueue(队列容量为 Integer.MAX_VALUE),而它会给线程池带来如下影响 :

当线程池中的线程数达到 corePoolSize 后,新任务将在无界队列中等待,因此线程池中的线程数不会超过 corePoolSize;

由于使用的是一个无界队列,所以 maximumPoolSize 将是一个无效参数,因为不可能存在任务队列满的情况,所以 FixedThreadPool 的 corePoolSize、maximumPoolSize 被设置为同一个值,且 keepAliveTime 将是一个无效参数;

运行中的 FixedThreadPool(指未执行 shutdown() 或 shutdownNow() 的)不会拒绝任务,因此在任务较多的时候可能会导致 OOM。

SingleThreadExecutor 单一线程池

SingleThreadExecutor 是只有一个线程的线程池。

除了池中只有一个线程外,其他和 FixThreadPool 是基本一致的。

CachedThreadPool 缓存线程池

CachedThreadPool 是一个会根据需要创建新线程的线程池,但会在先前构建的线程可用时重用它。

其 corePoolSize 被设置为 0,maximumPoolSize 被设置为 Integer.MAX.VALUE,也就是无界的。虽然是无界,但由于该线程池还存在一个销毁机制,即如果一个线程 60 秒内未被使用过,则该线程就会被销毁,这样就节省了很多资源。

但是,如果主线程提交任务的速度高于 maximunPool 中线程处理任务的速度,CachedThreadPool 将会源源不断地创建新的线程,从而依然可能导致 CPU 耗尽或内存溢出。

执行机制 :

首先执行 offer 操作,提交任务到任务队列。若当前 maximumPool 中有空闲线程正在执行 poll 操作,且主线程的 offer 与空闲线程的 poll 配对成功时,主线程将把任务交给空闲线程执行,此时视作 execute() 方法执行完成;否则,将执行下面的步骤。

当初始 maximum 为空,或 maximumPool 中没有空闲线程时,将没有线程执行 poll 操作。此时,CachedThreadPool 会创建新线程执行任务,execute() 方法执行完成。

如何拟定线程池的大小?

上下文切换

多线程变编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用。为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。

概括来说就是,当前任务在执行完 CPU 时间片切换到另一个任务之前,会先保存自己的状态,以便下次再切换回这个任务时,可以直接加载到上次的状态。任务从保存到再加载的过程就是一次上下文切换。

上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。

Linux 相比与其他操作系统(包括其他类 Unix 系统)有许多,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。

简单的拟定判断

CPU 密集型任务(N+1):

这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。

I/O 密集型任务(2N):

这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

好了,本文就到这里了,如果觉得文中有错误信息欢迎在评论区留言指出!

面试造火箭,入职拧螺丝,希望能够帮助到你。

多多转发,让更多人受益!

举报

相关推荐

0 条评论