文章目录
定时器&线程池
一、定时器
- 类似一个"闹钟",约定一个时间,时间到达之后,执行某个代码逻辑
- 在进行网络通信中很常见
客户端在发出请求后,就要等待响应。但是如果服务器迟迟没有响应,客户端不能无限的等下去,需要有一个最大的期限,等时间到了之后再次进行判断。而“等待的最大时间”就可以通过定时器的方式来实现。
1.标准库中的定时器
- import java.util.Timer 。 Timer这个类是在util里的
public static void main(String[] args) {
Timer timer = new Timer();
//给定时器安排了一个任务,预订在2秒后执行。
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("执行定时器");
}
},2000);
System.out.println("程序启动");
}
程序启动
执行定时器//两秒后
- schedule()方法的第一个参数:使用匿名内部类,创建一个TimerTask()实例,重写当中的run方法,通过run方法来描述任务的详细情况
TimerTask类本身就实现了Runnable接口。从而重写run方法
-
schedule方法的第二个参数:填写的时间,表示当前这个任务以此时此刻为基准,往后推X时间后再执行该任务。
当主线程在执行schedule方法的时候,就会把任务放进timer对象中。同时,timer当中,存在一个扫描线程。一旦时间到了,扫描线程就会执行刚才安排的任务。换句话说,timer当中的任务,是有当中的扫描线程来执行的。当任务结束时,扫描线程并未结束,还在等待执行后续可能安排的任务
public static void main(String[] args) {
Timer timer = new Timer();
//给定时器安排了一个任务,预订在2秒后执行。
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("执行定时器2");
}
},2000);
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("执行定时器3");
}
},3000);
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("执行定时器1");
}
},1000);
System.out.println("程序启动");
}
程序启动
执行定时器1
执行定时器2
执行定时器3
2.实现定时器
要有一个扫描线程,扫描任务是否到达时间执行
要有一个优先级对列来保存任务:o(1),优先取时间最小的任务执行。
创建一个类,通过类的对象来描述任务(任务内容、任务时间)
class MyTimerTask implements Comparable<MyTimerTask> {
//描述一个任务
private Runnable runnable;
//要执行的任务
private long time;
MyTimerTask(Runnable runnable, long delay) {
this.runnable = runnable;
this.time = System.currentTimeMillis() + delay;//当前的时间戳+要延迟的时间
}
public long getTime() {
return time;
}
public Runnable getRunnable() {
return runnable;
}
@Override
public int compareTo(MyTimerTask o) {
//保证队首的任务是是最小的时间
return (int) (this.time - o.time);
}
}
- 如果队列为空了,就需要 调用wait来进行阻塞,同时需要搭配synchronized来使用。因为wait的操作有三个:1在前提有锁的情况下,释放锁。2.等待通知。3.通知到来之后,进行唤醒,同时重新拿到锁。
- 对应的,也需要在调用schedule方法添加任务时,对之前因为队列为空而等待的wait进行唤醒(notify)
同时:由于schedule方法和扫描线程都会操作队列。存在线程安全问题。因此要加锁。
//自己实现的定时器
class MyTimer {
private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();
private Object locker = new Object();
//锁对象
//优先级队列存任务
public void schedule(Runnable runnable, long delay) {
//把要完成的任务和延迟的时间构造成一个任务对象,存进优先级队列
synchronized (locker) {
queue.offer(new MyTimerTask(runnable, delay));
locker.notify();//唤醒空对列的wait
}
}
public MyTimer() {
//创建一个扫描线程
Thread t = new Thread(() -> {
while (true) {
//不停扫描队首元素
try {
synchronized (locker) {
while (queue.isEmpty()) {
//使用wait进行等待
locker.wait();//需要由添加任务的时候唤醒
}
MyTimerTask task = queue.peek();
//比较一下当前时间是否可以执行任务
long curTime = System.currentTimeMillis();
if (curTime >= task.getTime()) {
task.getRunnable().run();//执行任务
queue.poll();
}else {
locker.wait(task.getTime()-curTime);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
由于扫描线程是while(true)循环,在队列不为空的情况下,不会进入等待。会一直持续循环,直到当前时间到达设定的时间为止。在此期间并没有进行任何操作,只是不断的对表忙等,但是消耗了很多cpu资源。所以在第一次判断时间时,在else中,当任务时间还没到的时候,进行wait阻塞,此时线程不会在CPU上调度,避免了忙等。
- 同时:如果schedule方法添加了一个比当前待任务要早执行的任务时,schedule方法内部的notify就会唤醒这个带参数的wait,让循环再执行一次,重新拿到新的队首元素,跟新wait的时间。
也就是说:当队列为空时进行阻塞等待,调用一次schedule方法,其中的notify可以唤醒wait。而在等待要执行的任务时,调用schedule方法存入一个任务。其中的notify会唤醒带参数的wait,再次循环,重新获取队首任务,更新等待时间。
二、线程池
1.线程池的概念
由于进程创建/销毁,太重量了(比较慢)才引进了线程的概念,但是如果进一步提高创建销毁的频率,线程的开销就不容忽视。
有两种办法来提高线程的效率:
-
1.协程(轻量级线程)
线程省略的是资源创建的过程。先比与线程,协程省略了系统调度的过程。(程序员手动调度)
Java虽然标准库中没有协程,但是一些第三方库实现了协程。
-
2.线程池 同样可以提高线程的效率,同时避免了全面的修改。减少了每次创建、销毁线程的损耗
线程池:
在使用第一个线程的时候,提前把后续多个线程创建好,放进池中。后续如果想使用多个线程,不必重新创建,直接从池中拿过来用,降低创建线程的开销。
而从池中取这个操作,是用户态的操作。而创建一个线程,则是用户态+内核态 相互配合来完成的操作。
如果一段程序在系统内核中执行,就被称为内核态。否则就是用户态。而一个操作系统则是由操作系统内核和配套的应用程序构成。创建一个线程,就会需要调用系统API,进入到内核中,按照内核态的方式来完成一系列操作。操作系统内核是要对所有的进程提供服务的,当要创建一个线程的时候,内核难免会被干扰做其他事情,不可控,不可预期。而用户态的操作不涉及系统内核,可控可预期。因此线程池的操作要比创建线程更高效。
2.标准库当中的线程池
ExecutorService service = Executors.newCachedThreadPool()
//可执行的服务
- 线程池对象不是直接new出来的,而是通过专门的方法,返回了一个线程池对象。这种写法叫做工厂模式
工厂模式
工厂模式是一种常见的设计模式
通常在使用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){
Point p =new Point();
p.setX(x){}
p.setY(y){}
return p;
}
public static Point makePointByRA(double x,double y){
return p;
}
}
Point p = PointFactory.makePointByXY(15,20);
//就类似于线程池的创建
ExecutorService service = Executors.newCachedThreadPool()
//工厂类 //工厂方法
- 通过方法名来完成区分
Executors 创建线程池
1.自适应线程池
newCachedThreadPool,可以动态适应。随着往线程池中添加任务,这个线程池中的线程会根据需要自动被创建出来,并且使用后不会立即销毁,会在池中保留一定的时间,以备后续再次使用
ExecutorService service = Executors.newCachedThreadPool();//可以动态适应
//cached:缓存,用过之后不着急释放,先保留,
2.固定数量线程池
ExecutorService service1 = Executors.newFixedThreadPool(10);//固定的
3.只有单个线程的线程池
ExecutorService service2 = Executors.newSingleThreadExecutor();
4.设定延迟时间后执行命令的线程池
ExecutorService service3 = Executors.newScheduledThreadPool(5);
//类似于定时器,但是不在是一个扫面线程在执行任务,而是变成了多个线程来执行任务。
ThreadPoolExecutor 类
Executors 本质上是 ThreadPoolExecutor 类的封装。ThreadPoolExecutor 类的功能非常丰富,提供了很多参数。标准库当中的几个工厂方法,其实就是给这个类填写了不同的参数来构造线程池
ThreadPoolExecutor 类的核心方法有两个:
1.注册任务
ExecutorService service1 = Executors.newFixedThreadPool(10);
service1.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
2.构造
ThreadPoolExecutor当中的构造参数有很多(面试题)
JUC这个包就是和并发编程相关的内容(多线程)
ThreadPoolExecutor的构造方法有四个版本,其中最后一个版本参数最多,可以涵盖其余方法的参数
int corePoolSize(核心线程数)int maximumPoolSize(最大线程数), 描述线程的数目
这个线程池中,线程的数目是可以动态变化的,线程数变化的范围就是 [ corePoolSize, maximumPoolSize ]
核心线程数(正式员工数量) ;最大线程数(正式员工数量 + 实习生的数量)
实习生不允许摸鱼,活多了招人,少了裁人。但是不会动正式员工
在满足效率的同时,又可以避免过多的系统开销
BlockingQueue 阻塞队列:可以根据需要灵活选择队列,需要有优先级,设置PriorityBlockingQueue
如果不需要优先级,并且任务数量相对恒定,使用ArrayBlockingQueue。如果任务数量变动较大,
使用LinkedBlockingQueue.
使用工厂类来创建线程,主要是为了在创建的过程中,对线程的属性做一些设置。如果手动创建线程,就需要手动设置这些属性,所以用工厂方法进行封装。
RejectedExecutionHandler handler 线程池的拒绝策略,一个线程池的容量是有限的,达到上限后,采用不同的拒绝策略会有不同的效果。(4种)
使用线程池,需要设置线程的数目
设置多少线程合适?
在接触到实际的项目代码之前,是无法确定的。
一个线程,要执行的代码主要有两大类:
1.CPU密集型:代码中主要的逻辑是进行算数运算/逻辑判断
2.IO密集型:代码里主要进行IO操作。(网络通信、写硬盘、读硬盘)
假设一个线程的所有代码都是CPU密集型代码,线程池中的线程数量不应该超过N(CPU核心数),设置的比N大,cpu吃满了,无法提高效率,此时添加更多的线程反而增加调度的开销。
假设一个线程的所有代码都是IO密集型的,这个时候不吃CPU,此时设置的线程数,就可以超过N.一个核心可以通过调度的方式,来并发执行。
代码不同,线程池的线程数目设置就不同。正确的设置方法:使用实验的方式,对程序进行性能测试。在测试的过程中,尝试修改不同的线程池的线程数目。看哪种情况最符合需求。
3.实现线程池
class MyThreadPool{
//任务队列
private BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(1000);
//通过submit方法,将任务添加到队列中
public void submit(Runnable runnable) throws InterruptedException {
//次处的拒绝策略相当于第5种策略:阻塞等待
queue.put(runnable);
}
public MyThreadPool(int n){
//创建出n个线程,负责执行上述队列中的任务
for (int i = 0; i < n; i++) {
Thread t = new Thread(()->{
//让线程,从队列中消费任务,并进行执行
try {
Runnable runnable = queue.take();
runnable.run();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t.start();
}
}
}
public class MakeMyThreadPoll {
public static void main(String[] args) throws InterruptedException {
MyThreadPool myThreadPool = new MyThreadPool(4);
for (int i = 0; i < 1000; i++) {
int id = i;
myThreadPool.submit(new Runnable() {
@Override
public void run() {
System.out.println("执行任务 " + id);
//防止匿名内部类的变量捕获
//此时捕获的是id,id没有人进行修改。每次循环都创建了新的id
}
});
}
}