不断学习,做更好的自己!💪
线程池
优点
- 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗;
- 提高响应速度:当任务到达时,任务可以不需要等到线程创建就能立即执行;
- 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
介绍
ThreadPoolExecutor
Java 为我们提供了 ThreadPoolExecutor 来创建一个线程池,其完整构造函数如下所示:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
- int corePoolSize(核心线程数):线程池新建线程的时候,如果当前线程总数小于corePoolSize,则新建的是核心线程,如果超过corePoolSize,则新建的是非核心线程;核心线程默认情况下会一直存活在线程池中,即使这个核心线程啥也不干(闲置状态);如果设置了 allowCoreThreadTimeOut 为 true,那么核心线程如果不干活(闲置状态)的话,超过一定时间(时长下面参数决定),就会被销毁掉。
- int maximumPoolSize(线程池能容纳的最大线程数量):线程总数 = 核心线程数 + 非核心线程数。
- long keepAliveTime(非核心线程空闲存活时长):非核心线程空闲时长超过该时长将会被回收,主要应用在缓存线程池中,当设置了 allowCoreThreadTimeOut 为 true 时,对核心线程同样起作用。
- TimeUnit unit(keepAliveTime 的单位):它是一个枚举类型,常用的如:TimeUnit.SECONDS(秒)、TimeUnit.MILLISECONDS(毫秒)。
- BlockingQueue workQueue(任务队列):当所有的核心线程都在干活时,新添加的任务会被添加到这个队列中等待处理,如果队列满了,则新建非核心线程执行任务,常用的 workQueue 类型:
- SynchronousQueue:这个队列接收到任务的时候,会直接提交给线程处理,而不保留它,如果所有线程都在工作怎么办?那就新建一个线程来处理这个任务!所以为了保证不出现 线程数达到了 maximumPoolSize 而不能新建线程 的错误,使用这个类型队列的时候,maximumPoolSize 一般指定成 Integer.MAX_VALUE,即无限大。
- LinkedBlockingQueue:这个队列接收到任务的时候,如果当前线程数小于核心线程数,则新建线程(核心线程)处理任务;如果当前线程数等于核心线程数,则进入队列等待。由于这个队列没有最大值限制,即所有超过核心线程数的任务都将被添加到队列中,这也就导致了 maximumPoolSize 的设定失效,因为总线程数永远不会超过 corePoolSize。
- ArrayBlockingQueue:可以限定队列的长度,接收到任务的时候,如果没有达到 corePoolSize 的值,则新建线程(核心线程)执行任务,如果达到了,则入队等候,如果队列已满,则新建线程(非核心线程)执行任务,又如果总线程数到了 maximumPoolSize,并且队列也满了,则发生错误。
- DelayQueue:队列内元素必须实现 Delayed 接口,这就意味着你传进去的任务必须先实现 Delayed 接口。这个队列接收到任务时,首先先入队,只有达到了指定的延时时间,才会执行任务。
- ThreadFactory threadFactory(线程工厂):用来创建线程池中的线程,通常用默认的即可。
- RejectedExecutionHandler handler(拒绝策略):在线程池已经关闭的情况下和任务太多导致最大线程数和任务队列已经饱和,无法再接收新的任务,在上面两种情况下,只要满足其中一种时,在使用 execute() 来提交新的任务时将会拒绝,线程池提供了以下 4 种策略:
- AbortPolicy:默认策略,在拒绝任务时,会抛出RejectedExecutionException。
- CallerRunsPolicy:只要线程池未关闭,该策略直接在调用者线程中,运行当前的被丢弃的任务。
- DiscardOldestPolicy:该策略将丢弃最老的一个请求,也就是即将被执行的任务,并尝试再次提交当前任务。
- DiscardPolicy:该策略默默的丢弃无法处理的任务,不予任何处理。
线程池执行策略
当一个任务要被添加进线程池时,有以下四种执行策略:
- 线程数量未达到 corePoolSize,则新建一个线程(核心线程)执行任务。
- 线程数量达到了 corePoolsSize,则将任务移入队列等待。
- 队列已满,新建非核心线程执行任务。
- 队列已满,总线程数又达到了 maximumPoolSize,就会由 RejectedExecutionHandler 抛出异常。
其流程图如下所示:
常见的四类线程池
常见的四类线程池分别有 FixedThreadPool
、SingleThreadExecutor
、ScheduledThreadPool
和 CachedThreadPool
,它们其实都是通过 ThreadPoolExecutor
创建的,其参数如下表所示:
如果你不想自己写一个线程池,那么你可以从上面看看有没有符合你要求的(一般都够用了),如果有,那么很好你直接用就行了,如果没有,那你就老老实实自己去写一个吧。
合理地配置线程池
需要针对具体情况而具体处理,不同的任务类别应采用不同规模的线程池,任务类别可划分为 CPU 密集型任务、IO 密集型任务和混合型任务。
- CPU 密集型任务:线程池中线程个数应尽量少,推荐配置为 (CPU 核心数 + 1);
- IO 密集型任务:由于 IO 操作速度远低于 CPU 速度,那么在运行这类任务时,CPU 绝大多数时间处于空闲状态,那么线程池可以配置尽量多些的线程,以提高 CPU 利用率,推荐配置为 (2 * CPU 核心数 + 1);
- 混合型任务:可以拆分为 CPU 密集型任务和 IO 密集型任务,当这两类任务执行时间相差无几时,通过拆分再执行的吞吐率高于串行执行的吞吐率,但若这两类任务执行时间有数据级的差距,那么没有拆分的意义。
运行时数据区域
JVM 在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。根据《Java 虚拟机规范(Java SE 7 版)》的规定,JVM 所管理的内存将会包括以下几个运行时数据区域:
程序计数器
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。由于 JVM 的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储。
如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在 JVM 规范中没有规定任何 OutOfMemoryError 情况的区域。
Java 虚拟机栈
Java 虚拟机栈也是线程私有的,它描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。它的内存模型如下所示:
局部变量表存放了编译器克制的各种基本数据类型(boolean
、byte
、char
、shot
、int
、float
、long
、double
)、对象引用(reference
类型,它不是对象本身)和 returnAddress
类型(指向一条字节码指令的地址)
在 JVM
规范中,对该区域规定了这两种异常情况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出
StackOverflowError
异常; - 如果虚拟机栈可以动态拓展,如果拓展时无法申请到足够的内存,就会抛出
OutOfMemoryError
异常。
本地方法栈
本地方法栈的作用与虚拟机栈作用是非常类似的,只不过虚拟机栈为执行 Java 方法(也就是字节码)服务,而本地方法栈则为 Native 方法服务。
Java 堆
对大多数应用来说,Java 堆是 JVM 所管理的内存中最大的一块。它在虚拟机启动时创建,被所有线程所共享,它的唯一目的就是存放对象实例。
Java 堆是垃圾收集器管理的主要区域,也被称作“GC”堆。
- 从内存回收的角度来看,现代收集器基本都采用分代收集算法,所以 Java 还可以细分为:新生代和老年代;再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等。
- 从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区,但存储的仍然是对象实例,进一步划分的目的是为了更好地回首内存,或者更快地分配内存。
Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可拓展的。如果在堆中没有内存完成实例分配,并且堆也无法再拓展时,将会抛出 OutOfMemoryError
异常。
方法区
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
方法区也被称作“永久代”,但两者并不等价,仅仅是因为方法区是靠使用永久代来实现。
*在 JDK 1.7 中,已经把原本放在永久代的字符串常量池移出; *在 JDK 1.8 中,移除了整个永久代,取而代之的是一个叫元空间(Metaspace)的区域。
方法区除了和 Java 堆一样不需要连续的内存空间和可以选择固定大小或者可拓展外,还可以选择不实现垃圾收集,但不实现垃圾回收可能会引发内存泄漏。
当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。
运行时常量池
它是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得较多的便是 String 类得 intern() 方法。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时便会抛出 OutOfMemoryError 异常。
直接内存
它并不是 JVM 运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。但是它也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现,所以放到这里一起讲解。
直接内存的分配不会收到 Java 堆大小的限制,它受本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间得限制。服务器管理员在配制虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制从而导致动态拓展时出现 OutOfMemoryError 异常。