使用多进程进行并发编程,会频繁的创建销毁进程,效率比较慢,所以引入了线程,线程使用复用资源的方式提高了创建销毁的效率,但是随着创建线程的频率进一步提高,开销仍然无法忽略不计了。
要想办法优化此处线程的创建销毁效率,方案有两种:
- 引入轻量级线程——纤程/协程。即Java 21里引入的”虚拟线程“。协程的本质是程序员在用户态代码中进行调度,不是靠内核的调度器调度的,节省了很多调度上的开销。
- 线程池。把要使用的线程提前创建好,用完了不销毁等待下次使用。每次创建一个新线程需要为该线程分配堆栈内存、初始化线程管理数据结构等等,这些操作都需要消耗一定的系统资源。使用线程池可以重复利用已经创建的线程,避免了这种开销。
1. Java标准库中的线程池
1.1 ThreadPoolExecutor类
ThreadPoolExecutor 类提供了如下四个构造方法:
我们重点理解最后一个:
- corePoolSize(核心线程数):这是线程池中始终保持活动状态的线程数量。即使没有任务需要执行,这些线程也会保持活跃,可以理解为最小线程数。
- maximumPoolSize(最大线程数):最大线程池大小。当提交的任务数大于核心线程池大小并且工作队列已满时,线程池会创建新线程来处理任务,但新线程数量不会超过最大线程池大小。
- keepAliveTime(非核心线程空闲时间):非核心线程空闲时间。当线程池中的线程数量大于核心线程池大小时,如果线程空闲时间超过了该参数所指定的时间,那么这个线程就会被销毁,直到线程数量等于核心线程池大小。
- unit(时间单位):keepAliveTime参数的时间单位,可以是秒、毫秒等。
- workQueue(工作队列):任务队列。用于存储尚未执行的任务。线程池会从任务队列中取出任务并进行处理。
- threadFactory(线程工厂):用于创建新线程的工厂。可以通过自定义线程工厂来设置线程的名称、优先级等属性。
- handler(拒绝策略):当线程池无法处理新提交的任务时,将使用此策略来处理。常见的拒绝策略有:
AbortPolicy:直接抛出异常,不处理任务。
CallerRunsPolicy:只用调用者所在的线程来运行任务。
DiscardOldestPolicy:丢弃队列中最旧的任务,然后尝试重新提交当前任务。
DiscardPolicy:直接丢弃任务,不处理。
需要注意的是,corePoolSize和maximumPoolSize参数决定了线程池的容量大小,而workQueue则决定了能够存储多少个等待执行的任务。如果任务量过大,超出了workQueue的容量,再加上全部的线程都在执行任务的情况下,那么就会触发线程池的拒绝策略来处理这些任务,从而保证线程池不会因为资源被耗尽而崩溃。
解释:
工厂模式:工厂模式是一种常见的设计模式,通过专门的 工厂类 / 工厂对象 来创建指定的对象,例如这里的 ThreadFactory。
工厂模式本质上是为了给Java语法填坑的,举个例子:
我要表示平面上的一个点,可以用笛卡尔坐标系,也可以用极坐标系:
很明显,这样的代码无法通过编译,因为,这两个构造方法无法构成重载。
为了解决上述问题,就引入了工厂模式,使用普通的方法来创建对象,就是把构造方法封装了一层:
此时这两个方法就叫工厂方法,如果把工厂方法放到其他的类里,这个类就叫工厂类,总的来说,通过静态方法封装new 操作,在方法内部设定不同的属性完成对象初始化,构造对象的过程就是工厂模式。
1.2 Executors 工厂类
ThreadPoolExecutor 类本身用起来比较复杂,所以标志库中还提供了另一个版本,把ThreadPoolExecutor 给封装了一下。即 Executors 工厂类,通过这个类来创建出不同的线程池对象(在内部把ThreadPoolExecutor 创建好了,并设置了不同的参数)。
SingleThreadExecutor:只包含单个线程的线程池
ScheduledThreadPool:定时器类似物,能延时执行任务
CachedThreadPool:线程数目能动态扩容
FixedThreadPool:线程数目固定
使用示例:
public class ThreadDemo28 {
public static void main(String[] args) {
//创建一个四个线程的线程池
ExecutorService service = Executors.newFixedThreadPool(4);
//通过submit方法添加任务
service.submit(() -> {
System.out.println("Ting");
});
}
}
ThreaPoolExecutor 也是通过submit 添加任务,只是构造方法不同。
希望高度定制化时使用 ThreadPoolExecutor
创建线程池的时候,怎么设置线程池的线程数量比较合适?
这个情况需要具体问题具体分析:
一个线程是CPU密集型任务,还是 IO 密集型任务
CPU密集型任务:这个线程大部分都在CPU上执行,如果所有线程都是CPU密集型的,这个时候建议线程数量不要大于设备的逻辑核心数量。
IO 密集型任务:这个线程大部分时间都在等待 IO ,如果所有线程都是 IO 密集型的,这个时候线程数量可以很多
上述两种情况是极端情况,大部分情况都是,有一部分线程是 CPU 密集型,一部分是 IO 密集型,所以,更适合的做法是通过测试的方式找到合适的线程数目。
即尝试给线程池设定不同的线程数目分别进行性能测试,对比每种线程数目下,总的时间开销,和系统资源占用的 开销,找到一个最合适的值。
2. 简单实现一个线程池
我们先来整理一下,实现一个线程池需要哪些内容:
- 一个任务队列:记录要执行的任务
- submit方法:添加任务
- 构造方法:指定线程的数量以及创建,运行线程
class MyTreadPoolExecutor {
//任务队列,这里使用一个阻塞队列
private BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(1000);
//构造方法
public MyTreadPoolExecutor(int num) {
for(int i = 0; i < num; i++) {
//创建线程
Thread t = new Thread(() -> {
//循环取出任务并执行
while(true) {
try {
queue.take().run();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t.start();
}
}
//submit
public void submit(Runnable runnable) {
//添加任务
try {
queue.put(runnable);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
public class TreadDemo29 {
public static void main(String[] args) throws InterruptedException {
MyTreadPoolExecutor pool = new MyTreadPoolExecutor(4);
for(int i = 0; i < 1000; i++) {
int n = i;
pool.submit(() -> {
System.out.println("线程:" + Thread.currentThread().getName() + "执行了任务:" + n);
});
}
}
}
运行效果:
注意这里的任务执行无序的原因是,多个线程并发执行。
例如:任务 0 刚被某个线程拿到,改线程就被调度出了cpu 此次任务 2 就可能被拿到并执行了