找往期文章包括但不限于本期文章中不懂的知识点:
到现在为止,我们已经学习了两个经典的多线程案例了:饿汉模式与懒汉模式、阻塞队列与生产者—消费者模型。想要详细了解的小伙伴,可以去看往期文章。现在我们来学习另外一个案例:线程池。
目录
线程池
概念:线程池,简单理解就是 一块内存中存放着多个线程。
作用:高效的创建与销毁线程。
在上面的例子中,老莫是充当CPU与操作系统的角色,而高小强是用户,高小强吃鱼这件事就是一个线程。当用户频繁地去创建与删除线程时,就会影响操作系统、CPU的效率(老莫只能经常去买鱼,而很少有时间去干自己想干的事情)。因此老莫这里的操作是建了一个鱼塘,对应计算机中的操作就是创建一个线程池。
线程池的工作原理:当外界有任务时,线程池的所在的线程就会随机启动其中的一个线程去执行任务,当任务执行完成时,这个线程并不会被销毁,而是重新回到线程池中等待下一次任务来临时,等待被调度。这就省下了创建线程与销毁线程所消耗资源。这里的资源主要是指CPU从用户态变为核心态以及操作系统切换到内核区工作。
Java标准库中的线程池
我们主要是要知道然后去创建并使用Java标准库中的线程池。
提供的类是:ThreadPoolExecutor。
参数解析
上面是这个类的构造方法,从上到下参数的个数是增多的。我们是要清楚构造方法的全部参数的。
下面是对最详细版的构造方法的参数解释:
注意:
1、 只有当核心线程数全部在工作时,如果这时需要处理新的任务,才会去创建新的线程。就好比一个公司,当内部员工足以处理这些业务时,就没必要花钱请外包,但是当内部员工已经忙不过来时,这时候才会需要外包来干新的任务。
2、当公司的业务过了旺季,到了淡季,这时候公司内部的员工都可能是出于空闲的状态,那外部更加没事干,因此公司就会考虑和外包解除合同。
3、线程工厂,这里使用了一种设计模式:工厂模式,其与我们前面学习的单例模式是出于同一级别的。工厂模式主要弥补构造方法的缺陷。例如,现在有一个类是用来描述平面直角坐标系中的一个点,描述的方式有两种:1、使用 (x,y) 坐标的方式;2、使用极坐标(用三角函数来实现)的方式;
因此,解决这样的问题,我们就可以使用工厂模式,将构造方法改为使用静态的方法,这样最终就不用通过构造方法来实现了。
代码演示:
class Point {
private double x = 0;
private double y = 0;
// 1、使用(x,y)的方式
public Point(double x, double y) {
this.x = x;
this.y = y;
}
// 2、使用极坐标的方式
/*public Point(double r, double a) {
this.x = r * Math.cos(a);
this.y = r * Math.cos(a);
}*/
public static Point getInstanceByXY(double x, double y) {
return new Point(x,y);
}
public static Point getInstanceByRA(double r, double a) {
return new Point(r*Math.cos(a), r*Math.sin(a));
}
}
4、拒绝策略:
对于这四种拒绝策略,1、3、4 应该是很好理解的,但对于第二种来说,可能有点模糊。第二种方式,是告诉需要执行任务的线程:这个任务,我现在没空,你自己去把这个任务完成吧。然后需要执行这个任务的线程,就会自己把这个任务执行完。
我们先来了解一下,任务是什么?通过前面的学习,我们已经知道了,线程就是轻量级进程,也就是一段需要执行的指令。任务同样也可以看作是一段需要执行的指令,并且任务所需要执行的代码都是用 Runnable给包装起来的。给线程池去执行的话,就是让线程池对象调用 submit 方法,然后把包含任务代码的Runnable给作为参数扔给 sumbit 去执行。这就是线程池执行任务的过程。
从上面的分析,我们也可以得出一个结论:交给线程池执行的任务,而让自己(需要执行该任务的线程)执行任务 的区别在于:线程池会利用多线程的方式去执行该任务,而自己只会去串行执行,这样就影响了程序最终的效率。而让自己去执行,其实就是底层让 Runnable 去调用 run 方法。
1)有小伙伴可能会对 3、4有疑惑:丢弃最新的任务和让需要执行该任务的线程自己去执行 的区别是不是前者根本就没有线程去执行,而后者是调用submit的线程(也就是需要执行该任务的线程)去执行。
2)也有小伙伴可能会遇到这种说法:4 是丢弃当前任务。这种说法也是正确的,这里最新的任务和当前的任务都是指需要被执行的任务。当前任务不就是需要被执行的任务嘛,最新的任务不也是需要被执行的任务嘛,对叭,细细品味一下。
使用线程池
上面就是对构造方法的参数的解析,下面我们就来使用一下这个线程池。由于原本的类参数过多,因此JVM又对其进行了部分封装,最终我们使用的类是 ExecutorSever 。
public class Test {
public static void main(String[] args) throws InterruptedException {
// 创建一个线程数目固定的线程池
ExecutorService service = Executors.newFixedThreadPool(2);
// 创建一个很大的线程池,最大线程的数目是Integer.MAX_VALUE
// ExecutorService service = Executors.newCachedThreadPool();
for (int i = 0; i < 10; i++) {
int id = i;
// 创建任务
Runnable task = new Runnable() {
@Override
public void run() {
System.out.println("Hello task"+id+"--->"+Thread.currentThread().getName());
}
};
// 调用线程池中的线程执行任务
service.submit(task);
Thread.sleep(1000);
}
}
}
我们去运行就会发现,上面的任务执行完成后,也就是所有的代码全部执行完成后,进程没有停下,还在继续。这是因为 线程池中的线程是前台线程的。
我们也可以去看这两个类的源码:
注意:上述代码的打印语句中不能使用 i ,因为在匿名内部类中,访问外部类的局部变量,采用的是变量捕获的方式,而这个方式固定了我们访问的变量必须是 final修饰的或者是事实 final(和我们上面一样,虽然没有用 final 修饰,但是最终的值并没有发生变化),i 在创建之后,还进行了 i++ 的操作,使得其发生了变化。
模拟实现线程池
接下来,我们就来模拟实现一个简单的线程池。
要求:与我们使用的线程池的效果要大致一样。
思路:实现线程池主要是要实现其中的 submit 方法,与构造方法。
模拟实现代码:
public class MyThreadPool {
// 定义一个阻塞队列,来接收要处理的任务
private final BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(1000);
public MyThreadPool() {
this(10);
}
public MyThreadPool(int nThread) {
// 分配好线程数
for (int i = 0; i < nThread; i++) {
// 创建线程
Thread t = new Thread(()->{
// 要执行的任务 ——> 工作队列中找
try {
while (true) { // 执行完成之后,不能让这个线程销毁(run方法执行完,线程就销毁了)
Runnable task = queue.take(); // 为空,内部会阻塞等待
task.run(); // 执行任务
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t.start();
}
}
public void submit(Runnable task){
// 把任务给到submit,然后由其来执行
try {
queue.put(task); // 为满,内部会阻塞等待
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
测试代码:
public class Test {
public static void main(String[] args) {
// 1、创建线程池
MyThreadPool threadPool = new MyThreadPool(2);
// 2、创建任务并执行
for (int i = 0; i < 10; i++) {
int id = i;
// 2.1 创建任务
Runnable task = new Runnable() {
@Override
public void run() {
System.out.println("Hello task"+id+"--->"+Thread.currentThread().getName());
}
};
// 2.2 执行任务
threadPool.submit(task);
}
}
}
注意:
1、我们手动创建的线程是属于前台线程。
2、
1)怎么样保证线程不会销毁(还在执行的过程中) —— 设置为前台线程。
2)怎么样保证线程在处于空闲状态,且不会被销毁 —— 只有线程处于 run方法中,那么线程就不会被销毁,即在run方法中搞一个死循环即可。并且当工作队列为空时,线程会阻塞等待在 take 方法。
那如果我们实在是想要线程池中的线程在执行完任务之后,就销毁呢?
在我们自己实现的线程池中,只需要把创建的线程设为后台线程即可,而在Java提供的类中,我们需要用到 shutdown ,这个方法是无脑关闭,即使是有没有执行完成的任务,也会关闭。因此面对这种情况,就需要用到另一个方法:awaitTermination,这个方法是用来阻塞关闭线程池的,当线程池中任务没有执行全部完时,便会去等待我们手动设置的超时时间,(在这个超时时间之内)这个方法便会阻塞关闭线程池的线程,因此,上面两个方法一般都是连在一起用的。而如果线程池中任务已经全部执行完毕,就不会发生阻塞等待的情况。简单来说,就是检查有没有全部执行完成,如果执行完成了,就直接往下走,如果还存在没有执行完的,就会阻塞等待这个超时时间段,当超过这个时间段了,即使还没有全部执行完,此时也会往下走。
代码演示:
public class Test {
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(2);
for (int i = 0; i < 10; i++) {
executorService.submit(() -> {
try {
Thread.sleep(500);
System.out.println("任务执行完成");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
try {
// 当线程池中还有任务没有执行完,就会阻塞等待 3s;反之,则直接往下执行
boolean terminated = executorService.awaitTermination(3000, TimeUnit.MILLISECONDS);
if (terminated) { // 当没有触发阻塞等待,就会返回true;反之,则返回false
System.out.println("所有的任务都已经执行完了");
} else {
System.out.println("存在部分任务没有执行完");
}
} catch (InterruptedException e) {
executorService.shutdownNow();
Thread.currentThread().interrupt();
}
executorService.shutdown(); // 这个代码就是线程池中还存在没有执行的任务
}
}
运行结果:
好啦!本期 初始JavaEE篇——多线程(6):线程池 的学习之旅到此结束啦!我们下一期再一起学习吧!