多线程案例四
四、线程池
线程池是什么
虽然创建线程 / 销毁线程 的开销
想象这么一个场景:
在学校附近新开了一家快递店,老板很精明,想到一个与众不同的办法来经营。店里没有雇人, 而是每次有业务来了,就现场找一名同学过来把快递送了,然后解雇同学。这个类比我们平时来一个任务,起一个线程进行处理的模式。
很快老板发现问题来了,每次招聘 + 解雇同学的成本还是非常高的。老板还是很善于变通的,知道了为什么大家都要雇人了,所以指定了一个指标,公司业务人员会扩张到 3 个人,但还是随着 业务逐步雇人。于是再有业务来了,老板就看,如果现在公司还没 3 个人,就雇一个人去送快 递,否则只是把业务放到一个本本上,等着 3 个快递人员空闲的时候去处理。这个就是我们要带出的线程池的模式。
🧨线程池最大的好处就是减少每次启动、销毁线程的损耗。🧨
标准库中的线程池
- 使用 Executors.newFixedThreadPool(10) 能创建出固定包含 10 个线程的线程池.
- 返回值类型为 ExecutorService
- 通过 ExecutorService.submit 可以注册一个任务到线程池中.
ExecutorService pool = Executors.newFixedThreadPool(10); pool.submit(new Runnable() { @Override public void run() { System.out.println("hello"); } });
上述代码第一行代码就是创建了一个线程池,池子里线程数目固定是10个。
这个操作,使用某个类的某个静态方法,直接构造出了一个对象来(相当于是把 new 操作,给隐藏到这样的方法后面了)
像这样的方法,就称为"工厂方法"
提供工厂方法的类,也就成为"工厂类"
此处这个代码就使用了"工厂模式"这种 设计模式
那么什么是工厂模式呢?
答:工厂模式,一句话表示,使用普通的方法来代替构造方法,创建对象。为啥要代替呢,因为构造方法有坑,他只能构造一种对象,如果要构造多种不同情况的对象,就难搞了。
例如我们需要使用笛卡尔坐标系来构造坐标,空间直角坐标系和极坐标系,那么此时我们的代码就会是:
class Point { //构造空间直角坐标系 public Point(double x, double y) { } //构造极坐标系 public Point(double r, double a) { } }
显然此时我们虽然可以用代码写出并构造出这两个坐标系,但是他们是❌错误的,因为我们的构造方法只能构造一个对象。而普通方法是可以构造多个对象的
那么就会有人问了我们不是可以通过重载的方法来进行多个构造方法吗,此时我们来看一下重载的要求,重载要求的是:方法名相同,参数的个数或者类型不相同。
因此我们这里根本不可能进行重载调用。
普通方法构建笛卡尔坐标系代码:
class PointFactory { public static Point makePointByXY(double x,double y) { } public static Point makePointByRA(double r,double a) { } } public class ThreadDemo26 { public static void main(String[] args) { Point P = PointFactory.makePointByXY(10,20); } }
普通方法,方法名字没有限制。因此有多种方式构造,就可以直接使用不同的方法名即可。此时,方法的参数是否要区分,已经不重要了
这里我们区分一下重载和重写的区别:
重载(overload):指一个类中可以有多个方法具有相同的名字,但这些方法的参数不同(参数的类型和个数不同)。分别在父类子类里也是可能构成重载的。
重写(override):在不同类中(指父类和子类)中,两个具有相同方法名和相同参数的方法,称作重写。 (如果是其他语言,重写方法不一定通过父类子类)
重写本质上就是用一个新的方法,代替旧的… 就得要求新的方法和旧的方法,名字/参数都得一摸一样
下面我们为大家带来几个线程池的使用举例:
运行程序之后发现,main线程结束了,但是整个进程没有结束。线程池中的线程都是 前台线程。此时会阻止进程结束。(前面定时器 Timer 也是同理)
下属代码为什么不能直接写 System.*out*.println("hello" + n); 呢?
答:因为变量捕获,这里的 i 是主线程里的局部变量(在主线程的栈上),随着主线程这里的代码块执行结束就销毁了。很可能主线程这里的 for 执行完了,当前 run 的任务在线程池里还没排到呢,此时 i 就销毁了
变量捕获(必须是不可修改的变量才能捕获 有final修饰的变量也可捕获):在定义 run 的时候,偷偷把 i 当前的值记住。后续执行run的时候,就创建一个也叫做 i 的局部变量,并且把这个值赋值过去
final:
final修饰类,表示该类是无法被任何其他类继承的,意味着此类在一个继承树中是一个叶子类,并且此类的设计已被认为很完美而不需要进行修改或扩展。
final修饰类中的方法,表示该类是无法被任何其他类继承的,不可以被重写;也就是把该方法锁定了,以防止继承类对其进行更改。
final修饰类中的变量,表示该变量一旦被初始化便不可改变。
此处要注意,当前是往线程池里放了1000个任务,1000个任务就是让这 10 个线程来平均分配一下,差不多一个线程执行100个任务,但是注意这里并非是严格的平均,可能有的多一个有的少一个,都很正常。(每个线程都执行完一个任务之后,再立即取下一个任务,由于每个任务执行时间都差不多,因此每个线程做的任务数量就差不多)
进一步的可以认为,这1000个任务,就在一个队列里排队,这10个线程,就依次来取队列中的任务,取一个就执行一个,执行完了之后再执行下一个。
因为线程调度是随机分配的,因此会出现顺序不一样的情况。
Executors 创建线程池的几种方式
- newFixedThreadPool: 创建固定线程数(可能是多个)的线程池
- newCachedThreadPool: 创建线程数目动态增长的线程池.
- newSingleThreadExecutor: 创建只包含单个线程的线程池.
- newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer. 执行的时候不是由扫描线程自己执行,而是由单独的线程池来执行。
总结:实践中确定的线程数量,也很简单,通过测试/实验的方式
Executors 本质上是 ThreadPoolExecutor 类的封装.
ThreadPoolExecutor 提供了更多的可选参数, 可以进一步细化线程池行为的设定. (后面再介绍)
实现线程池
- 核心操作为 submit, 将任务加入线程池中 使用 Worker 类描述一个工作线程.
- 使用 Runnable 描述一个任务.
- 使用一个 BlockingQueue 组织所有的任务
- 每个 worker 线程要做的事情: 不停的从 BlockingQueue 中取任务并执行.
- 指定一下线程池中的最大线程数 maxWorkerCount; 当当前线程数超过这个最大值时, 就不再新增 线程了.
class Worker extends Thread { private LinkedBlockingQueue<Runnable> queue = null; public Worker(LinkedBlockingQueue<Runnable> queue) { super("worker"); this.queue = queue; } @Override public void run() { // try 必须放在 while 外头, 或者 while 里头应该影响不大 try { while (!Thread.interrupted()) { Runnable runnable = queue.take(); runnable.run(); } } catch (InterruptedException e) { } } }
public class MyThreadPool { private int maxWorkerCount = 10; private LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue(); public void submit(Runnable command) { if (workerList.size() < maxWorkerCount) { // 当前 worker 数不足, 就继续创建 worker Worker worker = new Worker(queue); worker.start(); } // 将任务添加到任务队列中 queue.put(command); } public static void main(String[] args) throws InterruptedException { MyThreadPool myThreadPool = new MyThreadPool(); myThreadPool.execute(new Runnable() { @Override public void run() { System.out.println("吃饭"); } }); Thread.sleep(1000); } }
自己实现线程池版本二:
import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; /** * Created with IntelliJ IDEA. * Description: * User: 晓星航 * Date: 2023-08-01 * Time: 15:16 */ class MyThreadPoll { //此处不涉及 "时间",此处只有任务,就直接使用 Runnable 即可~~ private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(); //n 表示线程的数量 public MyThreadPoll(int n) { //在这里创建线程 for (int i = 0; i < n; i++) { Thread t = new Thread(()->{ while (true) { try { Runnable runnable = queue.take(); runnable.run(); } catch (InterruptedException e) { e.printStackTrace(); } } }); t.start(); } } //注册任务给线程池 public void submit(Runnable runnable) { try { queue.put(runnable); } catch (InterruptedException e) { e.printStackTrace(); } } } public class ThreadDemo27 { public static void main(String[] args) { MyThreadPoll pool = new MyThreadPoll(10); for (int i = 0; i < 1000; i++) { int n = i; pool.submit(new Runnable() { @Override public void run() { System.out.println("hello" + n); } }); } } }
四个拒绝策略:
例如我们此时在学习,而有好朋友叫我们去打游戏
那么对应上面情况:
- 要学习的东西太多,我直接开摆,啥也不干了…
- 学习任务太多了,让朋友们自己去玩。(此时朋友们自己去打游戏了)
- 直接不学了,开玩!
- 拒绝去玩,继续学习。(至于朋友们有没有玩我们不必管)
感谢各位读者的阅读,本文章有任何错误都可以在评论区发表你们的意见,我会对文章进行改正的。如果本文章对你有帮助请动一动你们敏捷的小手点一点赞,你的每一次鼓励都是作者创作的动力哦!😘