Java并发编程学习16-线程池的使用(上)

2023-11-13

引言

前面的章节介绍了任务执行框架及其实际应用的一些内容。

本篇开始将分析在使用任务执行框架时需要注意的各种情况,并介绍对线程池进行配置与调优的一些方法。

1. 任务和执行策略间的隐性耦合

我们知道,Executor 框架可以将任务的提交与任务的执行策略解耦开来。虽然这极大地方便执行策略的制定和执行,但实际上并不是所有的任务都适用所有的执行策略。

有些类型的任务需要明确地指定执行策略,例如:

  • 依赖性任务。大多数的任务,不会依赖于其他任务的执行时序或结果,这些任务可以随意地修改线程池的大小和配置,最终也只是会影响任务的执行性能。但当提交给线程池的任务需要依赖其他的任务,那就隐式地约束了执行策略,这时候就必须小心地控制执行策略以避免产生活跃性问题【这里会在下面的《线程饥饿死锁》详细说明】。

  • 使用线程封闭机制的任务。在单线程的 Executor 中,它执行时能够确保任务不会并发地执行。因为对象可以被封闭在任务线程中,所以我们在访问这些对象时也可以不需要同步,即使它们不是线程安全的。这就看出了任务与执行策略之间的隐性耦合,即任务要求其执行所在的 Executor 是单线程的。

  • 对响应时间敏感的任务。如果将一个运行时间较长的任务提交到单线程的 Executor 中,或者将多个运行时间较长的任务提交到一个只包含少量线程的线程池中,那么将大大降低其管理的服务的响应性。像 GUI 的应用程序就对响应时间很敏感,用户不可能忍受点击按钮后,需要很长的延迟才得到响应。

  • 使用 ThreadLocal 的任务。我们知道,ThreadLocal 可以在每个线程中创建一个变量,然后在线程生命周期内保持该变量的值,多个线程如果同时访问 ThreadLocal 变量时,它们只会获得自己独立的变量副本,而互相之间不会产生影响。然而,只要条件允许, Executor 可以自由地重用这些线程。因此只有当线程本地值的生命周期受限于任务的生命周期时,在线程池的线程中使用 ThreadLocal 才有意义,而在线程池的线程中也不应该使用 ThreadLocal 在任务之间传递值。

我们来假设下,如果将运行时间较长的与运行时间较短的任务混合在一起,会怎样?

我们知道,在 Java 中线程是由操作系统来进行调度的,而操作系统的调度策略通常是基于时间片轮转或者优先级抢占等算法。当一个线程运行时间过长时,它可能会占用太多的 CPU 时间片,导致其他线程没有机会执行,从而影响了整个系统的响应速度和吞吐量。

如果提交的任务依赖与其他的任务,但没有实现正确的线程间通信机制来确保它们的执行顺序和依赖关系,那么就可能会产生如下的严重后果:

  • 竞态条件: 当多个线程同时访问共享资源时,可能会导致竞态条件。例如,在一个任务中检查某个条件是否满足,并在另一个任务中修改该条件,就可能会出现竞态条件。如果没有使用适当的同步措施来保护这些共享资源,将会导致程序出现不可预测的错误。

  • 死锁: 当两个或多个线程相互等待对方释放已经占用的锁时,就可能会出现死锁。例如,当一个线程等待另一个线程完成任务并释放对象锁时,而另一个线程也在等待该线程释放对象锁时,就会出现死锁。

  • 饿死: 当一个或多个线程一直无法获取到需要的资源时,就可能会出现饿死问题。例如,如果高优先级的任务一直占用了某个共享资源,低优先级的任务可能永远无法获得该资源,从而无法完成自己的任务。

  • 性能下降: 当多个线程相互竞争共享资源时,可能会导致系统的整体性能下降。例如,在多个线程同时访问数据库时,由于每个数据库连接只能处理一个请求,如果同时有太多的请求被提交,将会导致系统响应变慢甚至崩溃。

只有当任务都是同类型的并且相互独立时,线程池的性能才能达到最佳状态。

1.1 线程饥饿死锁

上面提到,只要线程池中的任务需要无限期地等待一些必须由池中其他任务才能提供的资源或条件【例如某个任务等待另一个任务的返回值或执行结果】,那么就有可能产生饥饿和死锁。

下面我们来看如下的示例:



import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

/**
 * 在单线程 Executor 中任务发生死锁(Don't do that !!!)
 */
public class ThreadDeadLock {
    ExecutorService exec = Executors.newSingleThreadExecutor();

    public class RenderPageTask implements Callable<String> {

        @Override
        public String call() throws Exception {
            Future<String> header, footer;
            header = exec.submit(new LoadFileTask("header.html"));
            footer = exec.submit(new LoadFileTask("footer.html"));
            String page = renderBody();
            // 将发生死锁 ----- 由于任务在等待子任务的结果
            return header.get() + page + footer.get();
        }
    }

}
	

我们来简单分析下上面的示例:

  • 首先,RenderPageTaskExecutor 提交了两个任务来获取网页的页眉和页脚;
  • 接着,调用 renderBody 方法来绘制页面;
  • 然后,等待获取页眉和页脚任务的结果;
  • 最后,将页眉、页面主体和页脚组合起来并形成最终的页面。

由于 ThreadDeadLock 使用单线程的 Executor,那么显然在等待子任务的结果时,它会经常发生死锁。

1.2 运行时间较长的任务

前面我们已经了解了,运行时间较长的任务,会让系统的响应速度和吞吐量大大降低。

如果任务阻塞的时间过长,那么即使不出现死锁,也会阻塞线程池,甚至还会增加执行时间较短任务的服务时间,从而影响整体的响应性。

那有没有什么方法可以缓解执行时间较长任务造成的影响呢?

当然是有的,那就是要 限定任务等待资源的时间,而不要无限制地等待

Java 平台类库的大多数可阻塞方法中,都同时定义了 限时版本无限时版本,例如:

  • Thread.join
  • BlockingQueue.put
  • CountDownLatch.await
  • Selector.select

如果等待超时,那么就可以把这个任务标识为失败,然后中止任务或者将任务重新放回队列以便随后执行;这样,无论任务的最终结果是否成功,都可以将线程释放出来以执行一些能更快完成的任务,从而都能确保任务总能够继续执行下去。

注意:如果在线程池中总是充满了被阻塞的任务,那么也可能表明线程池的规模过小,这时候就需要调整线程池的大小,以满足要求。

2. 设置线程池的大小

在我们的应用代码中,通常不会固定线程池的大小,而应该通过某种配置来读取和设置,或者根据 Runtime.getRuntime().availableProcessors 来动态计算。

下面我们来考虑一下如何设置正确地设置线程池的大小 ?

这里考虑以下几个因素:

  • 任务类型: 如果您的应用程序主要是 CPU 密集型任务,则理想的线程池大小通常等于可用处理器核心数。如果您的应用程序包含大量的 I/O 密集型任务(如网络请求、文件读写等),则可以适当增加线程池大小,以充分利用空闲时间。

  • 线程任务平均执行时间: 如果线程任务执行时间很短(几毫秒或更少),那么可以使用较小的线程池来最大化线程复用。否则,如果线程任务执行时间很长(几秒钟或更多),那么线程池应该足够大,以避免出现线程饥饿问题。

  • 内存大小和硬件资源: 理想的线程池大小还应该考虑可用的内存大小和其他硬件资源,以确保不会过度消耗系统资源。

要想正确地设置线程池的大小,必须分析 计算环境资源预算任务的特性。如果需要执行不同类别的任务,并且它们之间的行为相差很大,那么应该考虑使用多个线程池,从而使每个线程池可以根据各自的工作负载来调整。

对于计算密集型的任务【也称为 CPU 密集型任务】,在拥有 N C P U N_{CPU} NCPU 个处理器的系统上,当线程池的大小为 N C P U N_{CPU} NCPU + 1 时,通常能实现最优的利用率。

即使当计算密集型的线程偶尔由于页缺失故障或者其他原因而暂停时,这个 ”额外“ 的线程也能确保 CPU 的时钟周期不会被浪费。

对于 I/O 密集型任务,由于线程并不会一直执行,因此线程池的规模应该更大。

当我们考虑了上面的各种因素之后,就可以使用如下的公式来计算线程池的理想大小:

N t h r e a d s = N C P U ∗ U C P U ∗ ( 1 + W C ) N_{threads} = N_{CPU} * U_{CPU} * (1 + \frac{W}{C}) Nthreads=NCPUUCPU(1+CW)

根据上述这个公式,我们可以通过动态调整线程池大小来达到最佳性能。其中,

  • N C P U N_{CPU} NCPU :可用的处理器核心数,即 CPU 的数目;
  • U C P U U_{CPU} UCPU :目标 CPU 利用率(0 <= U C P U U_{CPU} UCPU <= 1);
  • W C \frac{W}{C} CW :平均等待时间与平均工作时间之比。

我们可以通过 Runtime 来获取 CPU 的数目:

int N_CPUS = Runtime.getRuntime().availableProcessors();

当然 CPU 周期并不是唯一影响线程池大小的资源,还包括 内存文件句柄套接字句柄数据库连接 等。

那么这些资源对线程池的约束条件该如何计算呢?

首先计算每个任务对该资源的需求量,然后用该资源的可用总量除以每个任务的需求量,最后的计算所得就是线程池大小的上限。

注意:

  • 当任务需要某种通过资源池来管理的资源时,例如数据库连接,那么线程池和资源池的大小将会相互影响。
  • 如果每个任务都需要一个数据库连接,那么连接池的大小就限制了线程池的大小。
  • 当线程池中的任务是数据库连接的唯一使用者时,那么线程池的大小又将限制连接池的大小。

总结

《Java并发编程学习》系列停更了有一段时间,接下来笔者将继续不定期地更新这一系列,感谢大家多多支持!!!

热爱,可抵岁月漫长,共勉 !

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

Java并发编程学习16-线程池的使用(上) 的相关文章

  • 单例模式之懒汉式

    在上篇文章中 我们讲了单例模式中的饿汉式 今天接着来讲懒汉式 1 懒汉式单例模式的实现 public class LazySingleton private static LazySingleton instance null 让构造函数为
  • Java并发编程——ReentrantLock重入锁解析

    重入锁 所谓重入锁 即支持重入性 表示能够对共享资源重复加锁 即当前线程获取该锁再次获取不会被阻塞 重入性 在线程获取锁的时候 如果已经获取锁的线程是当前线程的话则直接再次获取成功 由于锁会被获取n次 那么只有锁在被释放同样的n次之后 该锁
  • Java 并发编程中篇 -(JMM、CAS 原理、Volatile 原理)

    并发编程已完结 章节如下 Java 并发编程上篇 Synchronized 原理 LockSupport 原理 ReentrantLock 原理 Java 并发编程中篇 JMM CAS 原理 Volatile 原理 Java 并发编程下篇
  • Java并发编程学习12-任务取消(上)

    任务取消 上 任务取消 由于篇幅较多 拆分了两篇来介绍各种实现取消和中断的机制 以及如何编写任务和服务 使它们能对取消请求做出响应 如何理解任务是可取消的 如果外部代码能在某个任务正常完成之前将其置入 完成 状态 那么这个任务就被认为是可取
  • Java并发编程学习15-任务关闭(下)

    任务关闭 下 任务关闭 由于篇幅较多 拆分了两篇来介绍各种任务和服务的关闭机制 以及如何编写任务和服务 使它们能够优雅地处理关闭 1 处理非正常的线程终止 我们知道 当单线程的控制台程序由于发生了一个未捕获的异常而终止时 程序将停止运行 并
  • ReentrantLock 锁详解

    ReentrantLock 支持公平锁和非公平锁 可重入锁 ReentrantLock的底层是通过 AQS 链接 实现 一 BAT 大厂的面试题 1 什么是可重入 什么是可重入锁 它用来解决什么问题 2 ReentrantLock 的核心是
  • 什么是 Thread 的中断标志?

    分析 回答 什么是 Thread 的中断标志 中断 interrupt 标志或中断状态是线程中断时设置的内部线程标志 flag 属性 怎么设置中断标志 要设置一个线程的中断标志 只需要简单的在线程对象上调用 thread interrupt
  • Java并发编程学习4-线程封闭和安全发布

    Java并发编程学习系列 线程封闭和安全发布 1 线程封闭 1 1 Ad hoc 线程封闭 1 2 栈封闭 1 3 ThreadLocal 类 2 不变性 2 1 Final 域 2 2 不可变对象的简单示例 3 安全发布 3 1 不正确的
  • Java并发编程学习9-并发基础演练

    Java并发编程学习系列 构建高效且可伸缩的结果缓存 引言 主要内容 1 HashMap 并发机制 2 ConcurrentHashMap 3 ConcurrentHashMap Future 4 ConcurrentHashMap Fut
  • Java并发总结之Java内存模型

    本文主要参考 深入理解Java虚拟机 和 Java并发编程的艺术 对Java内存模型进行简单总结 一 CPU和缓存一致性 1 CPU高速缓存 为了解决CPU处理速度和内存处理速度不对等的问题 就是在CPU和内存之间增加高速缓存 当程序在运行
  • 深入详解ThreadLocal内存泄漏问题

    1 造成内存泄漏的原因 threadLocal是为了解决对象不能被多线程共享访问的问题 通过threadLocal set方法将对象实例保存在每个线程自己所拥有的threadLocalMap中 这样每个线程使用自己的对象实例 彼此不会影响达
  • JUC并发编程--------线程安全篇

    目录 什么是线程安全性问题 如何实现线程安全 1 线程封闭 2 无状态的类 3 让类不可变 4 加锁和CAS 并发环境下的线程安全问题有哪些 1 死锁 2 活锁 3 线程饥饿 什么是线程安全性问题 我们可以这么理解 我们所写的代码在并发情况
  • java.util.concurrent.Future 使用指南

    1 介绍 本篇文章将了解 Future 一个自 Java 1 5 以来就存在的接口 它在处理异步调用和并发处理时非常有用 2 创建Futures 简单地说 Future 类表示异步计算的未来结果 这个结果最终会在处理完成后出现在 Futur
  • Java并发编程的相关知识(7)-阻塞队列

    阻塞队列 ArrayBlockingQueue LinkedBlockingQueue ProiorityBlockingQueue DelayQueue SynchronousQueue LinkedTransferQueue Linke
  • java并发编程:CopyOnWrite容器介绍

    前言 Copy On Write简称COW 是一种用于程序设计中的优化策略 其基本思路是 从一开始大家都在共享同一个内容 当某个人想要修改这个内容的时候 才会真正把内容Copy出去形成一个新的内容然后再改 这是一种延时懒惰策略 从JDK1
  • Java并发编程之CyclicBarrier详解

    简介 栅栏类似于闭锁 它能阻塞一组线程直到某个事件的发生 栅栏与闭锁的关键区别在于 所有的线程必须同时到达栅栏位置 才能继续执行 闭锁用于等待事件 而栅栏用于等待其他线程 CyclicBarrier可以使一定数量的线程反复地在栅栏位置处汇集
  • JVM入门必备

    1 JVM 的位置 2 JVM 的体系结构 JVM Java虚拟机 是Java程序的运行环境 它对于Java平台的运行和跨平台特性的实现有着重要的作用 JVM的体系结构有以下几个部分 类加载器 ClassLoader 负责将 class字节
  • Java并发编程学习2-线程安全性

    Java并发编程学习系列 线程安全性 引言 1 什么是线程安全性 1 1 如何编写线程安全的代码 1 2 线程安全类 1 3 无状态对象 2 原子性 2 1 竞态条件 2 2 延迟初始化 2 3 复合操作 3 加锁机制 3 1 内置锁 3
  • c3p0数据库连接池死锁问题和mysql重连,连接丢失

    c3p0参数解释 最常用配置 initialPoolSize 连接池初始化时创建的连接数 default 3 取值应在minPoolSize与maxPoolSize之间 c3p0 initialPoolSize 10 minPoolSize
  • JAVA并发:线程安全与Synchorinzed

    1 什么是线程安全问题 线程的合理使用能够提升程序的处理性能 主要有两个方面 第一个是能够利用多核 cpu 以及超线程技术来实现线程的并行执行 第二个是线程的异步化执行相比于同步执行来说 异步执行能够很好的优化程序的处理性能提升并发吞吐量

随机推荐